Files
sub2api/backend/internal/securityaudit/prompt_repository.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

451 lines
17 KiB
Go

package securityaudit
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
const (
promptAuditAdmissionLockKey int64 = 579147893221901921
promptAuditConfigLockKey int64 = 579147893221901922
)
var (
ErrQueueFull = errors.New("prompt audit queue full")
ErrQueueAdmissionBusy = errors.New("prompt audit queue admission busy")
ErrLeaseLost = errors.New("prompt audit worker lease lost")
ErrEventNotFound = errors.New("prompt audit event not found")
)
type Job struct {
ID int64
Snapshot PromptSnapshot
ExecutionMode Mode
ConfigVersion int64
Status string
Attempts int
MaxAttempts int
ClaimVersion int64
NextAttemptAt time.Time
ProcessingStartedAt *time.Time
ProcessedAt *time.Time
LastErrorCode string
LastErrorMessage string
CreatedAt time.Time
UpdatedAt time.Time
}
type Event struct {
ID int64 `json:"id"`
JobID int64 `json:"job_id"`
Snapshot PromptSnapshot `json:"snapshot"`
Decision EventDecision `json:"decision"`
RiskLevel RiskLevel `json:"risk_level"`
Action Action `json:"action"`
Categories []string `json:"categories"`
MatchedScanners []string `json:"matched_scanners"`
ScannerScores map[string]float64 `json:"scanner_scores"`
ScannerEvidence map[string]string `json:"scanner_evidence"`
ScannerBackend string `json:"scanner_backend"`
ScannerVersion string `json:"scanner_version"`
GuardEndpointID string `json:"guard_endpoint_id"`
PolicyID string `json:"policy_id"`
PolicyVersion int `json:"policy_version"`
ConfigVersion int64 `json:"config_version"`
ChunkTotal int `json:"chunk_total"`
LatencyMS int `json:"latency_ms"`
IssueSummaries []IssueSummary `json:"issue_summaries"`
CreatedAt time.Time `json:"created_at"`
}
type JobRepository interface {
CreateStagingWithCapacity(ctx context.Context, snapshot PromptSnapshot, configVersion int64, maxAttempts, capacity int) (*Job, error)
PublishQueued(ctx context.Context, jobID int64) error
MarkStagingFailed(ctx context.Context, jobID int64, code, message string) error
ClaimNextJob(ctx context.Context, now time.Time) (*Job, bool, error)
RefreshLease(ctx context.Context, jobID, claimVersion int64, now time.Time) error
Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error)
Retry(ctx context.Context, jobID, claimVersion int64, next time.Time, code, message string) error
Fail(ctx context.Context, jobID, claimVersion int64, code, message string) error
ReclaimStale(ctx context.Context, stagingBefore, processingBefore time.Time, limit int) (int64, error)
QueueStats(ctx context.Context) (QueueStats, error)
RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error)
}
type PostgreSQLRepository struct {
db *sql.DB
clock Clock
}
func NewPostgreSQLRepository(db *sql.DB) *PostgreSQLRepository {
return &PostgreSQLRepository{db: db, clock: realClock{}}
}
func (r *PostgreSQLRepository) CreateStagingWithCapacity(ctx context.Context, snapshot PromptSnapshot, configVersion int64, maxAttempts, capacity int) (*Job, error) {
if r == nil || r.db == nil {
return nil, errors.New("prompt audit database unavailable")
}
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
var locked bool
if err := tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock($1)`, promptAuditAdmissionLockKey).Scan(&locked); err != nil {
return nil, err
}
if !locked {
return nil, ErrQueueAdmissionBusy
}
var active int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*) FROM prompt_audit_jobs
WHERE status IN ('staging','queued','processing','retry')`).Scan(&active); err != nil {
return nil, err
}
if capacity <= 0 || active >= capacity {
return nil, ErrQueueFull
}
if maxAttempts <= 0 {
maxAttempts = 3
}
job, err := insertJob(ctx, tx, snapshot.Redacted(), ModeAsync, configVersion, "staging", maxAttempts)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return job, nil
}
func (r *PostgreSQLRepository) PublishQueued(ctx context.Context, jobID int64) error {
result, err := r.db.ExecContext(ctx, `
UPDATE prompt_audit_jobs SET status='queued', next_attempt_at=NOW(), updated_at=NOW()
WHERE id=$1 AND status='staging'`, jobID)
return requireOneRow(result, err, ErrLeaseLost)
}
func (r *PostgreSQLRepository) MarkStagingFailed(ctx context.Context, jobID int64, code, _ string) error {
code, message := sanitizeStoredError(code)
result, err := r.db.ExecContext(ctx, `
UPDATE prompt_audit_jobs
SET status='failed', processed_at=NOW(), updated_at=NOW(), last_error_code=$2, last_error_message=$3
WHERE id=$1 AND status='staging'`, jobID, code, message)
return requireOneRow(result, err, ErrLeaseLost)
}
func (r *PostgreSQLRepository) ClaimNextJob(ctx context.Context, now time.Time) (*Job, bool, error) {
row := r.db.QueryRowContext(ctx, `
WITH candidate AS (
SELECT id FROM prompt_audit_jobs
WHERE status IN ('queued','retry') AND next_attempt_at <= $1
ORDER BY next_attempt_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE prompt_audit_jobs AS j
SET status='processing', attempts=j.attempts+1, claim_version=j.claim_version+1,
processing_started_at=$1, updated_at=$1
FROM candidate
WHERE j.id=candidate.id
RETURNING `+jobColumns("j"), now.UTC())
job, err := scanJob(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
return job, err == nil, err
}
func (r *PostgreSQLRepository) RefreshLease(ctx context.Context, jobID, claimVersion int64, now time.Time) error {
result, err := r.db.ExecContext(ctx, `
UPDATE prompt_audit_jobs SET processing_started_at=$3, updated_at=$3
WHERE id=$1 AND status='processing' AND claim_version=$2`, jobID, claimVersion, now.UTC())
return requireOneRow(result, err, ErrLeaseLost)
}
func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error) {
if job == nil || result == nil {
return nil, errors.New("prompt audit completion requires job and result")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
updateResult, err := tx.ExecContext(ctx, `
UPDATE prompt_audit_jobs SET status='done', processed_at=NOW(), updated_at=NOW(),
last_error_code='', last_error_message=''
WHERE id=$1 AND status='processing' AND claim_version=$2`, job.ID, job.ClaimVersion)
if err := requireOneRow(updateResult, err, ErrLeaseLost); err != nil {
return nil, err
}
var event *Event
if shouldStorePromptAuditEvent(result.Decision, storePassEvents) {
event, err = insertEvent(ctx, tx, job.ID, job.Snapshot.Redacted(), job.ConfigVersion, result)
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return event, nil
}
func (r *PostgreSQLRepository) Retry(ctx context.Context, jobID, claimVersion int64, next time.Time, code, _ string) error {
code, message := sanitizeStoredError(code)
result, err := r.db.ExecContext(ctx, `
UPDATE prompt_audit_jobs SET status='retry', next_attempt_at=$3, processing_started_at=NULL,
updated_at=NOW(), last_error_code=$4, last_error_message=$5
WHERE id=$1 AND status='processing' AND claim_version=$2`,
jobID, claimVersion, next.UTC(), code, message)
return requireOneRow(result, err, ErrLeaseLost)
}
func (r *PostgreSQLRepository) Fail(ctx context.Context, jobID, claimVersion int64, code, _ string) error {
code, message := sanitizeStoredError(code)
result, err := r.db.ExecContext(ctx, `
UPDATE prompt_audit_jobs SET status='failed', processed_at=NOW(), processing_started_at=NULL,
updated_at=NOW(), last_error_code=$3, last_error_message=$4
WHERE id=$1 AND status='processing' AND claim_version=$2`,
jobID, claimVersion, code, message)
return requireOneRow(result, err, ErrLeaseLost)
}
func (r *PostgreSQLRepository) ReclaimStale(ctx context.Context, stagingBefore, processingBefore time.Time, limit int) (int64, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
result, err := r.db.ExecContext(ctx, `
WITH stale AS (
SELECT id FROM prompt_audit_jobs
WHERE (status='staging' AND updated_at < $1)
OR (status='processing' AND processing_started_at < $2)
ORDER BY updated_at, id FOR UPDATE SKIP LOCKED LIMIT $3
)
UPDATE prompt_audit_jobs AS j
SET status=CASE
WHEN j.status='staging' THEN 'failed'
WHEN j.attempts < j.max_attempts THEN 'retry'
ELSE 'failed' END,
next_attempt_at=CASE WHEN j.status='processing' AND j.attempts < j.max_attempts THEN NOW() ELSE j.next_attempt_at END,
processing_started_at=NULL,
processed_at=CASE WHEN j.status='staging' OR j.attempts >= j.max_attempts THEN NOW() ELSE NULL END,
last_error_code=CASE WHEN j.status='staging' THEN 'staging_timeout' ELSE 'processing_lease_expired' END,
last_error_message='', updated_at=NOW()
FROM stale WHERE j.id=stale.id`, stagingBefore.UTC(), processingBefore.UTC(), limit)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (r *PostgreSQLRepository) QueueStats(ctx context.Context) (QueueStats, error) {
rows, err := r.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM prompt_audit_jobs GROUP BY status`)
if err != nil {
return QueueStats{}, err
}
defer func() { _ = rows.Close() }()
var stats QueueStats
for rows.Next() {
var status string
var count int64
if err := rows.Scan(&status, &count); err != nil {
return QueueStats{}, err
}
switch status {
case "staging":
stats.Staging = count
case "queued":
stats.Queued = count
case "processing":
stats.Processing = count
case "retry":
stats.Retry = count
case "done":
stats.Done = count
case "failed":
stats.Failed = count
}
}
stats.Active = stats.Staging + stats.Queued + stats.Processing + stats.Retry
return stats, rows.Err()
}
func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error) {
if result == nil {
return nil, errors.New("prompt guard result required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
job, err := insertJob(ctx, tx, snapshot.Redacted(), ModeBlocking, configVersion, "done", 1)
if err != nil {
return nil, err
}
var event *Event
if shouldStorePromptAuditEvent(result.Decision, storePassEvents) {
event, err = insertEvent(ctx, tx, job.ID, snapshot.Redacted(), configVersion, result)
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return event, nil
}
// shouldStorePromptAuditEvent keeps store_pass_events scoped to safe results.
// Risk events are always persisted while prompt auditing itself is enabled.
func shouldStorePromptAuditEvent(decision EventDecision, storePassEvents bool) bool {
return decision != EventPass || storePassEvents
}
type sqlQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func insertJob(ctx context.Context, queryer sqlQueryer, snapshot PromptSnapshot, mode Mode, configVersion int64, status string, maxAttempts int) (*Job, error) {
processedExpr := "NULL"
if status == "done" || status == "failed" {
processedExpr = "NOW()"
}
row := queryer.QueryRowContext(ctx, `
INSERT INTO prompt_audit_jobs (
request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot,
group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,
prompt_length,message_count,stage,execution_mode,config_version,status,max_attempts,processed_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,`+processedExpr+`)
RETURNING `+jobColumns("prompt_audit_jobs"),
snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot,
nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName,
snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash,
snapshot.RedactedPreview, snapshot.PromptLength, snapshot.MessageCount, normalizeStage(snapshot.Stage),
string(mode), configVersion, status, maxAttempts)
return scanJob(row)
}
func insertEvent(ctx context.Context, queryer sqlQueryer, jobID int64, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult) (*Event, error) {
categories, _ := json.Marshal(result.Categories)
matched, _ := json.Marshal(result.MatchedScanners)
scores, _ := json.Marshal(result.ScannerScores)
evidence := make(map[string]string, len(result.ScannerEvidence))
for key, value := range result.ScannerEvidence {
evidence[key] = RedactPreview(value, 160)
}
evidenceJSON, _ := json.Marshal(evidence)
row := queryer.QueryRowContext(ctx, `
INSERT INTO prompt_audit_events (
job_id,request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot,
group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,stage,
decision,risk_level,action,categories,matched_scanners,scanner_scores,scanner_evidence,
scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms,
full_prompt
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,
$20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31,$32)
RETURNING `+eventDetailColumns("prompt_audit_events"),
jobID, snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot,
nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName,
snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash,
snapshot.RedactedPreview, normalizeStage(snapshot.Stage), string(result.Decision), string(result.RiskLevel),
string(result.Action), categories, matched, scores, evidenceJSON, result.ScannerBackend, result.ScannerVersion,
result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS,
snapshot.FullPrompt)
return scanEvent(row, true)
}
type rowScanner interface{ Scan(...any) error }
func scanJob(row rowScanner) (*Job, error) {
job := &Job{}
var userID, apiKeyID, groupID sql.NullInt64
var processingStarted, processed sql.NullTime
err := row.Scan(
&job.ID, &job.Snapshot.RequestID, &userID, &job.Snapshot.UsernameSnapshot, &job.Snapshot.UserEmailSnapshot,
&apiKeyID, &job.Snapshot.APIKeyNameSnapshot, &groupID, &job.Snapshot.GroupName, &job.Snapshot.Provider,
&job.Snapshot.Endpoint, &job.Snapshot.Protocol, &job.Snapshot.Model, &job.Snapshot.PromptHash,
&job.Snapshot.RedactedPreview, &job.Snapshot.PromptLength, &job.Snapshot.MessageCount, &job.Snapshot.Stage,
&job.ExecutionMode, &job.ConfigVersion, &job.Status, &job.Attempts, &job.MaxAttempts, &job.ClaimVersion,
&job.NextAttemptAt, &processingStarted, &processed, &job.LastErrorCode, &job.LastErrorMessage,
&job.CreatedAt, &job.UpdatedAt,
)
if err != nil {
return nil, err
}
job.Snapshot.UserID = nullableInt64Value(userID)
job.Snapshot.APIKeyID = nullableInt64Value(apiKeyID)
job.Snapshot.GroupID = nullableInt64Ptr(groupID)
if processingStarted.Valid {
value := processingStarted.Time
job.ProcessingStartedAt = &value
}
if processed.Valid {
value := processed.Time
job.ProcessedAt = &value
}
return job, nil
}
func jobColumns(alias string) string {
return fmt.Sprintf(`%[1]s.id,%[1]s.request_id,%[1]s.user_id,%[1]s.username_snapshot,%[1]s.user_email_snapshot,
%[1]s.api_key_id,%[1]s.api_key_name_snapshot,%[1]s.group_id,%[1]s.group_name,%[1]s.provider,
%[1]s.endpoint,%[1]s.protocol,%[1]s.model,%[1]s.prompt_hash,%[1]s.redacted_preview,
%[1]s.prompt_length,%[1]s.message_count,%[1]s.stage,%[1]s.execution_mode,%[1]s.config_version,%[1]s.status,
%[1]s.attempts,%[1]s.max_attempts,%[1]s.claim_version,%[1]s.next_attempt_at,
%[1]s.processing_started_at,%[1]s.processed_at,%[1]s.last_error_code,%[1]s.last_error_message,
%[1]s.created_at,%[1]s.updated_at`, alias)
}
func normalizeStage(stage string) string {
stage = strings.TrimSpace(stage)
if stage == "" {
return "http"
}
return stage
}
func requireOneRow(result sql.Result, err error, missing error) error {
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return missing
}
return nil
}
func nullableID(value int64) any {
if value <= 0 {
return nil
}
return value
}
func nullableInt64Value(value sql.NullInt64) int64 {
if !value.Valid {
return 0
}
return value.Int64
}
func nullableInt64Ptr(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
}
result := value.Int64
return &result
}