feat(qaops): 完成详情下拉与每周自动保存草稿
build / build (push) Successful in 1m7s

详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。
月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。
加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。

验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。
浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。
未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
This commit is contained in:
姚章浩
2026-10-20 05:41:57 +08:00
parent 154b0b62ad
commit 87f97a2f9b
47 changed files with 2587 additions and 180 deletions
@@ -0,0 +1,20 @@
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM qa_ops_weekly_draft_revisions) OR EXISTS(SELECT 1 FROM qa_ops_master_field_edits) THEN
RAISE EXCEPTION 'Saved human edits or weekly drafts exist; retain the additive schema for image rollback';
END IF;
END $$;
DROP TABLE qa_ops_weekly_draft_revisions;
DROP TABLE qa_ops_weekly_drafts;
DROP VIEW qa_ops_master_read_model;
CREATE VIEW qa_ops_master_read_model AS
SELECT m.*,a.ai_fields,t.background_tasks,qa_ops_fill_empty_fields(m.local_fields||jsonb_build_object('question_chinese',m.chinese_question),a.ai_fields) AS effective_fields
FROM qa_ops_master_records m LEFT JOIN LATERAL (
SELECT COALESCE(jsonb_object_agg(f.key,f.value ORDER BY o.created_at,o.work_id),'{}'::jsonb) AS ai_fields
FROM qa_ops_ai_outputs o CROSS JOIN LATERAL jsonb_each(o.applied_fields) f
WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.input_hash=CASE WHEN o.kind='evaluation' THEN m.quality_hash ELSE encode(sha256(convert_to(m.question,'UTF8')),'hex') END
) a ON true LEFT JOIN LATERAL (
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',w.id,'record_id',w.record_id,'kind',w.kind,'state',w.state,'stage',w.stage,'attempt',w.attempt,'error_code',w.error_code,'skip_reason',w.skip_reason,'started_at',w.started_at,'updated_at',w.updated_at)),'[]'::jsonb) AS background_tasks
FROM (SELECT DISTINCT ON (kind) * FROM qa_ops_ai_work WHERE tenant_key=m.tenant_key AND record_id=m.id ORDER BY kind,created_at DESC,id DESC) w
) t ON true;
DROP TABLE qa_ops_master_field_edits;
DROP TABLE qa_ops_master_field_overrides;
@@ -0,0 +1,27 @@
-- Sparse human overrides keep untouched stored/AI fields and original payload immutable.
CREATE TABLE qa_ops_master_field_overrides (
tenant_key text NOT NULL,record_id text NOT NULL,revision bigint NOT NULL CHECK(revision>0),input_hash text NOT NULL,review_revision bigint NOT NULL CHECK(review_revision>=0),fields jsonb NOT NULL CHECK(jsonb_typeof(fields)='object'),updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(tenant_key,record_id),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_master_records(tenant_key,id)
);
CREATE TABLE qa_ops_master_field_edits (
tenant_key text NOT NULL,record_id text NOT NULL,revision bigint NOT NULL CHECK(revision>0),input_hash text NOT NULL,review_revision bigint NOT NULL,changed_fields jsonb NOT NULL,before_fields jsonb NOT NULL,fields jsonb NOT NULL,actor text NOT NULL,nonce text NOT NULL,request_hash text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(tenant_key,record_id,revision),UNIQUE(tenant_key,record_id,nonce),FOREIGN KEY(tenant_key,record_id) REFERENCES qa_ops_master_records(tenant_key,id)
);
CREATE OR REPLACE VIEW qa_ops_master_read_model AS
SELECT m.*,a.ai_fields,t.background_tasks,qa_ops_fill_empty_fields(m.local_fields||jsonb_build_object('question_chinese',m.chinese_question),a.ai_fields)||COALESCE(CASE WHEN e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0) THEN e.fields END,'{}'::jsonb) AS effective_fields,COALESCE(e.revision,0) AS field_edit_revision,COALESCE(CASE WHEN e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0) THEN e.fields END,'{}'::jsonb) AS inline_fields
FROM qa_ops_master_records m LEFT JOIN LATERAL (
SELECT COALESCE(jsonb_object_agg(f.key,f.value ORDER BY o.created_at,o.work_id),'{}'::jsonb) AS ai_fields
FROM qa_ops_ai_outputs o CROSS JOIN LATERAL jsonb_each(o.applied_fields) f
WHERE o.tenant_key=m.tenant_key AND o.record_id=m.id AND o.input_hash=CASE WHEN o.kind='evaluation' THEN m.quality_hash ELSE encode(sha256(convert_to(m.question,'UTF8')),'hex') END
) a ON true LEFT JOIN LATERAL (
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',w.id,'record_id',w.record_id,'kind',w.kind,'state',w.state,'stage',w.stage,'attempt',w.attempt,'error_code',w.error_code,'skip_reason',w.skip_reason,'started_at',w.started_at,'updated_at',w.updated_at)),'[]'::jsonb) AS background_tasks
FROM (SELECT DISTINCT ON (kind) * FROM qa_ops_ai_work WHERE tenant_key=m.tenant_key AND record_id=m.id ORDER BY kind,created_at DESC,id DESC) w
) t ON true LEFT JOIN qa_ops_quality_records h ON h.tenant_key=m.tenant_key||'::master' AND h.record_id=m.id LEFT JOIN qa_ops_master_field_overrides e ON e.tenant_key=m.tenant_key AND e.record_id=m.id;
-- One shared editable document per tenant/week, with durable idempotent save receipts.
CREATE TABLE qa_ops_weekly_drafts (
tenant_key text NOT NULL,week text NOT NULL CHECK(week ~ '^[0-9]{4}-W[0-9]{2}$'),revision bigint NOT NULL CHECK(revision>0),markdown text NOT NULL CHECK(octet_length(markdown)<=262144),seed jsonb NOT NULL CHECK(jsonb_typeof(seed)='object'),updated_by text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,week)
);
CREATE TABLE qa_ops_weekly_draft_revisions (
tenant_key text NOT NULL,week text NOT NULL,revision bigint NOT NULL CHECK(revision>0),operation text NOT NULL CHECK(operation IN ('edit','prefill')),markdown text NOT NULL CHECK(octet_length(markdown)<=262144),seed jsonb NOT NULL CHECK(jsonb_typeof(seed)='object'),actor text NOT NULL,nonce text NOT NULL,request_hash text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(tenant_key,week,revision),UNIQUE(tenant_key,week,nonce),FOREIGN KEY(tenant_key,week) REFERENCES qa_ops_weekly_drafts(tenant_key,week)
);
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if err != nil {
t.Fatalf("loadMigrations() error = %v", err)
}
if len(migrations) != 57 {
t.Fatalf("migration count = %d, want 57", len(migrations))
if len(migrations) != 58 {
t.Fatalf("migration count = %d, want 58", len(migrations))
}
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
t.Fatalf("migration 1 = %#v", migrations[0])
@@ -0,0 +1,30 @@
package postgres
import (
"strings"
"testing"
)
func TestInlineFieldsWeeklyDraftMigrationIsAdditiveTenantScopedAndRollbackProtected(t *testing.T) {
migrations, e := loadMigrations()
if e != nil {
t.Fatal(e)
}
m := migrations[len(migrations)-1]
if m.Version != 58 {
t.Fatal("missing migration 58")
}
for _, marker := range []string{"CREATE TABLE qa_ops_master_field_overrides", "CREATE TABLE qa_ops_master_field_edits", "CREATE TABLE qa_ops_weekly_drafts", "CREATE TABLE qa_ops_weekly_draft_revisions", "UNIQUE(tenant_key,record_id,nonce)", "UNIQUE(tenant_key,week,nonce)", "e.input_hash=m.quality_hash AND e.review_revision=COALESCE(h.review_revision,0)", "field_edit_revision", "inline_fields"} {
if !strings.Contains(m.UpSQL, marker) {
t.Fatalf("migration lost sparse tenant/input/review/idempotency contract: %s", marker)
}
}
if strings.Contains(m.UpSQL, "UPDATE qa_ops_master_records SET payload") || strings.Contains(m.UpSQL, "UPDATE qa_ops_quality_reviews") {
t.Fatal("migration rewrites original records or human history")
}
for _, marker := range []string{"RAISE EXCEPTION", "SELECT 1 FROM qa_ops_weekly_draft_revisions", "SELECT 1 FROM qa_ops_master_field_edits"} {
if !strings.Contains(m.DownSQL, marker) {
t.Fatalf("down migration must refuse to discard saved human data: %s", marker)
}
}
}