Files
geo/server/internal/tenant/compliance/service.go
T

2606 lines
77 KiB
Go
Raw Normal View History

package compliance
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
"github.com/geo-platform/tenant-api/internal/shared/config"
sharedllm "github.com/geo-platform/tenant-api/internal/shared/llm"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
)
const (
triggerEditorManual = "editor_manual"
triggerPublishGate = "publish_gate"
triggerAPI = "api"
triggerSchedulerRecheck = "scheduler_recheck"
triggerManualReviewBypass = "manual_review_bypass"
globalPolicyCacheKey = int64(0)
sourceDict = "dict"
sourceRegex = "regex"
aiPointsQuotaType = "ai_points"
aiUsageTypeComplianceLLMJudge = "compliance_llm_judge"
aiResourceTypeComplianceReview = "compliance_review"
maxStoredViolations = 200
maxMatchedTextRunes = 80
)
type Service struct {
pool *pgxpool.Pool
mq *rabbitmq.Client
cfg config.Provider
llm sharedllm.Client
logger *zap.Logger
policyMu sync.Mutex
policies map[int64]cachedPolicy
reviewCache sync.Map
}
type cachedPolicy struct {
policy *policySnapshot
expires time.Time
}
func NewService(pool *pgxpool.Pool, cfg config.Provider, logger *zap.Logger) *Service {
return &Service{pool: pool, cfg: cfg, logger: logger, policies: make(map[int64]cachedPolicy)}
}
func (s *Service) WithRabbitMQ(mq *rabbitmq.Client) *Service {
if s != nil {
s.mq = mq
}
return s
}
func (s *Service) WithLLM(client sharedllm.Client) *Service {
if s != nil {
s.llm = client
}
return s
}
type GateForPublishRequest struct {
TenantID int64
ArticleID int64
ArticleVersionID int64
ActorID int64
TargetPlatforms []string
AckRecordID *int64
TriggerSource string
}
type CheckArticleRequest struct {
TenantID int64
ArticleID int64
ArticleVersionID *int64
ActorID int64
Title *string
Plaintext *string
TargetPlatforms []string
TriggerSource string
}
type CheckPlainRequest struct {
TenantID int64
ActorID int64
Title string
Plaintext string
TargetPlatforms []string
}
type ConsumeAckQuery struct {
TenantID int64
ArticleID int64
ArticleVersionID int64
CheckRecordID int64
ContentHash string
PolicyFingerprint string
ConsumedByJobID uuid.UUID
}
type CreateManualReviewRequest struct {
TenantID int64
ArticleID int64
ActorID int64
CheckRecordID int64
Note string
}
type CancelManualReviewRequest struct {
TenantID int64
ArticleID int64
ActorID int64
ReviewID int64
Reason string
}
type ReviewJobMessage struct {
JobID int64 `json:"job_id"`
MessageID string `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"`
}
type GateOutcome struct {
Decision sharedcompliance.GateDecision
Result *sharedcompliance.CheckResult
}
type articleVersionSnapshot struct {
ArticleID int64
ArticleVersionID int64
Title string
Plaintext string
ManualReviewStatus sharedcompliance.ManualReviewStatus
ManualReviewID *int64
ManualReviewReason *string
ManualReviewDecidedAt *time.Time
}
type policySnapshot struct {
LoadedAt time.Time
MasterEnabled bool
EnforcementMode string
LLMJudgeEnabled bool
Revision int64
DictionaryVersionSum int64
EnabledDictionaries []string
DictVersions map[string]int
Terms []termRule
}
type termRule struct {
DictionaryID int64
DictionaryCode string
DictionaryName string
DictionaryVersion int
PlatformScope string
ApplicablePlatforms []string
MatchType string
Pattern string
Level string
Hint *string
ReferenceLaw *string
compiledRegex *regexp.Regexp
compiledRegexPattern string
}
type checkRecordRow struct {
ID int64
TenantID int64
ArticleID int64
ArticleVersionID int64
TargetPlatforms []string
ContentHash string
PolicyFingerprint string
EnforcementMode string
HighestLevel *string
Passed bool
HitCount int
StoredHitCount int
Truncated bool
ManualReviewStatus sharedcompliance.ManualReviewStatus
ManualReviewID *int64
CheckedAt time.Time
DurationMS int
}
func (s *Service) RuntimeStatus(ctx context.Context, tenantID int64) (*sharedcompliance.RuntimeStatus, error) {
cfg := s.currentComplianceConfig()
if !cfg.Enabled {
return &sharedcompliance.RuntimeStatus{
Enabled: false,
ConfigEnabled: false,
MasterEnabled: false,
EnforcementMode: "disabled",
LLMJudgeEnabled: false,
SnapshotReloadInterval: cfg.SnapshotReloadInterval.String(),
PublishGateTimeout: cfg.PublishGateTimeout.String(),
}, nil
}
policy, err := s.resolvePolicy(ctx, tenantID)
if err != nil {
return nil, err
}
return &sharedcompliance.RuntimeStatus{
Enabled: policy.MasterEnabled && policy.EnforcementMode != "disabled",
ConfigEnabled: true,
MasterEnabled: policy.MasterEnabled,
EnforcementMode: policy.EnforcementMode,
LLMJudgeEnabled: policy.LLMJudgeEnabled,
SnapshotReloadInterval: cfg.SnapshotReloadInterval.String(),
PublishGateTimeout: cfg.PublishGateTimeout.String(),
DictionaryVersion: policy.DictionaryVersionSum,
EnabledDictionaries: append([]string(nil), policy.EnabledDictionaries...),
}, nil
}
func (s *Service) CheckPlain(ctx context.Context, req CheckPlainRequest) (*sharedcompliance.CheckResult, error) {
cfg := s.currentComplianceConfig()
if !cfg.Enabled {
return disabledCheckResult(req.TargetPlatforms), nil
}
policy, err := s.resolvePolicy(ctx, req.TenantID)
if err != nil {
return nil, err
}
return s.runAndPersist(ctx, runInput{
TenantID: req.TenantID,
ActorID: req.ActorID,
Title: req.Title,
Plaintext: req.Plaintext,
TargetPlatforms: req.TargetPlatforms,
TriggerSource: triggerAPI,
Policy: policy,
})
}
func (s *Service) CheckArticle(ctx context.Context, req CheckArticleRequest) (*sharedcompliance.CheckResult, error) {
cfg := s.currentComplianceConfig()
targets := normalizePlatforms(req.TargetPlatforms)
trigger := normalizeArticleCheckTrigger(req.TriggerSource)
if !cfg.Enabled && trigger != triggerEditorManual {
return disabledCheckResult(targets), nil
}
versionID, err := s.ResolvePublishableVersion(ctx, req.TenantID, req.ArticleID, req.ArticleVersionID)
if err != nil {
return nil, err
}
snapshot, err := s.loadArticleVersionSnapshot(ctx, req.TenantID, req.ArticleID, versionID)
if err != nil {
return nil, err
}
if snapshot.ManualReviewStatus == sharedcompliance.ManualReviewStatusPending ||
snapshot.ManualReviewStatus == sharedcompliance.ManualReviewStatusApproved ||
snapshot.ManualReviewStatus == sharedcompliance.ManualReviewStatusRejected {
if trigger != triggerEditorManual {
return manualReviewOnlyResult(snapshot, targets), nil
}
}
policy, err := s.resolvePolicy(ctx, req.TenantID)
if err != nil {
return nil, err
}
title := snapshot.Title
if req.Title != nil {
title = strings.TrimSpace(*req.Title)
}
plaintext := snapshot.Plaintext
if req.Plaintext != nil {
plaintext = strings.TrimSpace(*req.Plaintext)
}
if trigger == triggerEditorManual {
return s.runPreview(ctx, runInput{
TenantID: req.TenantID,
ArticleID: req.ArticleID,
ArticleVersionID: versionID,
ActorID: req.ActorID,
Title: title,
Plaintext: plaintext,
TargetPlatforms: targets,
TriggerSource: trigger,
Policy: editorPreviewPolicy(policy),
ManualStatus: snapshot.ManualReviewStatus,
ManualReviewID: snapshot.ManualReviewID,
})
}
return s.runAndPersist(ctx, runInput{
TenantID: req.TenantID,
ArticleID: req.ArticleID,
ArticleVersionID: versionID,
ActorID: req.ActorID,
Title: title,
Plaintext: plaintext,
TargetPlatforms: targets,
TriggerSource: trigger,
Policy: policy,
ManualStatus: snapshot.ManualReviewStatus,
ManualReviewID: snapshot.ManualReviewID,
})
}
func (s *Service) GateForPublish(ctx context.Context, req GateForPublishRequest) (*GateOutcome, error) {
cfg := s.currentComplianceConfig()
if !cfg.Enabled {
result := disabledCheckResult(req.TargetPlatforms)
result.Decision = sharedcompliance.GateDecisionPass
result.Passed = true
return &GateOutcome{Decision: sharedcompliance.GateDecisionPass, Result: result}, nil
}
req.TargetPlatforms = normalizePlatforms(req.TargetPlatforms)
trigger := strings.TrimSpace(req.TriggerSource)
if trigger == "" {
trigger = triggerPublishGate
}
req.TriggerSource = trigger
snapshot, err := s.loadArticleVersionSnapshot(ctx, req.TenantID, req.ArticleID, req.ArticleVersionID)
if err != nil {
if cached, ok := s.cachedManualReview(req.ArticleVersionID); ok && cached.ManualReviewStatus != sharedcompliance.ManualReviewStatusNone {
result := manualReviewOnlyResult(cached, req.TargetPlatforms)
return &GateOutcome{Decision: result.Decision, Result: result}, manualReviewGateError(cached)
}
return nil, response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "manual review status is unavailable")
}
s.cacheManualReview(snapshot)
switch snapshot.ManualReviewStatus {
case sharedcompliance.ManualReviewStatusPending:
result := manualReviewOnlyResult(snapshot, req.TargetPlatforms)
return &GateOutcome{Decision: sharedcompliance.GateDecisionBlock, Result: result}, response.ErrConflict(41007, "compliance_manual_review_pending", "current article version is waiting for manual review")
case sharedcompliance.ManualReviewStatusApproved:
result, err := s.createManualReviewBypassRecord(ctx, req, snapshot)
if err != nil {
return nil, err
}
return &GateOutcome{Decision: sharedcompliance.GateDecisionPass, Result: result}, nil
case sharedcompliance.ManualReviewStatusRejected:
result := manualReviewOnlyResult(snapshot, req.TargetPlatforms)
return &GateOutcome{Decision: sharedcompliance.GateDecisionBlock, Result: result}, response.ErrConflict(41006, "compliance_manual_review_rejected", "current article version was rejected by manual review")
}
policy, err := s.resolvePolicy(ctx, req.TenantID)
if err != nil {
return nil, err
}
if !policy.MasterEnabled || policy.EnforcementMode == "disabled" {
result := disabledCheckResult(req.TargetPlatforms)
result.EnforcementMode = policy.EnforcementMode
result.Passed = true
result.Decision = sharedcompliance.GateDecisionPass
return &GateOutcome{Decision: sharedcompliance.GateDecisionPass, Result: result}, nil
}
if req.AckRecordID != nil {
result, err := s.validateAckForPublish(ctx, req, policy)
if err != nil {
return nil, err
}
return &GateOutcome{Decision: sharedcompliance.GateDecisionPass, Result: result}, nil
}
result, err := s.runAndPersist(ctx, runInput{
TenantID: req.TenantID,
ArticleID: req.ArticleID,
ArticleVersionID: req.ArticleVersionID,
ActorID: req.ActorID,
Title: snapshot.Title,
Plaintext: snapshot.Plaintext,
TargetPlatforms: req.TargetPlatforms,
TriggerSource: trigger,
Policy: policy,
ManualStatus: snapshot.ManualReviewStatus,
ManualReviewID: snapshot.ManualReviewID,
})
if err != nil {
if policy.EnforcementMode == "mandatory" {
return nil, response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "compliance gate failed closed")
}
return &GateOutcome{Decision: sharedcompliance.GateDecisionPass, Result: disabledCheckResult(req.TargetPlatforms)}, nil
}
return &GateOutcome{Decision: result.Decision, Result: result}, nil
}
func (s *Service) ResolvePublishableVersion(ctx context.Context, tenantID, articleID int64, requested *int64) (int64, error) {
if requested != nil && *requested > 0 {
var id int64
err := s.pool.QueryRow(ctx, `
SELECT av.id
FROM article_versions av
JOIN articles a ON a.id = av.article_id
WHERE av.id = $1
AND av.article_id = $2
AND a.tenant_id = $3
AND a.deleted_at IS NULL
`, *requested, articleID, tenantID).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return 0, response.ErrInternal(50010, "query_failed", "failed to validate article version")
}
return id, nil
}
var versionValue pgtype.Int8
err := s.pool.QueryRow(ctx, `
SELECT current_version_id
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID).Scan(&versionValue)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrNotFound(40411, "article_not_found", "article not found")
}
return 0, response.ErrInternal(50010, "query_failed", "failed to resolve current article version")
}
if !versionValue.Valid || versionValue.Int64 <= 0 {
return 0, response.ErrBadRequest(41005, "invalid_article_version", "article has no publishable version")
}
return versionValue.Int64, nil
}
type runInput struct {
TenantID int64
ArticleID int64
ArticleVersionID int64
ActorID int64
Title string
Plaintext string
TargetPlatforms []string
TriggerSource string
Policy *policySnapshot
ManualStatus sharedcompliance.ManualReviewStatus
ManualReviewID *int64
}
func (s *Service) runAndPersist(ctx context.Context, input runInput) (*sharedcompliance.CheckResult, error) {
checkResult, violations, err := s.runCheck(input)
if err != nil {
return nil, err
}
persistedResult, err := s.insertCheckRecord(ctx, input, checkRecordRow{
TenantID: input.TenantID,
ArticleID: input.ArticleID,
ArticleVersionID: input.ArticleVersionID,
TargetPlatforms: checkResult.TargetPlatforms,
ContentHash: checkResult.ContentHash,
PolicyFingerprint: checkResult.PolicyFingerprint,
EnforcementMode: checkResult.EnforcementMode,
HighestLevel: checkResult.HighestLevel,
Passed: checkResult.Passed,
HitCount: checkResult.HitCount,
StoredHitCount: checkResult.StoredHitCount,
Truncated: checkResult.Truncated,
ManualReviewStatus: checkResult.ManualReviewStatus,
ManualReviewID: checkResult.ManualReviewID,
CheckedAt: checkResult.CheckedAt,
DurationMS: checkResult.DurationMS,
}, violations, checkResult.Decision)
if err != nil {
return nil, err
}
if input.Policy.LLMJudgeEnabled && input.ArticleID > 0 && input.ArticleVersionID > 0 && persistedResult.RecordID > 0 {
s.enqueueLLMReview(ctx, input, persistedResult.RecordID)
}
return persistedResult, nil
}
func (s *Service) runPreview(ctx context.Context, input runInput) (*sharedcompliance.CheckResult, error) {
result, _, err := s.runCheck(input)
return result, err
}
func (s *Service) runCheck(input runInput) (*sharedcompliance.CheckResult, []sharedcompliance.Violation, error) {
start := time.Now()
if input.Policy == nil {
return nil, nil, response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "compliance policy is unavailable")
}
input.TargetPlatforms = normalizePlatforms(input.TargetPlatforms)
plaintext := strings.TrimSpace(input.Plaintext)
title := strings.TrimSpace(input.Title)
contentHash := contentHash(title, plaintext)
fingerprint := policyFingerprint(input.Policy, input.TargetPlatforms)
violations := matchViolations(input.Policy, input.TenantID, input.TargetPlatforms, title+"\n"+plaintext)
highest := highestViolationLevel(violations)
hitCount := len(violations)
truncated := false
if len(violations) > maxStoredViolations {
violations = violations[:maxStoredViolations]
truncated = true
}
decision := decide(input.Policy.EnforcementMode, highest, hitCount)
passed := decision == sharedcompliance.GateDecisionPass
durationMS := int(time.Since(start).Milliseconds())
if input.ManualStatus == "" {
input.ManualStatus = sharedcompliance.ManualReviewStatusNone
}
result := &sharedcompliance.CheckResult{
Decision: decision,
Passed: passed,
HighestLevel: highest,
Violations: violations,
HitCount: hitCount,
StoredHitCount: len(violations),
Truncated: truncated,
EnforcementMode: input.Policy.EnforcementMode,
ContentHash: contentHash,
PolicyFingerprint: fingerprint,
TargetPlatforms: input.TargetPlatforms,
ManualReviewStatus: input.ManualStatus,
ManualReviewID: input.ManualReviewID,
CheckedAt: time.Now().UTC(),
DurationMS: durationMS,
}
if input.TriggerSource == triggerEditorManual {
result.StoredHitCount = 0
}
return result, violations, nil
}
func (s *Service) insertCheckRecord(ctx context.Context, input runInput, record checkRecordRow, violations []sharedcompliance.Violation, decision sharedcompliance.GateDecision) (*sharedcompliance.CheckResult, error) {
dictVersions, _ := json.Marshal(recordDictVersions(input.Policy))
var articleID any
if record.ArticleID > 0 {
articleID = &record.ArticleID
}
var articleVersionID any
if record.ArticleVersionID > 0 {
articleVersionID = &record.ArticleVersionID
}
var checkedBy any
if input.ActorID > 0 {
checkedBy = &input.ActorID
}
var highest any
if record.HighestLevel != nil {
highest = *record.HighestLevel
}
err := s.pool.QueryRow(ctx, `
INSERT INTO compliance_check_records (
tenant_id,
article_id,
article_version_id,
target_platforms,
content_hash,
policy_fingerprint,
trigger_source,
enforcement_mode,
highest_level,
passed,
hit_count,
stored_hit_count,
truncated,
dict_versions,
llm_used,
duration_ms,
checked_by,
manual_review_status,
manual_review_id
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,FALSE,$15,$16,$17,$18)
RETURNING id, checked_at
`,
record.TenantID,
articleID,
articleVersionID,
record.TargetPlatforms,
record.ContentHash,
record.PolicyFingerprint,
input.TriggerSource,
record.EnforcementMode,
highest,
record.Passed,
record.HitCount,
record.StoredHitCount,
record.Truncated,
dictVersions,
record.DurationMS,
checkedBy,
string(record.ManualReviewStatus),
int64PtrAny(record.ManualReviewID),
).Scan(&record.ID, &record.CheckedAt)
if err != nil {
return nil, response.ErrInternal(51001, "compliance_check_record_create_failed", "failed to create compliance check record")
}
for i := range violations {
violations[i].RecordID = record.ID
id, err := s.insertViolation(ctx, record.ID, record.TenantID, violations[i])
if err != nil {
return nil, err
}
violations[i].ID = id
}
return checkResultFromRecord(record, violations, decision), nil
}
func (s *Service) insertViolation(ctx context.Context, recordID, tenantID int64, v sharedcompliance.Violation) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO compliance_violations (
record_id,
tenant_id,
platform_code,
source,
dictionary_code,
dictionary_name,
term_pattern,
matched_text,
start_offset,
end_offset,
dom_path,
level,
hint,
reference_law
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id
`,
recordID,
tenantID,
v.PlatformCode,
v.Source,
v.DictionaryCode,
v.DictionaryName,
v.TermPattern,
truncateRunes(v.MatchedText, maxMatchedTextRunes),
v.StartOffset,
v.EndOffset,
v.DomPath,
v.Level,
v.Hint,
v.ReferenceLaw,
).Scan(&id)
if err != nil {
return 0, response.ErrInternal(51002, "compliance_violation_create_failed", "failed to create compliance violation")
}
return id, nil
}
func (s *Service) CreateAck(ctx context.Context, tenantID, checkRecordID, actorID int64, acknowledgedViolationIDs []int64) (*sharedcompliance.AckRecord, error) {
record, err := s.loadCheckRecord(ctx, tenantID, checkRecordID)
if err != nil {
return nil, err
}
if record.ArticleID <= 0 || record.ArticleVersionID <= 0 {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "check record is not bound to an article version")
}
violations, err := s.loadViolations(ctx, tenantID, checkRecordID)
if err != nil {
return nil, err
}
required := make([]int64, 0, len(violations))
for _, violation := range violations {
if record.EnforcementMode == "mandatory" && violation.Level == "block" {
return nil, response.ErrConflict(41001, "compliance_blocked", "blocked violations cannot be acknowledged")
}
required = append(required, violation.ID)
}
if !sameInt64Set(required, acknowledgedViolationIDs) {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "acknowledged_violation_ids must cover every acknowledged violation")
}
if len(required) == 0 {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "there are no violations to acknowledge")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(51003, "compliance_ack_create_failed", "failed to begin compliance ack transaction")
}
defer rollbackTx(tx)
expiresAt := time.Now().UTC().Add(24 * time.Hour)
var ack sharedcompliance.AckRecord
err = tx.QueryRow(ctx, `
INSERT INTO compliance_ack_records (
tenant_id,
article_id,
article_version_id,
check_record_id,
content_hash,
policy_fingerprint,
acknowledged_by,
ack_violation_ids,
expires_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, check_record_id, article_id, article_version_id, content_hash, policy_fingerprint, expires_at, created_at
`,
tenantID,
record.ArticleID,
record.ArticleVersionID,
record.ID,
record.ContentHash,
record.PolicyFingerprint,
actorID,
required,
expiresAt,
).Scan(&ack.ID, &ack.CheckRecordID, &ack.ArticleID, &ack.ArticleVersionID, &ack.ContentHash, &ack.PolicyFingerprint, &ack.ExpiresAt, &ack.CreatedAt)
if err != nil {
return nil, response.ErrInternal(51003, "compliance_ack_create_failed", "failed to create compliance ack")
}
if _, err := tx.Exec(ctx, `
UPDATE compliance_violations
SET acknowledged_in_ack_id = $1
WHERE record_id = $2 AND tenant_id = $3 AND id = ANY($4)
`, ack.ID, record.ID, tenantID, required); err != nil {
return nil, response.ErrInternal(51003, "compliance_ack_create_failed", "failed to mark acknowledged violations")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(51003, "compliance_ack_create_failed", "failed to commit compliance ack")
}
return &ack, nil
}
func (s *Service) ConsumeAckTx(ctx context.Context, tx pgx.Tx, ackRecordID int64, q ConsumeAckQuery) (bool, error) {
if tx == nil {
return false, errors.New("tx is nil")
}
tag, err := tx.Exec(ctx, `
UPDATE compliance_ack_records
SET consumed_at = NOW(), consumed_by_job_id = $1
WHERE id = $2
AND tenant_id = $3
AND article_id = $4
AND article_version_id = $5
AND check_record_id = $6
AND content_hash = $7
AND policy_fingerprint = $8
AND consumed_at IS NULL
AND expires_at > NOW()
`,
q.ConsumedByJobID,
ackRecordID,
q.TenantID,
q.ArticleID,
q.ArticleVersionID,
q.CheckRecordID,
q.ContentHash,
q.PolicyFingerprint,
)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *Service) validateAckForPublish(ctx context.Context, req GateForPublishRequest, policy *policySnapshot) (*sharedcompliance.CheckResult, error) {
var recordID int64
err := s.pool.QueryRow(ctx, `
SELECT check_record_id
FROM compliance_ack_records
WHERE id = $1
AND tenant_id = $2
AND article_id = $3
AND article_version_id = $4
AND consumed_at IS NULL
AND expires_at > NOW()
`, *req.AckRecordID, req.TenantID, req.ArticleID, req.ArticleVersionID).Scan(&recordID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is expired, consumed, or not bound to this article version")
}
return nil, response.ErrInternal(51004, "compliance_ack_lookup_failed", "failed to validate compliance ack")
}
record, err := s.loadCheckRecord(ctx, req.TenantID, recordID)
if err != nil {
return nil, err
}
expectedFingerprint := policyFingerprint(policy, req.TargetPlatforms)
if record.ArticleID != req.ArticleID ||
record.ArticleVersionID != req.ArticleVersionID ||
record.PolicyFingerprint != expectedFingerprint {
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record no longer matches current content or policy")
}
violations, err := s.loadViolations(ctx, req.TenantID, record.ID)
if err != nil {
return nil, err
}
for _, violation := range violations {
if record.EnforcementMode == "mandatory" && violation.Level == "block" {
return nil, response.ErrConflict(41001, "compliance_blocked", "blocked violations cannot be acknowledged")
}
}
result := checkResultFromRecord(record, violations, sharedcompliance.GateDecisionPass)
result.Decision = sharedcompliance.GateDecisionPass
result.Passed = true
return result, nil
}
func (s *Service) LatestCheck(ctx context.Context, tenantID, articleID int64) (*sharedcompliance.CheckResult, error) {
var recordID int64
err := s.pool.QueryRow(ctx, `
SELECT id
FROM compliance_check_records
WHERE tenant_id = $1 AND article_id = $2
ORDER BY checked_at DESC, id DESC
LIMIT 1
`, tenantID, articleID).Scan(&recordID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40461, "compliance_check_not_found", "no compliance check record found")
}
return nil, response.ErrInternal(51005, "compliance_check_lookup_failed", "failed to load latest compliance check")
}
record, err := s.loadCheckRecord(ctx, tenantID, recordID)
if err != nil {
return nil, err
}
violations, err := s.loadViolations(ctx, tenantID, recordID)
if err != nil {
return nil, err
}
return checkResultFromRecord(record, violations, decide(record.EnforcementMode, record.HighestLevel, record.HitCount)), nil
}
func (s *Service) MarkPublishJobBlockedByCompliance(ctx context.Context, jobID uuid.UUID, result *sharedcompliance.CheckResult) error {
if s == nil || s.pool == nil || jobID == uuid.Nil {
return nil
}
var recordID any
var reason any
if result != nil && result.RecordID > 0 {
recordID = result.RecordID
reasonBytes, _ := json.Marshal(result)
reason = string(reasonBytes)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(51012, "compliance_publish_job_block_failed", "failed to begin compliance block transaction")
}
defer rollbackTx(tx)
if _, err := tx.Exec(ctx, `
UPDATE desktop_publish_jobs
SET status = 'blocked_by_compliance',
compliance_blocked_record_id = $2,
compliance_blocked_at = NOW(),
compliance_blocked_reason = $3,
updated_at = NOW()
WHERE desktop_id = $1
AND status <> 'blocked_by_compliance'
`, jobID, recordID, reason); err != nil {
return response.ErrInternal(51012, "compliance_publish_job_block_failed", "failed to mark publish job blocked")
}
if _, err := tx.Exec(ctx, `
UPDATE desktop_tasks
SET status = 'aborted',
error = jsonb_build_object(
'message', 'compliance_blocked',
'record_id', $2::bigint,
'detail', $3::text
),
active_attempt_id = NULL,
lease_token_hash = NULL,
lease_expires_at = NULL,
updated_at = NOW()
WHERE job_id = $1
AND kind = 'publish'
AND status = 'queued'
`, jobID, recordID, reason); err != nil {
return response.ErrInternal(51012, "compliance_publish_job_block_failed", "failed to abort blocked publish tasks")
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(51012, "compliance_publish_job_block_failed", "failed to commit compliance block")
}
return nil
}
func (s *Service) SubmitManualReview(ctx context.Context, req CreateManualReviewRequest) (*sharedcompliance.ManualReview, error) {
note := strings.TrimSpace(req.Note)
if note == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "note is required")
}
if utf8.RuneCountInString(note) > 500 {
return nil, response.ErrBadRequest(40001, "invalid_params", "note must be 500 characters or less")
}
record, err := s.loadCheckRecord(ctx, req.TenantID, req.CheckRecordID)
if err != nil {
return nil, err
}
if record.ArticleID != req.ArticleID || record.ArticleVersionID <= 0 {
return nil, response.ErrBadRequest(41009, "compliance_manual_review_invalid_check_record", "check record does not belong to this article version")
}
violations, err := s.loadViolations(ctx, req.TenantID, record.ID)
if err != nil {
return nil, err
}
hasBlock := false
for _, violation := range violations {
if violation.Level == "block" {
hasBlock = true
break
}
}
if !hasBlock {
return nil, response.ErrBadRequest(41009, "compliance_manual_review_invalid_check_record", "manual review requires at least one block violation")
}
summaryJSON, err := json.Marshal(violations)
if err != nil {
return nil, response.ErrInternal(51006, "manual_review_create_failed", "failed to encode manual review summary")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(51006, "manual_review_create_failed", "failed to begin manual review transaction")
}
defer rollbackTx(tx)
var review sharedcompliance.ManualReview
err = tx.QueryRow(ctx, `
INSERT INTO compliance_manual_reviews (
tenant_id,
article_id,
article_version_id,
original_check_record_id,
original_content_hash,
original_policy_fingerprint,
original_target_platforms,
original_violation_summary,
requested_by,
request_note,
status
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending')
RETURNING id, tenant_id, article_id, article_version_id, original_check_record_id, requested_by, requested_at, request_note, status
`,
req.TenantID,
req.ArticleID,
record.ArticleVersionID,
record.ID,
record.ContentHash,
record.PolicyFingerprint,
record.TargetPlatforms,
summaryJSON,
req.ActorID,
note,
).Scan(&review.ID, &review.TenantID, &review.ArticleID, &review.ArticleVersionID, &review.CheckRecordID, &review.RequestedBy, &review.RequestedAt, &review.RequestNote, &review.Status)
if err != nil {
if isUniqueViolation(err) {
_ = tx.Rollback(ctx)
status, lookupErr := s.lookupManualReviewStatus(ctx, req.TenantID, record.ArticleVersionID)
if lookupErr == nil && status == sharedcompliance.ManualReviewStatusPending {
return nil, response.ErrConflict(41007, "compliance_manual_review_pending", "manual review is already pending")
}
return nil, response.ErrConflict(41008, "compliance_manual_review_terminal", "manual review for this version is already terminal")
}
return nil, response.ErrInternal(51006, "manual_review_create_failed", "failed to create manual review")
}
if _, err := tx.Exec(ctx, `
UPDATE article_versions
SET manual_review_status = 'pending',
manual_review_id = $1,
manual_review_updated_at = NOW()
WHERE id = $2 AND article_id = $3
`, review.ID, record.ArticleVersionID, req.ArticleID); err != nil {
return nil, response.ErrInternal(51006, "manual_review_create_failed", "failed to stamp article version")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(51006, "manual_review_create_failed", "failed to commit manual review")
}
review.ViolationSummary = violations
review.OriginalPlatforms = record.TargetPlatforms
s.reviewCache.Delete(record.ArticleVersionID)
return &review, nil
}
func (s *Service) LatestManualReview(ctx context.Context, tenantID, articleID int64, requestedVersionID *int64) (*sharedcompliance.ManualReview, error) {
versionID, err := s.ResolvePublishableVersion(ctx, tenantID, articleID, requestedVersionID)
if err != nil {
return nil, err
}
review, err := s.loadLatestManualReviewByVersion(ctx, tenantID, versionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &sharedcompliance.ManualReview{
TenantID: tenantID,
ArticleID: articleID,
ArticleVersionID: versionID,
Status: sharedcompliance.ManualReviewStatusNone,
}, nil
}
return nil, err
}
return review, nil
}
func (s *Service) CancelManualReview(ctx context.Context, req CancelManualReviewRequest) (*sharedcompliance.ManualReview, error) {
reason := strings.TrimSpace(req.Reason)
if utf8.RuneCountInString(reason) > 500 {
return nil, response.ErrBadRequest(40001, "invalid_params", "reason must be 500 characters or less")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(51007, "manual_review_cancel_failed", "failed to begin manual review cancellation")
}
defer rollbackTx(tx)
var review sharedcompliance.ManualReview
err = tx.QueryRow(ctx, `
UPDATE compliance_manual_reviews
SET status = 'cancelled',
cancelled_by = $1,
cancelled_at = NOW(),
cancel_reason = $2
WHERE id = $3
AND tenant_id = $4
AND article_id = $5
AND requested_by = $1
AND status = 'pending'
RETURNING id, tenant_id, article_id, article_version_id, original_check_record_id, requested_by, requested_at, request_note, status, cancelled_by, cancelled_at, cancel_reason
`, req.ActorID, nullableString(reason), req.ReviewID, req.TenantID, req.ArticleID).Scan(
&review.ID,
&review.TenantID,
&review.ArticleID,
&review.ArticleVersionID,
&review.CheckRecordID,
&review.RequestedBy,
&review.RequestedAt,
&review.RequestNote,
&review.Status,
&review.CancelledBy,
&review.CancelledAt,
&review.CancelReason,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrConflict(41010, "compliance_manual_review_not_cancellable", "manual review cannot be cancelled")
}
return nil, response.ErrInternal(51007, "manual_review_cancel_failed", "failed to cancel manual review")
}
if _, err := tx.Exec(ctx, `
UPDATE article_versions
SET manual_review_status = 'none',
manual_review_id = NULL,
manual_review_updated_at = NOW()
WHERE id = $1 AND article_id = $2
`, review.ArticleVersionID, req.ArticleID); err != nil {
return nil, response.ErrInternal(51007, "manual_review_cancel_failed", "failed to clear article version stamp")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(51007, "manual_review_cancel_failed", "failed to commit manual review cancellation")
}
s.reviewCache.Delete(review.ArticleVersionID)
return &review, nil
}
func DecodeReviewJobMessage(body []byte) (ReviewJobMessage, error) {
var msg ReviewJobMessage
if err := json.Unmarshal(body, &msg); err != nil {
return msg, err
}
if msg.JobID <= 0 || msg.TenantID <= 0 || msg.ArticleID <= 0 || msg.ArticleVersionID <= 0 || msg.CheckRecordID <= 0 {
return msg, fmt.Errorf("invalid compliance review job message")
}
return msg, nil
}
func (s *Service) ProcessReviewJob(ctx context.Context, msg ReviewJobMessage) error {
if s == nil || s.pool == nil {
return response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "compliance service is unavailable")
}
cfg := s.currentComplianceConfig()
if !cfg.Enabled || !cfg.LLMJudgeEnabled {
return s.markReviewJobDone(ctx, msg.JobID)
}
if s.llm == nil || s.llm.Validate() != nil {
return s.failReviewJob(ctx, msg.JobID, cfg.ReviewMaxAttempts, sharedllm.ErrNotConfigured)
}
job, claimed, err := s.claimReviewJob(ctx, msg)
if err != nil || !claimed {
return err
}
prompt, err := s.buildReviewPrompt(ctx, job)
if err != nil {
return s.failReviewJob(ctx, job.ID, cfg.ReviewMaxAttempts, err)
}
if strings.TrimSpace(prompt) == "" {
return s.markReviewJobDone(ctx, job.ID)
}
reservation, billingErr := s.reserveComplianceLLMJudgePoints(ctx, job, prompt)
if billingErr != nil {
if s.logger != nil {
s.logger.Warn("compliance llm billing skipped review",
zap.Error(billingErr),
zap.Int64("tenant_id", job.TenantID),
zap.Int64("job_id", job.ID),
zap.Int64("check_record_id", job.CheckRecordID),
)
}
return s.markReviewJobDone(ctx, job.ID)
}
result, err := s.llm.Generate(ctx, sharedllm.GenerateRequest{
Prompt: prompt,
Timeout: cfg.ReviewWorkerTimeout,
MaxOutputTokens: 900,
ResponseFormat: &sharedllm.ResponseFormat{
Type: sharedllm.ResponseFormatTypeJSONObject,
Name: "compliance_review",
Description: "Semantic compliance review result for a publish check record.",
},
}, nil)
if err != nil {
_ = s.refundComplianceLLMJudgePoints(context.Background(), job, reservation, err)
return s.failReviewJob(ctx, job.ID, cfg.ReviewMaxAttempts, err)
}
summary := decodeLLMReviewResult(result.Content)
if err := s.persistLLMReviewResult(ctx, job, summary); err != nil {
_ = s.refundComplianceLLMJudgePoints(context.Background(), job, reservation, err)
return s.failReviewJob(ctx, job.ID, cfg.ReviewMaxAttempts, err)
}
if err := s.completeComplianceLLMJudgePoints(ctx, job, reservation, result.Model); err != nil && s.logger != nil {
s.logger.Warn("compliance llm billing completion failed",
zap.Error(err),
zap.Int64("tenant_id", job.TenantID),
zap.Int64("job_id", job.ID),
zap.Int64("check_record_id", job.CheckRecordID),
)
}
return s.markReviewJobDone(ctx, job.ID)
}
type reviewJob struct {
ID int64
TenantID int64
ArticleID int64
ArticleVersionID int64
CheckRecordID int64
CheckedBy int64
Attempts int
}
func (s *Service) claimReviewJob(ctx context.Context, msg ReviewJobMessage) (reviewJob, bool, error) {
var job reviewJob
err := s.pool.QueryRow(ctx, `
UPDATE compliance_review_jobs
SET status = 'running',
attempts = attempts + 1,
locked_at = NOW(),
locked_by = $2,
updated_at = NOW()
WHERE id = $1
AND status IN ('pending','failed')
AND available_at <= NOW()
RETURNING
id,
tenant_id,
article_id,
article_version_id,
check_record_id,
COALESCE((
SELECT checked_by
FROM compliance_check_records cr
WHERE cr.id = compliance_review_jobs.check_record_id
), 0),
attempts
`, msg.JobID, strings.TrimSpace(msg.MessageID)).Scan(
&job.ID,
&job.TenantID,
&job.ArticleID,
&job.ArticleVersionID,
&job.CheckRecordID,
&job.CheckedBy,
&job.Attempts,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return job, false, nil
}
return job, false, err
}
return job, true, nil
}
func (s *Service) buildReviewPrompt(ctx context.Context, job reviewJob) (string, error) {
var title string
var plaintext pgtype.Text
err := s.pool.QueryRow(ctx, `
SELECT av.title, av.plaintext_snapshot
FROM article_versions av
JOIN articles a ON a.id = av.article_id
WHERE av.id = $1 AND a.id = $2 AND a.tenant_id = $3
`, job.ArticleVersionID, job.ArticleID, job.TenantID).Scan(&title, &plaintext)
if err != nil {
return "", err
}
text := strings.TrimSpace(plaintext.String)
if text == "" {
snapshot, err := s.loadArticleVersionSnapshot(ctx, job.TenantID, job.ArticleID, job.ArticleVersionID)
if err != nil {
return "", err
}
text = snapshot.Plaintext
title = snapshot.Title
}
return strings.TrimSpace(`你是内容合规复核助手。请只基于给定标题和正文判断是否存在广告法极限词、政治敏感、涉黄涉暴、行业违禁或明显平台高风险表达。
返回 JSON,不要输出 Markdown
{
"risk": true,
"level": "high",
"summary": "一句话说明风险",
"evidence": "命中的短句或关键词",
"hint": "给作者的修改建议"
}
若没有高风险,返回 {"risk": false, "level": "info", "summary": "", "evidence": "", "hint": ""}。
标题:` + title + `
正文:
` + truncateRunes(text, 6000)), nil
}
type llmReviewSummary struct {
Risk bool `json:"risk"`
Level string `json:"level"`
Summary string `json:"summary"`
Evidence string `json:"evidence"`
Hint string `json:"hint"`
}
func decodeLLMReviewResult(content string) llmReviewSummary {
var summary llmReviewSummary
_ = json.Unmarshal([]byte(strings.TrimSpace(content)), &summary)
summary.Level = normalizeLevel(summary.Level)
if summary.Level == "" {
summary.Level = "info"
}
summary.Summary = strings.TrimSpace(summary.Summary)
summary.Evidence = strings.TrimSpace(summary.Evidence)
summary.Hint = strings.TrimSpace(summary.Hint)
return summary
}
func (s *Service) persistLLMReviewResult(ctx context.Context, job reviewJob, summary llmReviewSummary) error {
if !summary.Risk {
_, err := s.pool.Exec(ctx, `
UPDATE compliance_check_records
SET llm_used = TRUE
WHERE id = $1 AND tenant_id = $2
`, job.CheckRecordID, job.TenantID)
return err
}
matchedText := summary.Evidence
if matchedText == "" {
matchedText = summary.Summary
}
hint := summary.Hint
if hint == "" {
hint = summary.Summary
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer rollbackTx(tx)
if _, err := tx.Exec(ctx, `
INSERT INTO compliance_violations (
record_id,
tenant_id,
source,
matched_text,
start_offset,
end_offset,
level,
hint
)
VALUES ($1,$2,'llm',$3,0,0,$4,$5)
`, job.CheckRecordID, job.TenantID, truncateRunes(matchedText, maxMatchedTextRunes), summary.Level, nullableString(hint)); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE compliance_check_records
SET llm_used = TRUE,
hit_count = hit_count + 1,
stored_hit_count = stored_hit_count + 1,
highest_level = CASE
WHEN highest_level IS NULL OR $3 > CASE highest_level
WHEN 'block' THEN 4
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'info' THEN 1
ELSE 0
END THEN $2
ELSE highest_level
END
WHERE id = $1 AND tenant_id = $4
`, job.CheckRecordID, summary.Level, levelRank(summary.Level), job.TenantID); err != nil {
return err
}
return tx.Commit(ctx)
}
type aiPointReservation struct {
ReservationID int64
UsageLogID int64
Points int
RequestChars int
BaseChars int
}
func (s *Service) reserveComplianceLLMJudgePoints(ctx context.Context, job reviewJob, meteredText string) (*aiPointReservation, error) {
if s == nil || s.pool == nil {
return nil, response.ErrServiceUnavailable(50344, "ai_points_unavailable", "ai points store is unavailable")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer rollbackTx(tx)
if err := lockTenantAIPoints(ctx, tx, job.TenantID); err != nil {
return nil, err
}
now := time.Now().UTC()
quotaRepo := tenantrepo.NewQuotaRepository(tx)
status, err := quotaRepo.GetAIQuotaStatus(ctx, job.TenantID, now)
if err != nil {
return nil, err
}
requestChars := countAIPointChars(meteredText)
points := calculateAIPointCost(requestChars, status.BaseChars)
if status.Total <= 0 || status.Balance < points {
return nil, response.ErrForbidden(40381, "ai_points_insufficient", "AI points are insufficient")
}
resourceType := aiResourceTypeComplianceReview
resourceID := job.CheckRecordID
reservationID, err := quotaRepo.CreateQuotaReservation(ctx, tenantrepo.QuotaReservationInput{
TenantID: job.TenantID,
OperatorID: job.CheckedBy,
QuotaType: aiPointsQuotaType,
ResourceType: resourceType,
ResourceID: &resourceID,
ReservedAmount: points,
ExpireAt: now.Add(time.Hour),
})
if err != nil {
return nil, err
}
metadataJSON, err := json.Marshal(map[string]any{
"article_id": job.ArticleID,
"article_version_id": job.ArticleVersionID,
"check_record_id": job.CheckRecordID,
"review_job_id": job.ID,
})
if err != nil {
return nil, err
}
usageID, err := tenantrepo.NewAIPointUsageRepository(tx).Create(ctx, tenantrepo.CreateAIPointUsageLogInput{
TenantID: job.TenantID,
OperatorID: job.CheckedBy,
QuotaReservationID: reservationID,
UsageType: aiUsageTypeComplianceLLMJudge,
ResourceType: &resourceType,
ResourceID: &resourceID,
RequestChars: requestChars,
BaseChars: status.BaseChars,
Points: points,
MetadataJSON: metadataJSON,
})
if err != nil {
return nil, err
}
reason := "ai_points_reserve"
referenceType := "ai_point_usage"
if _, err := quotaRepo.InsertQuotaLedger(ctx, tenantrepo.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.CheckedBy,
QuotaType: aiPointsQuotaType,
Delta: -points,
BalanceAfter: status.Balance - points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &aiPointReservation{
ReservationID: reservationID,
UsageLogID: usageID,
Points: points,
RequestChars: requestChars,
BaseChars: status.BaseChars,
}, nil
}
func (s *Service) completeComplianceLLMJudgePoints(ctx context.Context, job reviewJob, reservation *aiPointReservation, model string) error {
if s == nil || s.pool == nil || reservation == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 {
return nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer rollbackTx(tx)
pending, err := lockPendingAIPointUsage(ctx, tx, job.TenantID, reservation.UsageLogID)
if err != nil || !pending {
return err
}
2026-05-12 02:24:57 +08:00
if err := confirmAIPointReservationIfPending(ctx, tx, job.TenantID, reservation.ReservationID); err != nil {
return err
}
var modelPtr *string
if strings.TrimSpace(model) != "" {
normalized := strings.TrimSpace(model)
modelPtr = &normalized
}
if err := tenantrepo.NewAIPointUsageRepository(tx).MarkCompleted(ctx, job.TenantID, reservation.UsageLogID, modelPtr); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Service) refundComplianceLLMJudgePoints(ctx context.Context, job reviewJob, reservation *aiPointReservation, jobErr error) error {
if s == nil || s.pool == nil || reservation == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 || reservation.Points <= 0 {
return nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer rollbackTx(tx)
if err := lockTenantAIPoints(ctx, tx, job.TenantID); err != nil {
return err
}
pending, err := lockPendingAIPointUsage(ctx, tx, job.TenantID, reservation.UsageLogID)
if err != nil || !pending {
return err
}
2026-05-12 02:24:57 +08:00
refunded, err := refundAIPointReservationIfPending(ctx, tx, job.TenantID, reservation.ReservationID)
if err != nil {
return err
}
2026-05-12 02:24:57 +08:00
if refunded {
status, err := tenantrepo.NewQuotaRepository(tx).GetAIQuotaStatus(ctx, job.TenantID, time.Now().UTC())
if err != nil {
return err
}
quotaRepo := tenantrepo.NewQuotaRepository(tx)
reason := "ai_points_refund"
referenceType := "ai_point_usage"
usageID := reservation.UsageLogID
if _, err := quotaRepo.InsertQuotaLedger(ctx, tenantrepo.QuotaLedgerInput{
TenantID: job.TenantID,
OperatorID: job.CheckedBy,
QuotaType: aiPointsQuotaType,
Delta: reservation.Points,
BalanceAfter: status.Balance + reservation.Points,
Reason: &reason,
ReferenceType: &referenceType,
ReferenceID: &usageID,
}); err != nil {
return err
}
}
if err := tenantrepo.NewAIPointUsageRepository(tx).MarkRefunded(ctx, job.TenantID, reservation.UsageLogID, truncateRunes(errorString(jobErr), 2000)); err != nil {
return err
}
return tx.Commit(ctx)
}
func countAIPointChars(text string) int {
return utf8.RuneCountInString(strings.TrimSpace(text))
}
func calculateAIPointCost(chars, baseChars int) int {
if baseChars <= 0 {
baseChars = 1000
}
if chars <= 0 {
return 1
}
return chars/baseChars + 1
}
func lockTenantAIPoints(ctx context.Context, tx pgx.Tx, tenantID int64) error {
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(20260428, hashint8($1)::int)`, tenantID)
return err
}
func lockPendingAIPointUsage(ctx context.Context, tx pgx.Tx, tenantID, usageLogID int64) (bool, error) {
var status string
err := tx.QueryRow(ctx, `
SELECT status
FROM ai_point_usage_logs
WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, usageLogID, tenantID).Scan(&status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return status == "pending", nil
}
2026-05-12 02:24:57 +08:00
func confirmAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'confirmed',
consumed_amount = reserved_amount,
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType)
if err != nil {
return err
}
2026-05-12 02:24:57 +08:00
return ignoreAIPointReservationReset(ctx, tx, tag.RowsAffected() > 0, tenantID, reservationID)
}
2026-05-12 02:24:57 +08:00
func refundAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) (bool, error) {
tag, err := tx.Exec(ctx, `
UPDATE quota_reservations
SET status = 'refunded',
refunded_amount = reserved_amount,
updated_at = NOW()
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
AND status = 'pending'
`, reservationID, tenantID, aiPointsQuotaType)
2026-05-12 02:24:57 +08:00
if err != nil {
return false, err
}
if tag.RowsAffected() > 0 {
return true, nil
}
return false, ignoreAIPointReservationReset(ctx, tx, false, tenantID, reservationID)
}
func ignoreAIPointReservationReset(ctx context.Context, tx pgx.Tx, updated bool, tenantID, reservationID int64) error {
if updated {
return nil
}
var status string
err := tx.QueryRow(ctx, `
SELECT status
FROM quota_reservations
WHERE id = $1
AND tenant_id = $2
AND quota_type = $3
`, reservationID, tenantID, aiPointsQuotaType).Scan(&status)
if err != nil {
return err
}
2026-05-12 02:24:57 +08:00
if status == "reset" {
return nil
}
2026-05-12 02:24:57 +08:00
return errors.New("ai points reservation is not pending")
}
func (s *Service) markReviewJobDone(ctx context.Context, jobID int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE compliance_review_jobs
SET status = 'done',
locked_at = NULL,
locked_by = NULL,
updated_at = NOW()
WHERE id = $1
`, jobID)
return err
}
func (s *Service) failReviewJob(ctx context.Context, jobID int64, maxAttempts int, jobErr error) error {
if maxAttempts <= 0 {
maxAttempts = 3
}
_, err := s.pool.Exec(ctx, `
UPDATE compliance_review_jobs
SET status = CASE WHEN attempts >= $2 THEN 'failed' ELSE 'pending' END,
available_at = CASE WHEN attempts >= $2 THEN available_at ELSE NOW() + interval '5 minutes' END,
locked_at = NULL,
locked_by = NULL,
last_error = $3,
updated_at = NOW()
WHERE id = $1
`, jobID, maxAttempts, truncateRunes(errorString(jobErr), 2000))
if err != nil {
return err
}
return jobErr
}
func (s *Service) enqueueLLMReview(ctx context.Context, input runInput, checkRecordID int64) {
if s == nil || s.pool == nil || s.mq == nil || checkRecordID <= 0 {
return
}
messageID := uuid.New()
var jobID int64
err := s.pool.QueryRow(ctx, `
INSERT INTO compliance_review_jobs (
message_id,
tenant_id,
article_id,
article_version_id,
check_record_id,
status
)
VALUES ($1,$2,$3,$4,$5,'pending')
ON CONFLICT (check_record_id) DO UPDATE
SET available_at = LEAST(compliance_review_jobs.available_at, NOW()),
updated_at = NOW()
RETURNING id
`, messageID, input.TenantID, input.ArticleID, input.ArticleVersionID, checkRecordID).Scan(&jobID)
if err != nil {
if s.logger != nil {
s.logger.Warn("compliance review job enqueue failed",
zap.Error(err),
zap.Int64("tenant_id", input.TenantID),
zap.Int64("article_id", input.ArticleID),
zap.Int64("check_record_id", checkRecordID),
)
}
return
}
message := ReviewJobMessage{
JobID: jobID,
MessageID: messageID.String(),
TenantID: input.TenantID,
ArticleID: input.ArticleID,
ArticleVersionID: input.ArticleVersionID,
CheckRecordID: checkRecordID,
}
body, err := json.Marshal(message)
if err != nil {
return
}
if err := s.mq.PublishComplianceReviewTask(ctx, body); err != nil && s.logger != nil {
s.logger.Warn("compliance review message publish failed",
zap.Error(err),
zap.Int64("job_id", jobID),
zap.Int64("check_record_id", checkRecordID),
)
}
}
func (s *Service) createManualReviewBypassRecord(ctx context.Context, req GateForPublishRequest, snapshot articleVersionSnapshot) (*sharedcompliance.CheckResult, error) {
policy := &policySnapshot{
MasterEnabled: true,
EnforcementMode: "disabled",
DictVersions: map[string]int{},
}
return s.runAndPersist(ctx, runInput{
TenantID: req.TenantID,
ArticleID: req.ArticleID,
ArticleVersionID: req.ArticleVersionID,
ActorID: req.ActorID,
Title: snapshot.Title,
Plaintext: "",
TargetPlatforms: req.TargetPlatforms,
TriggerSource: triggerManualReviewBypass,
Policy: policy,
ManualStatus: snapshot.ManualReviewStatus,
ManualReviewID: snapshot.ManualReviewID,
})
}
func (s *Service) loadArticleVersionSnapshot(ctx context.Context, tenantID, articleID, versionID int64) (articleVersionSnapshot, error) {
var snapshot articleVersionSnapshot
var title pgtype.Text
var plaintext pgtype.Text
var manualStatus string
var manualReviewID pgtype.Int8
var decisionReason pgtype.Text
var reviewedAt pgtype.Timestamptz
err := s.pool.QueryRow(ctx, `
SELECT
a.id,
av.id,
av.title,
av.plaintext_snapshot,
av.manual_review_status,
av.manual_review_id,
mr.decision_reason,
mr.reviewed_at
FROM article_versions av
JOIN articles a ON a.id = av.article_id
LEFT JOIN compliance_manual_reviews mr ON mr.id = av.manual_review_id
WHERE a.tenant_id = $1
AND a.id = $2
AND av.id = $3
AND a.deleted_at IS NULL
`, tenantID, articleID, versionID).Scan(
&snapshot.ArticleID,
&snapshot.ArticleVersionID,
&title,
&plaintext,
&manualStatus,
&manualReviewID,
&decisionReason,
&reviewedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return snapshot, response.ErrBadRequest(41005, "invalid_article_version", "article version does not belong to this article")
}
return snapshot, err
}
snapshot.Title = strings.TrimSpace(title.String)
snapshot.Plaintext = strings.TrimSpace(plaintext.String)
if snapshot.Plaintext == "" {
content, err := tenantrepo.LoadArticleVersionContent(ctx, s.pool, articleID, versionID)
if err == nil && content != nil {
snapshot.Plaintext = strings.TrimSpace(snapshot.Title + "\n" + tenantrepo.ExtractArticlePlaintext(stringValue(content.MarkdownContent)))
_, _ = s.pool.Exec(ctx, `
UPDATE article_versions SET plaintext_snapshot = $1 WHERE id = $2 AND plaintext_snapshot IS NULL
`, snapshot.Plaintext, versionID)
}
}
snapshot.ManualReviewStatus = sharedcompliance.ManualReviewStatus(strings.TrimSpace(manualStatus))
if snapshot.ManualReviewStatus == "" {
snapshot.ManualReviewStatus = sharedcompliance.ManualReviewStatusNone
}
if manualReviewID.Valid {
value := manualReviewID.Int64
snapshot.ManualReviewID = &value
}
if decisionReason.Valid {
value := decisionReason.String
snapshot.ManualReviewReason = &value
}
if reviewedAt.Valid {
value := reviewedAt.Time
snapshot.ManualReviewDecidedAt = &value
}
return snapshot, nil
}
func (s *Service) resolvePolicy(ctx context.Context, _ int64) (*policySnapshot, error) {
cfg := s.currentComplianceConfig()
now := time.Now()
s.policyMu.Lock()
if cached, ok := s.policies[globalPolicyCacheKey]; ok && cached.policy != nil && now.Before(cached.expires) {
policy := cached.policy
s.policyMu.Unlock()
return policy, nil
}
s.policyMu.Unlock()
policy, err := s.loadPolicy(ctx)
if err != nil {
s.policyMu.Lock()
cached := s.policies[globalPolicyCacheKey]
s.policyMu.Unlock()
if cached.policy != nil && now.Sub(cached.policy.LoadedAt) < 24*time.Hour {
if s.logger != nil {
s.logger.Warn("using stale compliance policy", zap.Error(err))
}
return cached.policy, nil
}
return nil, response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "compliance policy is unavailable")
}
s.policyMu.Lock()
if s.policies == nil {
s.policies = make(map[int64]cachedPolicy)
}
s.policies[globalPolicyCacheKey] = cachedPolicy{policy: policy, expires: now.Add(cfg.SnapshotReloadInterval)}
s.policyMu.Unlock()
return policy, nil
}
func (s *Service) loadPolicy(ctx context.Context) (*policySnapshot, error) {
var (
globalMaster bool
globalMode string
globalEnabledRawText string
globalLLM bool
globalRevision int64
)
err := s.pool.QueryRow(ctx, `
SELECT
master_enabled,
enforcement_mode,
enabled_dictionaries::text,
llm_judge_enabled,
revision
FROM ops.compliance_policies
WHERE id = 1
LIMIT 1
`).Scan(
&globalMaster,
&globalMode,
&globalEnabledRawText,
&globalLLM,
&globalRevision,
)
if err != nil {
return nil, err
}
mode := strings.TrimSpace(globalMode)
enabledRaw := []byte(globalEnabledRawText)
llmEnabled := globalLLM
revision := globalRevision
enabledIDs := decodeJSONInt64Slice(enabledRaw)
terms, dictNames, dictVersions, versionSum, err := s.loadTerms(ctx, enabledIDs)
if err != nil {
return nil, err
}
return &policySnapshot{
LoadedAt: time.Now(),
MasterEnabled: globalMaster,
EnforcementMode: normalizeMode(mode),
LLMJudgeEnabled: llmEnabled && s.currentComplianceConfig().LLMJudgeEnabled,
Revision: revision,
DictionaryVersionSum: versionSum,
EnabledDictionaries: dictNames,
DictVersions: dictVersions,
Terms: terms,
}, nil
}
func editorPreviewPolicy(policy *policySnapshot) *policySnapshot {
if policy == nil {
return &policySnapshot{
LoadedAt: time.Now(),
MasterEnabled: true,
EnforcementMode: "advisory",
DictVersions: map[string]int{},
}
}
copyPolicy := *policy
if !copyPolicy.MasterEnabled {
copyPolicy.MasterEnabled = true
}
if copyPolicy.EnforcementMode == "disabled" || strings.TrimSpace(copyPolicy.EnforcementMode) == "" {
copyPolicy.EnforcementMode = "advisory"
}
copyPolicy.LLMJudgeEnabled = false
return &copyPolicy
}
func normalizeArticleCheckTrigger(trigger string) string {
switch strings.TrimSpace(trigger) {
case triggerPublishGate:
return triggerPublishGate
default:
return triggerEditorManual
}
}
func (s *Service) loadTerms(ctx context.Context, enabledIDs []int64) ([]termRule, []string, map[string]int, int64, error) {
filterEnabled := len(enabledIDs) > 0
rows, err := s.pool.Query(ctx, `
SELECT
d.id,
d.code,
d.name,
d.version,
d.platform_scope,
COALESCE(d.applicable_platforms, '{}') AS applicable_platforms,
t.match_type,
t.pattern,
COALESCE(t.level_override, d.default_level) AS level,
t.hint,
t.reference_law
FROM ops.compliance_dictionaries d
JOIN ops.compliance_dictionary_terms t ON t.dictionary_id = d.id
WHERE d.enabled = TRUE
AND t.enabled = TRUE
AND (NOT $1::boolean OR d.id = ANY($2::bigint[]))
ORDER BY d.id, t.id
`, filterEnabled, enabledIDs)
if err != nil {
return nil, nil, nil, 0, err
}
defer rows.Close()
terms := make([]termRule, 0)
dictSeen := map[string]struct{}{}
dictNames := make([]string, 0)
dictVersions := map[string]int{}
var versionSum int64
for rows.Next() {
var term termRule
var hint pgtype.Text
var ref pgtype.Text
if err := rows.Scan(
&term.DictionaryID,
&term.DictionaryCode,
&term.DictionaryName,
&term.DictionaryVersion,
&term.PlatformScope,
&term.ApplicablePlatforms,
&term.MatchType,
&term.Pattern,
&term.Level,
&hint,
&ref,
); err != nil {
return nil, nil, nil, 0, err
}
term.Pattern = strings.TrimSpace(term.Pattern)
if term.Pattern == "" {
continue
}
if hint.Valid {
value := hint.String
term.Hint = &value
}
if ref.Valid {
value := ref.String
term.ReferenceLaw = &value
}
if term.MatchType == "regex" {
compiled, err := regexp.Compile(term.Pattern)
if err != nil {
if s.logger != nil {
s.logger.Warn("skip invalid compliance regex", zap.String("pattern", term.Pattern), zap.Error(err))
}
continue
}
term.compiledRegex = compiled
term.compiledRegexPattern = term.Pattern
}
terms = append(terms, term)
if _, ok := dictSeen[term.DictionaryCode]; !ok {
dictSeen[term.DictionaryCode] = struct{}{}
dictNames = append(dictNames, term.DictionaryName)
dictVersions[term.DictionaryCode] = term.DictionaryVersion
versionSum += int64(term.DictionaryVersion)
}
}
return terms, dictNames, dictVersions, versionSum, rows.Err()
}
func matchViolations(policy *policySnapshot, tenantID int64, targetPlatforms []string, content string) []sharedcompliance.Violation {
if policy == nil || !policy.MasterEnabled || policy.EnforcementMode == "disabled" {
return nil
}
content = strings.TrimSpace(content)
if content == "" {
return nil
}
violations := make([]sharedcompliance.Violation, 0)
for _, term := range policy.Terms {
platforms := applicableViolationPlatforms(term, targetPlatforms)
if platforms == nil && term.PlatformScope == "specific" {
continue
}
switch term.MatchType {
case "regex":
if term.compiledRegex == nil {
continue
}
matches := term.compiledRegex.FindAllStringIndex(content, -1)
for _, match := range matches {
violations = append(violations, buildViolationsForTerm(term, platforms, content, match[0], match[1])...)
}
case "exact":
searchFrom := 0
for {
idx := strings.Index(content[searchFrom:], term.Pattern)
if idx < 0 {
break
}
start := searchFrom + idx
end := start + len(term.Pattern)
violations = append(violations, buildViolationsForTerm(term, platforms, content, start, end)...)
searchFrom = end
if searchFrom >= len(content) {
break
}
}
}
}
return violations
}
func applicableViolationPlatforms(term termRule, targetPlatforms []string) []string {
if term.PlatformScope != "specific" {
return nil
}
targetSet := make(map[string]struct{}, len(targetPlatforms))
for _, platform := range targetPlatforms {
targetSet[platform] = struct{}{}
}
out := make([]string, 0)
for _, platform := range term.ApplicablePlatforms {
platform = strings.TrimSpace(platform)
if platform == "" {
continue
}
if len(targetSet) == 0 {
out = append(out, platform)
continue
}
if _, ok := targetSet[platform]; ok {
out = append(out, platform)
}
}
sort.Strings(out)
return out
}
func buildViolationsForTerm(term termRule, platforms []string, content string, start, end int) []sharedcompliance.Violation {
matched := content[start:end]
base := sharedcompliance.Violation{
Source: sourceDict,
DictionaryCode: stringPtr(term.DictionaryCode),
DictionaryName: stringPtr(term.DictionaryName),
TermPattern: stringPtr(term.Pattern),
MatchedText: truncateRunes(matched, maxMatchedTextRunes),
StartOffset: start,
EndOffset: end,
Level: normalizeLevel(term.Level),
Hint: term.Hint,
ReferenceLaw: term.ReferenceLaw,
}
if len(platforms) == 0 {
return []sharedcompliance.Violation{base}
}
out := make([]sharedcompliance.Violation, 0, len(platforms))
for _, platform := range platforms {
next := base
next.PlatformCode = stringPtr(platform)
out = append(out, next)
}
return out
}
func (s *Service) loadCheckRecord(ctx context.Context, tenantID, recordID int64) (checkRecordRow, error) {
var record checkRecordRow
var articleID pgtype.Int8
var articleVersionID pgtype.Int8
var highest pgtype.Text
var manualReviewID pgtype.Int8
var manualStatus string
err := s.pool.QueryRow(ctx, `
SELECT
id,
tenant_id,
article_id,
article_version_id,
target_platforms,
content_hash,
policy_fingerprint,
enforcement_mode,
highest_level,
passed,
hit_count,
stored_hit_count,
truncated,
manual_review_status,
manual_review_id,
checked_at,
duration_ms
FROM compliance_check_records
WHERE id = $1 AND tenant_id = $2
`, recordID, tenantID).Scan(
&record.ID,
&record.TenantID,
&articleID,
&articleVersionID,
&record.TargetPlatforms,
&record.ContentHash,
&record.PolicyFingerprint,
&record.EnforcementMode,
&highest,
&record.Passed,
&record.HitCount,
&record.StoredHitCount,
&record.Truncated,
&manualStatus,
&manualReviewID,
&record.CheckedAt,
&record.DurationMS,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return record, response.ErrNotFound(40461, "compliance_check_not_found", "compliance check record not found")
}
return record, response.ErrInternal(51005, "compliance_check_lookup_failed", "failed to load compliance check record")
}
if articleID.Valid {
record.ArticleID = articleID.Int64
}
if articleVersionID.Valid {
record.ArticleVersionID = articleVersionID.Int64
}
if highest.Valid {
record.HighestLevel = &highest.String
}
if manualReviewID.Valid {
record.ManualReviewID = &manualReviewID.Int64
}
record.ManualReviewStatus = sharedcompliance.ManualReviewStatus(manualStatus)
return record, nil
}
func (s *Service) loadViolations(ctx context.Context, tenantID, recordID int64) ([]sharedcompliance.Violation, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id,
record_id,
platform_code,
source,
dictionary_code,
dictionary_name,
term_pattern,
matched_text,
start_offset,
end_offset,
dom_path,
level,
hint,
reference_law
FROM compliance_violations
WHERE tenant_id = $1 AND record_id = $2
ORDER BY id
`, tenantID, recordID)
if err != nil {
return nil, response.ErrInternal(51008, "compliance_violation_lookup_failed", "failed to load compliance violations")
}
defer rows.Close()
out := make([]sharedcompliance.Violation, 0)
for rows.Next() {
var v sharedcompliance.Violation
var platform pgtype.Text
var dictCode pgtype.Text
var dictName pgtype.Text
var termPattern pgtype.Text
var domPath pgtype.Text
var hint pgtype.Text
var ref pgtype.Text
if err := rows.Scan(
&v.ID,
&v.RecordID,
&platform,
&v.Source,
&dictCode,
&dictName,
&termPattern,
&v.MatchedText,
&v.StartOffset,
&v.EndOffset,
&domPath,
&v.Level,
&hint,
&ref,
); err != nil {
return nil, response.ErrInternal(51008, "compliance_violation_lookup_failed", "failed to scan compliance violations")
}
if platform.Valid {
v.PlatformCode = &platform.String
}
if dictCode.Valid {
v.DictionaryCode = &dictCode.String
}
if dictName.Valid {
v.DictionaryName = &dictName.String
}
if termPattern.Valid {
v.TermPattern = &termPattern.String
}
if domPath.Valid {
v.DomPath = &domPath.String
}
if hint.Valid {
v.Hint = &hint.String
}
if ref.Valid {
v.ReferenceLaw = &ref.String
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Service) loadLatestManualReviewByVersion(ctx context.Context, tenantID, versionID int64) (*sharedcompliance.ManualReview, error) {
var review sharedcompliance.ManualReview
var checkID pgtype.Int8
var requestNote pgtype.Text
var reviewedBy pgtype.Int8
var reviewedAt pgtype.Timestamptz
var decisionReason pgtype.Text
var cancelledBy pgtype.Int8
var cancelledAt pgtype.Timestamptz
var cancelReason pgtype.Text
var summaryRaw []byte
var platforms []string
err := s.pool.QueryRow(ctx, `
SELECT
mr.id,
mr.tenant_id,
mr.article_id,
mr.article_version_id,
mr.original_check_record_id,
mr.requested_by,
mr.requested_at,
mr.request_note,
mr.status,
mr.cancelled_by,
mr.cancelled_at,
mr.cancel_reason,
mr.reviewed_by,
mr.reviewed_at,
mr.decision_reason,
mr.original_violation_summary,
mr.original_target_platforms
FROM compliance_manual_reviews mr
WHERE mr.tenant_id = $1
AND mr.article_version_id = $2
AND mr.status <> 'cancelled'
ORDER BY mr.requested_at DESC, mr.id DESC
LIMIT 1
`, tenantID, versionID).Scan(
&review.ID,
&review.TenantID,
&review.ArticleID,
&review.ArticleVersionID,
&checkID,
&review.RequestedBy,
&review.RequestedAt,
&requestNote,
&review.Status,
&cancelledBy,
&cancelledAt,
&cancelReason,
&reviewedBy,
&reviewedAt,
&decisionReason,
&summaryRaw,
&platforms,
)
if err != nil {
return nil, err
}
if checkID.Valid {
review.CheckRecordID = &checkID.Int64
}
if requestNote.Valid {
review.RequestNote = &requestNote.String
}
if reviewedBy.Valid {
review.ReviewedBy = &reviewedBy.Int64
}
if reviewedAt.Valid {
review.ReviewedAt = &reviewedAt.Time
}
if decisionReason.Valid {
review.DecisionReason = &decisionReason.String
}
if cancelledBy.Valid {
review.CancelledBy = &cancelledBy.Int64
}
if cancelledAt.Valid {
review.CancelledAt = &cancelledAt.Time
}
if cancelReason.Valid {
review.CancelReason = &cancelReason.String
}
_ = json.Unmarshal(summaryRaw, &review.ViolationSummary)
review.OriginalPlatforms = platforms
return &review, nil
}
func (s *Service) lookupManualReviewStatus(ctx context.Context, tenantID, versionID int64) (sharedcompliance.ManualReviewStatus, error) {
var status string
err := s.pool.QueryRow(ctx, `
SELECT status
FROM compliance_manual_reviews
WHERE tenant_id = $1 AND article_version_id = $2 AND status <> 'cancelled'
ORDER BY requested_at DESC, id DESC
LIMIT 1
`, tenantID, versionID).Scan(&status)
if err != nil {
return "", err
}
return sharedcompliance.ManualReviewStatus(status), nil
}
func checkResultFromRecord(record checkRecordRow, violations []sharedcompliance.Violation, decision sharedcompliance.GateDecision) *sharedcompliance.CheckResult {
return &sharedcompliance.CheckResult{
RecordID: record.ID,
Decision: decision,
Passed: record.Passed,
HighestLevel: record.HighestLevel,
Violations: violations,
HitCount: record.HitCount,
StoredHitCount: record.StoredHitCount,
Truncated: record.Truncated,
EnforcementMode: record.EnforcementMode,
ContentHash: record.ContentHash,
PolicyFingerprint: record.PolicyFingerprint,
TargetPlatforms: record.TargetPlatforms,
ManualReviewStatus: record.ManualReviewStatus,
ManualReviewID: record.ManualReviewID,
CheckedAt: record.CheckedAt,
DurationMS: record.DurationMS,
}
}
func manualReviewOnlyResult(snapshot articleVersionSnapshot, targets []string) *sharedcompliance.CheckResult {
decision := sharedcompliance.GateDecisionBlock
passed := false
if snapshot.ManualReviewStatus == sharedcompliance.ManualReviewStatusApproved {
decision = sharedcompliance.GateDecisionPass
passed = true
}
return &sharedcompliance.CheckResult{
Decision: decision,
Passed: passed,
Violations: []sharedcompliance.Violation{},
EnforcementMode: "disabled",
ContentHash: contentHash(snapshot.Title, snapshot.Plaintext),
PolicyFingerprint: "",
TargetPlatforms: normalizePlatforms(targets),
ManualReviewStatus: snapshot.ManualReviewStatus,
ManualReviewID: snapshot.ManualReviewID,
ManualReviewReason: snapshot.ManualReviewReason,
ManualReviewDecidedAt: snapshot.ManualReviewDecidedAt,
CheckedAt: time.Now().UTC(),
}
}
func disabledCheckResult(targets []string) *sharedcompliance.CheckResult {
return &sharedcompliance.CheckResult{
Decision: sharedcompliance.GateDecisionPass,
Passed: true,
Violations: []sharedcompliance.Violation{},
EnforcementMode: "disabled",
TargetPlatforms: normalizePlatforms(targets),
ManualReviewStatus: sharedcompliance.ManualReviewStatusNone,
CheckedAt: time.Now().UTC(),
}
}
func (s *Service) currentComplianceConfig() config.ComplianceConfig {
if s != nil && s.cfg != nil {
if cfg := s.cfg.Current(); cfg != nil {
out := cfg.Compliance
config.NormalizeComplianceConfig(&out)
return out
}
}
out := config.ComplianceConfig{Enabled: true}
config.NormalizeComplianceConfig(&out)
return out
}
func decide(mode string, highest *string, hitCount int) sharedcompliance.GateDecision {
if hitCount <= 0 || highest == nil {
return sharedcompliance.GateDecisionPass
}
if mode == "mandatory" {
return sharedcompliance.GateDecisionBlock
}
return sharedcompliance.GateDecisionNeedsAck
}
func highestViolationLevel(violations []sharedcompliance.Violation) *string {
if len(violations) == 0 {
return nil
}
best := "info"
for _, violation := range violations {
if levelRank(violation.Level) > levelRank(best) {
best = violation.Level
}
}
return &best
}
func levelRank(level string) int {
switch strings.TrimSpace(level) {
case "block":
return 4
case "high":
return 3
case "medium":
return 2
case "info":
return 1
default:
return 0
}
}
func normalizeLevel(level string) string {
level = strings.TrimSpace(level)
switch level {
case "block", "high", "medium", "info":
return level
default:
return "high"
}
}
func normalizeMode(mode string) string {
mode = strings.TrimSpace(mode)
switch mode {
case "mandatory", "advisory", "disabled":
return mode
default:
return "mandatory"
}
}
func contentHash(title, plaintext string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(title) + "\n" + strings.TrimSpace(plaintext)))
return hex.EncodeToString(sum[:])
}
func policyFingerprint(policy *policySnapshot, targets []string) string {
targets = normalizePlatforms(targets)
parts := []string{
fmt.Sprintf("rev:%d", policy.Revision),
"mode:" + policy.EnforcementMode,
fmt.Sprintf("llm:%t", policy.LLMJudgeEnabled),
"targets:" + strings.Join(targets, ","),
}
keys := make([]string, 0, len(policy.DictVersions))
for key := range policy.DictVersions {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s:%d", key, policy.DictVersions[key]))
}
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(sum[:])
}
func recordDictVersions(policy *policySnapshot) map[string]int {
if policy == nil || policy.DictVersions == nil {
return map[string]int{}
}
out := make(map[string]int, len(policy.DictVersions))
for key, value := range policy.DictVersions {
out[key] = value
}
return out
}
func normalizePlatforms(platforms []string) []string {
seen := make(map[string]struct{}, len(platforms))
out := make([]string, 0, len(platforms))
for _, platform := range platforms {
platform = strings.TrimSpace(platform)
if platform == "" {
continue
}
if _, ok := seen[platform]; ok {
continue
}
seen[platform] = struct{}{}
out = append(out, platform)
}
sort.Strings(out)
return out
}
func decodeJSONInt64Slice(raw []byte) []int64 {
if len(raw) == 0 {
return nil
}
var nums []int64
if err := json.Unmarshal(raw, &nums); err == nil {
return nums
}
var floats []float64
if err := json.Unmarshal(raw, &floats); err != nil {
return nil
}
out := make([]int64, 0, len(floats))
for _, n := range floats {
out = append(out, int64(n))
}
return out
}
func sameInt64Set(a, b []int64) bool {
if len(a) != len(b) {
return false
}
a = append([]int64(nil), a...)
b = append([]int64(nil), b...)
sort.Slice(a, func(i, j int) bool { return a[i] < a[j] })
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func truncateRunes(value string, maxRunes int) string {
value = strings.TrimSpace(value)
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
func stringPtr(value string) *string {
return &value
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func nullableString(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func int64PtrAny(value *int64) any {
if value == nil {
return nil
}
return *value
}
func rollbackTx(tx pgx.Tx) {
if tx == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = tx.Rollback(ctx)
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func (s *Service) cacheManualReview(snapshot articleVersionSnapshot) {
if snapshot.ArticleVersionID <= 0 {
return
}
s.reviewCache.Store(snapshot.ArticleVersionID, struct {
snapshot articleVersionSnapshot
expires time.Time
}{snapshot: snapshot, expires: time.Now().Add(60 * time.Second)})
}
func (s *Service) cachedManualReview(versionID int64) (articleVersionSnapshot, bool) {
value, ok := s.reviewCache.Load(versionID)
if !ok {
return articleVersionSnapshot{}, false
}
typed, ok := value.(struct {
snapshot articleVersionSnapshot
expires time.Time
})
if !ok || time.Now().After(typed.expires) {
s.reviewCache.Delete(versionID)
return articleVersionSnapshot{}, false
}
return typed.snapshot, true
}
func manualReviewGateError(snapshot articleVersionSnapshot) error {
switch snapshot.ManualReviewStatus {
case sharedcompliance.ManualReviewStatusPending:
return response.ErrConflict(41007, "compliance_manual_review_pending", "current article version is waiting for manual review")
case sharedcompliance.ManualReviewStatusRejected:
return response.ErrConflict(41006, "compliance_manual_review_rejected", "current article version was rejected by manual review")
default:
return nil
}
}