Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
124 lines
5.2 KiB
Go
124 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// ---- Generative UI Surface 动作台账(提案 U5;迁移 v23)----
|
|
|
|
// SurfaceActionRecord 是一次动作提交的台账行。state: submitted(已分发)/
|
|
// awaiting_confirm(高危确认卡挂起,执行权威在命令总线审计)/ committed /
|
|
// rejected(分发被拒)。同 requestId 重放读此行返回原回执。
|
|
type SurfaceActionRecord struct {
|
|
Owner string
|
|
SurfaceID string
|
|
ActionID string
|
|
RequestID string
|
|
Revision int
|
|
ParamsHash string
|
|
State string
|
|
Receipt string
|
|
CardID string
|
|
CreatedAt string
|
|
}
|
|
|
|
// ErrIdempotencyConflict 同 requestId 但参数不同:拒绝(KTD6)。
|
|
var ErrIdempotencyConflict = errors.New("surface action: request id reused with different params")
|
|
|
|
// SaveSurfaceActionIfAbsent 幂等写入:首个提交插入成功(first=true);
|
|
// 已存在时返回既有行(first=false)。参数一致性由调用方比对 ParamsHash。
|
|
func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) {
|
|
return s.saveSurfaceActionIfAbsentExec(s.db, rec)
|
|
}
|
|
|
|
// SaveSurfaceActionIfAbsentAccountChecked fences the ledger insert and checks
|
|
// that the referenced surface still belongs to the actor in that transaction.
|
|
func (s *Store) SaveSurfaceActionIfAbsentAccountChecked(actor string, rec SurfaceActionRecord, allowImplicitDefault bool) (SurfaceActionRecord, bool, error) {
|
|
if actor != rec.Owner {
|
|
return rec, false, ErrAccountForbidden
|
|
}
|
|
var existing SurfaceActionRecord
|
|
var first bool
|
|
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
var owner string
|
|
if err := tx.QueryRow(s.q(`SELECT owner FROM surfaces WHERE surface_id=? AND owner=?`), rec.SurfaceID, actor).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
existing, first, err = s.saveSurfaceActionIfAbsentExec(tx, rec)
|
|
return err
|
|
})
|
|
return existing, first, err
|
|
}
|
|
|
|
type surfaceActionDB interface {
|
|
Exec(string, ...any) (sql.Result, error)
|
|
QueryRow(string, ...any) *sql.Row
|
|
}
|
|
|
|
func (s *Store) saveSurfaceActionIfAbsentExec(db surfaceActionDB, rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) {
|
|
rec.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
res, err := db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(owner, surface_id, action_id, request_id) DO NOTHING`),
|
|
rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID, rec.Revision,
|
|
rec.ParamsHash, rec.State, rec.Receipt, rec.CardID, rec.CreatedAt)
|
|
if err != nil {
|
|
return rec, false, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
existing, err := s.getSurfaceActionQuery(db, rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID)
|
|
if err != nil {
|
|
return rec, false, err
|
|
}
|
|
return existing, n == 1, nil // RowsAffected=1 → 本调用完成首插(时间戳同秒不可靠)
|
|
}
|
|
|
|
// GetSurfaceAction 读取台账行(不存在 → ErrNotFound)。
|
|
func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) {
|
|
return s.getSurfaceActionQuery(s.db, owner, surfaceID, actionID, requestID)
|
|
}
|
|
|
|
func (s *Store) getSurfaceActionQuery(db surfaceActionDB, owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) {
|
|
var r SurfaceActionRecord
|
|
err := db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at
|
|
FROM surface_actions WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`),
|
|
owner, surfaceID, actionID, requestID).
|
|
Scan(&r.Owner, &r.SurfaceID, &r.ActionID, &r.RequestID, &r.Revision, &r.ParamsHash, &r.State, &r.Receipt, &r.CardID, &r.CreatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return r, ErrNotFound
|
|
}
|
|
return r, err
|
|
}
|
|
|
|
// UpdateSurfaceActionState 回写分发结果(首个提交提交后的状态迁移)。
|
|
func (s *Store) UpdateSurfaceActionState(owner, surfaceID, actionID, requestID, state, receipt, cardID string) error {
|
|
return s.updateSurfaceActionStateExec(s.db, owner, surfaceID, actionID, requestID, state, receipt, cardID)
|
|
}
|
|
|
|
// UpdateSurfaceActionStateAccountChecked fences the final result write. A
|
|
// dispatch can have happened before disable, but its later local write cannot.
|
|
func (s *Store) UpdateSurfaceActionStateAccountChecked(actor, surfaceID, actionID, requestID, state, receipt, cardID string, allowImplicitDefault bool) error {
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
return s.updateSurfaceActionStateExec(tx, actor, surfaceID, actionID, requestID, state, receipt, cardID)
|
|
})
|
|
}
|
|
|
|
func (s *Store) updateSurfaceActionStateExec(db surfaceExecer, owner, surfaceID, actionID, requestID, state, receipt, cardID string) error {
|
|
res, err := db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=?
|
|
WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`),
|
|
state, receipt, cardID, owner, surfaceID, actionID, requestID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|