Files
geo/server/internal/tenant/compliance/service.go
T
root 01fa2b8309
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 3m57s
Backend CI / Backend (push) Successful in 17m3s
Desktop Client Build / Build Desktop Client (push) Successful in 26m8s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 51s
fix quota reset reservation race
2026-05-12 02:24:57 +08:00

2606 lines
77 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
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
}
refunded, err := refundAIPointReservationIfPending(ctx, tx, job.TenantID, reservation.ReservationID)
if err != nil {
return err
}
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
}
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
}
return ignoreAIPointReservationReset(ctx, tx, tag.RowsAffected() > 0, tenantID, reservationID)
}
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)
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
}
if status == "reset" {
return nil
}
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
}
}