04 · 技术资料:数据层与中文混合检索
本节 SQL 已按 Leo-hub 实际探查结果写好,可直接执行。⛔ 但执行前必须先读 §1 的三个决策点。
#1. P0 必须先解决的三个决策点
这三件事决定后面所有代码怎么写。P0 阶段(前 45 分钟)就要有结论,⛔ 不要等到 P2 才发现。
#决策点 D1:拿不拿得到 service_role key?
实测事实:Leo-hub(project_id <ID>)现有 21 张表的 RLS policy 全是 for all to public using (true) with check (true) —— RLS 完全放行,不提供任何隔离。所有项目都用 NEXT_PUBLIC_SUPABASE_KEY(publishable key)直连。
Baton 的核心卖点就是隔离,所以理想架构是:表对 anon 默认拒绝,只有服务端持 service_role key 能访问。
P0 要做的:
- 检查
~/Desktop/配置信息/下是否有人类留下的 service_role / secret key 文件(文件名可能形如supabase-leo-hub-service-key.txt) - 检查
~/personal/*/.env.local里是否有SUPABASE_SERVICE_ROLE_KEY(已实测:本机目前没有,只在brainknowledge-web/.env.example里有变量名无值) - 检查 Vercel 上现有项目的环境变量里有没有
分支:
| 情况 | 架构 | 动作 |
|---|---|---|
| ✅ 拿到了 | 表 RLS 默认拒绝(不给 anon 建任何 policy),所有访问走服务端 SUPABASE_SERVICE_ROLE_KEY |
走 §3 的「方案 A」DDL |
| ❌ 拿不到 | 表 RLS 沿用 using(true)(与 Leo-hub 现有表一致),用 publishable key;隔离 100% 靠应用层 |
走 §3 的「方案 B」DDL,并在 blockers.md + README 安全章节明写这个边界 |
⛔ 不管哪个分支,应用层的 scopedQuery 强制过滤都必须做。 方案 B 只是少了一层数据库兜底,不是可以不做隔离。
#决策点 D2:原始文件存哪?
| 优先级 | 方案 | 前置条件 | 风险 |
|---|---|---|---|
| 1 | Vercel Blob(client upload,绕过 4.5MB 限制) | 需要 BLOB_READ_WRITE_TOKEN。若 Vercel 项目还没有 Blob store,要跑 vercel blob create-store |
⚠️ 这条命令历史上覆写过 .env.local 导致孤本 key 丢失。跑之前必须 cp .env.local .env.local.bak |
| 2 | Supabase Storage | 建一个 bt-files bucket |
上传要经服务端中转,受 4.5MB 限制 |
| 3 | 降级:只支持 ≤4MB 文件,文本内容直接入库不存原文件 | 无 | 演示够用,但「原始文件一起交接」这条 AC 要标 PARTIAL |
DDL 已设计成三种都能用(storage_provider + storage_url 两列)。
#决策点 D3:embedding 用哪个 key?
云雾中转(https://yunwu.ai/v1,OpenAI 兼容),模型 text-embedding-3-small(1536 维)。
⚠️ 云雾现在一个项目一把 key,今晚不能自助新建(要人工登录后台)。做法:从现有项目的本地 .env.local 借一把跑通,在 blockers 里记一条「待人类建 baton 专属 key」。
拿不到任何可用 key → 走 00-总纲 §6.2 的降级:跳过向量列,只做模糊匹配检索。
#2. Leo-hub 现状(已实测,不用再查)
- project_id:
<ID>,区域ap-northeast-1(东京),Postgres 17.6 - 现有 21 张表:
messagesnote_entriesprivate_authauth_throttleopc_toolsopc_academy_favsopc_tool_favsbrain_notesse_hw_favsms_materialsms_topicsms_draftsms_sync_statewemark_docslfy_userslfy_bookingsen_profileen_articlesen_vocaben_word_statsgifmoji_state - ✅
bt_前缀零冲突 - 扩展:
vector(pgvector 0.8.2) 和pg_trgm(1.6) 可用但未安装,需要create extension pgcrypto已装,直接用gen_random_uuid()- ❌
zhparser/pg_jieba不存在(Supabase 托管版没有中文分词扩展)→to_tsvector('chinese', …)这条路彻底走不通,中文精确匹配只能靠pg_trgm - 备选:
pgroonga3.2.5 在允许列表里(CJK 全文检索更强),但索引体积/构建成本高,今晚不引入
⚠️ 跨区域延迟:Leo-hub 在东京,Vercel 函数默认在美东 iad1。给所有查库的 route 加:
export const preferredRegion = 'hnd1' // 东京,就近连库#3. DDL
#3.0 扩展与公共函数(两个方案都要)
create extension if not exists vector;
create extension if not exists pg_trgm;
-- 维护 updated_at 的公共触发器函数
create or replace function public.bt_set_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;#3.1 七张表
-- ========== 1. 员工 ==========
create table public.bt_employees (
id uuid primary key default gen_random_uuid(),
employee_code text not null unique,
display_name text not null,
avatar_emoji text,
title text,
department text,
role text not null default 'agent' check (role in ('agent','admin')),
status text not null default 'active' check (status in ('active','offboarding','offboarded')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.bt_employees is '接棒:员工主表,每个员工对应一份严格隔离的知识资料';
comment on column public.bt_employees.employee_code is '工号,前端身份切换用的短标识';
comment on column public.bt_employees.status is 'active=在职 / offboarding=交接中 / offboarded=已离职(资料只读并封存)';
create trigger trg_bt_employees_updated_at before update on public.bt_employees
for each row execute function public.bt_set_updated_at();
-- ========== 2. 文件 ==========
create table public.bt_files (
id uuid primary key default gen_random_uuid(),
owner_employee_id uuid not null references public.bt_employees(id) on delete restrict,
original_filename text not null,
storage_provider text not null default 'vercel_blob'
check (storage_provider in ('vercel_blob','supabase_storage','inline')),
storage_url text,
mime_type text not null,
file_size_bytes bigint not null check (file_size_bytes >= 0),
source_type text not null check (source_type in ('pdf','docx','xlsx','txt','md','other')),
page_count int,
checksum_sha256 text,
parse_status text not null default 'pending'
check (parse_status in ('pending','parsing','chunking','embedding','done','failed')),
parse_error text,
total_chunks int not null default 0,
embedded_chunks int not null default 0,
uploaded_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.bt_files is '接棒:员工上传的原始文件,归属唯一员工';
comment on column public.bt_files.parse_status is '状态机:pending→parsing→chunking→embedding→done,失败转 failed';
comment on column public.bt_files.storage_provider is '原文件存储后端,三种降级方案都支持';
create trigger trg_bt_files_updated_at before update on public.bt_files
for each row execute function public.bt_set_updated_at();
create index bt_files_owner_idx on public.bt_files (owner_employee_id);
create index bt_files_status_idx on public.bt_files (parse_status)
where parse_status not in ('done','failed');
-- ========== 3. 切片 ==========
create table public.bt_chunks (
id uuid primary key default gen_random_uuid(),
file_id uuid not null references public.bt_files(id) on delete cascade,
owner_employee_id uuid not null references public.bt_employees(id) on delete restrict,
chunk_index int not null,
page_no int,
page_label text not null,
heading_path text,
content text not null,
content_norm text not null,
token_count int,
char_count int,
embedding vector(1536),
embedding_model text not null default 'text-embedding-3-small',
embedding_status text not null default 'pending'
check (embedding_status in ('pending','done','failed')),
embedding_retry_count int not null default 0,
created_at timestamptz not null default now(),
unique (file_id, chunk_index)
);
comment on table public.bt_chunks is '接棒:文件切片,检索的最小召回单元,必须保留出处';
comment on column public.bt_chunks.page_label is '展示用出处文案,始终有值:PDF="第3页" / docx=章节路径 / xlsx="Sheet1!12-20行"';
comment on column public.bt_chunks.content_norm is '归一化文本(全角转半角、压缩空白),专供 pg_trgm 索引,不用于展示';
comment on column public.bt_chunks.owner_employee_id is '冗余存归属,避免每次检索都 join files';
create index bt_chunks_file_idx on public.bt_chunks (file_id);
create index bt_chunks_owner_idx on public.bt_chunks (owner_employee_id);
create index bt_chunks_pending_idx on public.bt_chunks (file_id, embedding_status)
where embedding_status = 'pending';
-- ========== 4. 记忆条目(交接的标的物) ==========
create table public.bt_memories (
id uuid primary key default gen_random_uuid(),
owner_employee_id uuid not null references public.bt_employees(id) on delete restrict,
category text not null check (category in
('客户约定','报价底线','供应商渠道','人际雷区','流程习惯')),
title text not null,
content text not null,
source_file_id uuid references public.bt_files(id) on delete set null,
source_chunk_id uuid references public.bt_chunks(id) on delete set null,
source_label text,
is_editable boolean not null default true,
visible_to_colleagues boolean not null default false,
include_in_handover_default boolean not null default true,
embedding vector(1536),
embedding_model text not null default 'text-embedding-3-small',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.bt_memories is '接棒:记忆条目,交接的标的物。三个开关分别控制可编辑、跨人可见、交接默认包含';
comment on column public.bt_memories.visible_to_colleagues is 'true 时其他员工的 Agent 可跨人问到(仅问答,不转移归属)';
comment on column public.bt_memories.include_in_handover_default is '发起交接单时是否默认勾选(用户仍可增删)';
create trigger trg_bt_memories_updated_at before update on public.bt_memories
for each row execute function public.bt_set_updated_at();
create index bt_memories_owner_idx on public.bt_memories (owner_employee_id);
create index bt_memories_cat_idx on public.bt_memories (owner_employee_id, category);
create index bt_memories_visible_idx on public.bt_memories (visible_to_colleagues)
where visible_to_colleagues = true;
-- ========== 5. 交接单 ==========
create table public.bt_handovers (
id uuid primary key default gen_random_uuid(),
from_employee_id uuid not null references public.bt_employees(id) on delete restrict,
to_employee_id uuid not null references public.bt_employees(id) on delete restrict,
reason text not null default 'daily_sync' check (reason in ('offboard','role_change','daily_sync')),
status text not null default 'draft'
check (status in ('draft','submitted','viewed','completed','cancelled')),
title text,
note text,
created_at timestamptz not null default now(),
submitted_at timestamptz,
viewed_at timestamptz,
completed_at timestamptz,
constraint bt_handovers_diff_employee check (from_employee_id <> to_employee_id)
);
comment on table public.bt_handovers is '接棒:交接单。一次交接 = 一个 from→to 的可见权授予事件,不搬走数据';
comment on column public.bt_handovers.status is 'draft→submitted→viewed→completed,对应页面上三步进度条';
create index bt_handovers_to_idx on public.bt_handovers (to_employee_id, status);
create index bt_handovers_from_idx on public.bt_handovers (from_employee_id, status);
-- ========== 6. 交接明细 ==========
create table public.bt_handover_items (
id uuid primary key default gen_random_uuid(),
handover_id uuid not null references public.bt_handovers(id) on delete cascade,
item_type text not null check (item_type in ('memory','file')),
memory_id uuid references public.bt_memories(id) on delete cascade,
file_id uuid references public.bt_files(id) on delete cascade,
source_note text,
included_by text not null default 'default'
check (included_by in ('default','manual_add')),
granted_at timestamptz,
created_at timestamptz not null default now(),
constraint bt_handover_items_type_consistency check (
(item_type = 'memory' and memory_id is not null and file_id is null) or
(item_type = 'file' and file_id is not null and memory_id is null)
)
);
comment on table public.bt_handover_items is '接棒:交接明细。本表即可见权授予记录,原 owner 不变';
comment on column public.bt_handover_items.granted_at is '交接单 completed 时回填,代表可见权正式生效';
create index bt_handover_items_handover_idx on public.bt_handover_items (handover_id);
create unique index bt_handover_items_memory_uk on public.bt_handover_items (handover_id, memory_id)
where item_type = 'memory';
create unique index bt_handover_items_file_uk on public.bt_handover_items (handover_id, file_id)
where item_type = 'file';
create index bt_handover_items_memory_idx on public.bt_handover_items (memory_id)
where memory_id is not null;
create index bt_handover_items_file_idx on public.bt_handover_items (file_id)
where file_id is not null;
-- ========== 7. 跨 Agent 提问日志 ==========
create table public.bt_agent_queries (
id uuid primary key default gen_random_uuid(),
asking_employee_id uuid not null references public.bt_employees(id) on delete restrict,
target_employee_id uuid references public.bt_employees(id) on delete set null,
query_text text not null,
answer_text text,
matched_memory_ids uuid[] not null default '{}',
matched_chunk_ids uuid[] not null default '{}',
was_cross_employee boolean not null default false,
hop int not null default 0,
latency_ms int,
created_at timestamptz not null default now()
);
comment on table public.bt_agent_queries is '接棒:问答与跨人提问日志,记录谁问了谁、命中了什么';
comment on column public.bt_agent_queries.hop is '跳数:0=问自己,1=问同事。⛔ 不允许出现 2(防无限套娃)';
create index bt_agent_queries_asking_idx on public.bt_agent_queries (asking_employee_id, created_at desc);
create index bt_agent_queries_target_idx on public.bt_agent_queries (target_employee_id, created_at desc);#3.2 索引(向量 + 模糊)
-- 向量:数据量 <10 万,用 HNSW(不用 IVFFlat,后者要按行数调 lists 且数据变化后要重建)
create index bt_chunks_embedding_hnsw on public.bt_chunks
using hnsw (embedding vector_cosine_ops) with (m = 16, ef_construction = 64);
create index bt_memories_embedding_hnsw on public.bt_memories
using hnsw (embedding vector_cosine_ops) with (m = 16, ef_construction = 64);
-- 中文模糊匹配:pg_trgm GIN
create index bt_chunks_trgm on public.bt_chunks using gin (content_norm gin_trgm_ops);
create index bt_memories_trgm on public.bt_memories using gin (content gin_trgm_ops);⚠️ 小数据量下向量索引可能比全表扫描还慢(HNSW 图遍历开销)。种子数据只有几十条 chunk 时这很正常,⛔ 不要因此怀疑方案有问题。
#3.3 RLS —— 二选一
方案 A(拿到了 service_role key,推荐)
alter table public.bt_employees enable row level security;
alter table public.bt_files enable row level security;
alter table public.bt_chunks enable row level security;
alter table public.bt_memories enable row level security;
alter table public.bt_handovers enable row level security;
alter table public.bt_handover_items enable row level security;
alter table public.bt_agent_queries enable row level security;
revoke all on public.bt_employees, public.bt_files, public.bt_chunks,
public.bt_memories, public.bt_handovers, public.bt_handover_items,
public.bt_agent_queries
from anon, authenticated;
-- 不给 anon/authenticated 建任何 policy = 默认全部拒绝。
-- service_role 自带 BYPASSRLS,只在服务端使用,是唯一真正能读写的角色。方案 B(只有 publishable key)
alter table public.bt_employees enable row level security;
create policy bt_employees_all on public.bt_employees for all to public using (true) with check (true);
-- 其余六张表同样处理(与 Leo-hub 现有表风格一致)⚠️ 方案 B 下,任何拿到 publishable key 的人都能直连 REST 读全部 bt_ 数据。这必须写进 README 的「安全边界」章节和 blockers.md,⛔ 不许藏着。
#4. 混合检索函数
create or replace function public.bt_hybrid_search(
query_text text,
query_embedding vector(1536),
target_employee_id uuid,
match_count int default 10,
candidate_pool int default 50
)
returns table (
item_type text, item_id uuid, file_id uuid,
page_no int, page_label text, heading_path text,
title text, snippet text, owner_employee_id uuid,
vec_score real, trgm_score real, rrf_score double precision
)
language sql stable as $$
with
chunk_vec as (
select c.id as item_id, c.file_id, c.page_no, c.page_label, c.heading_path,
null::text as title, c.content as snippet, c.owner_employee_id,
1 - (c.embedding <=> query_embedding) as score,
row_number() over (order by c.embedding <=> query_embedding) as rnk
from public.bt_chunks c
where c.embedding is not null
and (c.owner_employee_id = target_employee_id
or exists (select 1 from public.bt_handover_items hi
join public.bt_handovers h on h.id = hi.handover_id
where hi.item_type = 'file' and hi.file_id = c.file_id
and h.to_employee_id = target_employee_id and h.status = 'completed'))
order by c.embedding <=> query_embedding
limit candidate_pool
),
chunk_trgm as (
select c.id as item_id, c.file_id, c.page_no, c.page_label, c.heading_path,
null::text as title, c.content as snippet, c.owner_employee_id,
similarity(c.content_norm, query_text) as score,
row_number() over (order by similarity(c.content_norm, query_text) desc) as rnk
from public.bt_chunks c
where c.content_norm % query_text
and (c.owner_employee_id = target_employee_id
or exists (select 1 from public.bt_handover_items hi
join public.bt_handovers h on h.id = hi.handover_id
where hi.item_type = 'file' and hi.file_id = c.file_id
and h.to_employee_id = target_employee_id and h.status = 'completed'))
order by similarity(c.content_norm, query_text) desc
limit candidate_pool
),
memory_vec as (
select m.id as item_id, m.source_file_id as file_id, null::int as page_no,
m.source_label as page_label, null::text as heading_path,
m.title, m.content as snippet, m.owner_employee_id,
1 - (m.embedding <=> query_embedding) as score,
row_number() over (order by m.embedding <=> query_embedding) as rnk
from public.bt_memories m
where m.embedding is not null
and (m.owner_employee_id = target_employee_id
or exists (select 1 from public.bt_handover_items hi
join public.bt_handovers h on h.id = hi.handover_id
where hi.item_type = 'memory' and hi.memory_id = m.id
and h.to_employee_id = target_employee_id and h.status = 'completed'))
order by m.embedding <=> query_embedding
limit candidate_pool
),
memory_trgm as (
select m.id as item_id, m.source_file_id as file_id, null::int as page_no,
m.source_label as page_label, null::text as heading_path,
m.title, m.content as snippet, m.owner_employee_id,
similarity(m.content, query_text) as score,
row_number() over (order by similarity(m.content, query_text) desc) as rnk
from public.bt_memories m
where m.content % query_text
and (m.owner_employee_id = target_employee_id
or exists (select 1 from public.bt_handover_items hi
join public.bt_handovers h on h.id = hi.handover_id
where hi.item_type = 'memory' and hi.memory_id = m.id
and h.to_employee_id = target_employee_id and h.status = 'completed'))
order by similarity(m.content, query_text) desc
limit candidate_pool
),
unioned as (
select 'chunk'::text as item_type, item_id, file_id, page_no, page_label, heading_path,
title, snippet, owner_employee_id, score, rnk, 'vec'::text as method from chunk_vec
union all
select 'chunk'::text, item_id, file_id, page_no, page_label, heading_path,
title, snippet, owner_employee_id, score, rnk, 'trgm'::text from chunk_trgm
union all
select 'memory'::text, item_id, file_id, page_no, page_label, heading_path,
title, snippet, owner_employee_id, score, rnk, 'vec'::text from memory_vec
union all
select 'memory'::text, item_id, file_id, page_no, page_label, heading_path,
title, snippet, owner_employee_id, score, rnk, 'trgm'::text from memory_trgm
),
fused as (
select item_type, item_id,
max(file_id) as file_id, max(page_no) as page_no, max(page_label) as page_label,
max(heading_path) as heading_path, max(title) as title, max(snippet) as snippet,
max(owner_employee_id) as owner_employee_id,
max(score) filter (where method = 'vec') as vec_score,
max(score) filter (where method = 'trgm') as trgm_score,
sum(1.0 / (60 + rnk)) as rrf_score -- RRF,k=60 是业界常用默认
from unioned
group by item_type, item_id
)
select item_type, item_id, file_id, page_no, page_label, heading_path, title, snippet,
owner_employee_id, vec_score::real, trgm_score::real, rrf_score
from fused
order by rrf_score desc
limit match_count;
$$;
alter function public.bt_hybrid_search(text, vector, uuid, int, int) set hnsw.ef_search = 40;可见权判断函数(应用层也能直接调)
create or replace function public.bt_can_access_file(p_employee_id uuid, p_file_id uuid)
returns boolean language sql stable as $$
select exists (select 1 from public.bt_files f
where f.id = p_file_id and f.owner_employee_id = p_employee_id)
or exists (select 1 from public.bt_handover_items hi
join public.bt_handovers h on h.id = hi.handover_id
where hi.item_type = 'file' and hi.file_id = p_file_id
and h.to_employee_id = p_employee_id and h.status = 'completed');
$$;
create or replace function public.bt_can_access_memory(p_employee_id uuid, p_memory_id uuid)
returns boolean language sql stable as $$
select exists (select 1 from public.bt_memories m
where m.id = p_memory_id and m.owner_employee_id = p_employee_id)
or exists (select 1 from public.bt_handover_items hi
join public.bt_handovers h on h.id = hi.handover_id
where hi.item_type = 'memory' and hi.memory_id = p_memory_id
and h.to_employee_id = p_employee_id and h.status = 'completed');
$$;⚠️ 注意 bt_can_access_memory 故意不包含 visible_to_colleagues —— 那个开关只在「跨人提问」这一条路径上生效(SPEC-006),⛔ 不能让同事直接在自己的知识库页面里看到别人的条目。这两条路径要分开,很容易写混。
调用示例:
const { data, error } = await supabaseAdmin.rpc('bt_hybrid_search', {
query_text: q,
query_embedding: vec, // number[],supabase-js 自动序列化成 pgvector
target_employee_id: employeeId,
match_count: 10,
})#5. 切片策略(怎么保住"第几页")
铁律:先按源文件的物理/逻辑单元切,再在单元内按长度切。⛔ 绝对不要先拼成一整篇纯文本再无差别滑窗 —— 页码在拼接那一步就永久丢了。
| 格式 | 单元 | page_no |
page_label 示例 |
|---|---|---|---|
逐页 getTextContent() |
真实页码 | 第 3 页 |
|
| docx | 段落 + 标题层级 | null | 第2章 > 2.3 报价条款(用 heading_path 兜底) |
| xlsx | sheet + 行区间 | null | 报价表!12-20行 |
| txt/md | 标题分节 | null | 第 2 节 |
参数(中文按字符数算更直观):
- 单 chunk 目标 500–800 中文字符(约 350–500 token)
- overlap 约 15%(75–120 字符)
- 递归切分:先按段落/句号/分号/换行切,累积到上限再断;把上一片末尾 1–2 句带到下一片开头,避免关键数字被硬切断
- xlsx 的每个 chunk 必须带上表头行,否则脱离表头的数字没有语义
content_norm 归一化(供 pg_trgm 用):全角转半角、压缩连续空白、去控制字符、拉丁字母转小写。⛔ 不做分词(中文不需要,trigram 逐字滑窗天然贴合)。
#6. 坑清单
- 中文分词扩展不存在(已实证)→ ⛔ 不要试
to_tsvector('chinese'),全用 pg_trgm。 - 短查询词 trgm 召回为空:2 字的客户名简称凑不出足够 trigram,默认阈值 0.3 会把结果筛没。→ 查询词 < 3 字时
set pg_trgm.similarity_threshold = 0.1,或并入一路ILIKE '%kw%'兜底再参与 RRF。(对应 AC-3.1.3) - service_role key 泄漏到浏览器:一旦误加
NEXT_PUBLIC_前缀或被客户端组件 import,隔离全废。→ 构建后grep -r "service_role\|sb_secret_" .next/static,必须 0 命中(反作弊脚本已含这条)。 - 小数据量下向量索引更慢:正常现象,⛔ 不要为此改方案。用
explain analyze确认走没走索引即可。 - embedding 维度写死 1536:换模型必须整表重算,⛔ 不能新旧混用。
embedding_model字段已留,检索时可加where embedding_model = ...兜底。 - 跨区域延迟:Leo-hub 在东京、Vercel 默认美东。→ 所有查库 route 加
export const preferredRegion = 'hnd1'。 - Serverless 连接数:用
supabase-js(走 PostgREST/HTTP)就没有连接池问题。⛔ 不要为了图快改成pg直连 5432。 - PgBouncer 事务模式不支持 prepared statement:如果非要裸连 6543,
postgres.js要设{ prepare: false }。今晚 ⛔ 不要走这条路。 - embedding 批量限流:单批 30–50 条,
p-limit控并发 3–5,指数退避重试。失败的 chunk 留embedding_status='failed'+retry_count,超过 5 次就放弃这一条继续跑,⛔ 不要卡死整份文件。 bt_can_access_memory与visible_to_colleagues的区别(见 §4 的警告)—— 这是最容易写错、且会造成"越权可见"的一处。要有专门的负向测试(AC-3.2.1 / AC-6.1.2)。- 交接是授予不是搬移:⛔ 任何情况下都不要
update bt_memories set owner_employee_id = ...。原始归属必须留痕(AC-5.2.3)。 - 测试数据污染生产表:所有集成测试数据必须带 RUN_ID 前缀,删除语句必须带条件。见
02-TDD规程§5。
来源:沉淀/03-项目方案与交接/接棒-通宵施工包-20260731/04-技术资料-数据层与检索.md(整理于 2026-08-18)