feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type complianceDecisionRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
type complianceMasterSwitchRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type complianceRollbackRequest struct {
|
||||
Version int `json:"version" binding:"required"`
|
||||
}
|
||||
|
||||
func listComplianceDictionariesHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
data, err := svc.ListDictionaries(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func createComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body app.UpsertComplianceDictionaryRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.CreateDictionary(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func updateComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body app.UpsertComplianceDictionaryRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.UpdateDictionary(c.Request.Context(), id, body)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteDictionary(c.Request.Context(), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
}
|
||||
|
||||
func listComplianceTermsHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
data, err := svc.ListTerms(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func createComplianceTermHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body app.CreateComplianceDictionaryTermRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.CreateTerm(c.Request.Context(), id, body)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteComplianceTermHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
dictID, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
termID, err := parsePositiveInt64(c.Param("term_id"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的词条 ID"))
|
||||
return
|
||||
}
|
||||
if err := svc.DeleteTerm(c.Request.Context(), dictID, termID); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
}
|
||||
|
||||
func batchImportComplianceTermsHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body app.BatchImportComplianceTermsRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.BatchImportTerms(c.Request.Context(), id, body)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func publishComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
data, err := svc.PublishDictionary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func rollbackComplianceDictionaryHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body complianceRollbackRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.RollbackDictionary(c.Request.Context(), id, body.Version)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func getGlobalCompliancePolicyHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
data, err := svc.GetGlobalPolicy(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func updateGlobalCompliancePolicyHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body app.UpdateCompliancePolicyRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.UpdateGlobalPolicy(c.Request.Context(), body)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func setGlobalComplianceMasterSwitchHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body complianceMasterSwitchRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
data, err := svc.SetGlobalMasterSwitch(c.Request.Context(), body.Enabled)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func listComplianceManualReviewsHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", c.DefaultQuery("size", "50")))
|
||||
var tenantID *int64
|
||||
if raw := c.Query("tenant_id"); raw != "" {
|
||||
id, err := parsePositiveInt64(raw)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的租户 ID"))
|
||||
return
|
||||
}
|
||||
tenantID = &id
|
||||
}
|
||||
data, err := svc.ListManualReviews(c.Request.Context(), app.ComplianceManualReviewListRequest{
|
||||
Status: c.Query("status"),
|
||||
TenantID: tenantID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func getComplianceManualReviewHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
data, err := svc.GetManualReview(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func approveComplianceManualReviewHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return requireComplianceReviewPermission(decideComplianceManualReviewHandler(svc, true))
|
||||
}
|
||||
|
||||
func rejectComplianceManualReviewHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return requireComplianceReviewPermission(decideComplianceManualReviewHandler(svc, false))
|
||||
}
|
||||
|
||||
func decideComplianceManualReviewHandler(svc *app.ComplianceService, approve bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body complianceDecisionRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
var data any
|
||||
if approve {
|
||||
data, err = svc.ApproveManualReview(c.Request.Context(), id, body.Reason)
|
||||
} else {
|
||||
data, err = svc.RejectManualReview(c.Request.Context(), id, body.Reason)
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func listComplianceRecordsHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", c.DefaultQuery("size", "50")))
|
||||
var tenantID *int64
|
||||
if raw := c.Query("tenant_id"); raw != "" {
|
||||
id, err := parsePositiveInt64(raw)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的租户 ID"))
|
||||
return
|
||||
}
|
||||
tenantID = &id
|
||||
}
|
||||
var articleID *int64
|
||||
if raw := c.Query("article_id"); raw != "" {
|
||||
id, err := parsePositiveInt64(raw)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "无效的文章 ID"))
|
||||
return
|
||||
}
|
||||
articleID = &id
|
||||
}
|
||||
data, err := svc.ListRecords(c.Request.Context(), app.ComplianceRecordListRequest{
|
||||
TenantID: tenantID,
|
||||
ArticleID: articleID,
|
||||
Decision: c.Query("decision"),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func complianceStatsHandler(svc *app.ComplianceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
data, err := svc.Stats(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
}
|
||||
|
||||
func requireComplianceReviewPermission(next gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
actor := actorFromGin(c)
|
||||
if actor == nil || !strings.EqualFold(strings.TrimSpace(actor.Role), "admin") {
|
||||
response.Error(c, response.ErrForbidden(40331, "compliance_review_forbidden", "operator is not allowed to review compliance requests"))
|
||||
return
|
||||
}
|
||||
next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositiveInt64(raw string) (int64, error) {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, strconv.ErrSyntax
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type Deps struct {
|
||||
KolSubs *app.KolSubscriptionService
|
||||
Audits *app.AuditService
|
||||
SiteDomains *app.SiteDomainMappingService
|
||||
Compliance *app.ComplianceService
|
||||
}
|
||||
|
||||
func RegisterRoutes(d Deps) {
|
||||
@@ -96,5 +97,26 @@ func RegisterRoutes(d Deps) {
|
||||
authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains))
|
||||
authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains))
|
||||
authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(d.SiteDomains))
|
||||
|
||||
compliance := authed.Group("/compliance")
|
||||
compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries", createComplianceDictionaryHandler(d.Compliance))
|
||||
compliance.PUT("/dictionaries/:id", updateComplianceDictionaryHandler(d.Compliance))
|
||||
compliance.DELETE("/dictionaries/:id", deleteComplianceDictionaryHandler(d.Compliance))
|
||||
compliance.GET("/dictionaries/:id/terms", listComplianceTermsHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries/:id/terms", createComplianceTermHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries/:id/terms:batch-import", batchImportComplianceTermsHandler(d.Compliance))
|
||||
compliance.DELETE("/dictionaries/:id/terms/:term_id", deleteComplianceTermHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries/:id/publish", publishComplianceDictionaryHandler(d.Compliance))
|
||||
compliance.POST("/dictionaries/:id/rollback", rollbackComplianceDictionaryHandler(d.Compliance))
|
||||
compliance.GET("/policy/global", getGlobalCompliancePolicyHandler(d.Compliance))
|
||||
compliance.PUT("/policy/global", updateGlobalCompliancePolicyHandler(d.Compliance))
|
||||
compliance.POST("/policy/global/master-switch", setGlobalComplianceMasterSwitchHandler(d.Compliance))
|
||||
compliance.GET("/manual-reviews", listComplianceManualReviewsHandler(d.Compliance))
|
||||
compliance.GET("/manual-reviews/:id", getComplianceManualReviewHandler(d.Compliance))
|
||||
compliance.POST("/manual-reviews/:id/approve", approveComplianceManualReviewHandler(d.Compliance))
|
||||
compliance.POST("/manual-reviews/:id/reject", rejectComplianceManualReviewHandler(d.Compliance))
|
||||
compliance.GET("/stats", complianceStatsHandler(d.Compliance))
|
||||
compliance.GET("/records", listComplianceRecordsHandler(d.Compliance))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {"生成正文", "基于模板入参直接生成完整文章并落库。"},
|
||||
|
||||
// --- Tenant:KOL 管理(创作者侧) ---
|
||||
"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 看板趋势", "按日聚合的订阅、生成、收益趋势曲线。"},
|
||||
|
||||
// --- Tenant:KOL 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": {"删除知识条目", "彻底删除某条知识条目。"},
|
||||
|
||||
// --- Tenant:Prompt 规则 ---
|
||||
"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)
|
||||
}
|
||||
@@ -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 ©Err
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user