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:
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,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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user