Files
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
  GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
  POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
  两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
  (200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
  + documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动

缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

214 lines
7.9 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 history
// 本文件锁定 chat_messages.voice 列的**读写视图**(design/24 §10.4 / §10.9-4):
// ① 写入后能读回同样的 JSON 单对象,且列不存在时先幂等前滚(迁移 0020 之前建出的表);
// ② 坏形状**拒绝写入**(绝不落下读不回来的列);
// ③ 未知消息 id → ErrMessageNotFound(不假装写成功);
// ④ 音频副本的名字并入**同一套**引用计数(按新旧名字集合的差增减,同一事务);
// ⑤ 批量读是**按 id 集合一次查**,取不到的 id 不出现在映射里(与「取到空串」可区分)。
import (
"context"
"errors"
"testing"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// seedVoiceMessage 经真实存储追加一条消息并返回已存行(含生成的 id)。
func seedVoiceMessage(t *testing.T, st *Store, role, content string) Message {
t.Helper()
got, err := st.AppendFull(context.Background(), Message{SessionID: MainSessionID, Role: role, Content: content})
if err != nil {
t.Fatalf("append %q: %v", content, err)
}
return got
}
// voiceRawWithCopy 编码一条「有副本」的语音事实(ASR 转写成功 + 副本三字段齐备)。
func voiceRawWithCopy(t *testing.T, name string) string {
t.Helper()
raw, err := voiceref.Encode(voiceref.Ref{
Source: voiceref.SourceASR,
Status: voiceref.StatusTranscribed,
Name: name,
MIME: "audio/ogg",
Size: 1234,
DurationMs: 3200,
})
if err != nil {
t.Fatalf("编码语音事实: %v", err)
}
return raw
}
// voiceRawWithoutCopy 编码一条「没有副本」的语音事实(微信按既有裁决不取字节)。
func voiceRawWithoutCopy(t *testing.T) string {
t.Helper()
raw, err := voiceref.Encode(voiceref.Ref{
Source: voiceref.SourcePlatform,
Status: voiceref.StatusTranscribed,
})
if err != nil {
t.Fatalf("编码语音事实: %v", err)
}
return raw
}
func TestUpdateMessageVoiceRoundTrip(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg := seedVoiceMessage(t, st, RoleUser, "(语音)")
name := "0123456789abcdef0123456789abcdef.ogg"
raw := voiceRawWithCopy(t, name)
// 全新库由本包 DDL 建表(**不含** voice 列)⇒ 这一次写入顺带把列幂等前滚。
if err := st.UpdateMessageVoice(ctx, msg.ID, raw); err != nil {
t.Fatalf("UpdateMessageVoice: %v", err)
}
cols, err := st.VoiceColumns(ctx, []string{msg.ID, "does-not-exist"})
if err != nil {
t.Fatalf("VoiceColumns: %v", err)
}
if got := cols[msg.ID]; got != raw {
t.Fatalf("读回 = %q, want %q", got, raw)
}
if _, ok := cols["does-not-exist"]; ok {
t.Fatal("取不到的 id 不该出现在映射里(与「取到空串」必须可区分)")
}
if len(refs.retained) != 1 || refs.retained[0] != name {
t.Fatalf("引用计数 retain = %v, want [%s]", refs.retained, name)
}
if len(refs.released) != 0 {
t.Fatalf("首次写入不该 release:%v", refs.released)
}
// 清空(= 撤销语音事实)是合法值,且副本的名字被释放一次。
if err := st.UpdateMessageVoice(ctx, msg.ID, ""); err != nil {
t.Fatalf("清空 voice: %v", err)
}
if len(refs.released) != 1 || refs.released[0] != name {
t.Fatalf("清空后 release = %v, want [%s]", refs.released, name)
}
cols, err = st.VoiceColumns(ctx, []string{msg.ID})
if err != nil {
t.Fatalf("VoiceColumns(清空后): %v", err)
}
if got, ok := cols[msg.ID]; !ok || got != "" {
t.Fatalf("清空后列 = (%q, %v), want (\"\", true)", got, ok)
}
}
func TestUpdateMessageVoiceRejectsMalformedAndUnknown(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg := seedVoiceMessage(t, st, RoleUser, "(语音)")
good := voiceRawWithCopy(t, "0123456789abcdef0123456789abcdef.wav")
if err := st.UpdateMessageVoice(ctx, msg.ID, good); err != nil {
t.Fatalf("先写一条合法值: %v", err)
}
// 坏形状全部拒绝,且**列保持原值**(绝不落下读不回来的内容)。
bad := []string{
"{not json",
`{"source":"asr"}`, // 状态缺席(闭集外)
`{"source":"telepathy","status":"transcribed"}`, // 来源不在闭集
`{"source":"asr","status":"transcribed","name":"x.ogg"}`, // 有 name 没有 mime/size(同生共死被破)
`{"source":"asr","status":"transcribed","mime":"audio/x"}`, // 有 mime 没有 name
`{"source":"asr","status":"transcribed","durationMs":-1}`, // 负时长
}
for _, raw := range bad {
err := st.UpdateMessageVoice(ctx, msg.ID, raw)
if !errors.Is(err, voiceref.ErrBadRef) {
t.Fatalf("UpdateMessageVoice(%q) err = %v, want wrap of ErrBadRef", raw, err)
}
}
cols, err := st.VoiceColumns(ctx, []string{msg.ID})
if err != nil {
t.Fatalf("VoiceColumns: %v", err)
}
if cols[msg.ID] != good {
t.Fatalf("坏写入改动了列:%q, want %q", cols[msg.ID], good)
}
// 未知 id ⇒ ErrMessageNotFound(绝不假装写成功)。
if err := st.UpdateMessageVoice(ctx, "no-such-message", good); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("未知 id err = %v, want ErrMessageNotFound", err)
}
}
func TestUpdateMessageVoiceSyncsRefsByDifference(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg := seedVoiceMessage(t, st, RoleUser, "(语音)")
first := voiceRawWithCopy(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ogg")
second := voiceRawWithCopy(t, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3")
// ① 没有 → 有副本:retain 一次。
if err := st.UpdateMessageVoice(ctx, msg.ID, first); err != nil {
t.Fatalf("第一次写入: %v", err)
}
// ② 内容逐字不变:一次都不增减(重复计数会让音频永远回收不掉)。
if err := st.UpdateMessageVoice(ctx, msg.ID, first); err != nil {
t.Fatalf("同值重写: %v", err)
}
if len(refs.retained) != 1 || len(refs.released) != 0 {
t.Fatalf("同值重写后 retain/release = %v/%v, want 1/0", refs.retained, refs.released)
}
// ③ 换成另一份副本:旧的释放、新的保留(各一次)。
if err := st.UpdateMessageVoice(ctx, msg.ID, second); err != nil {
t.Fatalf("换副本: %v", err)
}
if len(refs.retained) != 2 || refs.retained[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3" {
t.Fatalf("换副本后 retain = %v", refs.retained)
}
if len(refs.released) != 1 || refs.released[0] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ogg" {
t.Fatalf("换副本后 release = %v", refs.released)
}
// ④ 有副本 → 无副本(微信那条路):release 一次,不新增 retain。
if err := st.UpdateMessageVoice(ctx, msg.ID, voiceRawWithoutCopy(t)); err != nil {
t.Fatalf("写成无副本: %v", err)
}
if len(refs.retained) != 2 {
t.Fatalf("无副本写法不该 retain:%v", refs.retained)
}
if len(refs.released) != 2 || refs.released[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3" {
t.Fatalf("无副本写法 release = %v", refs.released)
}
}
func TestVoiceColumnsBatchesAndSkipsBlanks(t *testing.T) {
t.Parallel()
st := newRefStore(t, &recordingRefs{})
ctx := context.Background()
withVoice := seedVoiceMessage(t, st, RoleUser, "语音")
without := seedVoiceMessage(t, st, RoleAssistant, "文本")
raw := voiceRawWithCopy(t, "cccccccccccccccccccccccccccccccc.webm")
if err := st.UpdateMessageVoice(ctx, withVoice.ID, raw); err != nil {
t.Fatalf("UpdateMessageVoice: %v", err)
}
cols, err := st.VoiceColumns(ctx, []string{withVoice.ID, without.ID, "", withVoice.ID})
if err != nil {
t.Fatalf("VoiceColumns: %v", err)
}
if len(cols) != 2 {
t.Fatalf("映射大小 = %d, want 2", len(cols))
}
if cols[withVoice.ID] != raw {
t.Fatalf("语音行 = %q, want %q", cols[withVoice.ID], raw)
}
if got, ok := cols[without.ID]; !ok || got != "" {
t.Fatalf("文本行 = (%q, %v), want (\"\", true)", got, ok)
}
if empty, err := st.VoiceColumns(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("空 id 列表 ⇒ 空映射且无错误,得到 (%v, %v)", empty, err)
}
}