feat(compliance): add content compliance detection across tenant, ops, and clients

Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web
content-safety pages, admin-web editor/publish gate integration, desktop publish
block surfacing, and supporting migrations / shared types / config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:48:14 +08:00
parent 81577b6154
commit 745cdd79cf
73 changed files with 12747 additions and 1892 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,388 @@
package transport
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/ops/app"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type complianceDecisionRequest struct {
Reason string `json:"reason" binding:"required"`
}
type complianceMasterSwitchRequest struct {
Enabled bool `json:"enabled"`
}
type complianceRollbackRequest struct {
Version int `json:"version" binding:"required"`
}
func listComplianceDictionariesHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
data, err := svc.ListDictionaries(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func createComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
var body app.UpsertComplianceDictionaryRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.CreateDictionary(c.Request.Context(), body)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func updateComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body app.UpsertComplianceDictionaryRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.UpdateDictionary(c.Request.Context(), id, body)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func deleteComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
if err := svc.DeleteDictionary(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
}
func listComplianceTermsHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
data, err := svc.ListTerms(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func createComplianceTermHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body app.CreateComplianceDictionaryTermRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.CreateTerm(c.Request.Context(), id, body)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func deleteComplianceTermHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
dictID, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
termID, err := parsePositiveInt64(c.Param("term_id"))
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的词条 ID"))
return
}
if err := svc.DeleteTerm(c.Request.Context(), dictID, termID); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
}
func batchImportComplianceTermsHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body app.BatchImportComplianceTermsRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.BatchImportTerms(c.Request.Context(), id, body)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func publishComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
data, err := svc.PublishDictionary(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func rollbackComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body complianceRollbackRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.RollbackDictionary(c.Request.Context(), id, body.Version)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func getGlobalCompliancePolicyHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
data, err := svc.GetGlobalPolicy(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func updateGlobalCompliancePolicyHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
var body app.UpdateCompliancePolicyRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.UpdateGlobalPolicy(c.Request.Context(), body)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func setGlobalComplianceMasterSwitchHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
var body complianceMasterSwitchRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
data, err := svc.SetGlobalMasterSwitch(c.Request.Context(), body.Enabled)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func listComplianceManualReviewsHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", c.DefaultQuery("size", "50")))
var tenantID *int64
if raw := c.Query("tenant_id"); raw != "" {
id, err := parsePositiveInt64(raw)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的租户 ID"))
return
}
tenantID = &id
}
data, err := svc.ListManualReviews(c.Request.Context(), app.ComplianceManualReviewListRequest{
Status: c.Query("status"),
TenantID: tenantID,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func getComplianceManualReviewHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
data, err := svc.GetManualReview(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func approveComplianceManualReviewHandler(svc *app.ComplianceService) gin.HandlerFunc {
return requireComplianceReviewPermission(decideComplianceManualReviewHandler(svc, true))
}
func rejectComplianceManualReviewHandler(svc *app.ComplianceService) gin.HandlerFunc {
return requireComplianceReviewPermission(decideComplianceManualReviewHandler(svc, false))
}
func decideComplianceManualReviewHandler(svc *app.ComplianceService, approve bool) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body complianceDecisionRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
var data any
if approve {
data, err = svc.ApproveManualReview(c.Request.Context(), id, body.Reason)
} else {
data, err = svc.RejectManualReview(c.Request.Context(), id, body.Reason)
}
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func listComplianceRecordsHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", c.DefaultQuery("size", "50")))
var tenantID *int64
if raw := c.Query("tenant_id"); raw != "" {
id, err := parsePositiveInt64(raw)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的租户 ID"))
return
}
tenantID = &id
}
var articleID *int64
if raw := c.Query("article_id"); raw != "" {
id, err := parsePositiveInt64(raw)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的文章 ID"))
return
}
articleID = &id
}
data, err := svc.ListRecords(c.Request.Context(), app.ComplianceRecordListRequest{
TenantID: tenantID,
ArticleID: articleID,
Decision: c.Query("decision"),
Page: page,
PageSize: pageSize,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func complianceStatsHandler(svc *app.ComplianceService) gin.HandlerFunc {
return func(c *gin.Context) {
data, err := svc.Stats(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
}
func requireComplianceReviewPermission(next gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
actor := actorFromGin(c)
if actor == nil || !strings.EqualFold(strings.TrimSpace(actor.Role), "admin") {
response.Error(c, response.ErrForbidden(40331, "compliance_review_forbidden", "operator is not allowed to review compliance requests"))
return
}
next(c)
}
}
func parsePositiveInt64(raw string) (int64, error) {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
return 0, strconv.ErrSyntax
}
return id, nil
}
+22
View File
@@ -22,6 +22,7 @@ type Deps struct {
KolSubs *app.KolSubscriptionService
Audits *app.AuditService
SiteDomains *app.SiteDomainMappingService
Compliance *app.ComplianceService
}
func RegisterRoutes(d Deps) {
@@ -96,5 +97,26 @@ func RegisterRoutes(d Deps) {
authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains))
authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains))
authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(d.SiteDomains))
compliance := authed.Group("/compliance")
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))
compliance.POST("/dictionaries", createComplianceDictionaryHandler(d.Compliance))
compliance.PUT("/dictionaries/:id", updateComplianceDictionaryHandler(d.Compliance))
compliance.DELETE("/dictionaries/:id", deleteComplianceDictionaryHandler(d.Compliance))
compliance.GET("/dictionaries/:id/terms", listComplianceTermsHandler(d.Compliance))
compliance.POST("/dictionaries/:id/terms", createComplianceTermHandler(d.Compliance))
compliance.POST("/dictionaries/:id/terms:batch-import", batchImportComplianceTermsHandler(d.Compliance))
compliance.DELETE("/dictionaries/:id/terms/:term_id", deleteComplianceTermHandler(d.Compliance))
compliance.POST("/dictionaries/:id/publish", publishComplianceDictionaryHandler(d.Compliance))
compliance.POST("/dictionaries/:id/rollback", rollbackComplianceDictionaryHandler(d.Compliance))
compliance.GET("/policy/global", getGlobalCompliancePolicyHandler(d.Compliance))
compliance.PUT("/policy/global", updateGlobalCompliancePolicyHandler(d.Compliance))
compliance.POST("/policy/global/master-switch", setGlobalComplianceMasterSwitchHandler(d.Compliance))
compliance.GET("/manual-reviews", listComplianceManualReviewsHandler(d.Compliance))
compliance.GET("/manual-reviews/:id", getComplianceManualReviewHandler(d.Compliance))
compliance.POST("/manual-reviews/:id/approve", approveComplianceManualReviewHandler(d.Compliance))
compliance.POST("/manual-reviews/:id/reject", rejectComplianceManualReviewHandler(d.Compliance))
compliance.GET("/stats", complianceStatsHandler(d.Compliance))
compliance.GET("/records", listComplianceRecordsHandler(d.Compliance))
}
}