From 0acf00c4a1cb8906bc75e124a10668db1d7cb556 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 10 Jun 2026 14:16:51 +0800 Subject: [PATCH] Add admin compliance acknowledgement gate --- .gitignore | 2 + backend/cmd/server/wire_gen.go | 3 +- .../handler/admin/compliance_handler.go | 67 ++++++ backend/internal/handler/handler.go | 1 + backend/internal/handler/page_handler.go | 1 + backend/internal/handler/wire.go | 3 + .../server/middleware/admin_compliance.go | 53 +++++ .../middleware/admin_compliance_test.go | 82 +++++++ backend/internal/server/router.go | 2 +- backend/internal/server/routes/admin.go | 14 ++ backend/internal/server/routes/payment.go | 1 + backend/internal/service/admin_compliance.go | 161 +++++++++++++ .../internal/service/admin_compliance_test.go | 133 +++++++++++ docs/legal/admin-compliance.en.md | 49 ++++ docs/legal/admin-compliance.zh.md | 49 ++++ frontend/src/App.vue | 20 +- frontend/src/api/__tests__/client.spec.ts | 47 ++++ frontend/src/api/admin/compliance.ts | 42 ++++ frontend/src/api/admin/index.ts | 7 +- frontend/src/api/client.ts | 17 ++ .../admin/AdminComplianceDialog.vue | 225 ++++++++++++++++++ frontend/src/components/common/BaseDialog.vue | 3 + frontend/src/i18n/locales/en.ts | 28 +++ frontend/src/i18n/locales/zh.ts | 28 +++ frontend/src/router/index.ts | 17 +- frontend/src/stores/adminCompliance.ts | 91 +++++++ frontend/src/stores/index.ts | 1 + .../src/views/public/LegalDocumentView.vue | 36 ++- frontend/src/vite-env.d.ts | 5 + 29 files changed, 1173 insertions(+), 15 deletions(-) create mode 100644 backend/internal/handler/admin/compliance_handler.go create mode 100644 backend/internal/server/middleware/admin_compliance.go create mode 100644 backend/internal/server/middleware/admin_compliance_test.go create mode 100644 backend/internal/service/admin_compliance.go create mode 100644 backend/internal/service/admin_compliance_test.go create mode 100644 docs/legal/admin-compliance.en.md create mode 100644 docs/legal/admin-compliance.zh.md create mode 100644 frontend/src/api/admin/compliance.ts create mode 100644 frontend/src/components/admin/AdminComplianceDialog.vue create mode 100644 frontend/src/stores/adminCompliance.ts diff --git a/.gitignore b/.gitignore index cf251f0715..0f4e3b0625 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,8 @@ docs/* !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md +!docs/legal/ +!docs/legal/*.md .serena/ .codex/ frontend/coverage/ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index bd6ca2315b..879d56d22f 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -240,7 +240,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { contentModerationHandler := admin.NewContentModerationHandler(contentModerationService) paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler) + complianceHandler := admin.NewComplianceHandler(settingService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) diff --git a/backend/internal/handler/admin/compliance_handler.go b/backend/internal/handler/admin/compliance_handler.go new file mode 100644 index 0000000000..3d4b34b780 --- /dev/null +++ b/backend/internal/handler/admin/compliance_handler.go @@ -0,0 +1,67 @@ +package admin + +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +type ComplianceHandler struct { + settingService *service.SettingService +} + +func NewComplianceHandler(settingService *service.SettingService) *ComplianceHandler { + return &ComplianceHandler{settingService: settingService} +} + +type AcceptAdminComplianceRequest struct { + Phrase string `json:"phrase" binding:"required"` + Language string `json:"language"` +} + +func (h *ComplianceHandler) GetStatus(c *gin.Context) { + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + status, err := h.settingService.GetAdminComplianceStatus(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, status) +} + +func (h *ComplianceHandler) Accept(c *gin.Context) { + var req AcceptAdminComplianceRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + status, err := h.settingService.AcceptAdminCompliance(c.Request.Context(), service.AdminComplianceAcceptInput{ + AdminUserID: subject.UserID, + Phrase: req.Phrase, + Language: req.Language, + IPAddress: ip.GetClientIP(c), + UserAgent: strings.TrimSpace(c.GetHeader("User-Agent")), + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, status) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 308b219921..95cac6274c 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -36,6 +36,7 @@ type AdminHandlers struct { ContentModeration *admin.ContentModerationHandler Payment *admin.PaymentHandler Affiliate *admin.AffiliateHandler + Compliance *admin.ComplianceHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/page_handler.go b/backend/internal/handler/page_handler.go index 7d4d507849..a80ae82b1c 100644 --- a/backend/internal/handler/page_handler.go +++ b/backend/internal/handler/page_handler.go @@ -277,6 +277,7 @@ func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.Handler // Admin-only: list all available pages adminPages := v1.Group("/pages") adminPages.Use(adminAuth) + adminPages.Use(middleware2.AdminComplianceGuard(settingService)) { adminPages.GET("", h.ListPages) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index c8c4615702..7d7f4dc622 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -39,6 +39,7 @@ func ProvideAdminHandlers( contentModerationHandler *admin.ContentModerationHandler, paymentHandler *admin.PaymentHandler, affiliateHandler *admin.AffiliateHandler, + complianceHandler *admin.ComplianceHandler, ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, @@ -71,6 +72,7 @@ func ProvideAdminHandlers( ContentModeration: contentModerationHandler, Payment: paymentHandler, Affiliate: affiliateHandler, + Compliance: complianceHandler, } } @@ -184,6 +186,7 @@ var ProviderSet = wire.NewSet( admin.NewContentModerationHandler, admin.NewPaymentHandler, admin.NewAffiliateHandler, + admin.NewComplianceHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/server/middleware/admin_compliance.go b/backend/internal/server/middleware/admin_compliance.go new file mode 100644 index 0000000000..39fe633f5f --- /dev/null +++ b/backend/internal/server/middleware/admin_compliance.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +func AdminComplianceGuard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService == nil || isAdminComplianceBypassPath(c.Request.URL.Path) { + c.Next() + return + } + + subject, ok := GetAuthSubjectFromContext(c) + if !ok { + AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required") + return + } + + acknowledged, err := settingService.IsAdminComplianceAcknowledged(c.Request.Context(), subject.UserID) + if err != nil { + AbortWithError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error") + return + } + if acknowledged { + c.Next() + return + } + + c.JSON(http.StatusLocked, gin.H{ + "code": "ADMIN_COMPLIANCE_ACK_REQUIRED", + "message": "administrator compliance acknowledgement is required", + "metadata": gin.H{ + "version": service.AdminComplianceVersion, + "document_path_zh": service.AdminComplianceDocumentPathZH, + "document_path_en": service.AdminComplianceDocumentPathEN, + "document_url_zh": service.AdminComplianceDocumentURLZH, + "document_url_en": service.AdminComplianceDocumentURLEN, + }, + }) + c.Abort() + } +} + +func isAdminComplianceBypassPath(path string) bool { + path = strings.TrimSpace(path) + return path == "/api/v1/admin/compliance" || strings.HasPrefix(path, "/api/v1/admin/compliance/") +} diff --git a/backend/internal/server/middleware/admin_compliance_test.go b/backend/internal/server/middleware/admin_compliance_test.go new file mode 100644 index 0000000000..7c996eb539 --- /dev/null +++ b/backend/internal/server/middleware/admin_compliance_test.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type complianceGuardRepoStub struct { + values map[string]string +} + +func (r *complianceGuardRepoStub) Get(ctx context.Context, key string) (*service.Setting, error) { + if value, ok := r.values[key]; ok { + return &service.Setting{Key: key, Value: value}, nil + } + return nil, service.ErrSettingNotFound +} + +func (r *complianceGuardRepoStub) GetValue(ctx context.Context, key string) (string, error) { + setting, err := r.Get(ctx, key) + if err != nil { + return "", err + } + return setting.Value, nil +} + +func (r *complianceGuardRepoStub) Set(ctx context.Context, key, value string) error { return nil } +func (r *complianceGuardRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *complianceGuardRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + return nil +} +func (r *complianceGuardRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *complianceGuardRepoStub) Delete(ctx context.Context, key string) error { return nil } + +func TestAdminComplianceGuardBlocksAdminRouteWhenMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := service.NewSettingService(&complianceGuardRepoStub{}, &config.Config{}) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + c.Next() + }) + router.Use(AdminComplianceGuard(svc)) + router.GET("/api/v1/admin/users", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusLocked, w.Code) + require.Contains(t, w.Body.String(), "ADMIN_COMPLIANCE_ACK_REQUIRED") +} + +func TestAdminComplianceGuardBypassesComplianceEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := service.NewSettingService(&complianceGuardRepoStub{}, &config.Config{}) + router := gin.New() + router.Use(AdminComplianceGuard(svc)) + router.GET("/api/v1/admin/compliance", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/compliance", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "ok", w.Body.String()) +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index f477f3a754..3d86373779 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -109,7 +109,7 @@ func registerRoutes( // 注册各模块路由 routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService) routes.RegisterUserRoutes(v1, h, jwtAuth, settingService) - routes.RegisterAdminRoutes(v1, h, adminAuth) + routes.RegisterAdminRoutes(v1, h, adminAuth, settingService) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 0045231239..e6afa1436c 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -4,6 +4,7 @@ package routes import ( "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) @@ -13,10 +14,15 @@ func RegisterAdminRoutes( v1 *gin.RouterGroup, h *handler.Handlers, adminAuth middleware.AdminAuthMiddleware, + settingService *service.SettingService, ) { admin := v1.Group("/admin") admin.Use(gin.HandlerFunc(adminAuth)) + admin.Use(middleware.AdminComplianceGuard(settingService)) { + // 部署与运营合规确认 + registerAdminComplianceRoutes(admin, h) + // 仪表盘 registerDashboardRoutes(admin, h) @@ -100,6 +106,14 @@ func RegisterAdminRoutes( } } +func registerAdminComplianceRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + compliance := admin.Group("/compliance") + { + compliance.GET("", h.Admin.Compliance.GetStatus) + compliance.POST("/accept", h.Admin.Compliance.Accept) + } +} + func registerContentModerationRoutes(admin *gin.RouterGroup, h *handler.Handlers) { risk := admin.Group("/risk-control") { diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index beeae61146..2e26f2c0a0 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -68,6 +68,7 @@ func RegisterPaymentRoutes( // --- Admin payment endpoints (admin auth) --- adminGroup := v1.Group("/admin/payment") adminGroup.Use(gin.HandlerFunc(adminAuth)) + adminGroup.Use(middleware.AdminComplianceGuard(settingService)) { // Dashboard adminGroup.GET("/dashboard", adminPaymentHandler.GetDashboard) diff --git a/backend/internal/service/admin_compliance.go b/backend/internal/service/admin_compliance.go new file mode 100644 index 0000000000..f07e9d9f80 --- /dev/null +++ b/backend/internal/service/admin_compliance.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + AdminComplianceVersion = "v2026.06.10" + AdminComplianceDocumentPathZH = "docs/legal/admin-compliance.zh.md" + AdminComplianceDocumentPathEN = "docs/legal/admin-compliance.en.md" + AdminComplianceDocumentURLZH = "https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.zh.md" + AdminComplianceDocumentURLEN = "https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.en.md" + AdminComplianceAckPhraseZH = "我已阅读、理解并同意 Sub2API 部署与运营合规承诺" + AdminComplianceAckPhraseEN = "I have read, understood, and agree to the Sub2API Deployment and Operation Compliance Commitment" + + settingKeyAdminComplianceAcknowledgement = "admin_compliance_acknowledgement" +) + +var ( + ErrAdminComplianceAcknowledgementRequired = infraerrors.New( + http.StatusLocked, + "ADMIN_COMPLIANCE_ACK_REQUIRED", + "administrator compliance acknowledgement is required", + ) + ErrAdminComplianceInvalidPhrase = infraerrors.BadRequest( + "ADMIN_COMPLIANCE_INVALID_PHRASE", + "confirmation phrase does not match", + ) +) + +type AdminComplianceAcknowledgement struct { + Version string `json:"version"` + DocumentZH string `json:"document_zh"` + DocumentEN string `json:"document_en"` + AdminUserID int64 `json:"admin_user_id"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + AcceptedAt time.Time `json:"accepted_at"` +} + +type AdminComplianceStatus struct { + Required bool `json:"required"` + Version string `json:"version"` + DocumentPathZH string `json:"document_path_zh"` + DocumentPathEN string `json:"document_path_en"` + DocumentURLZH string `json:"document_url_zh"` + DocumentURLEN string `json:"document_url_en"` + AckPhraseZH string `json:"ack_phrase_zh"` + AckPhraseEN string `json:"ack_phrase_en"` + Acknowledgement *AdminComplianceAcknowledgement `json:"acknowledgement,omitempty"` +} + +type AdminComplianceAcceptInput struct { + AdminUserID int64 + Phrase string + Language string + IPAddress string + UserAgent string +} + +func normalizeAdminComplianceLanguage(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if strings.HasPrefix(raw, "zh") { + return "zh" + } + return "en" +} + +func expectedAdminCompliancePhrase(language string) string { + if normalizeAdminComplianceLanguage(language) == "zh" { + return AdminComplianceAckPhraseZH + } + return AdminComplianceAckPhraseEN +} + +func adminComplianceAcknowledgementKey(adminUserID int64) string { + if adminUserID <= 0 { + return settingKeyAdminComplianceAcknowledgement + } + return settingKeyAdminComplianceAcknowledgement + ":" + strconv.FormatInt(adminUserID, 10) +} + +func (s *SettingService) GetAdminComplianceStatus(ctx context.Context, adminUserID int64) (*AdminComplianceStatus, error) { + status := &AdminComplianceStatus{ + Required: true, + Version: AdminComplianceVersion, + DocumentPathZH: AdminComplianceDocumentPathZH, + DocumentPathEN: AdminComplianceDocumentPathEN, + DocumentURLZH: AdminComplianceDocumentURLZH, + DocumentURLEN: AdminComplianceDocumentURLEN, + AckPhraseZH: AdminComplianceAckPhraseZH, + AckPhraseEN: AdminComplianceAckPhraseEN, + } + if s == nil || s.settingRepo == nil { + return status, nil + } + + raw, err := s.settingRepo.GetValue(ctx, adminComplianceAcknowledgementKey(adminUserID)) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return status, nil + } + return nil, fmt.Errorf("get admin compliance acknowledgement: %w", err) + } + + var ack AdminComplianceAcknowledgement + if err := json.Unmarshal([]byte(raw), &ack); err != nil { + return status, nil + } + if ack.Version == AdminComplianceVersion { + status.Required = false + status.Acknowledgement = &ack + } + return status, nil +} + +func (s *SettingService) IsAdminComplianceAcknowledged(ctx context.Context, adminUserID int64) (bool, error) { + status, err := s.GetAdminComplianceStatus(ctx, adminUserID) + if err != nil { + return false, err + } + return status != nil && !status.Required, nil +} + +func (s *SettingService) AcceptAdminCompliance(ctx context.Context, input AdminComplianceAcceptInput) (*AdminComplianceStatus, error) { + if s == nil || s.settingRepo == nil { + return nil, infraerrors.InternalServer("SETTING_SERVICE_UNAVAILABLE", "setting service is unavailable") + } + phrase := strings.TrimSpace(input.Phrase) + if phrase != expectedAdminCompliancePhrase(input.Language) { + return nil, ErrAdminComplianceInvalidPhrase + } + + ack := AdminComplianceAcknowledgement{ + Version: AdminComplianceVersion, + DocumentZH: AdminComplianceDocumentPathZH, + DocumentEN: AdminComplianceDocumentPathEN, + AdminUserID: input.AdminUserID, + IPAddress: strings.TrimSpace(input.IPAddress), + UserAgent: strings.TrimSpace(input.UserAgent), + AcceptedAt: time.Now().UTC(), + } + payload, err := json.Marshal(ack) + if err != nil { + return nil, fmt.Errorf("marshal admin compliance acknowledgement: %w", err) + } + if err := s.settingRepo.Set(ctx, adminComplianceAcknowledgementKey(input.AdminUserID), string(payload)); err != nil { + return nil, fmt.Errorf("save admin compliance acknowledgement: %w", err) + } + + return s.GetAdminComplianceStatus(ctx, input.AdminUserID) +} diff --git a/backend/internal/service/admin_compliance_test.go b/backend/internal/service/admin_compliance_test.go new file mode 100644 index 0000000000..1f1a2c862f --- /dev/null +++ b/backend/internal/service/admin_compliance_test.go @@ -0,0 +1,133 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type adminComplianceRepoStub struct { + values map[string]string +} + +func (r *adminComplianceRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + if value, ok := r.values[key]; ok { + return &Setting{Key: key, Value: value}, nil + } + return nil, ErrSettingNotFound +} + +func (r *adminComplianceRepoStub) GetValue(ctx context.Context, key string) (string, error) { + setting, err := r.Get(ctx, key) + if err != nil { + return "", err + } + return setting.Value, nil +} + +func (r *adminComplianceRepoStub) Set(ctx context.Context, key, value string) error { + if r.values == nil { + r.values = map[string]string{} + } + r.values[key] = value + return nil +} + +func (r *adminComplianceRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + return map[string]string{}, nil +} + +func (r *adminComplianceRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + return nil +} + +func (r *adminComplianceRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + return map[string]string{}, nil +} + +func (r *adminComplianceRepoStub) Delete(ctx context.Context, key string) error { + delete(r.values, key) + return nil +} + +func TestAdminComplianceStatusRequiresAckWhenMissing(t *testing.T) { + svc := NewSettingService(&adminComplianceRepoStub{}, &config.Config{}) + + status, err := svc.GetAdminComplianceStatus(context.Background(), 1) + require.NoError(t, err) + require.True(t, status.Required) + require.Equal(t, AdminComplianceVersion, status.Version) + require.Equal(t, AdminComplianceAckPhraseZH, status.AckPhraseZH) + require.Equal(t, AdminComplianceDocumentPathZH, status.DocumentPathZH) +} + +func TestAcceptAdminComplianceRejectsWrongPhrase(t *testing.T) { + svc := NewSettingService(&adminComplianceRepoStub{}, &config.Config{}) + + _, err := svc.AcceptAdminCompliance(context.Background(), AdminComplianceAcceptInput{ + AdminUserID: 1, + Language: "zh", + Phrase: "我同意", + }) + require.Error(t, err) + require.True(t, errors.Is(err, ErrAdminComplianceInvalidPhrase)) +} + +func TestAcceptAdminCompliancePersistsCurrentVersion(t *testing.T) { + repo := &adminComplianceRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + status, err := svc.AcceptAdminCompliance(context.Background(), AdminComplianceAcceptInput{ + AdminUserID: 42, + Language: "zh-CN", + Phrase: AdminComplianceAckPhraseZH, + IPAddress: "203.0.113.10", + UserAgent: "test-agent", + }) + require.NoError(t, err) + require.False(t, status.Required) + require.NotNil(t, status.Acknowledgement) + require.Equal(t, int64(42), status.Acknowledgement.AdminUserID) + require.Equal(t, "203.0.113.10", status.Acknowledgement.IPAddress) + + var stored AdminComplianceAcknowledgement + require.NoError(t, json.Unmarshal([]byte(repo.values[adminComplianceAcknowledgementKey(42)]), &stored)) + require.Equal(t, AdminComplianceVersion, stored.Version) + require.Equal(t, AdminComplianceDocumentPathZH, stored.DocumentZH) +} + +func TestAdminComplianceStatusRequiresAckOnOldVersion(t *testing.T) { + old, err := json.Marshal(AdminComplianceAcknowledgement{Version: "v2026.01.01"}) + require.NoError(t, err) + svc := NewSettingService(&adminComplianceRepoStub{ + values: map[string]string{adminComplianceAcknowledgementKey(1): string(old)}, + }, &config.Config{}) + + status, err := svc.GetAdminComplianceStatus(context.Background(), 1) + require.NoError(t, err) + require.True(t, status.Required) + require.Nil(t, status.Acknowledgement) +} + +func TestAdminComplianceStatusIsPerAdminUser(t *testing.T) { + current, err := json.Marshal(AdminComplianceAcknowledgement{ + Version: AdminComplianceVersion, + AdminUserID: 1, + }) + require.NoError(t, err) + svc := NewSettingService(&adminComplianceRepoStub{ + values: map[string]string{adminComplianceAcknowledgementKey(1): string(current)}, + }, &config.Config{}) + + statusForUserOne, err := svc.GetAdminComplianceStatus(context.Background(), 1) + require.NoError(t, err) + require.False(t, statusForUserOne.Required) + + statusForUserTwo, err := svc.GetAdminComplianceStatus(context.Background(), 2) + require.NoError(t, err) + require.True(t, statusForUserTwo.Required) +} diff --git a/docs/legal/admin-compliance.en.md b/docs/legal/admin-compliance.en.md new file mode 100644 index 0000000000..b03f1f08b4 --- /dev/null +++ b/docs/legal/admin-compliance.en.md @@ -0,0 +1,49 @@ +# Sub2API Deployment and Operation Compliance Commitment + +Version: v2026.06.10 + +This document applies to any individual, organization, or authorized representative that deploys, configures, manages, operates, or effectively controls a Sub2API instance. Before continuing to access or use console functions, the acknowledging party must read, understand, and accept this document in full. + +## 1. Scope + +Sub2API is open-source software. Any self-hosted deployment, modification, hosted operation, external service, commercial use, user management, content processing, data processing, payment settlement, customer support, or upstream account/API usage based on Sub2API is the sole responsibility of the party that deploys, operates, or controls the relevant instance. + +This document does not replace the open-source license, upstream terms of service, user agreements, privacy policies, data processing agreements, commercial contracts, regulatory filings, administrative permits, security assessments, or any other documents, procedures, or obligations required by applicable law or contract. + +## 2. Responsibility of the Deploying or Operating Party + +The acknowledging party must independently assess and continuously comply with the laws, regulations, regulatory requirements, industry rules, contractual obligations, and platform policies that may apply in its location, server location, target-user location, place of actual business operation, and the locations of upstream service providers. + +The acknowledging party must ensure that it has all authorizations, qualifications, filings, permits, assessments, contracts, risk-control capabilities, content-safety capabilities, data-protection capabilities, complaint-handling mechanisms, and emergency-response capabilities required for deploying and operating the relevant instance. Such obligations are not transferred, waived, or reduced by the use of open-source software. + +## 3. No Affiliation and Allocation of Responsibility + +Any third-party instance, commercial service, paid plan, user solicitation, content processing, data processing, account usage, API call, payment settlement, customer support, or promotional activity is independently carried out by the corresponding deploying, operating, or controlling party. The open-source nature of this project, code contributions, issue discussions, documentation maintenance, version releases, bug fixes, community communications, or general technical explanations do not create participation in, authorization of, approval of, warranty for, joint operation, agency, partnership, employment, authorized operation, joint control, revenue sharing, joint tort, or any other joint-and-several liability relationship between the open-source project, copyright holders, contributors, or maintainers and such activities. + +The acknowledging party must not use the project name, marks, documentation, screenshots, community content, or open-source repository information to state or imply that its third-party instance, commercial service, paid plan, or operation is participated in, authorized, approved, warranted, or endorsed by the open-source project, copyright holders, contributors, maintainers, or community. + +The acknowledging party is independently responsible for consequences arising from its deployment, configuration, operation, promotion, charging, user-behavior management, content processing, data processing, account usage, API calls, or violations of laws, regulations, regulatory requirements, contractual obligations, or upstream rules. + +Any mandatory liability that cannot be excluded or limited by agreement shall be handled according to applicable law. Such statutory exception does not constitute participation in, authorization of, approval of, warranty for, or endorsement of any third-party deployment, operation, or commercial activity. + +## 4. Compliance Commitments + +By continuing to use console functions, the acknowledging party makes the following commitments: + +1. It has independently reviewed and will continuously comply with the terms of service, acceptable use policies, supported countries and regions, account/API key rules, commercial-use requirements, resale restrictions, risk-control requirements, and technical restrictions of OpenAI, Anthropic, Google, and any other upstream service providers. +2. It will not use this project to bypass, or assist others in bypassing, upstream regional restrictions, access restrictions, account restrictions, risk controls, billing restrictions, identity verification, usage limits, or terms of service. +3. It will not provide API relay, model-call resale, account quota distribution, shared subscriptions, paid calls, top-up/payment agency, or similar services to the public or an indefinite group of users unless all necessary authorizations, qualifications, filings, permits, assessments, or contractual arrangements have been obtained. +4. If it provides generative AI services, deep synthesis services, algorithm-related services, API relay, paid calls, or other potentially regulated services within Mainland China or to the Mainland China public, it will independently complete all potentially applicable obligations regarding internet information services, generative AI services, deep synthesis, algorithm filing, security assessment, cybersecurity, data security, personal information protection, content safety, payment settlement, taxes, and upstream authorization. +5. It will maintain user management, access control, content review, abuse handling, log retention, privacy protection, data deletion, complaint handling, emergency takedown, and security incident response mechanisms appropriate to the scale and risk of its business. +6. It will not make any statement, commitment, marketing representation, or warranty to any user, customer, partner, channel, regulator, or third party that conflicts with Section 3 of this document. +7. It will be independently responsible for consequences arising from its deployment, operation, promotion, charging, user-behavior management, content processing, data processing, account usage, API calls, or violations of laws, regulations, regulatory requirements, contractual obligations, or upstream rules. + +## 5. Risk and Responsibility Notice + +Using Sub2API for public API services, commercial relay, quota distribution, team sharing, paid calls, or similar purposes may involve risks relating to terms of service, contractual breach, data protection, content safety, consumer protection, payment settlement, taxes, export controls, sanctions compliance, cybersecurity, industry access, and administrative regulation. Requirements vary by jurisdiction and business model and may change over time. + +The mandatory notice, document link, exact-phrase acknowledgment, and local acknowledgment record in the console are intended to provide clear, conspicuous, and reproducible notice of deployment and operation risks, confirm that the console user has read the current version of this document, and create a clear responsibility-separation record between the open-source project, copyright holders, contributors, maintainers and any third-party deploying, operating, or controlling party. + +## 6. Electronic Acknowledgment + +By continuing to use the console, opening the document link, reading this document, and typing the required confirmation phrase exactly as displayed, the acknowledging party electronically confirms that it has read, understood, and agreed to this document, and agrees that the system may record necessary evidence including the acknowledged version, acknowledgment time, console account identifier, IP address, and User-Agent. diff --git a/docs/legal/admin-compliance.zh.md b/docs/legal/admin-compliance.zh.md new file mode 100644 index 0000000000..9e0c580de9 --- /dev/null +++ b/docs/legal/admin-compliance.zh.md @@ -0,0 +1,49 @@ +# Sub2API 部署与运营合规承诺 + +版本:v2026.06.10 + +本文件适用于部署、配置、管理、运营或实际控制 Sub2API 实例的个人、组织及其授权代表。继续访问或使用控制台功能前,确认主体应完整阅读、理解并接受本文件。 + +## 一、适用范围 + +Sub2API 是开源软件。任何基于 Sub2API 进行的自部署、二次开发、托管运行、对外服务、商业化使用、用户管理、内容处理、数据处理、支付结算、客户支持及上游账号或接口使用行为,均由相应实例的部署、运营或控制主体自行负责。 + +本文件不替代开源许可证、上游服务条款、用户协议、隐私政策、数据处理协议、商业合同、监管备案、行政许可、安全评估或其他依法应当具备的文件、手续或义务。 + +## 二、主体责任 + +确认主体应自行评估并持续遵守其所在地、服务器所在地、目标用户所在地、业务实际开展地以及上游服务提供方所在地可能适用的法律法规、监管要求、行业规范、合同约定和平台规则。 + +确认主体应确保其已具备部署和运营相关实例所需的授权、资质、备案、许可、评估、合同、风控能力、内容安全能力、数据保护能力、投诉处理机制和应急处置能力。相关义务不得因使用开源软件而转移、免除或降低。 + +## 三、非关联关系与责任隔离 + +任何第三方实例、商业服务、收费套餐、用户招揽、内容处理、数据处理、账号使用、接口调用、支付结算、客户支持或推广活动,均由相应部署、运营或控制主体独立实施,并不因本项目开源、代码贡献、议题讨论、文档维护、版本发布、缺陷修复、社区交流或一般性技术说明而形成开源项目、著作权人、贡献者或维护者对该等活动的参与、授权、认可、担保、共同经营、代理、合伙、雇佣、授权运营、共同控制、收益分配、共同侵权或其他连带责任关系。 + +确认主体不得以项目名称、标识、文档、截图、社区内容或开源仓库信息明示或暗示其第三方实例、商业服务、收费套餐或运营活动获得开源项目、著作权人、贡献者、维护者或社区的参与、授权、认可、担保或背书。 + +确认主体应独立承担因其部署、配置、运营、推广、收费、用户行为管理、内容处理、数据处理、账号使用、接口调用及违反法律法规、监管要求、合同约定或上游规则所产生的相关后果。 + +依法不得由协议排除或限制的强制性责任,依相关法律规定处理;该等法定例外不构成对任何第三方部署、运营或商业活动的参与、授权、认可、担保或背书。 + +## 四、合规承诺 + +确认主体在继续使用控制台功能时,作出以下承诺: + +1. 已独立审阅并将持续遵守 OpenAI、Anthropic、Google 及其他上游服务提供方的服务条款、可接受使用政策、支持国家和地区、账号/API Key 使用规则、商业使用要求、转售限制、风控要求和技术限制。 +2. 不利用本项目规避或协助他人规避上游服务的地区限制、访问限制、账号限制、风控限制、计费限制、身份验证、使用限制或服务条款。 +3. 不在缺乏必要授权、资质、备案、许可、评估或合同安排的情况下,向公众或不特定对象提供 API 中转、模型调用转售、账号额度分发、共享订阅、付费调用、代充代付或其他类似服务。 +4. 如在中国大陆境内或面向中国大陆公众提供生成式人工智能服务、深度合成服务、算法相关服务、API 中转、付费调用或其他可能受监管服务,将自行完成可能适用的互联网信息服务、生成式人工智能服务、深度合成、算法备案、安全评估、网络安全、数据安全、个人信息保护、内容安全、支付结算、税务及上游授权等义务。 +5. 建立与业务规模和风险相匹配的用户管理、访问控制、内容审核、滥用处理、日志留存、隐私保护、数据删除、投诉处理、应急下线和安全事件响应机制。 +6. 不向任何用户、客户、合作方、渠道方、监管机构或第三方作出与本文件第三条相冲突的陈述、承诺、宣传或保证。 +7. 对其部署、运营、推广、收费、用户行为管理、内容处理、数据处理、账号使用、接口调用及违反法律法规、监管要求、合同约定或上游规则所产生的后果独立承担责任。 + +## 五、风险与责任提示 + +将 Sub2API 用于公开 API 服务、商业中转、额度分发、团队共享、付费调用或类似用途,可能涉及服务条款、合同违约、数据保护、内容安全、消费者权益、支付结算、税务、出口管制、制裁合规、网络安全、行业准入及行政监管等风险。不同司法辖区和业务场景的要求可能不同,并可能随时间变化。 + +控制台中的强制提示、协议链接、逐字输入确认和本地确认记录,旨在以清晰、显著、可留痕的方式提示部署与运营风险,确认控制台使用者已阅读当前版本文件,并在开源项目、著作权人、贡献者、维护者与第三方部署、运营或控制主体之间形成明确的责任隔离记录。 + +## 六、电子确认 + +确认主体通过继续使用控制台、打开协议链接、阅读本文件并按页面要求逐字输入确认短语,即表示其以电子方式确认已阅读、理解并同意本文件,并同意系统记录确认版本、确认时间、控制台账户标识、IP 地址和 User-Agent 等必要留痕信息。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7485aa1afc..ece3bf7680 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,9 +3,10 @@ import { RouterView, useRouter, useRoute } from 'vue-router' import { onMounted, onBeforeUnmount, watch } from 'vue' import Toast from '@/components/common/Toast.vue' import NavigationProgress from '@/components/common/NavigationProgress.vue' +import AdminComplianceDialog from '@/components/admin/AdminComplianceDialog.vue' import { resolveDocumentTitle } from '@/router/title' import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue' -import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores' +import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore } from '@/stores' import { getSetupStatus } from '@/api/setup' const router = useRouter() @@ -14,6 +15,7 @@ const appStore = useAppStore() const authStore = useAuthStore() const subscriptionStore = useSubscriptionStore() const announcementStore = useAnnouncementStore() +const adminComplianceStore = useAdminComplianceStore() /** * Update favicon dynamically @@ -49,10 +51,21 @@ function onVisibilityChange() { } } +function onAdminComplianceRequired(event: Event) { + const detail = (event as CustomEvent>).detail || {} + adminComplianceStore.requireAcknowledgement(detail) +} + watch( () => authStore.isAuthenticated, (isAuthenticated, oldValue) => { if (isAuthenticated) { + if (authStore.isAdmin) { + adminComplianceStore.fetchStatus().catch((error) => { + console.error('Failed to fetch admin compliance status:', error) + }) + } + // User logged in: preload subscriptions and start polling subscriptionStore.fetchActiveSubscriptions().catch((error) => { console.error('Failed to preload subscriptions:', error) @@ -74,6 +87,7 @@ watch( // User logged out: clear data and stop polling subscriptionStore.clear() announcementStore.reset() + adminComplianceStore.reset() document.removeEventListener('visibilitychange', onVisibilityChange) } }, @@ -89,9 +103,12 @@ router.afterEach(() => { onBeforeUnmount(() => { document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('admin-compliance-required', onAdminComplianceRequired) }) onMounted(async () => { + window.addEventListener('admin-compliance-required', onAdminComplianceRequired) + // Check if setup is needed try { const status = await getSetupStatus() @@ -116,4 +133,5 @@ onMounted(async () => { + diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index a46c39eb46..8745df0292 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -143,6 +143,53 @@ describe('API Client', () => { }) ) }) + + it('部署与运营合规未确认时广播事件且保留登录态', async () => { + localStorage.setItem('auth_token', 'admin-token') + const listener = vi.fn() + window.addEventListener('admin-compliance-required', listener) + + const adapter = vi.fn().mockRejectedValue({ + response: { + status: 423, + data: { + code: 'ADMIN_COMPLIANCE_ACK_REQUIRED', + message: 'administrator compliance acknowledgement is required', + metadata: { + version: 'v2026.06.10', + document_path_zh: 'docs/legal/admin-compliance.zh.md', + document_path_en: 'docs/legal/admin-compliance.en.md', + }, + }, + }, + config: { + url: '/admin/users', + headers: { Authorization: 'Bearer admin-token' }, + }, + code: 'ERR_BAD_REQUEST', + }) + apiClient.defaults.adapter = adapter + + await expect(apiClient.get('/admin/users')).rejects.toEqual( + expect.objectContaining({ + status: 423, + code: 'ADMIN_COMPLIANCE_ACK_REQUIRED', + metadata: expect.objectContaining({ + version: 'v2026.06.10', + }), + }) + ) + + expect(listener).toHaveBeenCalledTimes(1) + expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual( + expect.objectContaining({ + version: 'v2026.06.10', + }) + ) + expect(localStorage.getItem('auth_token')).toBe('admin-token') + + window.removeEventListener('admin-compliance-required', listener) + }) }) // --- 401 Token 刷新 --- diff --git a/frontend/src/api/admin/compliance.ts b/frontend/src/api/admin/compliance.ts new file mode 100644 index 0000000000..0e0c89e918 --- /dev/null +++ b/frontend/src/api/admin/compliance.ts @@ -0,0 +1,42 @@ +import { apiClient } from '@/api/client' + +export interface AdminComplianceAcknowledgement { + version: string + document_zh: string + document_en: string + admin_user_id: number + ip_address?: string + user_agent?: string + accepted_at: string +} + +export interface AdminComplianceStatus { + required: boolean + version: string + document_path_zh: string + document_path_en: string + document_url_zh: string + document_url_en: string + ack_phrase_zh: string + ack_phrase_en: string + acknowledgement?: AdminComplianceAcknowledgement +} + +export interface AcceptAdminComplianceRequest { + phrase: string + language: string +} + +export const adminComplianceAPI = { + async getStatus(): Promise { + const { data } = await apiClient.get('/admin/compliance') + return data + }, + + async accept(payload: AcceptAdminComplianceRequest): Promise { + const { data } = await apiClient.post('/admin/compliance/accept', payload) + return data + } +} + +export default adminComplianceAPI diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 384e3796da..176498a287 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -31,6 +31,7 @@ import channelMonitorTemplateAPI from './channelMonitorTemplate' import adminPaymentAPI from './payment' import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' +import adminComplianceAPI from './compliance' /** * Unified admin API object for convenient access @@ -63,7 +64,8 @@ export const adminAPI = { channelMonitorTemplate: channelMonitorTemplateAPI, payment: adminPaymentAPI, affiliates: affiliatesAPI, - riskControl: riskControlAPI + riskControl: riskControlAPI, + compliance: adminComplianceAPI } export { @@ -94,7 +96,8 @@ export { channelMonitorTemplateAPI, adminPaymentAPI, affiliatesAPI, - riskControlAPI + riskControlAPI, + adminComplianceAPI } export default adminAPI diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 54ea452009..1ba3c0676a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -148,6 +148,23 @@ apiClient.interceptors.response.use( }) } + if (status === 423 && apiData.code === 'ADMIN_COMPLIANCE_ACK_REQUIRED') { + try { + window.dispatchEvent(new CustomEvent('admin-compliance-required', { + detail: apiData.metadata || {} + })) + } catch { + // ignore event failures + } + + return Promise.reject({ + status, + code: apiData.code, + message: apiData.message || error.message, + metadata: apiData.metadata, + }) + } + // 401: Try to refresh the token if we have a refresh token // This handles TOKEN_EXPIRED, INVALID_TOKEN, TOKEN_REVOKED, etc. if (status === 401 && !originalRequest._retry) { diff --git a/frontend/src/components/admin/AdminComplianceDialog.vue b/frontend/src/components/admin/AdminComplianceDialog.vue new file mode 100644 index 0000000000..998202044f --- /dev/null +++ b/frontend/src/components/admin/AdminComplianceDialog.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index 93e4ba3676..6d9a08caa2 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -18,6 +18,7 @@ {{ title }}