feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,7 @@ type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
CurrentVersionID *int64 `json:"current_version_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
@@ -401,6 +402,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
||||
|
||||
item.SourceType = dbSourceType
|
||||
if currentVersionID != nil {
|
||||
item.CurrentVersionID = currentVersionID
|
||||
content, err := repository.LoadArticleVersionContent(ctx, s.pool, item.ID, *currentVersionID)
|
||||
if err != nil {
|
||||
return nil, false, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
||||
|
||||
@@ -16,9 +16,12 @@ import (
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
@@ -30,6 +33,7 @@ type DesktopTaskService struct {
|
||||
redis *goredis.Client
|
||||
messaging *rabbitmq.Client
|
||||
logger *zap.Logger
|
||||
compliance *tenantcompliance.Service
|
||||
}
|
||||
|
||||
func NewDesktopTaskService(pool *pgxpool.Pool, monitoringPool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger) *DesktopTaskService {
|
||||
@@ -39,9 +43,18 @@ func NewDesktopTaskService(pool *pgxpool.Pool, monitoringPool *pgxpool.Pool, mes
|
||||
repo: repository.NewDesktopTaskRepository(pool),
|
||||
messaging: messaging,
|
||||
logger: logger,
|
||||
compliance: tenantcompliance.NewService(pool, config.NewStaticProvider(&config.Config{
|
||||
Compliance: config.ComplianceConfig{Enabled: true},
|
||||
}), logger).WithRabbitMQ(messaging),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDesktopTaskServiceWithConfig(pool *pgxpool.Pool, monitoringPool *pgxpool.Pool, messaging *rabbitmq.Client, logger *zap.Logger, cfg config.Provider) *DesktopTaskService {
|
||||
svc := NewDesktopTaskService(pool, monitoringPool, messaging, logger)
|
||||
svc.compliance = tenantcompliance.NewService(pool, cfg, logger).WithRabbitMQ(messaging)
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) WithCache(c sharedcache.Cache) *DesktopTaskService {
|
||||
s.cache = c
|
||||
return s
|
||||
@@ -55,24 +68,28 @@ func (s *DesktopTaskService) WithRedis(redis *goredis.Client) *DesktopTaskServic
|
||||
}
|
||||
|
||||
type DesktopTaskView struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
TargetAccountID string `json:"target_account_id"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
Platform string `json:"platform"`
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
DedupKey *string `json:"dedup_key"`
|
||||
ActiveAttemptID *string `json:"active_attempt_id"`
|
||||
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
|
||||
Attempts int `json:"attempts"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
TargetAccountID string `json:"target_account_id"`
|
||||
TargetClientID string `json:"target_client_id"`
|
||||
Platform string `json:"platform"`
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
PublishJobStatus *string `json:"publish_job_status,omitempty"`
|
||||
ComplianceBlockedRecordID *int64 `json:"compliance_blocked_record_id,omitempty"`
|
||||
ComplianceBlockedAt *time.Time `json:"compliance_blocked_at,omitempty"`
|
||||
ComplianceBlockedReason *string `json:"compliance_blocked_reason,omitempty"`
|
||||
DedupKey *string `json:"dedup_key"`
|
||||
ActiveAttemptID *string `json:"active_attempt_id"`
|
||||
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
|
||||
Attempts int `json:"attempts"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type LeaseDesktopTaskRequest struct {
|
||||
@@ -144,6 +161,9 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
|
||||
if err := s.recoverExpiredClientTasks(ctx, client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.recheckQueuedPublishTasksForClient(ctx, client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawToken, tokenHash, err := newDesktopClientToken()
|
||||
if err != nil {
|
||||
@@ -230,6 +250,86 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopTaskService) recheckQueuedPublishTasksForClient(ctx context.Context, client *repository.DesktopClient) error {
|
||||
if s == nil || s.pool == nil || s.compliance == nil || client == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT
|
||||
j.desktop_id,
|
||||
j.tenant_id,
|
||||
j.workspace_id,
|
||||
j.created_by_user_id,
|
||||
j.article_id,
|
||||
j.article_version_id,
|
||||
COALESCE(array_agg(DISTINCT dt.platform_id) FILTER (WHERE dt.platform_id <> ''), '{}') AS platforms
|
||||
FROM desktop_tasks dt
|
||||
JOIN desktop_publish_jobs j ON j.desktop_id = dt.job_id
|
||||
WHERE dt.target_client_id = $1
|
||||
AND dt.kind = 'publish'
|
||||
AND dt.status = 'queued'
|
||||
AND j.status = 'queued'
|
||||
AND j.article_id IS NOT NULL
|
||||
AND j.article_version_id IS NOT NULL
|
||||
GROUP BY j.desktop_id, j.tenant_id, j.workspace_id, j.created_by_user_id, j.article_id, j.article_version_id
|
||||
ORDER BY MIN(dt.created_at)
|
||||
LIMIT 5
|
||||
`, client.ID)
|
||||
if err != nil {
|
||||
return response.ErrInternal(50118, "desktop_publish_compliance_recheck_failed", "failed to load publish jobs for compliance recheck")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type candidate struct {
|
||||
JobID uuid.UUID
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
CreatedByUserID int64
|
||||
ArticleID int64
|
||||
ArticleVersionID int64
|
||||
Platforms []string
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if scanErr := rows.Scan(&item.JobID, &item.TenantID, &item.WorkspaceID, &item.CreatedByUserID, &item.ArticleID, &item.ArticleVersionID, &item.Platforms); scanErr != nil {
|
||||
return response.ErrInternal(50118, "desktop_publish_compliance_recheck_failed", "failed to scan publish job for compliance recheck")
|
||||
}
|
||||
if item.WorkspaceID == client.WorkspaceID && item.TenantID == client.TenantID {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return response.ErrInternal(50118, "desktop_publish_compliance_recheck_failed", "failed to iterate publish jobs for compliance recheck")
|
||||
}
|
||||
|
||||
for _, item := range candidates {
|
||||
gate, gateErr := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
|
||||
TenantID: item.TenantID,
|
||||
ArticleID: item.ArticleID,
|
||||
ArticleVersionID: item.ArticleVersionID,
|
||||
ActorID: item.CreatedByUserID,
|
||||
TargetPlatforms: item.Platforms,
|
||||
TriggerSource: "scheduler_recheck",
|
||||
})
|
||||
if gateErr != nil && gate != nil && gate.Decision == sharedcompliance.GateDecisionBlock {
|
||||
if markErr := s.compliance.MarkPublishJobBlockedByCompliance(ctx, item.JobID, gate.Result); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if gateErr != nil {
|
||||
return response.ErrServiceUnavailable(41004, "compliance_engine_unavailable", "scheduled publish compliance recheck failed")
|
||||
}
|
||||
if gate != nil && gate.Decision == sharedcompliance.GateDecisionBlock {
|
||||
if markErr := s.compliance.MarkPublishJobBlockedByCompliance(ctx, item.JobID, gate.Result); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const desktopTaskRepositoryReturningColumns = `
|
||||
t.desktop_id,
|
||||
t.job_id,
|
||||
@@ -862,7 +962,11 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
|
||||
t.result,
|
||||
t.error,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
t.updated_at,
|
||||
j.status,
|
||||
j.compliance_blocked_record_id,
|
||||
j.compliance_blocked_at,
|
||||
j.compliance_blocked_reason
|
||||
FROM desktop_tasks AS t
|
||||
JOIN desktop_publish_jobs AS j
|
||||
ON j.desktop_id = t.job_id
|
||||
@@ -945,6 +1049,10 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
|
||||
errorJSON []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
jobStatus string
|
||||
blockedRecordID pgtype.Int8
|
||||
blockedAt pgtype.Timestamptz
|
||||
blockedReason pgtype.Text
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&desktopID,
|
||||
@@ -965,6 +1073,10 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
|
||||
&errorJSON,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&jobStatus,
|
||||
&blockedRecordID,
|
||||
&blockedAt,
|
||||
&blockedReason,
|
||||
); err != nil {
|
||||
return nil, response.ErrInternal(50110, "desktop_publish_tasks_scan_failed", "failed to scan desktop publish tasks")
|
||||
}
|
||||
@@ -990,25 +1102,53 @@ func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
|
||||
leaseExpiresAtValue = &value
|
||||
}
|
||||
|
||||
publishJobStatusText := strings.TrimSpace(jobStatus)
|
||||
var publishJobStatus *string
|
||||
if publishJobStatusText != "" {
|
||||
publishJobStatus = &publishJobStatusText
|
||||
}
|
||||
|
||||
var complianceBlockedRecordID *int64
|
||||
if blockedRecordID.Valid {
|
||||
value := blockedRecordID.Int64
|
||||
complianceBlockedRecordID = &value
|
||||
}
|
||||
|
||||
var complianceBlockedAt *time.Time
|
||||
if blockedAt.Valid {
|
||||
value := blockedAt.Time
|
||||
complianceBlockedAt = &value
|
||||
}
|
||||
|
||||
var complianceBlockedReason *string
|
||||
if blockedReason.Valid {
|
||||
value := blockedReason.String
|
||||
complianceBlockedReason = &value
|
||||
}
|
||||
|
||||
items = append(items, DesktopTaskView{
|
||||
ID: desktopID.String(),
|
||||
JobID: jobID.String(),
|
||||
TenantID: tenantID,
|
||||
WorkspaceID: workspaceID,
|
||||
TargetAccountID: targetAccountID.String(),
|
||||
TargetClientID: targetClientID.String(),
|
||||
Platform: platform,
|
||||
Kind: kind,
|
||||
Payload: json.RawMessage(payload),
|
||||
Status: normalizeDesktopTaskTerminalStatus(status),
|
||||
DedupKey: dedupKeyText,
|
||||
ActiveAttemptID: activeAttemptIDText,
|
||||
LeaseExpiresAt: leaseExpiresAtValue,
|
||||
Attempts: int(attempts),
|
||||
Result: json.RawMessage(resultJSON),
|
||||
Error: json.RawMessage(errorJSON),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
ID: desktopID.String(),
|
||||
JobID: jobID.String(),
|
||||
TenantID: tenantID,
|
||||
WorkspaceID: workspaceID,
|
||||
TargetAccountID: targetAccountID.String(),
|
||||
TargetClientID: targetClientID.String(),
|
||||
Platform: platform,
|
||||
Kind: kind,
|
||||
Payload: json.RawMessage(payload),
|
||||
Status: normalizeDesktopTaskTerminalStatus(status),
|
||||
PublishJobStatus: publishJobStatus,
|
||||
ComplianceBlockedRecordID: complianceBlockedRecordID,
|
||||
ComplianceBlockedAt: complianceBlockedAt,
|
||||
ComplianceBlockedReason: complianceBlockedReason,
|
||||
DedupKey: dedupKeyText,
|
||||
ActiveAttemptID: activeAttemptIDText,
|
||||
LeaseExpiresAt: leaseExpiresAtValue,
|
||||
Attempts: int(attempts),
|
||||
Result: json.RawMessage(resultJSON),
|
||||
Error: json.RawMessage(errorJSON),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
||||
)
|
||||
|
||||
type onlineGenerationFixture struct {
|
||||
GenerationTasks []onlineGenerationTask `json:"generation_tasks"`
|
||||
}
|
||||
|
||||
type onlineGenerationTask struct {
|
||||
ID int64 `json:"id"`
|
||||
ArticleID int64 `json:"article_id"`
|
||||
InputParamsJSON map[string]interface{} `json:"input_params_json"`
|
||||
}
|
||||
|
||||
type onlineKnowledgeFixture struct {
|
||||
KnowledgeItems []onlineKnowledgeItem `json:"knowledge_items"`
|
||||
}
|
||||
|
||||
type onlineKnowledgeItem struct {
|
||||
ID int64 `json:"id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceURI *string `json:"source_uri"`
|
||||
Status string `json:"status"`
|
||||
ContentText *string `json:"content_text"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
}
|
||||
|
||||
func TestReplayOnlineFailedArticleGeneration(t *testing.T) {
|
||||
if os.Getenv("RUN_ONLINE_GENERATION_REPLAY") != "1" {
|
||||
t.Skip("set RUN_ONLINE_GENERATION_REPLAY=1 to replay online failed generation against Ark")
|
||||
}
|
||||
|
||||
fixtureDir := strings.TrimSpace(os.Getenv("ONLINE_GENERATION_FIXTURE_DIR"))
|
||||
if fixtureDir == "" {
|
||||
fixtureDir = filepath.Clean("../../../../tmp/online-generation-fixtures")
|
||||
}
|
||||
taskID := int64(2)
|
||||
if strings.TrimSpace(os.Getenv("ONLINE_GENERATION_TASK_ID")) == "1" {
|
||||
taskID = 1
|
||||
}
|
||||
|
||||
var fixture onlineGenerationFixture
|
||||
readJSONFixture(t, filepath.Join(fixtureDir, "db-compact.json"), &fixture)
|
||||
task := findOnlineGenerationTask(t, fixture.GenerationTasks, taskID)
|
||||
|
||||
var knowledge onlineKnowledgeFixture
|
||||
readJSONFixture(t, filepath.Join(fixtureDir, "knowledge-compact.json"), &knowledge)
|
||||
|
||||
if promptsPath := strings.TrimSpace(os.Getenv("PROMPTS_CONFIG_FILE")); promptsPath == "" {
|
||||
prompts.SetConfigFile(filepath.Clean("../../../configs/prompts.yml"))
|
||||
}
|
||||
|
||||
knowledgePrompt := buildReplayKnowledgePrompt(task.InputParamsJSON, knowledge)
|
||||
templateKey, templateName, promptTemplate := extractTemplateSnapshot(task.InputParamsJSON)
|
||||
prompt := buildGenerationPrompt(templateKey, templateName, promptTemplate, task.InputParamsJSON, knowledgePrompt)
|
||||
t.Logf("task_id=%d article_id=%d prompt_chars=%d knowledge_chars=%d", task.ID, task.ArticleID, len([]rune(prompt)), len([]rune(knowledgePrompt)))
|
||||
|
||||
cfg, err := sharedconfig.Load(filepath.Clean("../../../configs/config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
client := llm.New(cfg.LLM)
|
||||
if err := client.Validate(); err != nil {
|
||||
t.Fatalf("validate llm: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var outputChars int
|
||||
result, err := client.Generate(context.Background(), llm.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Timeout: cfg.Generation.ArticleTimeout,
|
||||
MaxOutputTokens: cfg.LLM.MaxOutputTokens,
|
||||
}, func(delta string) {
|
||||
outputChars += len([]rune(delta))
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("replay generation failed after %s with %d streamed chars: %v", elapsed.Round(time.Second), outputChars, err)
|
||||
}
|
||||
t.Logf("replay generation completed after %s with %d chars, model=%s", elapsed.Round(time.Second), len([]rune(result.Content)), result.Model)
|
||||
}
|
||||
|
||||
func readJSONFixture(t *testing.T, path string, target interface{}) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
t.Fatalf("parse fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func findOnlineGenerationTask(t *testing.T, tasks []onlineGenerationTask, id int64) onlineGenerationTask {
|
||||
t.Helper()
|
||||
for _, task := range tasks {
|
||||
if task.ID == id {
|
||||
return task
|
||||
}
|
||||
}
|
||||
t.Fatalf("task %d not found in fixture", id)
|
||||
return onlineGenerationTask{}
|
||||
}
|
||||
|
||||
func buildReplayKnowledgePrompt(params map[string]interface{}, fixture onlineKnowledgeFixture) string {
|
||||
groupIDs := make(map[int64]struct{})
|
||||
for _, id := range extractKnowledgeGroupIDs(params["knowledge_group_ids"]) {
|
||||
groupIDs[id] = struct{}{}
|
||||
}
|
||||
if len(groupIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx := &KnowledgeContext{Snippets: []KnowledgeSnippet{}, PreciseFacts: []string{}}
|
||||
seenFacts := make(map[string]struct{})
|
||||
for _, item := range fixture.KnowledgeItems {
|
||||
if strings.TrimSpace(item.Status) != "completed" {
|
||||
continue
|
||||
}
|
||||
if _, ok := groupIDs[item.GroupID]; !ok {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(derefKnowledgeString(item.MarkdownContent))
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(derefKnowledgeString(item.ContentText))
|
||||
}
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
facts := extractKnowledgePreciseFacts(text)
|
||||
for _, fact := range facts {
|
||||
entry := strings.TrimSpace(fact)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenFacts[entry]; exists {
|
||||
continue
|
||||
}
|
||||
seenFacts[entry] = struct{}{}
|
||||
ctx.PreciseFacts = append(ctx.PreciseFacts, entry)
|
||||
}
|
||||
ctx.Snippets = append(ctx.Snippets, KnowledgeSnippet{
|
||||
GroupID: item.GroupID,
|
||||
KnowledgeItemID: item.ID,
|
||||
ItemName: item.Name,
|
||||
SourceType: item.SourceType,
|
||||
SourceURI: item.SourceURI,
|
||||
Text: text,
|
||||
PreciseFacts: facts,
|
||||
})
|
||||
}
|
||||
return (&KnowledgeService{}).RenderPromptSection(ctx)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,18 +15,22 @@ import (
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type PublishJobService struct {
|
||||
pool *pgxpool.Pool
|
||||
messaging *rabbitmq.Client
|
||||
redis *goredis.Client
|
||||
logger *zap.Logger
|
||||
cache sharedcache.Cache
|
||||
pool *pgxpool.Pool
|
||||
messaging *rabbitmq.Client
|
||||
redis *goredis.Client
|
||||
logger *zap.Logger
|
||||
cache sharedcache.Cache
|
||||
compliance *tenantcompliance.Service
|
||||
}
|
||||
|
||||
func NewPublishJobService(
|
||||
@@ -33,12 +38,25 @@ func NewPublishJobService(
|
||||
messaging *rabbitmq.Client,
|
||||
redis *goredis.Client,
|
||||
logger *zap.Logger,
|
||||
) *PublishJobService {
|
||||
return NewPublishJobServiceWithConfig(pool, messaging, redis, logger, config.NewStaticProvider(&config.Config{
|
||||
Compliance: config.ComplianceConfig{Enabled: true},
|
||||
}))
|
||||
}
|
||||
|
||||
func NewPublishJobServiceWithConfig(
|
||||
pool *pgxpool.Pool,
|
||||
messaging *rabbitmq.Client,
|
||||
redis *goredis.Client,
|
||||
logger *zap.Logger,
|
||||
cfg config.Provider,
|
||||
) *PublishJobService {
|
||||
return &PublishJobService{
|
||||
pool: pool,
|
||||
messaging: messaging,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
pool: pool,
|
||||
messaging: messaging,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
compliance: tenantcompliance.NewService(pool, cfg, logger).WithRabbitMQ(messaging),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +70,12 @@ type CreatePublishJobAccountRequest struct {
|
||||
}
|
||||
|
||||
type CreatePublishJobRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
ContentRef map[string]any `json:"content_ref" binding:"required"`
|
||||
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
ContentRef map[string]any `json:"content_ref" binding:"required"`
|
||||
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
ArticleVersionID *int64 `json:"article_version_id,omitempty"`
|
||||
AckRecordID *int64 `json:"ack_record_id,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePublishJobResponse struct {
|
||||
@@ -110,6 +130,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
targetClientID uuid.UUID
|
||||
}
|
||||
targets := make([]publishTarget, 0, len(req.Accounts))
|
||||
targetPlatformSet := make(map[string]struct{})
|
||||
|
||||
for _, accountID := range requestedAccountIDs {
|
||||
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
|
||||
@@ -141,17 +162,51 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
accountSeed: accountSeed,
|
||||
targetClientID: *targetClientID,
|
||||
})
|
||||
if platformID := strings.TrimSpace(accountSeed.PlatformID); platformID != "" {
|
||||
targetPlatformSet[platformID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, articleID, req.ArticleVersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPlatforms := make([]string, 0, len(targetPlatformSet))
|
||||
for platformID := range targetPlatformSet {
|
||||
targetPlatforms = append(targetPlatforms, platformID)
|
||||
}
|
||||
gate, err := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
ArticleVersionID: effectiveVersionID,
|
||||
ActorID: actor.UserID,
|
||||
TargetPlatforms: targetPlatforms,
|
||||
AckRecordID: req.AckRecordID,
|
||||
TriggerSource: "publish_gate",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, compliancePublishError(err, gate)
|
||||
}
|
||||
if gate != nil {
|
||||
switch gate.Decision {
|
||||
case sharedcompliance.GateDecisionBlock:
|
||||
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "content compliance blocked this publish request"), gate)
|
||||
case sharedcompliance.GateDecisionNeedsAck:
|
||||
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), gate)
|
||||
}
|
||||
}
|
||||
|
||||
jobID := uuid.New()
|
||||
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
|
||||
DesktopID: jobID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
CreatedByUserID: actor.UserID,
|
||||
Title: req.Title,
|
||||
ContentRef: contentRefJSON,
|
||||
ScheduledAt: req.ScheduledAt,
|
||||
DesktopID: jobID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
CreatedByUserID: actor.UserID,
|
||||
Title: req.Title,
|
||||
ContentRef: contentRefJSON,
|
||||
ScheduledAt: req.ScheduledAt,
|
||||
ArticleID: &articleID,
|
||||
ArticleVersionID: &effectiveVersionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
|
||||
@@ -189,6 +244,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
"account_id": target.account.DesktopID.String(),
|
||||
"platform": target.account.Platform,
|
||||
"article_id": articleID,
|
||||
"article_version_id": effectiveVersionID,
|
||||
"publish_batch_id": publishBatchID,
|
||||
"publish_record_id": publishRecordID,
|
||||
"platform_account_id": target.accountSeed.ID,
|
||||
@@ -217,6 +273,27 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
taskIDs = append(taskIDs, task.DesktopID.String())
|
||||
}
|
||||
|
||||
if req.AckRecordID != nil {
|
||||
if gate == nil || gate.Result == nil {
|
||||
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record cannot be consumed without a matching compliance result")
|
||||
}
|
||||
consumed, consumeErr := s.compliance.ConsumeAckTx(ctx, tx, *req.AckRecordID, tenantcompliance.ConsumeAckQuery{
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
ArticleVersionID: effectiveVersionID,
|
||||
CheckRecordID: gate.Result.RecordID,
|
||||
ContentHash: gate.Result.ContentHash,
|
||||
PolicyFingerprint: gate.Result.PolicyFingerprint,
|
||||
ConsumedByJobID: jobID,
|
||||
})
|
||||
if consumeErr != nil {
|
||||
return nil, response.ErrInternal(51004, "compliance_ack_lookup_failed", "failed to consume compliance ack")
|
||||
}
|
||||
if !consumed {
|
||||
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is expired, consumed, or no longer matches this publish request")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
|
||||
}
|
||||
@@ -267,6 +344,20 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compliancePublishError(err error, gate *tenantcompliance.GateOutcome) error {
|
||||
appErr := response.Normalize(err)
|
||||
if gate == nil || gate.Result == nil {
|
||||
return appErr
|
||||
}
|
||||
detail, marshalErr := json.Marshal(gate.Result)
|
||||
if marshalErr != nil {
|
||||
return appErr
|
||||
}
|
||||
copyErr := *appErr
|
||||
copyErr.Detail = string(detail)
|
||||
return ©Err
|
||||
}
|
||||
|
||||
func (s *PublishJobService) RetryByDesktopTask(
|
||||
ctx context.Context,
|
||||
client *repository.DesktopClient,
|
||||
|
||||
Reference in New Issue
Block a user