Add admin compliance acknowledgement gate

This commit is contained in:
shaw
2026-06-10 14:16:51 +08:00
parent c32e29bab0
commit 0acf00c4a1
29 changed files with 1173 additions and 15 deletions
+2
View File
@@ -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/
+2 -1
View File
@@ -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)
@@ -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)
}
+1
View File
@@ -36,6 +36,7 @@ type AdminHandlers struct {
ContentModeration *admin.ContentModerationHandler
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
Compliance *admin.ComplianceHandler
}
// Handlers contains all HTTP handlers
+1
View File
@@ -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)
}
+3
View File
@@ -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,
@@ -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/")
}
@@ -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())
}
+1 -1
View File
@@ -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)
+14
View File
@@ -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")
{
@@ -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)
@@ -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)
}
@@ -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)
}
+49
View File
@@ -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.
+49
View File
@@ -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 等必要留痕信息。
+19 -1
View File
@@ -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<Record<string, string>>).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 () => {
<RouterView />
<Toast />
<AnnouncementPopup />
<AdminComplianceDialog />
</template>
+47
View File
@@ -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 刷新 ---
+42
View File
@@ -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<AdminComplianceStatus> {
const { data } = await apiClient.get<AdminComplianceStatus>('/admin/compliance')
return data
},
async accept(payload: AcceptAdminComplianceRequest): Promise<AdminComplianceStatus> {
const { data } = await apiClient.post<AdminComplianceStatus>('/admin/compliance/accept', payload)
return data
}
}
export default adminComplianceAPI
+5 -2
View File
@@ -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
+17
View File
@@ -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) {
@@ -0,0 +1,225 @@
<template>
<BaseDialog
:show="visible"
:title="t('adminCompliance.title')"
width="wide"
:close-on-escape="false"
:close-on-click-outside="false"
:show-close-button="false"
:z-index="80"
@close="noop"
>
<div class="space-y-5">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
<div class="flex gap-3">
<Icon name="exclamationTriangle" size="md" class="mt-0.5 flex-shrink-0" />
<div class="space-y-2">
<p class="font-semibold">{{ t('adminCompliance.blockingNotice') }}</p>
<p class="leading-6">{{ t('adminCompliance.riskNotice') }}</p>
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_240px]">
<section class="min-h-[320px] max-h-[46vh] overflow-y-auto rounded-lg border border-gray-200 bg-white p-5 dark:border-dark-700 dark:bg-dark-900">
<div class="legal-document-content" v-html="renderedDocument"></div>
</section>
<aside class="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm dark:border-dark-700 dark:bg-dark-900/60">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">
{{ t('adminCompliance.version') }}
</p>
<p class="mt-1 break-all font-mono text-gray-900 dark:text-white">
{{ complianceStore.status?.version || 'v2026.06.10' }}
</p>
</div>
<a
:href="documentUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-primary-600 underline underline-offset-4 hover:text-primary-700 dark:text-primary-300 dark:hover:text-primary-200"
>
<Icon name="externalLink" size="sm" />
{{ t('adminCompliance.openDocument') }}
</a>
<p class="leading-6 text-gray-600 dark:text-dark-300">
{{ t('adminCompliance.documentSource') }}
</p>
</aside>
</div>
<div class="space-y-3">
<label for="admin-compliance-phrase" class="block text-sm font-semibold text-gray-900 dark:text-white">
{{ t('adminCompliance.inputLabel') }}
</label>
<div class="rounded-lg bg-gray-100 px-3 py-2 font-mono text-sm text-gray-900 dark:bg-dark-800 dark:text-dark-100">
{{ expectedPhrase }}
</div>
<Input
id="admin-compliance-phrase"
v-model="typedPhrase"
:placeholder="t('adminCompliance.inputPlaceholder')"
autocomplete="off"
:disabled="complianceStore.submitting"
:error="inputError"
@enter="submit"
/>
</div>
<p class="text-xs leading-5 text-gray-500 dark:text-dark-400">
{{ t('adminCompliance.legalNote') }}
</p>
</div>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
class="btn btn-secondary"
:disabled="complianceStore.submitting"
@click="logout"
>
{{ t('adminCompliance.logout') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!canSubmit || complianceStore.submitting"
@click="submit"
>
<span v-if="complianceStore.submitting">{{ t('common.submitting') }}</span>
<span v-else>{{ t('adminCompliance.accept') }}</span>
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Input from '@/components/common/Input.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAdminComplianceStore, useAppStore, useAuthStore } from '@/stores'
import { getLocale } from '@/i18n'
import zhDocument from '../../../../docs/legal/admin-compliance.zh.md?raw'
import enDocument from '../../../../docs/legal/admin-compliance.en.md?raw'
const { t } = useI18n()
const complianceStore = useAdminComplianceStore()
const authStore = useAuthStore()
const appStore = useAppStore()
const typedPhrase = ref('')
const attemptedSubmit = ref(false)
marked.setOptions({
breaks: true,
gfm: true,
})
const visible = computed(() => authStore.isAuthenticated && authStore.isAdmin && complianceStore.shouldShow)
const expectedPhrase = computed(() => complianceStore.expectedPhrase)
const canSubmit = computed(() => typedPhrase.value.trim() === expectedPhrase.value)
const currentDocument = computed(() => getLocale() === 'zh' ? zhDocument : enDocument)
const documentUrl = computed(() => {
if (getLocale() === 'zh') {
return complianceStore.status?.document_url_zh || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.zh.md'
}
return complianceStore.status?.document_url_en || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.en.md'
})
const inputError = computed(() => {
if (!attemptedSubmit.value || canSubmit.value) {
return ''
}
return t('adminCompliance.inputMismatch')
})
const renderedDocument = computed(() => {
const html = marked.parse(currentDocument.value) as string
return DOMPurify.sanitize(html)
})
watch(expectedPhrase, () => {
typedPhrase.value = ''
attemptedSubmit.value = false
})
watch(visible, (isVisible) => {
if (isVisible) {
typedPhrase.value = ''
attemptedSubmit.value = false
}
})
function noop(): void {
// 强制确认弹窗不允许通过关闭按钮绕过。
}
async function submit(): Promise<void> {
attemptedSubmit.value = true
if (!canSubmit.value) {
return
}
try {
const status = await complianceStore.accept(typedPhrase.value.trim())
if (!status.required) {
appStore.showSuccess(t('adminCompliance.accepted'))
typedPhrase.value = ''
attemptedSubmit.value = false
}
} catch (error) {
const message = (error as { message?: string })?.message || t('adminCompliance.acceptFailed')
appStore.showError(message)
}
}
async function logout(): Promise<void> {
await authStore.logout()
window.location.href = '/login'
}
</script>
<style scoped>
.legal-document-content {
line-height: 1.75;
overflow-wrap: anywhere;
color: inherit;
}
.legal-document-content :deep(h1) {
@apply mb-4 text-2xl font-bold text-gray-950 dark:text-white;
}
.legal-document-content :deep(h2) {
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
}
.legal-document-content :deep(p) {
@apply mb-4 text-sm text-gray-700 dark:text-dark-200;
}
.legal-document-content :deep(ul),
.legal-document-content :deep(ol) {
@apply mb-4 pl-6 text-sm text-gray-700 dark:text-dark-200;
}
.legal-document-content :deep(ul) {
@apply list-disc;
}
.legal-document-content :deep(ol) {
@apply list-decimal;
}
.legal-document-content :deep(li) {
@apply mb-1;
}
.legal-document-content :deep(strong) {
@apply font-semibold text-gray-950 dark:text-white;
}
</style>
@@ -18,6 +18,7 @@
{{ title }}
</h3>
<button
v-if="showCloseButton"
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
@@ -61,6 +62,7 @@ interface Props {
width?: DialogWidth
closeOnEscape?: boolean
closeOnClickOutside?: boolean
showCloseButton?: boolean
zIndex?: number
}
@@ -72,6 +74,7 @@ const props = withDefaults(defineProps<Props>(), {
width: 'normal',
closeOnEscape: true,
closeOnClickOutside: false,
showCloseButton: true,
zIndex: 50
})
+28
View File
@@ -344,6 +344,34 @@ export default {
}
},
adminCompliance: {
title: 'Deployment and Operation Compliance Acknowledgment',
blockingNotice: 'Deployment and operation compliance acknowledgment is required before continuing to use the console.',
riskNotice: 'This acknowledgment provides clear, conspicuous, and reproducible notice of compliance obligations and operation risks for self-hosted instances.',
version: 'Document Version',
openDocument: 'Open the GitHub document',
documentSource: 'The agreement text comes from Markdown files in this project repository. When the agreement content changes, the document version must be incremented; acknowledgments of older versions become invalid and console users must acknowledge again.',
inputLabel: 'Type the following confirmation phrase exactly',
inputPlaceholder: 'Type the confirmation phrase to continue',
inputMismatch: 'The confirmation phrase does not match. Type the displayed text exactly.',
legalNote: 'This acknowledgment defines the no-affiliation relationship and responsibility boundary between self-hosted instances and the open-source project, copyright holders, contributors, and maintainers. The party that deploys, operates, or controls the relevant instance remains independently responsible for its applicable obligations.',
logout: 'Log out',
accept: 'Acknowledge and Continue',
accepted: 'Compliance acknowledgment recorded',
acceptFailed: 'Failed to submit acknowledgment'
},
legal: {
loadFailed: 'Failed to load document',
retryLater: 'Refresh the page and try again later.',
notFound: 'Document not found',
notFoundDescription: 'This legal document does not exist or has been removed by an administrator.',
updatedAt: 'Updated: {date}',
empty: 'No content',
loginAgreement: 'Login Agreement',
adminCompliance: 'Deployment and Operation Compliance Commitment'
},
// Navigation
nav: {
dashboard: 'Dashboard',
+28
View File
@@ -344,6 +344,34 @@ export default {
}
},
adminCompliance: {
title: '部署与运营合规确认',
blockingNotice: '继续使用控制台前,须完成部署与运营合规确认。',
riskNotice: '本确认用于以清晰、显著、可留痕的方式提示自部署实例的合规义务与运营风险。',
version: '协议版本',
openDocument: '在 GitHub 查看协议文件',
documentSource: '协议正文来自本项目仓库中的 Markdown 文件。修改协议内容时必须同步递增协议版本;已确认的旧版本将失效,控制台使用者须重新确认。',
inputLabel: '请逐字输入以下确认短语',
inputPlaceholder: '输入确认短语以继续',
inputMismatch: '确认短语不匹配,请逐字输入提示内容。',
legalNote: '本确认用于明确自部署实例与开源项目、著作权人、贡献者及维护者之间的非关联关系和责任边界;部署、运营或控制相关实例的主体应独立承担其适用义务。',
logout: '退出登录',
accept: '确认并继续',
accepted: '合规确认已记录',
acceptFailed: '提交确认失败'
},
legal: {
loadFailed: '文档加载失败',
retryLater: '请稍后刷新页面重试。',
notFound: '文档不存在',
notFoundDescription: '当前条款文档不存在或已被管理员移除。',
updatedAt: '更新日期:{date}',
empty: '暂无正文内容',
loginAgreement: '登录条款',
adminCompliance: '部署与运营合规承诺'
},
// Navigation
nav: {
dashboard: '仪表盘',
+16 -1
View File
@@ -7,6 +7,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import { useAdminComplianceStore } from '@/stores/adminCompliance'
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
import { getSetupStatus } from '@/api/setup'
@@ -45,7 +46,7 @@ const routes: RouteRecordRaw[] = [
meta: {
requiresAuth: false,
title: 'Login',
titleKey: 'common.login'
titleKey: 'home.login'
}
},
{
@@ -807,6 +808,20 @@ router.beforeEach(async (to, _from, next) => {
return
}
if (requiresAdmin && authStore.isAdmin) {
const adminComplianceStore = useAdminComplianceStore()
if (!adminComplianceStore.initialized) {
try {
await adminComplianceStore.fetchStatus()
} catch (error) {
const err = error as { status?: number; code?: string; metadata?: Record<string, string> }
if (err.status === 423 && err.code === 'ADMIN_COMPLIANCE_ACK_REQUIRED') {
adminComplianceStore.requireAcknowledgement(err.metadata)
}
}
}
}
// Check payment requirement (internal payment system only)
if (to.meta.requiresPayment) {
+91
View File
@@ -0,0 +1,91 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import adminComplianceAPI, { type AdminComplianceStatus } from '@/api/admin/compliance'
import { getLocale } from '@/i18n'
const FALLBACK_ZH_PHRASE = '我已阅读、理解并同意 Sub2API 部署与运营合规承诺'
const FALLBACK_EN_PHRASE = 'I have read, understood, and agree to the Sub2API Deployment and Operation Compliance Commitment'
export const useAdminComplianceStore = defineStore('adminCompliance', () => {
const status = ref<AdminComplianceStatus | null>(null)
const loading = ref(false)
const submitting = ref(false)
const initialized = ref(false)
const forceVisible = ref(false)
const required = computed(() => status.value?.required === true)
const shouldShow = computed(() => required.value || forceVisible.value)
const currentLocale = computed(() => getLocale())
const expectedPhrase = computed(() => {
if (currentLocale.value === 'zh') {
return status.value?.ack_phrase_zh || FALLBACK_ZH_PHRASE
}
return status.value?.ack_phrase_en || FALLBACK_EN_PHRASE
})
async function fetchStatus(): Promise<AdminComplianceStatus> {
loading.value = true
try {
const nextStatus = await adminComplianceAPI.getStatus()
status.value = nextStatus
initialized.value = true
forceVisible.value = nextStatus.required
return nextStatus
} finally {
loading.value = false
}
}
async function accept(phrase: string): Promise<AdminComplianceStatus> {
submitting.value = true
try {
const nextStatus = await adminComplianceAPI.accept({
phrase,
language: currentLocale.value
})
status.value = nextStatus
forceVisible.value = nextStatus.required
return nextStatus
} finally {
submitting.value = false
}
}
function requireAcknowledgement(partialStatus?: Partial<AdminComplianceStatus>): void {
status.value = {
required: true,
version: partialStatus?.version || status.value?.version || 'v2026.06.10',
document_path_zh: partialStatus?.document_path_zh || status.value?.document_path_zh || 'docs/legal/admin-compliance.zh.md',
document_path_en: partialStatus?.document_path_en || status.value?.document_path_en || 'docs/legal/admin-compliance.en.md',
document_url_zh: partialStatus?.document_url_zh || status.value?.document_url_zh || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.zh.md',
document_url_en: partialStatus?.document_url_en || status.value?.document_url_en || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.en.md',
ack_phrase_zh: partialStatus?.ack_phrase_zh || status.value?.ack_phrase_zh || FALLBACK_ZH_PHRASE,
ack_phrase_en: partialStatus?.ack_phrase_en || status.value?.ack_phrase_en || FALLBACK_EN_PHRASE,
acknowledgement: status.value?.acknowledgement
}
initialized.value = true
forceVisible.value = true
}
function reset(): void {
status.value = null
loading.value = false
submitting.value = false
initialized.value = false
forceVisible.value = false
}
return {
status,
loading,
submitting,
initialized,
required,
shouldShow,
expectedPhrase,
fetchStatus,
accept,
requireAcknowledgement,
reset
}
})
+1
View File
@@ -10,6 +10,7 @@ export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding'
export { useAnnouncementStore } from './announcements'
export { usePaymentStore } from './payment'
export { useAdminComplianceStore } from './adminCompliance'
// Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
@@ -14,7 +14,7 @@
to="/login"
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
>
登录
{{ t('home.login') }}
</RouterLink>
</div>
</header>
@@ -28,8 +28,8 @@
v-else-if="loadError"
class="rounded-lg border border-red-200 bg-red-50 p-6 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200"
>
<h1 class="text-lg font-semibold">文档加载失败</h1>
<p class="mt-2 text-sm">请稍后刷新页面重试。</p>
<h1 class="text-lg font-semibold">{{ t('legal.loadFailed') }}</h1>
<p class="mt-2 text-sm">{{ t('legal.retryLater') }}</p>
</section>
<section
@@ -41,9 +41,9 @@
<Icon name="document" size="sm" />
</span>
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">文档不存在</h1>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('legal.notFound') }}</h1>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
当前条款文档不存在或已被管理员移除。
{{ t('legal.notFoundDescription') }}
</p>
</div>
</div>
@@ -56,12 +56,12 @@
<Icon :name="documentIcon" size="md" />
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-primary-700 dark:text-primary-300">登录条款</p>
<p class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ documentTypeLabel }}</p>
<h1 class="mt-2 break-words text-2xl font-bold tracking-normal text-gray-950 dark:text-white sm:text-3xl">
{{ currentDocument.title }}
</h1>
<p v-if="updatedAt" class="mt-3 text-sm text-gray-500 dark:text-dark-400">
更新日期:{{ updatedAt }}
{{ t('legal.updatedAt', { date: updatedAt }) }}
</p>
</div>
</div>
@@ -76,7 +76,7 @@
v-else
class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-14 text-center text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-900 dark:text-dark-400"
>
暂无正文内容
{{ t('legal.empty') }}
</div>
</article>
</main>
@@ -88,14 +88,19 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { getPublicSettings } from '@/api/auth'
import { getLocale } from '@/i18n'
import { sanitizeUrl } from '@/utils/url'
import type { LoginAgreementDocument, PublicSettings } from '@/types'
import zhAdminCompliance from '../../../../docs/legal/admin-compliance.zh.md?raw'
import enAdminCompliance from '../../../../docs/legal/admin-compliance.en.md?raw'
type LegalDocumentIcon = 'document' | 'shield' | 'globe' | 'cog'
const route = useRoute()
const { t } = useI18n()
const settings = ref<PublicSettings | null>(null)
const loading = ref(true)
const loadError = ref(false)
@@ -106,15 +111,28 @@ marked.setOptions({
})
const documentId = computed(() => String(route.params.documentId || ''))
const isAdminComplianceDocument = computed(() => documentId.value === 'admin-compliance')
const documents = computed(() => settings.value?.login_agreement_documents ?? [])
const siteName = computed(() => settings.value?.site_name || 'Sub2API')
const siteLogo = computed(() => sanitizeUrl(settings.value?.site_logo || '', {
allowRelative: true,
allowDataUrl: true,
}))
const updatedAt = computed(() => settings.value?.login_agreement_updated_at || '')
const updatedAt = computed(() =>
isAdminComplianceDocument.value ? '' : settings.value?.login_agreement_updated_at || ''
)
const documentTypeLabel = computed(() =>
isAdminComplianceDocument.value ? t('legal.adminCompliance') : t('legal.loginAgreement')
)
const currentDocument = computed<LoginAgreementDocument | null>(() => {
if (isAdminComplianceDocument.value) {
return {
id: 'admin-compliance',
title: t('adminCompliance.title'),
content_md: getLocale() === 'zh' ? zhAdminCompliance : enAdminCompliance
}
}
const id = documentId.value
if (!id) {
return null
+5
View File
@@ -14,3 +14,8 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.md?raw' {
const content: string
export default content
}