package repository import ( "context" "encoding/json" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type CandidateAnalysisRepository struct{ pool *pgxpool.Pool } func NewCandidateAnalysisRepository(pool *pgxpool.Pool) *CandidateAnalysisRepository { return &CandidateAnalysisRepository{pool: pool} } const policyID = "a945c2bf-9c77-4d4a-9f4f-a986b729776c" func scanPolicy(row pgx.Row) (model.AIReviewPolicy, error) { var p model.AIReviewPolicy err := row.Scan(&p.ID, &p.Enabled, &p.AutoPublish, &p.Version, &p.UpdatedBy) return p, mapError(err) } func (r *CandidateAnalysisRepository) Policy(ctx context.Context) (model.AIReviewPolicy, error) { return scanPolicy(r.pool.QueryRow(ctx, `SELECT id,enabled,auto_publish,version,updated_by FROM veyra_ai_review_policy WHERE id=$1`, policyID)) } func (r *CandidateAnalysisRepository) SavePolicy(ctx context.Context, p model.AIReviewPolicy, actor, requestID string) (model.AIReviewPolicy, error) { tx, err := r.pool.Begin(ctx) if err != nil { return p, mapError(err) } defer tx.Rollback(context.Background()) row := tx.QueryRow(ctx, `UPDATE veyra_ai_review_policy SET enabled=$2,auto_publish=$3,version=version+1,updated_by=$4,updated_at=now() WHERE id=$1 AND version=$5 RETURNING id,enabled,auto_publish,version,updated_by`, policyID, p.Enabled, p.AutoPublish, actor, p.Version) p, err = scanPolicy(row) if err == apperror.ErrNotFound { return p, apperror.ErrConflict } if err != nil { return p, err } if err = appendAudit(ctx, tx, actor, "AI_REVIEW_POLICY_SAVED", "ai_review_policy", policyID, requestID, map[string]any{"enabled": p.Enabled, "auto_publish": p.AutoPublish, "version": p.Version}); err != nil { return p, err } return p, mapError(tx.Commit(ctx)) } func (r *CandidateAnalysisRepository) Claim(ctx context.Context, p model.AIReviewPolicy) (model.CandidateRevision, string, error) { tx, err := r.pool.Begin(ctx) if err != nil { return model.CandidateRevision{}, "", mapError(err) } defer tx.Rollback(context.Background()) if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('veyra:ai-claim')::bigint)`); err != nil { return model.CandidateRevision{}, "", mapError(err) } c, err := scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE r.status='PENDING_REVIEW' AND r.revision=c.latest_revision AND r.valid_until>now() AND `+screeningEligibility+` AND NOT EXISTS(SELECT 1 FROM veyra_candidate_analysis a WHERE a.candidate_revision_id=r.id AND a.input_version=r.version AND a.policy_version=$1 AND (a.status NOT IN ('FAILED','RUNNING') OR a.attempts>=3 OR a.lease_until>now())) ORDER BY r.created_at LIMIT 1`, p.Version)) if err != nil { return c, "", err } if c.Screening != nil { if err = tx.QueryRow(ctx, `SELECT input FROM veyra_strategy_screening WHERE id=$1`, c.Screening.ID).Scan(&c.Screening.Input); err != nil { return c, "", mapError(err) } } token := uuid.NewString() _, err = tx.Exec(ctx, `INSERT INTO veyra_candidate_analysis(id,candidate_revision_id,input_version,policy_version,status,claim_token,lease_until) VALUES($1,$2,$3,$4,'RUNNING',$5,now()+interval '5 minutes') ON CONFLICT(candidate_revision_id,input_version,policy_version) DO UPDATE SET status='RUNNING',attempts=veyra_candidate_analysis.attempts+1,claim_token=excluded.claim_token,lease_until=excluded.lease_until,updated_at=now(),message=''`, uuid.NewString(), c.ID, c.Version, p.Version, token) if err != nil { return c, "", mapError(err) } return c, token, mapError(tx.Commit(ctx)) } func (r *CandidateAnalysisRepository) Complete(ctx context.Context, p model.AIReviewPolicy, c model.CandidateRevision, token string, primary, review model.ProviderConfig, a, b *model.AIConclusion, message string) error { tx, err := r.pool.Begin(ctx) if err != nil { return mapError(err) } defer tx.Rollback(context.Background()) current, err := scanPolicy(tx.QueryRow(ctx, `SELECT id,enabled,auto_publish,version,updated_by FROM veyra_ai_review_policy WHERE id=$1 FOR SHARE`, policyID)) if err != nil { return err } status := "FAILED" if message == "" { status = "REJECTED" if a != nil && b != nil && a.Decision == "APPROVE" && b.Decision == "APPROVE" { status = "APPROVED" } } if !current.Enabled || current.Version != p.Version { status = "STALE" message = "审核策略已变更" } // Lock provider rows in stable key order; a changed route cannot publish old output. rows, err := tx.Query(ctx, `SELECT config_key,version,enabled FROM veyra_provider_config WHERE config_key IN ('market_primary','model_primary','model_review') ORDER BY config_key FOR SHARE`) if err != nil { return mapError(err) } valid := 0 sourceValid := c.Screening == nil for rows.Next() { var key string var version int64 var enabled bool if err = rows.Scan(&key, &version, &enabled); err != nil { rows.Close() return mapError(err) } if key == "market_primary" && c.Screening != nil && enabled && version == c.Screening.SourceVersion { sourceValid = true } if enabled && ((key == primary.ConfigKey && version == primary.Version) || (key == review.ConfigKey && version == review.Version)) { valid++ } } err = rows.Err() rows.Close() if err != nil { return mapError(err) } if valid != 2 { status = "STALE" message = "模型配置已变更或未启用" } if !sourceValid { status = "STALE" message = "行情来源已停用或版本已变化" } var storedToken, storedStatus string if err = tx.QueryRow(ctx, `SELECT claim_token,status FROM veyra_candidate_analysis WHERE candidate_revision_id=$1 AND input_version=$2 AND policy_version=$3 FOR UPDATE`, c.ID, c.Version, p.Version).Scan(&storedToken, &storedStatus); err != nil { return mapError(err) } if token != storedToken || storedStatus != "RUNNING" { return apperror.ErrConflict } // Existing transition owns candidate/strategy locks and validates source/version/time. if status == "APPROVED" && current.AutoPublish && current.UpdatedBy != nil { var actorRole, actorStatus string err = tx.QueryRow(ctx, `SELECT role,status FROM veyra_user WHERE id=$1 FOR SHARE`, *current.UpdatedBy).Scan(&actorRole, &actorStatus) if err != nil { return mapError(err) } if actorRole != "ADMIN" || actorStatus != "ACTIVE" { status = "STALE" message = "自动发布授权身份已停用" } else { // Savepoint allows a rejected rule check to remain visible without publishing. if _, err = tx.Exec(ctx, "SAVEPOINT ai_publish"); err != nil { return mapError(err) } _, err = transitionCandidate(ctx, tx, c.ID, "PUBLISH", model.CandidateActionInput{ExpectedVersion: c.Version, Reason: "AI 自动审核通过;主分析与复核已保存,价格由策略规则生成"}, *current.UpdatedBy, uuid.NewString()) if err != nil { if _, rollbackErr := tx.Exec(ctx, "ROLLBACK TO SAVEPOINT ai_publish"); rollbackErr != nil { return mapError(rollbackErr) } status = "STALE" message = "发布前规则校验未通过,请检查候选时效与策略版本" } else { status = "PUBLISHED" message = "AI 分析与复核通过,已自动发布" } } } if message == "" { if status == "REJECTED" { message = "AI 未通过,保留人工复核" } else { message = "AI 审核通过,当前为人工发布模式" } } rawA, _ := json.Marshal(a) rawB, _ := json.Marshal(b) _, err = tx.Exec(ctx, `UPDATE veyra_candidate_analysis SET status=$4,primary_model=$5,review_model=$6,primary_version=$7,review_version=$8,analysis=$9,review=$10,message=$11,lease_until=now()+interval '5 minutes',updated_at=now() WHERE candidate_revision_id=$1 AND input_version=$2 AND policy_version=$3`, c.ID, c.Version, p.Version, status, primary.ModelName, review.ModelName, primary.Version, review.Version, rawA, rawB, message) if err != nil { return mapError(err) } if current.UpdatedBy != nil { if err = appendAudit(ctx, tx, *current.UpdatedBy, "AI_CANDIDATE_COMPLETED", "candidate_revision", c.ID, uuid.NewString(), map[string]any{"status": status, "policy_version": p.Version, "input_version": c.Version}); err != nil { return err } } return mapError(tx.Commit(ctx)) } func (r *CandidateAnalysisRepository) List(ctx context.Context) ([]model.CandidateAnalysis, error) { rows, err := r.pool.Query(ctx, `SELECT a.id,a.candidate_revision_id,a.input_version,a.policy_version,a.status,a.attempts,a.primary_model,a.review_model,a.primary_version,a.review_version,a.analysis,a.review,a.message,a.created_at,a.updated_at,r.snapshot->>'name',r.snapshot->>'symbol',r.snapshot->>'exchange' FROM veyra_candidate_analysis a JOIN veyra_candidate_revision r ON r.id=a.candidate_revision_id ORDER BY a.updated_at DESC LIMIT 100`) if err != nil { return nil, mapError(err) } defer rows.Close() out := make([]model.CandidateAnalysis, 0) for rows.Next() { var v model.CandidateAnalysis var a, b []byte if err = rows.Scan(&v.ID, &v.CandidateRevisionID, &v.InputVersion, &v.PolicyVersion, &v.Status, &v.Attempts, &v.PrimaryModel, &v.ReviewModel, &v.PrimaryVersion, &v.ReviewVersion, &a, &b, &v.Message, &v.CreatedAt, &v.UpdatedAt, &v.Name, &v.Symbol, &v.Exchange); err != nil { return nil, mapError(err) } if len(a) > 0 { if err = json.Unmarshal(a, &v.Analysis); err != nil { return nil, apperror.ErrUnavailable } } if len(b) > 0 { if err = json.Unmarshal(b, &v.Review); err != nil { return nil, apperror.ErrUnavailable } } out = append(out, v) } return out, mapError(rows.Err()) }