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))
}
}
+106
View File
@@ -0,0 +1,106 @@
package compliance
import "time"
type GateDecision string
const (
GateDecisionPass GateDecision = "pass"
GateDecisionBlock GateDecision = "block"
GateDecisionNeedsAck GateDecision = "needs_ack"
)
type ManualReviewStatus string
const (
ManualReviewStatusNone ManualReviewStatus = "none"
ManualReviewStatusPending ManualReviewStatus = "pending"
ManualReviewStatusApproved ManualReviewStatus = "approved"
ManualReviewStatusRejected ManualReviewStatus = "rejected"
ManualReviewStatusCancelled ManualReviewStatus = "cancelled"
)
type RuntimeStatus struct {
Enabled bool `json:"enabled"`
ConfigEnabled bool `json:"config_enabled"`
MasterEnabled bool `json:"master_enabled"`
EnforcementMode string `json:"enforcement_mode"`
LLMJudgeEnabled bool `json:"llm_judge_enabled"`
SnapshotReloadInterval string `json:"snapshot_reload_interval"`
PublishGateTimeout string `json:"publish_gate_timeout"`
DictionaryVersion int64 `json:"dictionary_version"`
EnabledDictionaries []string `json:"enabled_dictionaries"`
}
type Violation struct {
ID int64 `json:"id"`
RecordID int64 `json:"record_id,omitempty"`
PlatformCode *string `json:"platform_code,omitempty"`
Source string `json:"source"`
DictionaryCode *string `json:"dictionary_code,omitempty"`
DictionaryName *string `json:"dictionary_name,omitempty"`
TermPattern *string `json:"term_pattern,omitempty"`
MatchedText string `json:"matched_text"`
StartOffset int `json:"start_offset"`
EndOffset int `json:"end_offset"`
DomPath *string `json:"dom_path,omitempty"`
Level string `json:"level"`
Hint *string `json:"hint,omitempty"`
ReferenceLaw *string `json:"reference_law,omitempty"`
}
type CheckResult struct {
RecordID int64 `json:"record_id,omitempty"`
Decision GateDecision `json:"decision"`
Passed bool `json:"passed"`
HighestLevel *string `json:"highest_level,omitempty"`
Violations []Violation `json:"violations"`
HitCount int `json:"hit_count"`
StoredHitCount int `json:"stored_hit_count"`
Truncated bool `json:"truncated"`
EnforcementMode string `json:"enforcement_mode"`
ContentHash string `json:"content_hash"`
PolicyFingerprint string `json:"policy_fingerprint"`
TargetPlatforms []string `json:"target_platforms"`
ManualReviewStatus ManualReviewStatus `json:"manual_review_status"`
ManualReviewID *int64 `json:"manual_review_id,omitempty"`
ManualReviewReason *string `json:"manual_review_reason,omitempty"`
ManualReviewDecidedAt *time.Time `json:"manual_review_decided_at,omitempty"`
CheckedAt time.Time `json:"checked_at"`
DurationMS int `json:"duration_ms"`
}
type AckRecord struct {
ID int64 `json:"id"`
CheckRecordID int64 `json:"check_record_id"`
ArticleID int64 `json:"article_id"`
ArticleVersionID int64 `json:"article_version_id"`
ContentHash string `json:"content_hash"`
PolicyFingerprint string `json:"policy_fingerprint"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
type ManualReview struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ArticleID int64 `json:"article_id"`
ArticleVersionID int64 `json:"article_version_id"`
CheckRecordID *int64 `json:"check_record_id,omitempty"`
Status ManualReviewStatus `json:"status"`
RequestedBy int64 `json:"requested_by"`
RequestedAt time.Time `json:"requested_at"`
RequestNote *string `json:"request_note,omitempty"`
ReviewedBy *int64 `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
DecisionReason *string `json:"decision_reason,omitempty"`
CancelledBy *int64 `json:"cancelled_by,omitempty"`
CancelledAt *time.Time `json:"cancelled_at,omitempty"`
CancelReason *string `json:"cancel_reason,omitempty"`
ViolationSummary []Violation `json:"violation_summary"`
OriginalPlatforms []string `json:"original_target_platforms"`
ArticleTitle *string `json:"article_title,omitempty"`
ArticlePlaintext *string `json:"article_plaintext,omitempty"`
ApplicantName *string `json:"applicant_name,omitempty"`
TenantName *string `json:"tenant_name,omitempty"`
}
+96
View File
@@ -7,6 +7,7 @@ import (
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -34,6 +35,7 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
LLM LLMConfig `mapstructure:"llm"`
Compliance ComplianceConfig `mapstructure:"compliance"`
Retrieval RetrievalConfig `mapstructure:"retrieval"`
Generation GenerationConfig `mapstructure:"generation"`
}
@@ -137,6 +139,12 @@ type RabbitMQConfig struct {
ImageAssetDLX string `mapstructure:"image_asset_dlx"`
ImageAssetDLQ string `mapstructure:"image_asset_dlq"`
ImageAssetDLQRouteKey string `mapstructure:"image_asset_dlq_routing_key"`
ComplianceReviewExchange string `mapstructure:"compliance_review_exchange"`
ComplianceReviewRoutingKey string `mapstructure:"compliance_review_routing_key"`
ComplianceReviewQueue string `mapstructure:"compliance_review_queue"`
ComplianceReviewDLX string `mapstructure:"compliance_review_dlx"`
ComplianceReviewDLQ string `mapstructure:"compliance_review_dlq"`
ComplianceReviewDLQRouteKey string `mapstructure:"compliance_review_dlq_routing_key"`
ConsumerPrefetch int `mapstructure:"consumer_prefetch"`
PublishChannelPoolSize int `mapstructure:"publish_channel_pool_size"`
}
@@ -282,6 +290,16 @@ type LLMConfig struct {
WebSearchLimit int32 `mapstructure:"web_search_limit"`
}
type ComplianceConfig struct {
Enabled bool `mapstructure:"enabled"`
LLMJudgeEnabled bool `mapstructure:"llm_judge_enabled"`
SnapshotReloadInterval time.Duration `mapstructure:"snapshot_reload_interval"`
PublishGateTimeout time.Duration `mapstructure:"publish_gate_timeout"`
ReviewWorkerConcurrency int `mapstructure:"review_worker_concurrency"`
ReviewWorkerTimeout time.Duration `mapstructure:"review_worker_timeout"`
ReviewMaxAttempts int `mapstructure:"review_max_attempts"`
}
type RetrievalConfig struct {
Provider string `mapstructure:"provider"`
BaseURL string `mapstructure:"base_url"`
@@ -356,6 +374,7 @@ func loadWithFiles(configPath string) (*Config, []string, error) {
normalizeMonitoringDispatchConfig(&cfg.MonitoringDispatch)
normalizeMembershipConfig(&cfg.Membership)
normalizeBrandLibraryConfig(&cfg.BrandLibrary)
NormalizeComplianceConfig(&cfg.Compliance)
NormalizeGenerationConfig(&cfg.Generation)
files := []string{configFile}
@@ -432,6 +451,10 @@ func applyConfigDefaults(settings map[string]any) {
if _, ok := cacheSettings["metrics_enabled"]; !ok {
cacheSettings["metrics_enabled"] = true
}
complianceSettings := ensureMapSetting(settings, "compliance")
if _, ok := complianceSettings["enabled"]; !ok {
complianceSettings["enabled"] = true
}
}
func ensureMapSetting(settings map[string]any, key string) map[string]any {
@@ -566,6 +589,27 @@ func NormalizeGenerationConfig(cfg *GenerationConfig) {
}
}
func NormalizeComplianceConfig(cfg *ComplianceConfig) {
if cfg == nil {
return
}
if cfg.SnapshotReloadInterval <= 0 {
cfg.SnapshotReloadInterval = 30 * time.Second
}
if cfg.PublishGateTimeout <= 0 {
cfg.PublishGateTimeout = 800 * time.Millisecond
}
if cfg.ReviewWorkerConcurrency <= 0 {
cfg.ReviewWorkerConcurrency = 1
}
if cfg.ReviewWorkerTimeout <= 0 {
cfg.ReviewWorkerTimeout = 45 * time.Second
}
if cfg.ReviewMaxAttempts <= 0 {
cfg.ReviewMaxAttempts = 3
}
}
func candidateConfigPaths(configPath string, local bool) []string {
trimmed := strings.TrimSpace(configPath)
if trimmed == "" {
@@ -628,6 +672,27 @@ func applyEnvOverrides(cfg *Config) {
if model, ok := lookupNonEmptyEnv("LLM_KNOWLEDGE_URL_MODEL"); ok {
cfg.LLM.KnowledgeURLModel = model
}
if enabled, ok := lookupBoolEnv("COMPLIANCE_ENABLED"); ok {
cfg.Compliance.Enabled = enabled
}
if enabled, ok := lookupBoolEnv("COMPLIANCE_LLM_ENABLED"); ok {
cfg.Compliance.LLMJudgeEnabled = enabled
}
if d, ok := lookupDurationEnv("COMPLIANCE_SNAPSHOT_RELOAD_INTERVAL"); ok {
cfg.Compliance.SnapshotReloadInterval = d
}
if d, ok := lookupDurationEnv("COMPLIANCE_PUBLISH_GATE_TIMEOUT"); ok {
cfg.Compliance.PublishGateTimeout = d
}
if n, ok := lookupIntEnv("COMPLIANCE_REVIEW_WORKER_CONCURRENCY"); ok {
cfg.Compliance.ReviewWorkerConcurrency = n
}
if d, ok := lookupDurationEnv("COMPLIANCE_REVIEW_WORKER_TIMEOUT"); ok {
cfg.Compliance.ReviewWorkerTimeout = d
}
if n, ok := lookupIntEnv("COMPLIANCE_REVIEW_MAX_ATTEMPTS"); ok {
cfg.Compliance.ReviewMaxAttempts = n
}
if apiKey, ok := lookupNonEmptyEnv("QDRANT_API_KEY"); ok {
cfg.Qdrant.APIKey = apiKey
}
@@ -832,6 +897,24 @@ func normalizeRabbitMQConfig(cfg *RabbitMQConfig) {
if strings.TrimSpace(cfg.ImageAssetDLQRouteKey) == "" {
cfg.ImageAssetDLQRouteKey = "image.asset.finalize.dlq"
}
if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" {
cfg.ComplianceReviewExchange = "compliance.review"
}
if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" {
cfg.ComplianceReviewRoutingKey = "compliance.review.run"
}
if strings.TrimSpace(cfg.ComplianceReviewQueue) == "" {
cfg.ComplianceReviewQueue = "compliance.review.run"
}
if strings.TrimSpace(cfg.ComplianceReviewDLX) == "" {
cfg.ComplianceReviewDLX = "compliance.review.dlx"
}
if strings.TrimSpace(cfg.ComplianceReviewDLQ) == "" {
cfg.ComplianceReviewDLQ = "compliance.review.run.dlq"
}
if strings.TrimSpace(cfg.ComplianceReviewDLQRouteKey) == "" {
cfg.ComplianceReviewDLQRouteKey = "compliance.review.run.dlq"
}
if cfg.PublishChannelPoolSize <= 0 {
cfg.PublishChannelPoolSize = 16
}
@@ -1076,6 +1159,19 @@ func lookupDurationEnv(key string) (time.Duration, bool) {
return duration, true
}
func lookupIntEnv(key string) (int, bool) {
value, ok := lookupNonEmptyEnv(key)
if !ok {
return 0, false
}
number, err := strconv.Atoi(value)
if err != nil {
return 0, false
}
return number, true
}
func maxInt(value, fallback int) int {
if value < fallback {
return fallback
@@ -213,6 +213,10 @@ func (c *Client) PublishImageAssetTask(ctx context.Context, body []byte) error {
return c.publish(ctx, c.cfg.ImageAssetExchange, c.cfg.ImageAssetRoutingKey, body)
}
func (c *Client) PublishComplianceReviewTask(ctx context.Context, body []byte) error {
return c.publish(ctx, c.cfg.ComplianceReviewExchange, c.cfg.ComplianceReviewRoutingKey, body)
}
func (c *Client) publish(ctx context.Context, exchange, routingKey string, body []byte) error {
for attempt := 0; attempt < 2; attempt++ {
conn, ch, pooled, err := c.acquirePublishChannel()
@@ -303,6 +307,10 @@ func (c *Client) ConsumeImageAssetTask(consumerName string) (<-chan amqp.Deliver
return c.consume(consumerName, c.cfg.ImageAssetQueue)
}
func (c *Client) ConsumeComplianceReviewTask(consumerName string) (<-chan amqp.Delivery, *amqp.Channel, error) {
return c.consume(consumerName, c.cfg.ComplianceReviewQueue)
}
func (c *Client) consume(consumerName, queue string) (<-chan amqp.Delivery, *amqp.Channel, error) {
for attempt := 0; attempt < 2; attempt++ {
conn, ch, err := c.openChannel()
@@ -741,6 +749,18 @@ func (c *Client) ensureTopology(conn *amqp.Connection) error {
return err
}
if err := c.ensureWorkQueue(
ch,
c.cfg.ComplianceReviewExchange,
c.cfg.ComplianceReviewRoutingKey,
c.cfg.ComplianceReviewQueue,
c.cfg.ComplianceReviewDLX,
c.cfg.ComplianceReviewDLQ,
c.cfg.ComplianceReviewDLQRouteKey,
); err != nil {
return err
}
return nil
}
+108 -98
View File
@@ -35,24 +35,24 @@ var routeDocs = map[string]routeDoc{
"POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"},
// --- Desktop:派单与拉取 ---
"GET /api/desktop/dispatch": {"派单 WebSocket", "桌面客户端通过 WebSocket 长连接订阅派单事件,断线后自动重连。"},
"GET /api/desktop/accounts": {"拉取账号列表(桌面)", "桌面客户端拉取本机绑定/可用的媒体账号,支持增量同步。"},
"GET /api/desktop/content/articles/:id": {"拉取文章正文(桌面)", "桌面客户端在执行发布任务时拉取目标文章的最新内容。"},
"GET /api/desktop/content/assets/:token": {"桌面端访问素材", "桌面客户端使用签名 token 拉取文章中的图片/附件。"},
"GET /api/desktop/publish-tasks": {"拉取发布任务(桌面)", "桌面客户端分页查询当前账号需要执行的发布任务。"},
"GET /api/desktop/dispatch": {"派单 WebSocket", "桌面客户端通过 WebSocket 长连接订阅派单事件,断线后自动重连。"},
"GET /api/desktop/accounts": {"拉取账号列表(桌面)", "桌面客户端拉取本机绑定/可用的媒体账号,支持增量同步。"},
"GET /api/desktop/content/articles/:id": {"拉取文章正文(桌面)", "桌面客户端在执行发布任务时拉取目标文章的最新内容。"},
"GET /api/desktop/content/assets/:token": {"桌面端访问素材", "桌面客户端使用签名 token 拉取文章中的图片/附件。"},
"GET /api/desktop/publish-tasks": {"拉取发布任务(桌面)", "桌面客户端分页查询当前账号需要执行的发布任务。"},
"POST /api/desktop/accounts/health-reports": {"上报账号健康状态", "桌面客户端定期上报媒体账号的登录态、风控信号等。"},
// --- Desktop:账号增删改 ---
"POST /api/desktop/accounts": {"上报/绑定媒体账号", "桌面客户端登录媒体平台后,将账号信息上报到云端。"},
"PATCH /api/desktop/accounts/:id": {"更新媒体账号", "桌面客户端更新已绑定的媒体账号字段(昵称、头像、Cookie 等)。"},
"DELETE /api/desktop/accounts/:id": {"删除媒体账号(桌面)", "桌面客户端解绑账号,仅清理本地侧记录。"},
"POST /api/desktop/accounts": {"上报/绑定媒体账号", "桌面客户端登录媒体平台后,将账号信息上报到云端。"},
"PATCH /api/desktop/accounts/:id": {"更新媒体账号", "桌面客户端更新已绑定的媒体账号字段(昵称、头像、Cookie 等)。"},
"DELETE /api/desktop/accounts/:id": {"删除媒体账号(桌面)", "桌面客户端解绑账号,仅清理本地侧记录。"},
// --- Desktop:发布任务执行 ---
"POST /api/desktop/tasks/lease": {"领取发布任务", "桌面客户端从队列领取一批可执行任务,原子加锁。"},
"POST /api/desktop/tasks/:id/lease": {"续锁/重领任务", "对指定任务再次加锁,常用于异常重试场景。"},
"POST /api/desktop/tasks/:id/extend": {"延长任务锁", "执行较慢时延长锁定时间,避免任务被其他客户端抢走。"},
"POST /api/desktop/tasks/:id/cancel": {"取消任务(桌面)", "桌面客户端在执行失败/用户中止时通知服务端取消。"},
"POST /api/desktop/tasks/:id/result": {"上报任务结果", "桌面客户端将发布结果(成功/失败/截图/链接)回传服务端。"},
"POST /api/desktop/tasks/lease": {"领取发布任务", "桌面客户端从队列领取一批可执行任务,原子加锁。"},
"POST /api/desktop/tasks/:id/lease": {"续锁/重领任务", "对指定任务再次加锁,常用于异常重试场景。"},
"POST /api/desktop/tasks/:id/extend": {"延长任务锁", "执行较慢时延长锁定时间,避免任务被其他客户端抢走。"},
"POST /api/desktop/tasks/:id/cancel": {"取消任务(桌面)", "桌面客户端在执行失败/用户中止时通知服务端取消。"},
"POST /api/desktop/tasks/:id/result": {"上报任务结果", "桌面客户端将发布结果(成功/失败/截图/链接)回传服务端。"},
"POST /api/desktop/publish-tasks/:id/retry": {"重试发布任务", "对失败的发布任务发起重试。"},
// --- Desktop:监控采集任务 ---
@@ -63,15 +63,25 @@ var routeDocs = map[string]routeDoc{
"POST /api/desktop/monitoring/tasks/:id/cancel": {"取消监控任务", "桌面端主动取消正在执行的监控任务。"},
// --- Tenant:账号 / 任务统一视图(管理员视角) ---
"GET /api/tenant/accounts": {"租户媒体账号列表", "运营人员在管理后台查看本租户下所有媒体账号。"},
"GET /api/tenant/accounts": {"租户媒体账号列表", "运营人员在管理后台查看本租户下所有媒体账号。"},
"POST /api/tenant/accounts/:id/request-delete": {"申请删除媒体账号", "由后台发起异步删除,桌面端确认后真正解绑。"},
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
"POST /api/tenant/publish-jobs": {"批量创建发布任务", "为一批文章/账号批量生成发布任务,进入派发队列。"},
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
"POST /api/tenant/publish-jobs": {"批量创建发布任务", "为一批文章/账号批量生成发布任务,进入派发队列。"},
// --- Tenant:媒体 ---
"GET /api/tenant/media/platforms": {"媒体平台列表", "返回当前租户可用的媒体平台元数据(图标、能力开关等)。"},
// --- Tenant:内容合规 ---
"GET /api/tenant/compliance/runtime-status": {"合规运行状态", "返回当前租户内容合规模块是否启用、执行模式、词库版本与检测预算。"},
"POST /api/tenant/compliance/check": {"纯文本合规检测", "对标题/正文纯文本执行一次合规检测,返回聚合 check_record 与违规项。"},
"POST /api/tenant/compliance/check-records/:id/ack": {"确认合规风险", "对 advisory 检测结果进行一次性确认,返回发布时可消费的 ack_record_id。"},
"POST /api/tenant/articles/:id/compliance/check": {"文章合规检测", "对指定文章版本和目标平台集合执行一次合规检测。"},
"GET /api/tenant/articles/:id/compliance/latest": {"最新合规记录", "读取指定文章最近一次合规检测记录和违规明细。"},
"POST /api/tenant/articles/:id/compliance/manual-reviews": {"提交人工审阅", "将 block 检测结果提交运营人工审阅,审阅绑定到当前文章版本。"},
"GET /api/tenant/articles/:id/compliance/manual-reviews/latest": {"最新人工审阅", "读取当前文章版本最近的人工审阅状态。"},
"POST /api/tenant/articles/:id/compliance/manual-reviews/:review_id/cancel": {"撤回人工审阅", "作者撤回仍处于 pending 状态的人工审阅申请。"},
// --- Tenant:工作台 ---
"GET /api/tenant/workspace/overview": {"工作台概览", "首页顶部统计:文章数、发布数、KOL 订阅数等。"},
"GET /api/tenant/workspace/recent-articles": {"最近文章", "工作台展示最近创建/更新的若干篇文章。"},
@@ -81,28 +91,28 @@ var routeDocs = map[string]routeDoc{
"GET /api/tenant/workspace/kol-cards": {"KOL 入口卡片", "工作台首页的 KOL 订阅推荐卡片。"},
// --- Tenant:模板 ---
"GET /api/tenant/templates": {"模板列表", "返回当前 Workspace 可见的内容模板。"},
"GET /api/tenant/templates/:id": {"模板详情", "返回模板字段定义、prompt 结构、示例等。"},
"POST /api/tenant/templates/:id/drafts": {"保存模板草稿", "保存用户在模板表单上的临时输入,便于稍后续填。"},
"POST /api/tenant/templates/:id/analyze-tasks": {"创建主题分析任务", "基于用户输入创建主题分析异步任务。"},
"GET /api/tenant/templates/:id/analyze_task_result": {"获取主题分析结果", "轮询主题分析任务结果,需要带上 task_id。"},
"POST /api/tenant/templates/:id/title-tasks": {"创建标题生成任务", "基于主题创建标题候选生成任务。"},
"GET /api/tenant/templates/:id/gen_title_task_result": {"获取标题生成结果", "轮询标题生成任务结果。"},
"POST /api/tenant/templates/:id/outline-tasks": {"创建大纲生成任务", "基于选定标题创建大纲生成任务。"},
"GET /api/tenant/templates/:id/gen_outline_task_result": {"获取大纲生成结果", "轮询大纲生成任务结果。"},
"POST /api/tenant/templates/:id/generate": {"生成正文", "基于模板入参直接生成完整文章并落库。"},
"GET /api/tenant/templates": {"模板列表", "返回当前 Workspace 可见的内容模板。"},
"GET /api/tenant/templates/:id": {"模板详情", "返回模板字段定义、prompt 结构、示例等。"},
"POST /api/tenant/templates/:id/drafts": {"保存模板草稿", "保存用户在模板表单上的临时输入,便于稍后续填。"},
"POST /api/tenant/templates/:id/analyze-tasks": {"创建主题分析任务", "基于用户输入创建主题分析异步任务。"},
"GET /api/tenant/templates/:id/analyze_task_result": {"获取主题分析结果", "轮询主题分析任务结果,需要带上 task_id。"},
"POST /api/tenant/templates/:id/title-tasks": {"创建标题生成任务", "基于主题创建标题候选生成任务。"},
"GET /api/tenant/templates/:id/gen_title_task_result": {"获取标题生成结果", "轮询标题生成任务结果。"},
"POST /api/tenant/templates/:id/outline-tasks": {"创建大纲生成任务", "基于选定标题创建大纲生成任务。"},
"GET /api/tenant/templates/:id/gen_outline_task_result": {"获取大纲生成结果", "轮询大纲生成任务结果。"},
"POST /api/tenant/templates/:id/generate": {"生成正文", "基于模板入参直接生成完整文章并落库。"},
// --- TenantKOL 管理(创作者侧) ---
"GET /api/tenant/kol/manage/profile": {"KOL 主页信息", "获取当前用户作为 KOL 的资料卡。"},
"PUT /api/tenant/kol/manage/profile": {"更新 KOL 主页", "更新 KOL 名称、简介、行业、联系方式等。"},
"POST /api/tenant/kol/manage/profile/avatar": {"上传 KOL 头像", "Multipart 上传 KOL 头像,返回访问 URL。"},
"GET /api/tenant/kol/manage/packages": {"KOL 套餐列表", "KOL 自己管理名下的套餐(产品包)列表。"},
"POST /api/tenant/kol/manage/packages": {"新建 KOL 套餐", "创建新的 KOL 套餐草稿。"},
"PUT /api/tenant/kol/manage/packages/:id": {"更新 KOL 套餐", "修改套餐基础信息(名称、价格、描述、封面等)。"},
"DELETE /api/tenant/kol/manage/packages/:id": {"删除 KOL 套餐", "仅未发布或已归档的套餐可删除。"},
"PUT /api/tenant/kol/manage/packages/:id/publish": {"发布 KOL 套餐", "把草稿态套餐发布到市场,对所有租户可见。"},
"PUT /api/tenant/kol/manage/packages/:id/archive": {"下架 KOL 套餐", "将已发布的套餐归档,不再对外展示。"},
"GET /api/tenant/kol/manage/packages": {"KOL 套餐列表", "KOL 自己管理名下的套餐(产品包)列表。"},
"POST /api/tenant/kol/manage/packages": {"新建 KOL 套餐", "创建新的 KOL 套餐草稿。"},
"PUT /api/tenant/kol/manage/packages/:id": {"更新 KOL 套餐", "修改套餐基础信息(名称、价格、描述、封面等)。"},
"DELETE /api/tenant/kol/manage/packages/:id": {"删除 KOL 套餐", "仅未发布或已归档的套餐可删除。"},
"PUT /api/tenant/kol/manage/packages/:id/publish": {"发布 KOL 套餐", "把草稿态套餐发布到市场,对所有租户可见。"},
"PUT /api/tenant/kol/manage/packages/:id/archive": {"下架 KOL 套餐", "将已发布的套餐归档,不再对外展示。"},
"POST /api/tenant/kol/manage/packages/:id/self-subscription": {"KOL 自订阅套餐", "KOL 自己订阅自己的套餐用于体验/测试。"},
"DELETE /api/tenant/kol/manage/packages/:id/self-subscription": {"取消 KOL 自订阅", "取消 KOL 对自己套餐的自订阅。"},
@@ -112,9 +122,9 @@ var routeDocs = map[string]routeDoc{
"PUT /api/tenant/kol/manage/prompts/:id": {"更新 Prompt 元信息", "更新 Prompt 名称、说明、可见性等元数据。"},
"DELETE /api/tenant/kol/manage/prompts/:id": {"删除 Prompt", "彻底删除该 Prompt 及其历史版本(若已发布且有订阅则禁止)。"},
"POST /api/tenant/kol/manage/prompts/:id/save": {"保存 Prompt 草稿", "保存 Prompt 正文与变量,作为新的草稿版本。"},
"POST /api/tenant/kol/manage/prompts/:id/publish": {"发布 Prompt 版本", "将当前草稿发布为新版本,订阅者可使用。"},
"PUT /api/tenant/kol/manage/prompts/:id/activate": {"激活 Prompt", "把已发布的 Prompt 切换为当前激活版本。"},
"PUT /api/tenant/kol/manage/prompts/:id/archive": {"归档 Prompt", "归档某个 Prompt,新订阅不再可见。"},
"POST /api/tenant/kol/manage/prompts/:id/publish": {"发布 Prompt 版本", "将当前草稿发布为新版本,订阅者可使用。"},
"PUT /api/tenant/kol/manage/prompts/:id/activate": {"激活 Prompt", "把已发布的 Prompt 切换为当前激活版本。"},
"PUT /api/tenant/kol/manage/prompts/:id/archive": {"归档 Prompt", "归档某个 Prompt,新订阅不再可见。"},
"POST /api/tenant/kol/manage/assist": {"KOL 创作助手(同步)", "基于上下文一次性生成 Prompt 优化建议。"},
"POST /api/tenant/kol/manage/assist/stream": {"KOL 创作助手(SSE 流式)", "通过 Server-Sent Events 流式返回创作助手输出。"},
@@ -126,63 +136,63 @@ var routeDocs = map[string]routeDoc{
"GET /api/tenant/kol/dashboard/trend": {"KOL 看板趋势", "按日聚合的订阅、生成、收益趋势曲线。"},
// --- TenantKOL Marketplace(订阅方视角) ---
"GET /api/tenant/kol/marketplace/packages": {"KOL 市场套餐列表", "面向订阅方的 KOL 市场列表,可按行业/关键词筛选。"},
"GET /api/tenant/kol/marketplace/packages/:id": {"KOL 市场套餐详情", "套餐详情页:包含价格、Prompt 列表、案例等。"},
"GET /api/tenant/kol/marketplace/packages": {"KOL 市场套餐列表", "面向订阅方的 KOL 市场列表,可按行业/关键词筛选。"},
"GET /api/tenant/kol/marketplace/packages/:id": {"KOL 市场套餐详情", "套餐详情页:包含价格、Prompt 列表、案例等。"},
"POST /api/tenant/kol/marketplace/packages/:id/subscribe": {"订阅 KOL 套餐", "当前 Workspace 订阅指定套餐,进入计费/试用流程。"},
"GET /api/tenant/kol/subscriptions": {"我的 KOL 订阅", "返回当前 Workspace 已订阅的 KOL 套餐列表。"},
"GET /api/tenant/kol/subscription-prompts": {"我的订阅 Prompt 列表", "已订阅套餐下可用的 Prompt 集合。"},
"GET /api/tenant/kol/subscription-prompts/:id/schema": {"订阅 Prompt 输入 Schema", "返回该 Prompt 的入参字段定义,用于前端渲染表单。"},
"GET /api/tenant/kol/subscriptions": {"我的 KOL 订阅", "返回当前 Workspace 已订阅的 KOL 套餐列表。"},
"GET /api/tenant/kol/subscription-prompts": {"我的订阅 Prompt 列表", "已订阅套餐下可用的 Prompt 集合。"},
"GET /api/tenant/kol/subscription-prompts/:id/schema": {"订阅 Prompt 输入 Schema", "返回该 Prompt 的入参字段定义,用于前端渲染表单。"},
"POST /api/tenant/kol/subscription-prompts/:id/generate": {"用订阅 Prompt 生成内容", "基于订阅 Prompt 生成一篇文章,自动扣 KOL 计费点数。"},
// --- Tenant:文章 ---
"GET /api/tenant/articles": {"文章列表", "支持按生成/发布状态、来源、关键词、时间等筛选。"},
"POST /api/tenant/articles": {"创建文章", "手动创建一篇空文章草稿。"},
"POST /api/tenant/articles/generate-from-rule": {"根据 Prompt 规则生成文章", "基于已配置的 Prompt 规则生成一篇文章。"},
"POST /api/tenant/articles/imitations/generate": {"仿写生成文章", "基于参考文链接/原文进行仿写并生成新文章。"},
"POST /api/tenant/articles/:id/images": {"上传文章配图", "Multipart 上传文章正文使用的图片。"},
"GET /api/tenant/articles": {"文章列表", "支持按生成/发布状态、来源、关键词、时间等筛选。"},
"POST /api/tenant/articles": {"创建文章", "手动创建一篇空文章草稿。"},
"POST /api/tenant/articles/generate-from-rule": {"根据 Prompt 规则生成文章", "基于已配置的 Prompt 规则生成一篇文章。"},
"POST /api/tenant/articles/imitations/generate": {"仿写生成文章", "基于参考文链接/原文进行仿写并生成新文章。"},
"POST /api/tenant/articles/:id/images": {"上传文章配图", "Multipart 上传文章正文使用的图片。"},
"POST /api/tenant/articles/:id/selection-optimize/stream": {"选区优化(SSE 流式)", "针对用户选中的段落做润色/改写,结果流式返回。"},
"GET /api/tenant/articles/:id": {"文章详情", "返回文章的完整结构(标题、大纲、正文、版本号等)。"},
"PUT /api/tenant/articles/:id": {"保存文章", "全量更新文章内容(标题、正文、媒体附件等)。"},
"GET /api/tenant/articles/:id/stream": {"文章生成 SSE 流", "订阅文章正文生成过程的流式输出。"},
"GET /api/tenant/articles/:id/versions": {"文章版本历史", "返回该文章的历史版本列表,可用于回滚。"},
"GET /api/tenant/articles/:id/publish-records": {"文章发布记录", "返回该文章在各媒体平台的发布历史。"},
"DELETE /api/tenant/articles/:id": {"删除文章", "软删除文章;可在回收站撤回(需带 undo 参数)。"},
"GET /api/tenant/articles/:id": {"文章详情", "返回文章的完整结构(标题、大纲、正文、版本号等)。"},
"PUT /api/tenant/articles/:id": {"保存文章", "全量更新文章内容(标题、正文、媒体附件等)。"},
"GET /api/tenant/articles/:id/stream": {"文章生成 SSE 流", "订阅文章正文生成过程的流式输出。"},
"GET /api/tenant/articles/:id/versions": {"文章版本历史", "返回该文章的历史版本列表,可用于回滚。"},
"GET /api/tenant/articles/:id/publish-records": {"文章发布记录", "返回该文章在各媒体平台的发布历史。"},
"DELETE /api/tenant/articles/:id": {"删除文章", "软删除文章;可在回收站撤回(需带 undo 参数)。"},
// --- Tenant:品牌库 ---
"GET /api/tenant/brands": {"品牌列表", "返回当前 Workspace 下的品牌列表。"},
"GET /api/tenant/brands": {"品牌列表", "返回当前 Workspace 下的品牌列表。"},
"GET /api/tenant/brands/library-summary": {"品牌库概览", "品牌、关键词、问题、竞品的总数与最近变更。"},
"POST /api/tenant/brands": {"新建品牌", "创建一个新品牌。"},
"GET /api/tenant/brands/:id": {"品牌详情", "返回品牌基础信息及统计。"},
"PUT /api/tenant/brands/:id": {"更新品牌", "更新品牌名称、行业、官网等。"},
"DELETE /api/tenant/brands/:id": {"删除品牌", "删除品牌及其下属关键词、问题、竞品。"},
"GET /api/tenant/brands/:id/keywords": {"品牌关键词列表", "返回某品牌下的关键词。"},
"POST /api/tenant/brands/:id/keywords": {"新增关键词", "为某品牌添加关键词。"},
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
"GET /api/tenant/brands/:id/keywords": {"品牌关键词列表", "返回某品牌下的关键词。"},
"POST /api/tenant/brands/:id/keywords": {"新增关键词", "为某品牌添加关键词。"},
"PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"},
"DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"},
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
"PUT /api/tenant/brands/:id/questions/:qid": {"更新监控问题", "修改问题文本或所属关键词。"},
"DELETE /api/tenant/brands/:id/questions/:qid": {"删除监控问题", "删除某条监控问题。"},
"GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"},
"POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"},
"PUT /api/tenant/brands/:id/questions/:qid": {"更新监控问题", "修改问题文本或所属关键词。"},
"DELETE /api/tenant/brands/:id/questions/:qid": {"删除监控问题", "删除某条监控问题。"},
"GET /api/tenant/brands/:id/competitors": {"竞品列表", "返回品牌下登记的竞品。"},
"POST /api/tenant/brands/:id/competitors": {"新增竞品", "为品牌添加竞品记录。"},
"PUT /api/tenant/brands/:id/competitors/:cid": {"更新竞品", "修改竞品名称/官网/备注。"},
"DELETE /api/tenant/brands/:id/competitors/:cid": {"删除竞品", "删除某条竞品记录。"},
"GET /api/tenant/brands/:id/competitors": {"竞品列表", "返回品牌下登记的竞品。"},
"POST /api/tenant/brands/:id/competitors": {"新增竞品", "为品牌添加竞品记录。"},
"PUT /api/tenant/brands/:id/competitors/:cid": {"更新竞品", "修改竞品名称/官网/备注。"},
"DELETE /api/tenant/brands/:id/competitors/:cid": {"删除竞品", "删除某条竞品记录。"},
// --- Tenant:监控(GEO 仪表盘) ---
"GET /api/tenant/monitoring/dashboard/composite": {"监控仪表盘综合视图", "组合返回品牌曝光、引用率、问答覆盖等监控指标。"},
"GET /api/tenant/monitoring/citation-summary": {"引用情况汇总", "按 AI 平台维度返回品牌的引用频次与变化。"},
"GET /api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail": {"问题级监控详情", "某问题在各 AI 平台的回答样本与引用拆解。"},
"POST /api/tenant/monitoring/brands/:brand_id/collect-now": {"立即触发采集", "对某品牌立即下发一次监控采集任务,跳过定时计划。"},
"GET /api/tenant/monitoring/dashboard/composite": {"监控仪表盘综合视图", "组合返回品牌曝光、引用率、问答覆盖等监控指标。"},
"GET /api/tenant/monitoring/citation-summary": {"引用情况汇总", "按 AI 平台维度返回品牌的引用频次与变化。"},
"GET /api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail": {"问题级监控详情", "某问题在各 AI 平台的回答样本与引用拆解。"},
"POST /api/tenant/monitoring/brands/:brand_id/collect-now": {"立即触发采集", "对某品牌立即下发一次监控采集任务,跳过定时计划。"},
// --- Tenant:知识库 ---
"GET /api/tenant/knowledge/groups": {"知识库分组列表", "返回当前 Workspace 的知识库分组。"},
"POST /api/tenant/knowledge/groups": {"新建知识库分组", "创建新的知识库分组。"},
"PUT /api/tenant/knowledge/groups/:gid": {"更新知识库分组", "重命名/移动分组。"},
"DELETE /api/tenant/knowledge/groups/:gid": {"删除知识库分组", "删除分组及其下条目(按业务规则限制)。"},
"GET /api/tenant/knowledge/groups": {"知识库分组列表", "返回当前 Workspace 的知识库分组。"},
"POST /api/tenant/knowledge/groups": {"新建知识库分组", "创建新的知识库分组。"},
"PUT /api/tenant/knowledge/groups/:gid": {"更新知识库分组", "重命名/移动分组。"},
"DELETE /api/tenant/knowledge/groups/:gid": {"删除知识库分组", "删除分组及其下条目(按业务规则限制)。"},
"GET /api/tenant/knowledge/items": {"知识条目列表", "按分组返回知识库条目,可分页。"},
"GET /api/tenant/knowledge/items/:id": {"知识条目详情", "返回条目的解析后正文、来源、向量化状态。"},
@@ -193,17 +203,17 @@ var routeDocs = map[string]routeDoc{
"DELETE /api/tenant/knowledge/items/:id": {"删除知识条目", "彻底删除某条知识条目。"},
// --- TenantPrompt 规则 ---
"GET /api/tenant/prompt-rules": {"Prompt 规则列表", "查询 Prompt 规则,支持分组、状态、关键字筛选。"},
"GET /api/tenant/prompt-rules/simple": {"Prompt 规则下拉项", "为下拉框/选择器提供精简结构(id + 名称)。"},
"POST /api/tenant/prompt-rules": {"新建 Prompt 规则", "创建新的 Prompt 生成规则。"},
"GET /api/tenant/prompt-rules/:id": {"Prompt 规则详情", "返回规则完整配置(变量、模板、约束)。"},
"PUT /api/tenant/prompt-rules/:id": {"更新 Prompt 规则", "修改规则配置。"},
"DELETE /api/tenant/prompt-rules/:id": {"删除 Prompt 规则", "删除规则;若有定时任务在引用则禁止。"},
"PUT /api/tenant/prompt-rules/:id/status": {"切换 Prompt 规则启停", "启用或禁用 Prompt 规则。"},
"GET /api/tenant/prompt-rules/groups": {"Prompt 规则分组列表", "返回 Prompt 规则的分组用于左侧导航。"},
"POST /api/tenant/prompt-rules/groups": {"新建分组", "创建 Prompt 规则分组。"},
"PUT /api/tenant/prompt-rules/groups/:gid": {"更新分组", "重命名分组或调整顺序。"},
"DELETE /api/tenant/prompt-rules/groups/:gid": {"删除分组", "删除空分组。"},
"GET /api/tenant/prompt-rules": {"Prompt 规则列表", "查询 Prompt 规则,支持分组、状态、关键字筛选。"},
"GET /api/tenant/prompt-rules/simple": {"Prompt 规则下拉项", "为下拉框/选择器提供精简结构(id + 名称)。"},
"POST /api/tenant/prompt-rules": {"新建 Prompt 规则", "创建新的 Prompt 生成规则。"},
"GET /api/tenant/prompt-rules/:id": {"Prompt 规则详情", "返回规则完整配置(变量、模板、约束)。"},
"PUT /api/tenant/prompt-rules/:id": {"更新 Prompt 规则", "修改规则配置。"},
"DELETE /api/tenant/prompt-rules/:id": {"删除 Prompt 规则", "删除规则;若有定时任务在引用则禁止。"},
"PUT /api/tenant/prompt-rules/:id/status": {"切换 Prompt 规则启停", "启用或禁用 Prompt 规则。"},
"GET /api/tenant/prompt-rules/groups": {"Prompt 规则分组列表", "返回 Prompt 规则的分组用于左侧导航。"},
"POST /api/tenant/prompt-rules/groups": {"新建分组", "创建 Prompt 规则分组。"},
"PUT /api/tenant/prompt-rules/groups/:gid": {"更新分组", "重命名分组或调整顺序。"},
"DELETE /api/tenant/prompt-rules/groups/:gid": {"删除分组", "删除空分组。"},
// --- Tenant:定时任务 ---
"GET /api/tenant/schedules": {"定时任务列表", "查询定时生成任务,支持按 Prompt 规则、状态、时间筛选。"},
@@ -217,24 +227,24 @@ var routeDocs = map[string]routeDoc{
"GET /api/tenant/instant-tasks": {"即时任务列表", "查询用户手动触发的即时生成任务。"},
// --- Tenant:图片库 ---
"GET /api/tenant/images": {"图片列表", "分页查询图片,可按分组、关键词筛选。"},
"POST /api/tenant/images": {"上传图片", "Multipart 上传单张图片到指定分组。"},
"PUT /api/tenant/images/:id": {"更新图片", "重命名、移动分组或修改 alt。"},
"GET /api/tenant/images/:id/references": {"图片引用情况", "查询图片被哪些文章/草稿引用。"},
"DELETE /api/tenant/images/:id": {"删除图片", "软删除图片,可强制(force=1)跳过引用校验。"},
"GET /api/tenant/images/storage-usage": {"图片存储用量", "返回当前 Workspace 已使用的图片存储与配额。"},
"GET /api/tenant/images": {"图片列表", "分页查询图片,可按分组、关键词筛选。"},
"POST /api/tenant/images": {"上传图片", "Multipart 上传单张图片到指定分组。"},
"PUT /api/tenant/images/:id": {"更新图片", "重命名、移动分组或修改 alt。"},
"GET /api/tenant/images/:id/references": {"图片引用情况", "查询图片被哪些文章/草稿引用。"},
"DELETE /api/tenant/images/:id": {"删除图片", "软删除图片,可强制(force=1)跳过引用校验。"},
"GET /api/tenant/images/storage-usage": {"图片存储用量", "返回当前 Workspace 已使用的图片存储与配额。"},
"GET /api/tenant/images/folders": {"图片分组列表", "返回图片分组树。"},
"POST /api/tenant/images/folders": {"创建图片分组", "新建图片分组。"},
"PUT /api/tenant/images/folders/:id": {"更新图片分组", "重命名或移动分组。"},
"GET /api/tenant/images/folders": {"图片分组列表", "返回图片分组树。"},
"POST /api/tenant/images/folders": {"创建图片分组", "新建图片分组。"},
"PUT /api/tenant/images/folders/:id": {"更新图片分组", "重命名或移动分组。"},
"GET /api/tenant/images/folders/:id/delete-preview": {"删除分组预览", "返回删除该分组将影响的图片数量。"},
"DELETE /api/tenant/images/folders/:id": {"删除图片分组", "删除空分组。"},
"DELETE /api/tenant/images/folders/:id": {"删除图片分组", "删除空分组。"},
// --- Swagger 自身 ---
"GET /swagger": {"Swagger 入口", "重定向至 /swagger/index.html。"},
"GET /swagger/": {"Swagger 入口", "重定向至 /swagger/index.html。"},
"GET /swagger/index.html": {"Swagger UI", "提供本服务的 Swagger UI 页面。"},
"GET /swagger/openapi.json": {"OpenAPI 规范", "返回当前服务自动生成的 OpenAPI 3 文档。"},
"GET /swagger": {"Swagger 入口", "重定向至 /swagger/index.html。"},
"GET /swagger/": {"Swagger 入口", "重定向至 /swagger/index.html。"},
"GET /swagger/index.html": {"Swagger UI", "提供本服务的 Swagger UI 页面。"},
"GET /swagger/openapi.json": {"OpenAPI 规范", "返回当前服务自动生成的 OpenAPI 3 文档。"},
}
// docFor returns the documented summary/description for a route, falling back
@@ -146,6 +146,7 @@ type ArticleDetailResponse struct {
ID int64 `json:"id"`
SourceType string `json:"source_type"`
TemplateID *int64 `json:"template_id"`
CurrentVersionID *int64 `json:"current_version_id"`
TemplateName *string `json:"template_name"`
GenerationMode *string `json:"generation_mode"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
@@ -401,6 +402,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
item.SourceType = dbSourceType
if currentVersionID != nil {
item.CurrentVersionID = currentVersionID
content, err := repository.LoadArticleVersionContent(ctx, s.pool, item.ID, *currentVersionID)
if err != nil {
return nil, false, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
@@ -16,9 +16,12 @@ 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"
)
@@ -30,6 +33,7 @@ type DesktopTaskService struct {
redis *goredis.Client
messaging *rabbitmq.Client
logger *zap.Logger
compliance *tenantcompliance.Service
}
func NewDesktopTaskService(pool *pgxpool.Pool, monitoringPool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *DesktopTaskService {
@@ -39,9 +43,18 @@ func NewDesktopTaskService(pool *pgxpool.Pool, monitoringPool *pgxpool.Pool, mes
repo: repository.NewDesktopTaskRepository(pool),
messaging: messaging,
logger: logger,
compliance: tenantcompliance.NewService(pool, config.NewStaticProvider(&config.Config{
Compliance: config.ComplianceConfig{Enabled: true},
}), logger).WithRabbitMQ(messaging),
}
}
func NewDesktopTaskServiceWithConfig(pool *pgxpool.Pool, monitoringPool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger, cfg config.Provider) *DesktopTaskService {
svc := NewDesktopTaskService(pool, monitoringPool, messaging, logger)
svc.compliance = tenantcompliance.NewService(pool, cfg, logger).WithRabbitMQ(messaging)
return svc
}
func (s *DesktopTaskService) WithCache(c sharedcache.Cache) *DesktopTaskService {
s.cache = c
return s
@@ -55,24 +68,28 @@ func (s *DesktopTaskService) WithRedis(redis *goredis.Client) *DesktopTaskServic
}
type DesktopTaskView struct {
ID string `json:"id"`
JobID string `json:"job_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
TargetAccountID string `json:"target_account_id"`
TargetClientID string `json:"target_client_id"`
Platform string `json:"platform"`
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
Status string `json:"status"`
DedupKey *string `json:"dedup_key"`
ActiveAttemptID *string `json:"active_attempt_id"`
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
Attempts int `json:"attempts"`
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
JobID string `json:"job_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
TargetAccountID string `json:"target_account_id"`
TargetClientID string `json:"target_client_id"`
Platform string `json:"platform"`
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
Status string `json:"status"`
PublishJobStatus *string `json:"publish_job_status,omitempty"`
ComplianceBlockedRecordID *int64 `json:"compliance_blocked_record_id,omitempty"`
ComplianceBlockedAt *time.Time `json:"compliance_blocked_at,omitempty"`
ComplianceBlockedReason *string `json:"compliance_blocked_reason,omitempty"`
DedupKey *string `json:"dedup_key"`
ActiveAttemptID *string `json:"active_attempt_id"`
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
Attempts int `json:"attempts"`
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type LeaseDesktopTaskRequest struct {
@@ -144,6 +161,9 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
if err := s.recoverExpiredClientTasks(ctx, client); err != nil {
return nil, err
}
if err := s.recheckQueuedPublishTasksForClient(ctx, client); err != nil {
return nil, err
}
rawToken, tokenHash, err := newDesktopClientToken()
if err != nil {
@@ -230,6 +250,86 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
}, nil
}
func (s *DesktopTaskService) recheckQueuedPublishTasksForClient(ctx context.Context, client *repository.DesktopClient) error {
if s == nil || s.pool == nil || s.compliance == nil || client == nil {
return nil
}
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT
j.desktop_id,
j.tenant_id,
j.workspace_id,
j.created_by_user_id,
j.article_id,
j.article_version_id,
COALESCE(array_agg(DISTINCT dt.platform_id) FILTER (WHERE dt.platform_id <> ''), '{}') AS platforms
FROM desktop_tasks dt
JOIN desktop_publish_jobs j ON j.desktop_id = dt.job_id
WHERE dt.target_client_id = $1
AND dt.kind = 'publish'
AND dt.status = 'queued'
AND j.status = 'queued'
AND j.article_id IS NOT NULL
AND j.article_version_id IS NOT NULL
GROUP BY j.desktop_id, j.tenant_id, j.workspace_id, j.created_by_user_id, j.article_id, j.article_version_id
ORDER BY MIN(dt.created_at)
LIMIT 5
`, client.ID)
if err != nil {
return response.ErrInternal(50118, "desktop_publish_compliance_recheck_failed", "failed to load publish jobs for compliance recheck")
}
defer rows.Close()
type candidate struct {
JobID uuid.UUID
TenantID int64
WorkspaceID int64
CreatedByUserID int64
ArticleID int64
ArticleVersionID int64
Platforms []string
}
candidates := make([]candidate, 0)
for rows.Next() {
var item candidate
if scanErr := rows.Scan(&item.JobID, &item.TenantID, &item.WorkspaceID, &item.CreatedByUserID, &item.ArticleID, &item.ArticleVersionID, &item.Platforms); scanErr != nil {
return response.ErrInternal(50118, "desktop_publish_compliance_recheck_failed", "failed to scan publish job for compliance recheck")
}
if item.WorkspaceID == client.WorkspaceID && item.TenantID == client.TenantID {
candidates = append(candidates, item)
}
}
if err := rows.Err(); err != nil {
return response.ErrInternal(50118, "desktop_publish_compliance_recheck_failed", "failed to iterate publish jobs for compliance recheck")
}
for _, item := range candidates {
gate, gateErr := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
TenantID: item.TenantID,
ArticleID: item.ArticleID,
ArticleVersionID: item.ArticleVersionID,
ActorID: item.CreatedByUserID,
TargetPlatforms: item.Platforms,
TriggerSource: "scheduler_recheck",
})
if gateErr != nil && gate != nil && gate.Decision == sharedcompliance.GateDecisionBlock {
if markErr := s.compliance.MarkPublishJobBlockedByCompliance(ctx, item.JobID, gate.Result); markErr != nil {
return markErr
}
continue
}
if gateErr != nil {
return response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "scheduled publish compliance recheck failed")
}
if gate != nil && gate.Decision == sharedcompliance.GateDecisionBlock {
if markErr := s.compliance.MarkPublishJobBlockedByCompliance(ctx, item.JobID, gate.Result); markErr != nil {
return markErr
}
}
}
return nil
}
const desktopTaskRepositoryReturningColumns = `
t.desktop_id,
t.job_id,
@@ -862,7 +962,11 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
t.result,
t.error,
t.created_at,
t.updated_at
t.updated_at,
j.status,
j.compliance_blocked_record_id,
j.compliance_blocked_at,
j.compliance_blocked_reason
FROM desktop_tasks AS t
JOIN desktop_publish_jobs AS j
ON j.desktop_id = t.job_id
@@ -945,6 +1049,10 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
errorJSON []byte
createdAt time.Time
updatedAt time.Time
jobStatus string
blockedRecordID pgtype.Int8
blockedAt pgtype.Timestamptz
blockedReason pgtype.Text
)
if err := rows.Scan(
&desktopID,
@@ -965,6 +1073,10 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
&errorJSON,
&createdAt,
&updatedAt,
&jobStatus,
&blockedRecordID,
&blockedAt,
&blockedReason,
); err != nil {
return nil, response.ErrInternal(50110, "desktop_publish_tasks_scan_failed", "failed to scan desktop publish tasks")
}
@@ -990,25 +1102,53 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
leaseExpiresAtValue = &value
}
publishJobStatusText := strings.TrimSpace(jobStatus)
var publishJobStatus *string
if publishJobStatusText != "" {
publishJobStatus = &publishJobStatusText
}
var complianceBlockedRecordID *int64
if blockedRecordID.Valid {
value := blockedRecordID.Int64
complianceBlockedRecordID = &value
}
var complianceBlockedAt *time.Time
if blockedAt.Valid {
value := blockedAt.Time
complianceBlockedAt = &value
}
var complianceBlockedReason *string
if blockedReason.Valid {
value := blockedReason.String
complianceBlockedReason = &value
}
items = append(items, DesktopTaskView{
ID: desktopID.String(),
JobID: jobID.String(),
TenantID: tenantID,
WorkspaceID: workspaceID,
TargetAccountID: targetAccountID.String(),
TargetClientID: targetClientID.String(),
Platform: platform,
Kind: kind,
Payload: json.RawMessage(payload),
Status: normalizeDesktopTaskTerminalStatus(status),
DedupKey: dedupKeyText,
ActiveAttemptID: activeAttemptIDText,
LeaseExpiresAt: leaseExpiresAtValue,
Attempts: int(attempts),
Result: json.RawMessage(resultJSON),
Error: json.RawMessage(errorJSON),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: desktopID.String(),
JobID: jobID.String(),
TenantID: tenantID,
WorkspaceID: workspaceID,
TargetAccountID: targetAccountID.String(),
TargetClientID: targetClientID.String(),
Platform: platform,
Kind: kind,
Payload: json.RawMessage(payload),
Status: normalizeDesktopTaskTerminalStatus(status),
PublishJobStatus: publishJobStatus,
ComplianceBlockedRecordID: complianceBlockedRecordID,
ComplianceBlockedAt: complianceBlockedAt,
ComplianceBlockedReason: complianceBlockedReason,
DedupKey: dedupKeyText,
ActiveAttemptID: activeAttemptIDText,
LeaseExpiresAt: leaseExpiresAtValue,
Attempts: int(attempts),
Result: json.RawMessage(resultJSON),
Error: json.RawMessage(errorJSON),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
if err := rows.Err(); err != nil {
@@ -0,0 +1,167 @@
package app
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/llm"
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
)
type onlineGenerationFixture struct {
GenerationTasks []onlineGenerationTask `json:"generation_tasks"`
}
type onlineGenerationTask struct {
ID int64 `json:"id"`
ArticleID int64 `json:"article_id"`
InputParamsJSON map[string]interface{} `json:"input_params_json"`
}
type onlineKnowledgeFixture struct {
KnowledgeItems []onlineKnowledgeItem `json:"knowledge_items"`
}
type onlineKnowledgeItem struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
SourceURI *string `json:"source_uri"`
Status string `json:"status"`
ContentText *string `json:"content_text"`
MarkdownContent *string `json:"markdown_content"`
}
func TestReplayOnlineFailedArticleGeneration(t *testing.T) {
if os.Getenv("RUN_ONLINE_GENERATION_REPLAY") != "1" {
t.Skip("set RUN_ONLINE_GENERATION_REPLAY=1 to replay online failed generation against Ark")
}
fixtureDir := strings.TrimSpace(os.Getenv("ONLINE_GENERATION_FIXTURE_DIR"))
if fixtureDir == "" {
fixtureDir = filepath.Clean("../../../../tmp/online-generation-fixtures")
}
taskID := int64(2)
if strings.TrimSpace(os.Getenv("ONLINE_GENERATION_TASK_ID")) == "1" {
taskID = 1
}
var fixture onlineGenerationFixture
readJSONFixture(t, filepath.Join(fixtureDir, "db-compact.json"), &fixture)
task := findOnlineGenerationTask(t, fixture.GenerationTasks, taskID)
var knowledge onlineKnowledgeFixture
readJSONFixture(t, filepath.Join(fixtureDir, "knowledge-compact.json"), &knowledge)
if promptsPath := strings.TrimSpace(os.Getenv("PROMPTS_CONFIG_FILE")); promptsPath == "" {
prompts.SetConfigFile(filepath.Clean("../../../configs/prompts.yml"))
}
knowledgePrompt := buildReplayKnowledgePrompt(task.InputParamsJSON, knowledge)
templateKey, templateName, promptTemplate := extractTemplateSnapshot(task.InputParamsJSON)
prompt := buildGenerationPrompt(templateKey, templateName, promptTemplate, task.InputParamsJSON, knowledgePrompt)
t.Logf("task_id=%d article_id=%d prompt_chars=%d knowledge_chars=%d", task.ID, task.ArticleID, len([]rune(prompt)), len([]rune(knowledgePrompt)))
cfg, err := sharedconfig.Load(filepath.Clean("../../../configs/config.yaml"))
if err != nil {
t.Fatalf("load config: %v", err)
}
client := llm.New(cfg.LLM)
if err := client.Validate(); err != nil {
t.Fatalf("validate llm: %v", err)
}
start := time.Now()
var outputChars int
result, err := client.Generate(context.Background(), llm.GenerateRequest{
Prompt: prompt,
Timeout: cfg.Generation.ArticleTimeout,
MaxOutputTokens: cfg.LLM.MaxOutputTokens,
}, func(delta string) {
outputChars += len([]rune(delta))
})
elapsed := time.Since(start)
if err != nil {
t.Fatalf("replay generation failed after %s with %d streamed chars: %v", elapsed.Round(time.Second), outputChars, err)
}
t.Logf("replay generation completed after %s with %d chars, model=%s", elapsed.Round(time.Second), len([]rune(result.Content)), result.Model)
}
func readJSONFixture(t *testing.T, path string, target interface{}) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("parse fixture %s: %v", path, err)
}
}
func findOnlineGenerationTask(t *testing.T, tasks []onlineGenerationTask, id int64) onlineGenerationTask {
t.Helper()
for _, task := range tasks {
if task.ID == id {
return task
}
}
t.Fatalf("task %d not found in fixture", id)
return onlineGenerationTask{}
}
func buildReplayKnowledgePrompt(params map[string]interface{}, fixture onlineKnowledgeFixture) string {
groupIDs := make(map[int64]struct{})
for _, id := range extractKnowledgeGroupIDs(params["knowledge_group_ids"]) {
groupIDs[id] = struct{}{}
}
if len(groupIDs) == 0 {
return ""
}
ctx := &KnowledgeContext{Snippets: []KnowledgeSnippet{}, PreciseFacts: []string{}}
seenFacts := make(map[string]struct{})
for _, item := range fixture.KnowledgeItems {
if strings.TrimSpace(item.Status) != "completed" {
continue
}
if _, ok := groupIDs[item.GroupID]; !ok {
continue
}
text := strings.TrimSpace(derefKnowledgeString(item.MarkdownContent))
if text == "" {
text = strings.TrimSpace(derefKnowledgeString(item.ContentText))
}
if text == "" {
continue
}
facts := extractKnowledgePreciseFacts(text)
for _, fact := range facts {
entry := strings.TrimSpace(fact)
if entry == "" {
continue
}
if _, exists := seenFacts[entry]; exists {
continue
}
seenFacts[entry] = struct{}{}
ctx.PreciseFacts = append(ctx.PreciseFacts, entry)
}
ctx.Snippets = append(ctx.Snippets, KnowledgeSnippet{
GroupID: item.GroupID,
KnowledgeItemID: item.ID,
ItemName: item.Name,
SourceType: item.SourceType,
SourceURI: item.SourceURI,
Text: text,
PreciseFacts: facts,
})
}
return (&KnowledgeService{}).RenderPromptSection(ctx)
}
+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,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
package compliance
import (
"testing"
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
)
func TestDecideMandatoryBlocksAnyHit(t *testing.T) {
levels := []string{"block", "high", "medium", "info"}
for _, level := range levels {
level := level
t.Run(level, func(t *testing.T) {
got := decide("mandatory", &level, 1)
if got != sharedcompliance.GateDecisionBlock {
t.Fatalf("decide mandatory %s hit = %s, want %s", level, got, sharedcompliance.GateDecisionBlock)
}
})
}
}
func TestDecideAdvisoryNeedsAckOnAnyHit(t *testing.T) {
level := "info"
got := decide("advisory", &level, 1)
if got != sharedcompliance.GateDecisionNeedsAck {
t.Fatalf("decide advisory hit = %s, want %s", got, sharedcompliance.GateDecisionNeedsAck)
}
}
func TestDecidePassesWithoutHits(t *testing.T) {
level := "block"
got := decide("mandatory", &level, 0)
if got != sharedcompliance.GateDecisionPass {
t.Fatalf("decide without hits = %s, want %s", got, sharedcompliance.GateDecisionPass)
}
}
@@ -2,7 +2,9 @@ package repository
import (
"context"
"strings"
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
"github.com/jackc/pgx/v5"
)
@@ -69,9 +71,10 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea
html_content,
markdown_content,
word_count,
source_label
source_label,
plaintext_snapshot
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`,
input.ArticleID,
@@ -81,6 +84,7 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea
pgText(storedMarkdown),
input.WordCount,
pgText(&input.SourceLabel),
strings.TrimSpace(input.Title+"\n"+ExtractArticlePlaintext(input.MarkdownContent)),
).Scan(&versionID)
if err != nil {
return 0, err
@@ -88,6 +92,60 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea
return versionID, nil
}
func ExtractArticlePlaintext(markdown string) string {
markdown = strings.TrimSpace(markdown)
if markdown == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(markdown))
inFence := false
lines := strings.Split(markdown, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
inFence = !inFence
continue
}
if inFence {
builder.WriteString(trimmed)
builder.WriteByte('\n')
continue
}
line = stripMarkdownLine(line)
if strings.TrimSpace(line) == "" {
continue
}
builder.WriteString(line)
builder.WriteByte('\n')
}
return strings.TrimSpace(builder.String())
}
func stripMarkdownLine(line string) string {
replacer := strings.NewReplacer(
"#", " ",
"*", " ",
"_", " ",
"`", " ",
">", " ",
"[", " ",
"]", " ",
"(", " ",
")", " ",
"!", " ",
"|", " ",
)
line = replacer.Replace(line)
line = strings.TrimLeft(line, "-+0123456789. \t")
return strings.Join(strings.Fields(line), " ")
}
func CountArticlePlaintextWords(markdown string) int {
return contentstats.CountWords(ExtractArticlePlaintext(markdown))
}
func (r *articleRepository) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error {
tag, err := r.db.Exec(ctx, `
UPDATE articles
@@ -10,25 +10,30 @@ import (
)
type DesktopPublishJob struct {
DesktopID uuid.UUID
TenantID int64
WorkspaceID int64
CreatedByUserID int64
Title string
ContentRef []byte
ScheduledAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
DesktopID uuid.UUID
TenantID int64
WorkspaceID int64
CreatedByUserID int64
Title string
ContentRef []byte
ScheduledAt *time.Time
ArticleID *int64
ArticleVersionID *int64
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateDesktopPublishJobParams struct {
DesktopID uuid.UUID
TenantID int64
WorkspaceID int64
CreatedByUserID int64
Title string
ContentRef []byte
ScheduledAt *time.Time
DesktopID uuid.UUID
TenantID int64
WorkspaceID int64
CreatedByUserID int64
Title string
ContentRef []byte
ScheduledAt *time.Time
ArticleID *int64
ArticleVersionID *int64
}
type DesktopTask struct {
@@ -136,13 +141,15 @@ func NewDesktopTaskRepository(db generated.DBTX) DesktopTaskRepository {
func (r *desktopTaskRepository) CreatePublishJob(ctx context.Context, params CreateDesktopPublishJobParams) (*DesktopPublishJob, error) {
row, err := r.q.CreateDesktopPublishJob(ctx, generated.CreateDesktopPublishJobParams{
DesktopID: pgUUID(params.DesktopID),
TenantID: params.TenantID,
WorkspaceID: params.WorkspaceID,
CreatedByUserID: params.CreatedByUserID,
Title: params.Title,
ContentRef: params.ContentRef,
ScheduledAt: pgTimestamp(params.ScheduledAt),
DesktopID: pgUUID(params.DesktopID),
TenantID: params.TenantID,
WorkspaceID: params.WorkspaceID,
CreatedByUserID: params.CreatedByUserID,
Title: params.Title,
ContentRef: params.ContentRef,
ScheduledAt: pgTimestamp(params.ScheduledAt),
ArticleID: pgInt8(params.ArticleID),
ArticleVersionID: pgInt8(params.ArticleVersionID),
})
if err != nil {
return nil, err
@@ -314,15 +321,18 @@ func (r *desktopTaskRepository) FinishAttempt(ctx context.Context, params Finish
func desktopPublishJobFromGenerated(row generated.DesktopPublishJob) *DesktopPublishJob {
return &DesktopPublishJob{
DesktopID: uuidFromPG(row.DesktopID),
TenantID: row.TenantID,
WorkspaceID: row.WorkspaceID,
CreatedByUserID: row.CreatedByUserID,
Title: row.Title,
ContentRef: row.ContentRef,
ScheduledAt: optionalTime(row.ScheduledAt),
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
DesktopID: uuidFromPG(row.DesktopID),
TenantID: row.TenantID,
WorkspaceID: row.WorkspaceID,
CreatedByUserID: row.CreatedByUserID,
Title: row.Title,
ContentRef: row.ContentRef,
ScheduledAt: optionalTime(row.ScheduledAt),
ArticleID: nullableInt64(row.ArticleID),
ArticleVersionID: nullableInt64(row.ArticleVersionID),
Status: row.Status,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}
}
@@ -211,7 +211,9 @@ INSERT INTO desktop_publish_jobs (
created_by_user_id,
title,
content_ref,
scheduled_at
scheduled_at,
article_id,
article_version_id
)
VALUES (
$1,
@@ -220,19 +222,23 @@ VALUES (
$4,
$5,
$6,
$7
$7,
$8,
$9
)
RETURNING id, desktop_id, tenant_id, workspace_id, created_by_user_id, title, content_ref, scheduled_at, created_at, updated_at
RETURNING id, desktop_id, tenant_id, workspace_id, created_by_user_id, title, content_ref, scheduled_at, created_at, updated_at, article_id, article_version_id, status, compliance_blocked_record_id, compliance_blocked_at, compliance_blocked_reason
`
type CreateDesktopPublishJobParams struct {
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
CreatedByUserID int64 `json:"created_by_user_id"`
Title string `json:"title"`
ContentRef []byte `json:"content_ref"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
CreatedByUserID int64 `json:"created_by_user_id"`
Title string `json:"title"`
ContentRef []byte `json:"content_ref"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
ArticleID pgtype.Int8 `json:"article_id"`
ArticleVersionID pgtype.Int8 `json:"article_version_id"`
}
func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktopPublishJobParams) (DesktopPublishJob, error) {
@@ -244,6 +250,8 @@ func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktop
arg.Title,
arg.ContentRef,
arg.ScheduledAt,
arg.ArticleID,
arg.ArticleVersionID,
)
var i DesktopPublishJob
err := row.Scan(
@@ -257,6 +265,12 @@ func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktop
&i.ScheduledAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ArticleID,
&i.ArticleVersionID,
&i.Status,
&i.ComplianceBlockedRecordID,
&i.ComplianceBlockedAt,
&i.ComplianceBlockedReason,
)
return i, err
}
@@ -544,12 +558,18 @@ const leaseNextQueuedDesktopTask = `-- name: LeaseNextQueuedDesktopTask :one
WITH candidate AS (
SELECT dt.desktop_id
FROM desktop_tasks AS dt
LEFT JOIN desktop_publish_jobs AS j ON j.desktop_id = dt.job_id
WHERE dt.target_client_id = $3
AND dt.status = 'queued'
AND (
$4::text IS NULL
OR dt.kind = $4::text
)
AND (
dt.kind <> 'publish'
OR j.desktop_id IS NULL
OR j.status = 'queued'
)
ORDER BY dt.lane_weight DESC,
dt.priority DESC,
COALESCE(dt.enqueued_at, dt.created_at) ASC,
@@ -87,6 +87,12 @@ type ArticleVersion struct {
WordCount int32 `json:"word_count"`
SourceLabel pgtype.Text `json:"source_label"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
// Plaintext extracted at save time, used by compliance gate to skip diff reconstruction.
PlaintextSnapshot pgtype.Text `json:"plaintext_snapshot"`
// Hot-path manual review stamp: none|pending|approved|rejected.
ManualReviewStatus string `json:"manual_review_status"`
ManualReviewID pgtype.Int8 `json:"manual_review_id"`
ManualReviewUpdatedAt pgtype.Timestamptz `json:"manual_review_updated_at"`
}
type AuditLog struct {
@@ -153,6 +159,105 @@ type Competitor struct {
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
}
type ComplianceAckRecord struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ArticleID int64 `json:"article_id"`
ArticleVersionID int64 `json:"article_version_id"`
CheckRecordID int64 `json:"check_record_id"`
ContentHash string `json:"content_hash"`
PolicyFingerprint string `json:"policy_fingerprint"`
AcknowledgedBy int64 `json:"acknowledged_by"`
AckViolationIds []int64 `json:"ack_violation_ids"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
ConsumedAt pgtype.Timestamptz `json:"consumed_at"`
ConsumedByJobID pgtype.UUID `json:"consumed_by_job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ComplianceCheckRecord struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ArticleID pgtype.Int8 `json:"article_id"`
ArticleVersionID pgtype.Int8 `json:"article_version_id"`
TargetPlatforms []string `json:"target_platforms"`
ContentHash string `json:"content_hash"`
PolicyFingerprint string `json:"policy_fingerprint"`
TriggerSource string `json:"trigger_source"`
EnforcementMode string `json:"enforcement_mode"`
HighestLevel pgtype.Text `json:"highest_level"`
Passed bool `json:"passed"`
HitCount int32 `json:"hit_count"`
StoredHitCount int32 `json:"stored_hit_count"`
Truncated bool `json:"truncated"`
DictVersions []byte `json:"dict_versions"`
LlmUsed bool `json:"llm_used"`
DurationMs int32 `json:"duration_ms"`
CheckedBy pgtype.Int8 `json:"checked_by"`
CheckedAt pgtype.Timestamptz `json:"checked_at"`
ManualReviewStatus string `json:"manual_review_status"`
ManualReviewID pgtype.Int8 `json:"manual_review_id"`
}
type ComplianceManualReview struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
ArticleID int64 `json:"article_id"`
ArticleVersionID int64 `json:"article_version_id"`
OriginalCheckRecordID pgtype.Int8 `json:"original_check_record_id"`
OriginalContentHash string `json:"original_content_hash"`
OriginalPolicyFingerprint string `json:"original_policy_fingerprint"`
OriginalTargetPlatforms []string `json:"original_target_platforms"`
OriginalViolationSummary []byte `json:"original_violation_summary"`
RequestedBy int64 `json:"requested_by"`
RequestedAt pgtype.Timestamptz `json:"requested_at"`
RequestNote pgtype.Text `json:"request_note"`
Status string `json:"status"`
CancelledBy pgtype.Int8 `json:"cancelled_by"`
CancelledAt pgtype.Timestamptz `json:"cancelled_at"`
CancelReason pgtype.Text `json:"cancel_reason"`
ReviewedBy pgtype.Int8 `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
DecisionReason pgtype.Text `json:"decision_reason"`
}
type ComplianceReviewJob struct {
ID int64 `json:"id"`
MessageID pgtype.UUID `json:"message_id"`
TenantID int64 `json:"tenant_id"`
ArticleID int64 `json:"article_id"`
ArticleVersionID int64 `json:"article_version_id"`
CheckRecordID int64 `json:"check_record_id"`
Status string `json:"status"`
Attempts int32 `json:"attempts"`
AvailableAt pgtype.Timestamptz `json:"available_at"`
LockedAt pgtype.Timestamptz `json:"locked_at"`
LockedBy pgtype.Text `json:"locked_by"`
LastError pgtype.Text `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ComplianceViolation struct {
ID int64 `json:"id"`
RecordID int64 `json:"record_id"`
TenantID int64 `json:"tenant_id"`
PlatformCode pgtype.Text `json:"platform_code"`
Source string `json:"source"`
DictionaryCode pgtype.Text `json:"dictionary_code"`
DictionaryName pgtype.Text `json:"dictionary_name"`
TermPattern pgtype.Text `json:"term_pattern"`
MatchedText string `json:"matched_text"`
StartOffset int32 `json:"start_offset"`
EndOffset int32 `json:"end_offset"`
DomPath pgtype.Text `json:"dom_path"`
Level string `json:"level"`
Hint pgtype.Text `json:"hint"`
ReferenceLaw pgtype.Text `json:"reference_law"`
AcknowledgedInAckID pgtype.Int8 `json:"acknowledged_in_ack_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type DesktopClient struct {
ID pgtype.UUID `json:"id"`
TenantID int64 `json:"tenant_id"`
@@ -180,16 +285,22 @@ type DesktopClientPrimaryLease struct {
}
type DesktopPublishJob struct {
ID int64 `json:"id"`
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
CreatedByUserID int64 `json:"created_by_user_id"`
Title string `json:"title"`
ContentRef []byte `json:"content_ref"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID int64 `json:"id"`
DesktopID pgtype.UUID `json:"desktop_id"`
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
CreatedByUserID int64 `json:"created_by_user_id"`
Title string `json:"title"`
ContentRef []byte `json:"content_ref"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ArticleID pgtype.Int8 `json:"article_id"`
ArticleVersionID pgtype.Int8 `json:"article_version_id"`
Status string `json:"status"`
ComplianceBlockedRecordID pgtype.Int8 `json:"compliance_blocked_record_id"`
ComplianceBlockedAt pgtype.Timestamptz `json:"compliance_blocked_at"`
ComplianceBlockedReason pgtype.Text `json:"compliance_blocked_reason"`
}
type DesktopTask struct {
@@ -6,7 +6,9 @@ INSERT INTO desktop_publish_jobs (
created_by_user_id,
title,
content_ref,
scheduled_at
scheduled_at,
article_id,
article_version_id
)
VALUES (
sqlc.arg(desktop_id),
@@ -15,7 +17,9 @@ VALUES (
sqlc.arg(created_by_user_id),
sqlc.arg(title),
sqlc.arg(content_ref),
sqlc.narg(scheduled_at)
sqlc.narg(scheduled_at),
sqlc.narg(article_id),
sqlc.narg(article_version_id)
)
RETURNING *;
@@ -59,12 +63,18 @@ LIMIT 1;
WITH candidate AS (
SELECT dt.desktop_id
FROM desktop_tasks AS dt
LEFT JOIN desktop_publish_jobs AS j ON j.desktop_id = dt.job_id
WHERE dt.target_client_id = sqlc.arg(client_id)
AND dt.status = 'queued'
AND (
sqlc.narg(kind)::text IS NULL
OR dt.kind = sqlc.narg(kind)::text
)
AND (
dt.kind <> 'publish'
OR j.desktop_id IS NULL
OR j.status = 'queued'
)
ORDER BY dt.lane_weight DESC,
dt.priority DESC,
COALESCE(dt.enqueued_at, dt.created_at) ASC,
@@ -0,0 +1,238 @@
package transport
import (
"errors"
"io"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
)
type ComplianceHandler struct {
svc *tenantcompliance.Service
}
func NewComplianceHandler(a *bootstrap.App) *ComplianceHandler {
return &ComplianceHandler{
svc: tenantcompliance.NewService(a.DB, a.ConfigStore, a.Logger).WithRabbitMQ(a.RabbitMQ).WithLLM(a.LLM),
}
}
type complianceCheckPlainRequest struct {
Title string `json:"title"`
Plaintext string `json:"plaintext" binding:"required"`
TargetPlatforms []string `json:"target_platforms"`
}
type complianceCheckArticleRequest struct {
ArticleVersionID *int64 `json:"article_version_id"`
Title *string `json:"title"`
Plaintext *string `json:"plaintext"`
TargetPlatforms []string `json:"target_platforms"`
TriggerSource string `json:"trigger_source"`
}
type complianceCreateAckRequest struct {
AcknowledgedViolationIDs []int64 `json:"acknowledged_violation_ids" binding:"required"`
}
type complianceCreateManualReviewRequest struct {
CheckRecordID int64 `json:"check_record_id" binding:"required"`
Note string `json:"note" binding:"required"`
}
type complianceCancelManualReviewRequest struct {
Reason string `json:"reason"`
}
func (h *ComplianceHandler) RuntimeStatus(c *gin.Context) {
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.RuntimeStatus(c.Request.Context(), actor.TenantID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *ComplianceHandler) CheckPlain(c *gin.Context) {
var req complianceCheckPlainRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.CheckPlain(c.Request.Context(), tenantcompliance.CheckPlainRequest{
TenantID: actor.TenantID,
ActorID: actor.UserID,
Title: req.Title,
Plaintext: req.Plaintext,
TargetPlatforms: req.TargetPlatforms,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *ComplianceHandler) CreateAck(c *gin.Context) {
recordID, ok := parseComplianceInt64Param(c, "id", "check record id")
if !ok {
return
}
var req complianceCreateAckRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.CreateAck(c.Request.Context(), actor.TenantID, recordID, actor.UserID, req.AcknowledgedViolationIDs)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *ComplianceHandler) CheckArticle(c *gin.Context) {
articleID, ok := parseComplianceInt64Param(c, "id", "article id")
if !ok {
return
}
var req complianceCheckArticleRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.CheckArticle(c.Request.Context(), tenantcompliance.CheckArticleRequest{
TenantID: actor.TenantID,
ArticleID: articleID,
ArticleVersionID: req.ArticleVersionID,
ActorID: actor.UserID,
Title: req.Title,
Plaintext: req.Plaintext,
TargetPlatforms: req.TargetPlatforms,
TriggerSource: req.TriggerSource,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *ComplianceHandler) LatestCheck(c *gin.Context) {
articleID, ok := parseComplianceInt64Param(c, "id", "article id")
if !ok {
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.LatestCheck(c.Request.Context(), actor.TenantID, articleID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *ComplianceHandler) SubmitManualReview(c *gin.Context) {
articleID, ok := parseComplianceInt64Param(c, "id", "article id")
if !ok {
return
}
var req complianceCreateManualReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.SubmitManualReview(c.Request.Context(), tenantcompliance.CreateManualReviewRequest{
TenantID: actor.TenantID,
ArticleID: articleID,
ActorID: actor.UserID,
CheckRecordID: req.CheckRecordID,
Note: req.Note,
})
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *ComplianceHandler) LatestManualReview(c *gin.Context) {
articleID, ok := parseComplianceInt64Param(c, "id", "article id")
if !ok {
return
}
versionID, ok := optionalComplianceInt64Query(c, "article_version_id")
if !ok {
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.LatestManualReview(c.Request.Context(), actor.TenantID, articleID, versionID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *ComplianceHandler) CancelManualReview(c *gin.Context) {
articleID, ok := parseComplianceInt64Param(c, "id", "article id")
if !ok {
return
}
reviewID, ok := parseComplianceInt64Param(c, "review_id", "manual review id")
if !ok {
return
}
var req complianceCancelManualReviewRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
actor := auth.MustActor(c.Request.Context())
data, err := h.svc.CancelManualReview(c.Request.Context(), tenantcompliance.CancelManualReviewRequest{
TenantID: actor.TenantID,
ArticleID: articleID,
ActorID: actor.UserID,
ReviewID: reviewID,
Reason: req.Reason,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func parseComplianceInt64Param(c *gin.Context, name, label string) (int64, bool) {
value, err := strconv.ParseInt(strings.TrimSpace(c.Param(name)), 10, 64)
if err != nil || value <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", label+" must be a positive integer"))
return 0, false
}
return value, true
}
func optionalComplianceInt64Query(c *gin.Context, name string) (*int64, bool) {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return nil, true
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", name+" must be a positive integer"))
return nil, false
}
return &value, true
}
@@ -22,17 +22,19 @@ type DesktopTaskHandler struct {
func NewDesktopTaskHandler(a *bootstrap.App) *DesktopTaskHandler {
return &DesktopTaskHandler{
svc: app.NewDesktopTaskService(
svc: app.NewDesktopTaskServiceWithConfig(
a.DB,
a.MonitoringDB,
a.RabbitMQ,
a.Logger,
a.ConfigStore,
).WithCache(a.Cache).WithRedis(a.Redis),
publishSvc: app.NewPublishJobService(
publishSvc: app.NewPublishJobServiceWithConfig(
a.DB,
a.RabbitMQ,
a.Redis,
a.Logger,
a.ConfigStore,
).WithCache(a.Cache),
}
}
@@ -15,7 +15,7 @@ type PublishJobHandler struct {
func NewPublishJobHandler(a *bootstrap.App) *PublishJobHandler {
return &PublishJobHandler{
svc: app.NewPublishJobService(a.DB, a.RabbitMQ, a.Redis, a.Logger).WithCache(a.Cache),
svc: app.NewPublishJobServiceWithConfig(a.DB, a.RabbitMQ, a.Redis, a.Logger, a.ConfigStore).WithCache(a.Cache),
}
}
@@ -76,6 +76,12 @@ func RegisterRoutes(app *bootstrap.App) {
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
tenantProtected.POST("/publish-jobs", publishJobHandler.Create)
complianceHandler := NewComplianceHandler(app)
compliance := tenantProtected.Group("/compliance")
compliance.GET("/runtime-status", complianceHandler.RuntimeStatus)
compliance.POST("/check", complianceHandler.CheckPlain)
compliance.POST("/check-records/:id/ack", complianceHandler.CreateAck)
media := tenantProtected.Group("/media")
media.GET("/platforms", mediaHandler.ListPlatforms)
@@ -157,6 +163,11 @@ func RegisterRoutes(app *bootstrap.App) {
articles.PUT("/:id", artHandler.Update)
articles.GET("/:id/stream", artHandler.Stream)
articles.GET("/:id/versions", artHandler.Versions)
articles.POST("/:id/compliance/check", complianceHandler.CheckArticle)
articles.GET("/:id/compliance/latest", complianceHandler.LatestCheck)
articles.POST("/:id/compliance/manual-reviews", complianceHandler.SubmitManualReview)
articles.GET("/:id/compliance/manual-reviews/latest", complianceHandler.LatestManualReview)
articles.POST("/:id/compliance/manual-reviews/:review_id/cancel", complianceHandler.CancelManualReview)
articles.GET("/:id/publish-records", mediaHandler.ListPublishRecords)
articles.DELETE("/:id", artHandler.Delete)
@@ -0,0 +1,163 @@
package generate
import (
"context"
"errors"
"fmt"
"os"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
)
var errComplianceReviewConsumerClosed = errors.New("compliance review consumer channel closed")
type ComplianceReviewWorker struct {
rabbitMQ *rabbitmq.Client
compliance *tenantcompliance.Service
logger *zap.Logger
configProvider config.Provider
retryInterval time.Duration
consumerPrefix string
workerConcurrency int
}
func NewComplianceReviewWorker(
rabbitMQClient *rabbitmq.Client,
complianceService *tenantcompliance.Service,
logger *zap.Logger,
cfg config.ComplianceConfig,
) *ComplianceReviewWorker {
config.NormalizeComplianceConfig(&cfg)
return &ComplianceReviewWorker{
rabbitMQ: rabbitMQClient,
compliance: complianceService,
logger: logger,
retryInterval: 5 * time.Second,
consumerPrefix: fmt.Sprintf("compliance-review-%d", os.Getpid()),
workerConcurrency: cfg.ReviewWorkerConcurrency,
}
}
func (w *ComplianceReviewWorker) WithConfigProvider(provider config.Provider) *ComplianceReviewWorker {
if w != nil {
w.configProvider = provider
}
return w
}
func (w *ComplianceReviewWorker) Start(ctx context.Context) {
if w == nil || w.rabbitMQ == nil || w.compliance == nil {
return
}
concurrency := w.runtimeConfig().ReviewWorkerConcurrency
if concurrency <= 0 {
concurrency = 1
}
for i := 0; i < concurrency; i++ {
consumerName := fmt.Sprintf("%s-%d", w.consumerPrefix, i+1)
go w.run(ctx, consumerName)
}
}
func (w *ComplianceReviewWorker) run(ctx context.Context, consumerName string) {
for {
if err := w.consumeOnce(ctx, consumerName); err != nil && ctx.Err() == nil && w.logger != nil {
if errors.Is(err, errComplianceReviewConsumerClosed) {
w.logger.Info("compliance review consumer channel closed, retrying",
zap.String("consumer", consumerName),
)
} else {
w.logger.Warn("compliance review worker stopped unexpectedly",
zap.Error(err),
zap.String("consumer", consumerName),
)
}
}
select {
case <-ctx.Done():
return
case <-time.After(w.retryInterval):
}
}
}
func (w *ComplianceReviewWorker) consumeOnce(ctx context.Context, consumerName string) error {
deliveries, ch, err := w.rabbitMQ.ConsumeComplianceReviewTask(consumerName)
if err != nil {
return err
}
defer ch.Close()
for {
select {
case <-ctx.Done():
return nil
case delivery, ok := <-deliveries:
if !ok {
return errComplianceReviewConsumerClosed
}
w.handleDelivery(ctx, delivery)
}
}
}
func (w *ComplianceReviewWorker) handleDelivery(parent context.Context, delivery amqp.Delivery) {
msg, err := tenantcompliance.DecodeReviewJobMessage(delivery.Body)
if err != nil {
w.rejectDelivery(delivery, false, err, 0)
return
}
ctx, cancel := context.WithTimeout(parent, w.runtimeConfig().ReviewWorkerTimeout)
defer cancel()
err = w.compliance.ProcessReviewJob(ctx, msg)
if err != nil {
w.rejectDelivery(delivery, false, err, msg.JobID)
return
}
if err := delivery.Ack(false); err != nil && w.logger != nil {
w.logger.Warn("compliance review ack failed",
zap.Error(err),
zap.Int64("job_id", msg.JobID),
zap.Int64("check_record_id", msg.CheckRecordID),
)
}
}
func (w *ComplianceReviewWorker) runtimeConfig() config.ComplianceConfig {
if w != nil && w.configProvider != nil {
if cfg := w.configProvider.Current(); cfg != nil {
out := cfg.Compliance
config.NormalizeComplianceConfig(&out)
return out
}
}
out := config.ComplianceConfig{Enabled: true}
config.NormalizeComplianceConfig(&out)
return out
}
func (w *ComplianceReviewWorker) rejectDelivery(delivery amqp.Delivery, requeue bool, err error, jobID int64) {
if nackErr := delivery.Nack(false, requeue); nackErr != nil && w.logger != nil {
w.logger.Warn("compliance review nack failed",
zap.Error(nackErr),
zap.Int64("job_id", jobID),
)
}
if w.logger != nil {
w.logger.Warn("compliance review processing failed",
zap.Error(err),
zap.Bool("requeue", requeue),
zap.Bool("redelivered", delivery.Redelivered),
zap.Int64("job_id", jobID),
)
}
}