87f97a2f9b
build / build (push) Successful in 1m7s
详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。 月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。 加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。 验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。 浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。 未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
180 lines
7.4 KiB
Go
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(¤t.Revision, ¤t.Markdown, ¤t.Seed, ¤t.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
|
|
}
|