Files
AskaEth 509dc412eb
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆 P2(项目索引物化 / decay 回写 / 作用域写规则 / 静默吞点 + loop 级只读红线测试)
2026-10-05 11:47:39 +08:00

170 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package projectindex
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// Project 是一个项目索引条目(design §3):链接其
// 各章节的项目标示,连同项目描述。它是层级的第 1 层(索引
// 条目)与第 2 层(描述)。零值不是
// 合法项目:ID 与 Title 必填。
type Project struct {
ID string
Title string
Desc string
CreatedAt time.Time
UpdatedAt time.Time
}
// PutProject 按 id upsert p。id 已存在时更新 title、desc 与
// updated_at,并保留原始的 created_at;id 为新值时
// 补齐缺失时间戳并插入。id 或 title 为空返回 ErrMissingProject
// 或 ErrMissingTitle。
func (s *Store) PutProject(ctx context.Context, p Project) error {
if strings.TrimSpace(p.ID) == "" {
return ErrMissingProject
}
if strings.TrimSpace(p.Title) == "" {
return ErrMissingTitle
}
now := time.Now().UTC()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = now
}
_, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
desc = excluded.desc,
updated_at = excluded.updated_at`,
p.ID, p.Title, p.Desc, formatTime(p.CreatedAt), formatTime(p.UpdatedAt))
if err != nil {
return fmt.Errorf("projectindex: upsert project: %w", err)
}
s.log.Debug("project upserted", "id", p.ID)
return nil
}
// EnsureProject 在 id 尚无项目条目时插入一条最小索引记录,已存在时
// **原样保留**(绝不覆盖既有 title/desc)。title 为空时取 id。
//
// 它是「项目记忆链接项目索引」(design §3)的落地路径:项目作用域记忆
// 携带 project_id 指向项目索引层级,只有索引条目存在时该链接才可解析
// (审计 2026-10-05 §4:project 域记忆的 project_id 大量悬空、表为空)。
// 与 PutProject 的区别是**不覆盖**:写入来自记忆时只有 id 可信,不能拿它
// 去改写由 RAG 导入建立的真实 title/desc。空 id 返回 ErrMissingProject。
func (s *Store) EnsureProject(ctx context.Context, id, title string) error {
if strings.TrimSpace(id) == "" {
return ErrMissingProject
}
if strings.TrimSpace(title) == "" {
title = id
}
now := formatTime(time.Now().UTC())
_, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at)
VALUES (?, ?, '', ?, ?)
ON CONFLICT(id) DO NOTHING`, id, title, now, now)
if err != nil {
return fmt.Errorf("projectindex: ensure project: %w", err)
}
return nil
}
// GetProject 返回给定 id 的项目。id 为空或
// 不存在时返回 ErrProjectNotFound。
func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
if strings.TrimSpace(id) == "" {
return nil, ErrProjectNotFound
}
row := s.db.QueryRowContext(ctx, `SELECT id, title, desc, created_at, updated_at
FROM project WHERE id = ?`, id)
p, err := scanProject(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrProjectNotFound
}
if err != nil {
return nil, fmt.Errorf("projectindex: get project: %w", err)
}
return p, nil
}
// DeleteProject 按 id 删除项目,并级联删除引用它的每个
// 章节。id 为空、或没有删除任何
// 项目行时返回 ErrProjectNotFound。删项目与删章节发生在
// 同一事务内,因此失败绝不会留下孤儿项目或章节。
func (s *Store) DeleteProject(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrProjectNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("projectindex: begin delete tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx, `DELETE FROM project_chapter WHERE project_id = ?`, id)
if err != nil {
return fmt.Errorf("projectindex: delete chapters: %w", err)
}
if _, err := res.RowsAffected(); err != nil {
return fmt.Errorf("projectindex: delete chapters row count: %w", err)
}
res, err = tx.ExecContext(ctx, `DELETE FROM project WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("projectindex: delete project: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("projectindex: delete row count: %w", err)
}
if n == 0 {
return ErrProjectNotFound
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("projectindex: commit delete tx: %w", err)
}
s.log.Debug("project deleted", "id", id)
return nil
}
// scanProject 读取一行 project。
func scanProject(sc rowScanner) (*Project, error) {
var (
p Project
createdAt, updatedAt string
)
if err := sc.Scan(&p.ID, &p.Title, &p.Desc, &createdAt, &updatedAt); err != nil {
return nil, err
}
var err error
if p.CreatedAt, err = parseTime(createdAt); err != nil {
return nil, err
}
if p.UpdatedAt, err = parseTime(updatedAt); err != nil {
return nil, err
}
return &p, nil
}
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("projectindex: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}