170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
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
|
||
}
|