Files
knowledge-governance/internal/qaopsmirror/weekly_draft.go
T
姚章浩 87f97a2f9b
build / build (push) Successful in 1m7s
feat(qaops): 完成详情下拉与每周自动保存草稿
详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。
月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。
加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。

验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。
浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。
未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
2026-10-20 05:44:02 +08:00

180 lines
7.4 KiB
Go

package qaopsmirror
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"strings"
"time"
"unicode/utf8"
)
func (s *Store) PrefillWeeklyDraft(ctx context.Context, week string, q WeeklyDraftPrefillRequest, actor string, now time.Time) (WeeklyDraftReceipt, error) {
if !s.valid() {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
start, end, err := weeklyDraftBounds(week, now)
if err != nil {
return WeeklyDraftReceipt{}, err
}
if q.BaseRevision < 0 || !inlineNonce.MatchString(q.Nonce) || !validInlineID(actor) {
return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery
}
request := WeeklyDraftRequest{BaseRevision: q.BaseRevision, Nonce: q.Nonce}
saved := WeeklyDraft{Week: week}
var hash, owner string
err = s.pool.QueryRow(ctx, `SELECT revision,markdown,seed,created_at,actor,request_hash FROM qa_ops_weekly_draft_revisions WHERE tenant_key=$1 AND week=$2 AND nonce=$3`, s.tenant, week, q.Nonce).Scan(&saved.Revision, &saved.Markdown, &saved.Seed, &saved.UpdatedAt, &owner, &hash)
if err == nil {
if hash != weeklyRequestHash(request, "prefill") || owner != actor {
return WeeklyDraftReceipt{}, qaopsquality.ErrConflict
}
return WeeklyDraftReceipt{Draft: saved, Replayed: true}, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
data, err := s.ReadMasterDashboard(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: start, End: end}})
if err != nil {
return WeeklyDraftReceipt{}, err
}
markdown, seed, err := renderWeeklyTemplate(week, data, now)
if err != nil {
return WeeklyDraftReceipt{}, err
}
request.Markdown = markdown
return s.saveWeeklyDraft(ctx, week, request, actor, now, "prefill", seed)
}
const maxWeeklyDraftBytes = 256 << 10
type WeeklyDraft struct {
Week string `json:"week"`
Revision int64 `json:"revision"`
Markdown string `json:"markdown"`
Seed json.RawMessage `json:"seed"`
UpdatedAt *time.Time `json:"updated_at"`
}
type WeeklyDraftRequest struct {
BaseRevision int64 `json:"base_revision"`
Nonce string `json:"nonce"`
Markdown string `json:"markdown"`
}
type WeeklyDraftPrefillRequest struct {
BaseRevision int64 `json:"base_revision"`
Nonce string `json:"nonce"`
}
type WeeklyDraftReceipt struct {
Draft WeeklyDraft `json:"draft"`
Replayed bool `json:"replayed"`
}
func weeklyDraftBounds(week string, now time.Time) (time.Time, time.Time, error) {
start, end, err := (DepartmentStatisticsQuery{Period: "week", Week: week}).Bounds(now)
if err != nil {
return time.Time{}, time.Time{}, err
}
if start == nil {
return time.Time{}, time.Time{}, dobotaiops.ErrInvalidQuery
}
return *start, end, nil
}
func (s *Store) ReadWeeklyDraft(ctx context.Context, week string, now time.Time) (WeeklyDraft, error) {
out := WeeklyDraft{Week: week, Seed: json.RawMessage(`{}`)}
if !s.valid() {
return out, dobotaiops.ErrUnavailable
}
if _, _, e := weeklyDraftBounds(week, now); e != nil {
return out, e
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
e := s.pool.QueryRow(ctx, `SELECT revision,markdown,seed,updated_at FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2`, s.tenant, week).Scan(&out.Revision, &out.Markdown, &out.Seed, &out.UpdatedAt)
if errors.Is(e, pgx.ErrNoRows) {
return out, nil
}
if e != nil {
return WeeklyDraft{}, dobotaiops.ErrUnavailable
}
return out, nil
}
func weeklyRequestHash(q WeeklyDraftRequest, operation string) string {
if operation == "prefill" {
q.Markdown = ""
}
raw, _ := json.Marshal(struct {
Request WeeklyDraftRequest
Operation string
}{q, operation})
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func (s *Store) SaveWeeklyDraft(ctx context.Context, week string, q WeeklyDraftRequest, actor string, now time.Time) (WeeklyDraftReceipt, error) {
return s.saveWeeklyDraft(ctx, week, q, actor, now, "edit", nil)
}
func (s *Store) saveWeeklyDraft(ctx context.Context, week string, q WeeklyDraftRequest, actor string, now time.Time, operation string, seed json.RawMessage) (WeeklyDraftReceipt, error) {
if !s.valid() {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
if _, _, e := weeklyDraftBounds(week, now); e != nil {
return WeeklyDraftReceipt{}, e
}
if q.BaseRevision < 0 || !inlineNonce.MatchString(q.Nonce) || !validInlineID(actor) || !utf8.ValidString(q.Markdown) || len(q.Markdown) > maxWeeklyDraftBytes || strings.ContainsRune(q.Markdown, 0) || (operation != "edit" && operation != "prefill") {
return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
tx, e := s.pool.Begin(ctx)
if e != nil {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
defer tx.Rollback(context.Background())
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1||chr(31)||$2,259))`, s.tenant, week); e != nil {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
requestHash := weeklyRequestHash(q, operation)
saved := WeeklyDraft{Week: week}
var savedActor, savedHash string
e = tx.QueryRow(ctx, `SELECT revision,markdown,seed,created_at,actor,request_hash FROM qa_ops_weekly_draft_revisions WHERE tenant_key=$1 AND week=$2 AND nonce=$3`, s.tenant, week, q.Nonce).Scan(&saved.Revision, &saved.Markdown, &saved.Seed, &saved.UpdatedAt, &savedActor, &savedHash)
if e == nil {
if savedActor != actor || savedHash != requestHash {
return WeeklyDraftReceipt{}, qaopsquality.ErrConflict
}
return WeeklyDraftReceipt{Draft: saved, Replayed: true}, nil
}
if !errors.Is(e, pgx.ErrNoRows) {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
current := WeeklyDraft{Week: week, Seed: json.RawMessage(`{}`)}
e = tx.QueryRow(ctx, `SELECT revision,markdown,seed,updated_at FROM qa_ops_weekly_drafts WHERE tenant_key=$1 AND week=$2 FOR UPDATE`, s.tenant, week).Scan(&current.Revision, &current.Markdown, &current.Seed, &current.UpdatedAt)
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
if current.Revision != q.BaseRevision {
return WeeklyDraftReceipt{}, qaopsquality.ErrConflict
}
if seed == nil {
seed = current.Seed
}
if len(seed) == 0 || !json.Valid(seed) {
return WeeklyDraftReceipt{}, dobotaiops.ErrInvalidQuery
}
var created time.Time
next := current.Revision + 1
e = tx.QueryRow(ctx, `INSERT INTO qa_ops_weekly_drafts(tenant_key,week,revision,markdown,seed,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(tenant_key,week) DO UPDATE SET revision=EXCLUDED.revision,markdown=EXCLUDED.markdown,seed=EXCLUDED.seed,updated_by=EXCLUDED.updated_by,updated_at=now() RETURNING updated_at`, s.tenant, week, next, q.Markdown, seed, actor).Scan(&created)
if e != nil {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_weekly_draft_revisions(tenant_key,week,revision,operation,markdown,seed,actor,nonce,request_hash,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, s.tenant, week, next, operation, q.Markdown, seed, actor, q.Nonce, requestHash, created); e != nil {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
if e = tx.Commit(ctx); e != nil {
return WeeklyDraftReceipt{}, dobotaiops.ErrUnavailable
}
return WeeklyDraftReceipt{Draft: WeeklyDraft{Week: week, Revision: next, Markdown: q.Markdown, Seed: seed, UpdatedAt: &created}}, nil
}