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
2606 lines
77 KiB
Go
2606 lines
77 KiB
Go
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 ©Policy
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|