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
+111 -20
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
@@ -14,18 +15,22 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/stream"
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type PublishJobService struct {
pool *pgxpool.Pool
messaging *rabbitmq.Client
redis *goredis.Client
logger *zap.Logger
cache sharedcache.Cache
pool *pgxpool.Pool
messaging *rabbitmq.Client
redis *goredis.Client
logger *zap.Logger
cache sharedcache.Cache
compliance *tenantcompliance.Service
}
func NewPublishJobService(
@@ -33,12 +38,25 @@ func NewPublishJobService(
messaging *rabbitmq.Client,
redis *goredis.Client,
logger *zap.Logger,
) *PublishJobService {
return NewPublishJobServiceWithConfig(pool, messaging, redis, logger, config.NewStaticProvider(&config.Config{
Compliance: config.ComplianceConfig{Enabled: true},
}))
}
func NewPublishJobServiceWithConfig(
pool *pgxpool.Pool,
messaging *rabbitmq.Client,
redis *goredis.Client,
logger *zap.Logger,
cfg config.Provider,
) *PublishJobService {
return &PublishJobService{
pool: pool,
messaging: messaging,
redis: redis,
logger: logger,
pool: pool,
messaging: messaging,
redis: redis,
logger: logger,
compliance: tenantcompliance.NewService(pool, cfg, logger).WithRabbitMQ(messaging),
}
}
@@ -52,10 +70,12 @@ type CreatePublishJobAccountRequest struct {
}
type CreatePublishJobRequest struct {
Title string `json:"title" binding:"required"`
ContentRef map[string]any `json:"content_ref" binding:"required"`
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at"`
Title string `json:"title" binding:"required"`
ContentRef map[string]any `json:"content_ref" binding:"required"`
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at"`
ArticleVersionID *int64 `json:"article_version_id,omitempty"`
AckRecordID *int64 `json:"ack_record_id,omitempty"`
}
type CreatePublishJobResponse struct {
@@ -110,6 +130,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
targetClientID uuid.UUID
}
targets := make([]publishTarget, 0, len(req.Accounts))
targetPlatformSet := make(map[string]struct{})
for _, accountID := range requestedAccountIDs {
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
@@ -141,17 +162,51 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
accountSeed: accountSeed,
targetClientID: *targetClientID,
})
if platformID := strings.TrimSpace(accountSeed.PlatformID); platformID != "" {
targetPlatformSet[platformID] = struct{}{}
}
}
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, articleID, req.ArticleVersionID)
if err != nil {
return nil, err
}
targetPlatforms := make([]string, 0, len(targetPlatformSet))
for platformID := range targetPlatformSet {
targetPlatforms = append(targetPlatforms, platformID)
}
gate, err := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
TenantID: actor.TenantID,
ArticleID: articleID,
ArticleVersionID: effectiveVersionID,
ActorID: actor.UserID,
TargetPlatforms: targetPlatforms,
AckRecordID: req.AckRecordID,
TriggerSource: "publish_gate",
})
if err != nil {
return nil, compliancePublishError(err, gate)
}
if gate != nil {
switch gate.Decision {
case sharedcompliance.GateDecisionBlock:
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "content compliance blocked this publish request"), gate)
case sharedcompliance.GateDecisionNeedsAck:
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), gate)
}
}
jobID := uuid.New()
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
DesktopID: jobID,
TenantID: actor.TenantID,
WorkspaceID: actor.PrimaryWorkspaceID,
CreatedByUserID: actor.UserID,
Title: req.Title,
ContentRef: contentRefJSON,
ScheduledAt: req.ScheduledAt,
DesktopID: jobID,
TenantID: actor.TenantID,
WorkspaceID: actor.PrimaryWorkspaceID,
CreatedByUserID: actor.UserID,
Title: req.Title,
ContentRef: contentRefJSON,
ScheduledAt: req.ScheduledAt,
ArticleID: &articleID,
ArticleVersionID: &effectiveVersionID,
})
if err != nil {
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
@@ -189,6 +244,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
"account_id": target.account.DesktopID.String(),
"platform": target.account.Platform,
"article_id": articleID,
"article_version_id": effectiveVersionID,
"publish_batch_id": publishBatchID,
"publish_record_id": publishRecordID,
"platform_account_id": target.accountSeed.ID,
@@ -217,6 +273,27 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
taskIDs = append(taskIDs, task.DesktopID.String())
}
if req.AckRecordID != nil {
if gate == nil || gate.Result == nil {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record cannot be consumed without a matching compliance result")
}
consumed, consumeErr := s.compliance.ConsumeAckTx(ctx, tx, *req.AckRecordID, tenantcompliance.ConsumeAckQuery{
TenantID: actor.TenantID,
ArticleID: articleID,
ArticleVersionID: effectiveVersionID,
CheckRecordID: gate.Result.RecordID,
ContentHash: gate.Result.ContentHash,
PolicyFingerprint: gate.Result.PolicyFingerprint,
ConsumedByJobID: jobID,
})
if consumeErr != nil {
return nil, response.ErrInternal(51004, "compliance_ack_lookup_failed", "failed to consume compliance ack")
}
if !consumed {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is expired, consumed, or no longer matches this publish request")
}
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
}
@@ -267,6 +344,20 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
}, nil
}
func compliancePublishError(err error, gate *tenantcompliance.GateOutcome) error {
appErr := response.Normalize(err)
if gate == nil || gate.Result == nil {
return appErr
}
detail, marshalErr := json.Marshal(gate.Result)
if marshalErr != nil {
return appErr
}
copyErr := *appErr
copyErr.Detail = string(detail)
return &copyErr
}
func (s *PublishJobService) RetryByDesktopTask(
ctx context.Context,
client *repository.DesktopClient,