Commit Graph

363 Commits

Author SHA1 Message Date
Blizzard fb3685532c polish(admin): 补齐侧栏菜单缺失图标,全项统一对齐
之前 登录设置/语音设置/微信用户/订阅 四项无 ICON_MAP 条目→图标位空、与兄弟项不齐。
补齐 heroicons 描边风一致图标:登录设置=钥匙、语音设置=麦克风、微信用户=聊天气泡、
订阅=循环;删掉无对应路由的死条目 usage。现 17 条路由全有图标。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:34:28 +08:00
Blizzard 95f3781076 feat(admin): 模型配置加「编辑」按钮 + 语音默认提示改 deepseek-v4-flash
- ModelManager: 每行加「编辑」→ 载入表单就地改(api_key 留空=沿用现有密钥),
  后端按 id 走更新而非新增;表单头/按钮随编辑态切换,带「取消」;省去删了重登记
- ModelsPage: 语音默认模型提示 deepseek-chat→deepseek-v4-flash(chat 2026/07/24 弃用)

注:语音模型实例已通过 admin API 从 deepseek-chat 热切到 deepseek-v4-flash(dispatcher
现读 v4-flash,无需重启)——验证了编辑/激活的 NATS 热更新链路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:07:13 +08:00
Blizzard a4669a0d82 fix(usage): 用量按实际所用模型计量(语音任务归语音模型,别错记工作模型)
分开工作/语音模型后,语音任务实际跑语音模型(deepseek-chat),但 emitUsage 没带 Model,
网关 SaveUsageEvent 拿激活 chat 模型(deepseek-v4-pro)兜底 → 语音用量被错价成工作模型。
修:emitUsage 按任务 model_profile 选池、type-assert ModelName() 填真实模型名。

真机验证:语音任务首字出声 ~2s(deepseek-chat,较 v4-pro 的 5-7s 骤降),usage_event.model
正确记为 deepseek-chat。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:43:30 +08:00
Blizzard aec7ad949c feat(model): 工作模型与 JARVIS 语音模型分开配置 + 语音任务路由到快模型
模型配置加"用途"维度:工作主力(chat,要强)与 JARVIS 语音(voice,要快/低时延)各配各激活,
语音任务走语音模型池,未配置则透明回落工作模型——不影响现有功能。

- contract: ConfigKindVoice="voice" + Meta[model_profile]=voice(与 intent==report 同类路由)
- gateway: ServeConfig/broadcastActive 循环纳入 voice;submitVoiceTask 打 model_profile=voice 标记
- dispatcher: 第二个 llm.Pool(voicePool)吃 voice 配置热更新;board.useVoice 从 Meta 派生(含快照);
  Orchestrator.agentPool(b) 按黑板选池——语音且语音池就绪→语音池,否则工作池;
  agent 生成路径(graph/react/coordinator/compose)全改走 agentPool(b),报告/护栏/记忆固定工作池
- admin: 模型页三 Tab(工作主力/JARVIS语音/向量化),复用 ModelManager;api Kind 加 voice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:39:17 +08:00
Blizzard b6927247da feat(voice): 下行打字机文本流 + 首块快出,即时反馈
- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)

管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:25:20 +08:00
Blizzard 6b31856551 perf(voice): 砍兜底等待 1.2s→0.5s + 语音提示词强制简短 + 时延测量
- voice.go: ClientEnd 兜底等待 1200ms→500ms(白吃的时延),首字出声 6.6s→5.3s
- voice_task.go: JARVIS 系统提示词改"必须简短/最多三句/先给结论"(语音场景长答案既拖慢
  首字又难听;注:deepseek-v4-pro 仍可能不理会长度指令,可靠收短需 max_tokens 硬顶)
- voicesim: 时延拆解(提交/首字出声/朗读完毕,以"说完"为0点)

现状:首字出声 ~5.3s,大头是 deepseek 生成第一句(大模型 TTFT);管线开销(兜底+TTS握手)已压到~1s。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:17:28 +08:00
Blizzard 9ae06229b8 feat(voice): 显式 end 兜底提交 + 端到端模拟工具,全链路真机跑通
火山流式 ASR 只在 VAD 静音时发 Final;客户端点"停"(ClientEnd)不能干等——否则整段说完
却因没触发 VAD Final 而永不提交任务(实测复现)。改为:
- voice.go 累计 latestText(每条转写更新);ClientEnd 后 1.2s 用最新转写兜底提交;
  turnMu+submitted 保证 Final 与 ClientEnd 两路只提交一次(替换旧 lastFinal 去重)
- cmd/voicesim: 端到端模拟(免麦)——TTS 合成问话→灌网关语音WS→ASR转写→提交任务→
  大模型回答→TTS朗读回推,问答音频各存 wav,签发测试用户 JWT(auth.Issue)

真机验证:问"你是谁?你能做什么?"→ task_bab1c6ee → JARVIS 语音回答 18.6s(sim_answer.wav)。
麦克风音频→ASR→任务→大模型→TTS 全程走网关跑通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:08:17 +08:00
Blizzard 08e6425fec feat(voice): 双向流式 TTS 真机跑通——TTS合成→ASR往返全绿
对齐官方 python demo 修 TTS payload:
- TaskRequest 必须带完整 req_params(speaker+audio_params)再加 text,只发 {text} 火山收不到
  (会回空 text 的 TTSSentenceStart 后直接结束、无音频)
- StartSession 去掉自造的 namespace/user,就是 {req_params:{speaker,audio_params}}
- ttsReqBase 基底 StartSession/TaskRequest 复用;TTSSession 存 reqBase
- 加 VOICE_DEBUG 帧级调试日志(联调用,默认关无开销)

真机验证(voicecheck):TTS 合成 143KB PCM24k → 降采样喂 ASR → 转写"北京今天的天气怎么样?
需要带伞吗?"≈原句。ASR(volc.bigasr.sauc.duration)+TTS(seed-tts-2.0)两协议全通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:49:43 +08:00
Blizzard d6c1a787f4 fix(voice): 改用火山「新版控制台」API Key 鉴权(ASR 握手真机验通)
一直误用旧版 5 头方案(X-Api-App-Key+Access-Key+App-ID),火山始终 401 grant not found。
用户提供官方文档「新版本控制台」证实:新版只需**单个 X-Api-Key** 头。改对后真机联调:

- frame.go setVolcAuthHeaders: X-Api-Key + X-Api-Resource-Id + X-Api-Request-Id + X-Api-Sequence:-1
  (删旧 App-Key/Access-Key/App-ID/Connect-Id;去 appID 参数)
- asr/tts 调用点同步;config.go 删 Config.AppID;voicecheck/voiceconfig 删 VOLC_APP_ID
- 删临时诊断工具 voiceprobe

真机结果:ASR 握手(volc.bigasr.sauc.duration 有效);TTS 用 seed-tts-2.0 会话正常建立
(不再 resource mismatch),但暂无音频返回→双向 TTS 事件/payload 待调(下一步)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:42:36 +08:00
Blizzard c5397438d4 docs(voice): 记录联调结论——协议验通,剩服务开通(账号侧)
联调确认:鉴权/帧协议已被真火山完全接受。剩 grant not found=账号未开通对应服务。
- TTS 正确 Resource-Id 是 volc.service_type.10029(语音合成大模型),非 seed-tts-2.0(那是模型名)
- ASR 端点 sauc/bigmodel 认 volc.bigasr.sauc.duration(格式被识别)
- 两者均 grant not found → App 9604175735 需在语音控制台开通这两个大模型服务

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:07:48 +08:00
Blizzard 8283bc542f fix(voice): 火山鉴权头精确大小写(X-Api-App-ID 免被 Go 规范化)
Go 的 Header.Set 会把 X-Api-App-ID 规范化成 X-Api-App-Id;改直接赋 map 保留精确
大小写,对齐官方 demo。鉴权方案已与参考实现(realtime_dialog demo)完全一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:59:58 +08:00
Blizzard bb9c73a9b7 fix(voice): 修正火山 V3 流式端点鉴权头(真火山联调验通协议格式)
联调发现 Authorization: Bearer 不被 openspeech V3 二进制流端点接受。改用官方规范头:
- X-Api-App-Key: 固定常量 PlgvMymc7f3tQnJ6(所有客户端同值,缺它 400 "app key not found")
- X-Api-Access-Key: 新版 API Key(账号鉴权)
- X-Api-App-ID: 账号 App ID(解析资源授权,缺它 401 "grant not found")
- X-Api-Resource-Id / X-Api-Connect-Id

改动:Config 加 AppID 字段;frame.go setVolcAuthHeaders 统一挂头 + handshakeDetail
榨取握手失败的 HTTP 状态/logid/body(联调可诊断);asr/tts 共用;voicecheck 加握手探针
分别验 ASR/TTS 端点;voiceconfig/voicecheck 读 VOLC_APP_ID。

现状:协议格式已被真火山接受(过了 400 格式错,进到账号/资源授权查询)。剩 App ID +
资源开通确认(账号侧,联调补)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:53:26 +08:00
Blizzard b718bdbb21 feat(voice): voiceconfig 工具——语音配置一键入库(联调/无头环境免开 admin)
等价于 admin「语音设置」保存一次:读 VOLC_* 环境变量→AES 加密(复用 gateway
EncryptedForStore)→写 sundynix_setting。网关每请求现读,无需重启。凭证只走 env 不进 git。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:42:47 +08:00
Blizzard 5b871bc363 feat(voice): 火山协议自检工具 voicecheck(本地端到端验协议,免麦克风免全栈)
不用部署、不用麦克风:本机直连火山公网端点,端到端验证手搓的 ASR/TTS 帧协议。
TTS 合成一句→收音频存 out_tts.wav→降采样 24k→16k 喂 ASR→打印转写。
转写≈原句即证明两套协议都被真火山接受。凭证只走环境变量(VOLC_API_KEY 等),不进 git。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:33:55 +08:00
Blizzard 9458bf21f3 feat(voice): 桌面端 JARVIS 语音坞——麦克风上行 + TTS 播放队列(Phase 1 收口)
右下角悬浮麦克风:点按说话→转写→提交任务→跳运行页→朗读回答,接入既有运行·观测流。

- lib/voice.ts: VoiceClient——一条 WS 承载上行 PCM16k/下行转写/下行 TTS PCM24k;
  ScriptProcessorNode 采麦(48k→16k 降采样+Float32→Int16);AudioBufferSourceNode
  排队调度做无缝连续朗读;start/end/barge_in/bye 控制;ready/transcript/task/speaking/tts_end
- shell/VoiceDock.tsx: 悬浮麦克风 UI(聆听/思考/朗读态 + 转写气泡 + 打断),懒建客户端(用户手势启 AudioContext)
- App.tsx: onVoiceTask 把语音 task 挂回 attachRun(后端已提交,前端只 attach);挂载 VoiceDock

真识别/合成需部署联调(真连火山+admin录入语音配置);本地过 tsc/build/68 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:29:47 +08:00
Blizzard 6fe0a58f1b feat(voice): 火山双向流 TTS 客户端 + 下行接线(Phase 1 嘴巴)
回答 token 流 → 攒句器 → 火山双向 TTS → 音频帧回推客户端,端到端连续朗读打通。

- tts_frame.go: V3 事件族帧编解码(事件号+会话ID+gzip),与 ASR 简帧不同族;
  从官方参考实现核实 generate_header/parse_response 字节布局;3 解析单测
- tts.go: seed-tts-2.0 双向流客户端 StartTTS(握手ConnectionStarted/SessionStarted)
  /Speak(逐句TaskRequest)/Finish/Audio()/Close,PCM 24k;新版 API Key 鉴权
- voice_tts.go: speak() 先订阅token流再建TTS(core NATS无持久,握手期攒句入pending
  就绪补吐,不丢开头);音频泵首帧ServerSpeaking、收尾ServerTTSEnd;打断stopTTS
- voice.go: barge_in→stopTTS;会话结束连带停TTS;final转写→go speak(taskID)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:25:49 +08:00
Blizzard 302e1ebaff feat(voice): 上行接线——最终转写→组DSL→提交任务(Phase 1 打通)
ASR 最终转写触发一次任务提交,复用 HTTP SubmitTask 那条共用关卡
(preflightCore/launchCore),语音只是"嘴替键盘",编排/工具/计费一行不新造。

- task_handler.go: preflight/launch 抽出无 gin 内核 preflightCore/launchCore
  (preflightBlock 承载拦截态),gin 版做薄封装;语音会话无 gin.Context 也走同一关卡
- voice_task.go: buildVoiceGraph(转写→input→agent 单图) + submitVoiceTask(校验/关卡/落库发射)
- voice.go: 会话升级时抓租户/会话;结果 goroutine 见 Final→onFinalTranscript 提交、
  回 ServerTask{task_id};去重连发的重复 final;画布图一次性消费
- voice_task_test.go: 组图合法性 + 带转写 + input→agent 连边

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:16:29 +08:00
Blizzard 4ee4a91a51 feat(voice): 火山 ASR 流式识别客户端 + 会话接线(Phase 1 耳朵)
voice/asr.go: 连 wss://openspeech.bytedance.com/api/v3/sauc/bigmodel,新版 API Key 鉴权
(Authorization: Bearer + X-Api-Resource-Id + Connect-Id),发初始配置帧(bigmodel/zh/ITN/
标点/VAD),PushAudio 流式喂 PCM、Finish 收尾;读 goroutine 解析响应(result 支持数组/对象/
字符串,type=final 为最终)推入 Results 通道。

handler/voice.go 接线:onAudio→PushAudio、start→重开识别、end→Finish;起 goroutine 把
转写 send(transcript) 实时回推客户端。加 writeMu 串行化写(读循环与 ASR 结果 goroutine
都写同一 WS,gorilla 禁并发写)。连接结束 stopASR 收尾。

带单测(配置JSON字段/result三形态解析)。真识别需部署联调(要真连火山);API Key 是否
还需 X-Api-App-Key 联调若 401 再补。上行接线(final→SubmitTask)与 TTS 是下一步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:27:32 +08:00
Blizzard b526b21dee feat(voice): 火山语音 WS 二进制帧编解码(ASR/TTS 共用核心)
从官方参考实现核实的 V3 协议帧格式,港到 Go:byte0=0x11、byte1=(msgType<<4)|flags、
byte2=序列化<<4|压缩、byte3=0x00、大端 uint32 payload 长度、payload;服务端响应含 4B
序列号故 payload 从第 12 字节起。encodeFrame/jsonFrame/audioFrame/parseServerFrame +
msgType(0x01 config/0x02 音频/0x09 结果/0x0F 错误)与 flags(0x02 最终)常量。
3 个单测钉死头布局/音频最终帧/响应解析(确定性,不依赖网络)。

ASR 初始配置 JSON、WS 连接与流式识别(接 onAudio)是下一步网络层实现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:22:09 +08:00
Blizzard 15f5a85612 feat(voice): WebSocket 端点 + 客户端↔网关协议(Phase 1 地基)
加 gorilla/websocket(无 genproto 冲突)。voice/protocol.go 定死单条 WS 的消息协议:
二进制帧=音频(上行麦克风/下行TTS),文本帧=JSON 控制/事件(ClientMsg:start/end/barge_in/
bye;ServerMsg:ready/transcript/task/speaking/tts_end/error);音频 PCM 16k 单声道。
handler/voice.go: GET /api/v1/voice/stream 升级 WS,鉴权走 AuthFromHeaderOrQuery(?token=,
WS 带不了 Bearer),会话外壳 + 协议读循环(音频帧/控制消息分派)已通,火山 ASR/TTS 客户端
在下一步挂 onAudio/onControl 的 TODO 点接入。build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:17:58 +08:00
Blizzard a28ae49b6a refactor(voice): 语音配置改用新版 API Key 鉴权(弃旧版 appid+token)
用户提醒:火山新版走 API Key 鉴权,不用旧版 appid+access_token。新版 WS 握手只带两个
header——Authorization(Bearer <APIKey>) + X-Api-Resource-Id。

配置从 {appid, access_token, 2×resource-id, voice} 收敛为 {api_key, 2×resource-id,
voice}:APIKey 走 secrets AES 加密入库;ASREnabled/TTSEnabled 改为只看 api_key+对应
resource-id。admin 配置页两个字段并一个 API Key 字段,清单同步改为新版口径。build+tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:06:22 +08:00
Blizzard 618184689e feat(voice): 语音配置控制面——火山豆包 appid/token/resource-id/音色(参数落地处)
JARVIS 不依赖火山账号能先做的第二块:配置层,做成'你参数一填就落库'的形态。
- voice.Config: AppID/AccessToken/ASRResourceID/TTSResourceID/TTSVoiceType,AccessToken
  走 secrets AES 加密入库(镜像微信配置);ASREnabled/TTSEnabled 可各自独立判断。
- admin GET/PUT /admin/voice(RequireAdmin),token 空串=沿用已存、明文回显。
- admin「语音设置」页(运维组):五个字段 + ASR/TTS 就绪徽标 + '去哪拿参数'清单。

端点/音频格式(PCM 16k 单声道)由代码固定不入配置。WS 会话 + 火山 ASR/TTS 客户端等
用户回传 resource-id 后按官方 demo 协议做。build+tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:00:35 +08:00
Blizzard 644b635d73 feat(voice): 攒句器——语音下行 TTS 的 token 攒句(JARVIS 地基第一块)
语音交互(VOICE_DESIGN.md)不依赖火山账号的第一块地基:SentenceBuffer 把 LLM 逐 token
输出攒成'适合喂 TTS 的句子片段'。逐字喂 TTS 太碎(单字合成不自然、首包延迟高);句末标点
(。!?.!?;;换行)即成一句,从句标点(,,::)且攒够 12 rune 也吐(让长回答尽早出声)。
跨 Push 续半句,Flush 收尾吐无标点结尾。纯逻辑无外部依赖、4 个单测(跨 Push/短从句不断/
长从句先吐/Flush 收尾)。

WS 会话 + 火山 ASR/TTS 客户端 + 控制面配置等到真 API 参数到手再按真形状做,不写猜测桩。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:30:50 +08:00
Blizzard f16f63284f feat(cluster): gateway 后台定时器加 leader 锁——多副本安全(B5)
此前两个 gateway 定时器(订阅推进/掉单补偿)每实例各扫一遍:多副本下重复查库,
且对微信查单调用量随副本线性放大(微信有频控,先被限流的是自己)。

store.TryRunExclusive:用 PG advisory try-lock 做集群级单实例执行(leader 选举)——每轮
tick 非阻塞抢锁,抢到才跑、跑完释放;抢不到说明别的实例是 leader、本轮跳过。自愈:锁随
持有连接释放,leader 挂了下一轮别的实例自然抢到接管,无需显式故障转移。订阅/补偿用不同
锁键(可由不同实例分别 lead)。非 PG(sqlite 测试)/无 DB → 退回本地直接跑(单实例安全)。

两个定时器的 tick 各包一层 TryRunExclusive。至此 gateway 可安全多副本(SSE 走共享 Redis
流无需粘性、状态全外置、JWT 无状态,剩数据层 HA 属 C 层 ops)。带 fallback 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:21:31 +08:00
Blizzard 9b153871eb feat(monitor): NATS 集群 Raft 副本健康 + 基建 ping 延迟(监测组完善)
此前 /status 把 NATS 当一盏二元灯(连不上就 fatal 故恒真),看不出 3 节点集群里
哪个节点掉了、JetStream 持久流的 Raft 副本是否还齐(计费/状态/评测流不丢的关键)。
DB/Redis/MinIO 也只二元 ping、无延迟。

- bus.ClusterStatus:连的节点名 + 集群发现节点数(nc.Servers) + RTT(nc.RTT) + 6 条关键
  持久流(tasks/status/usage/eval/ingest/approvals)的 Raft 副本健康(leader + healthy/total,
  单节点部署记 1/1;某节点掉队 → healthy<total 记降级)。
- /status 新增 nats 集群对象 + NATS 灯改为'连接且无副本降级才绿'、detail 显示'N 节点·连 X·
  流副本齐全/降级'、latency=RTT;PG/Redis/MinIO 加 ping 往返耗时。
- admin StatusPage:新增 NATS 集群面板(节点/RTT/连接 + 各流 leader/副本健康/消息数表),
  基建行显示延迟。

sundynix-shared/gateway build+vet+test 绿;admin tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:15:22 +08:00
Blizzard a42f30239f fix(payment): 微信 API 调用加超时兜底(B3 出网韧性)
微信是外部第三方、最可能慢/挂,而 wechatpay-go SDK 默认 http.Client 无 Timeout:
一次卡住的 Prepay/QueryOrder 会拖住请求 goroutine;尤其掉单补偿定时器用的是
context.Background()(无超时)→ 微信一挂那轮 tick 无限期卡死。

双保险:客户端级 HTTP 超时(WithHTTPClient Timeout=15s,belt) + CreatePay/QueryOrder
每次调用 ctx 超时(suspenders,兜住 SDK 忽略或背景 ctx 的情况)。

范围克制:只硬化微信这个真外部依赖。PG/Redis(连接池+降级)、NATS(无限重连)、mcp-go RAG
客户端(embed 30s/chat 60s/rerank 20s 已有超时)本就有韧性,不额外套熔断(那对本规模是过度设计)。

build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:01:09 +08:00
Blizzard ebbeed90e9 feat(auth): 登录账户级失败锁定 + 密码强度 8-72 位(B4)
补上 A5 只做了 IP 限流、没做账户锁定的缺口(审计 B4)。

- 账户级失败锁定:连续登录失败达 5 次即临时锁定 15min(Redis 计数+TTL),锁定期直接拒绝
  不再校验密码,成功登录即清零。与 A5 的 IP 限流互补:IP 限流挡'一个 IP 猛打',账户锁定
  挡'分布式慢速猜一个号'。权衡:per-account 可被人为锁死受害者(lockout DoS),故窗口设短
  自动解锁+叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
- 密码强度:注册从'至少 6 位'提到 8-72 位。上限 72 字节——bcrypt 超 72 字节静默截断,
  不拦会让超长密码实际只用前 72 字节。桌面端注册提示同步改 8-72 位。

存量用户不受影响(只校验注册/改密时的新密码)。build+vet+test 绿;desktop tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:56:29 +08:00
Blizzard da9c76f073 feat(prod): 实时就绪探针——gateway /readyz 真 ping + dispatcher/mcp-go 加 HTTP 探针(B2)
此前 gateway /readyz 用的是 Enabled() 启动期降级标志(反映不了运行中 PG 掉线,LB 会继续
往已不可用实例导流);dispatcher/mcp-go 干脆没有 HTTP 探针(只有 NATS ServeHealth,k8s/LB
够不着、只能靠 gateway 经 NATS 代探)。

- gateway /readyz 改用 db.Ping 实时探活。只把 **DB 当硬依赖门**:Redis 掉线仍可服务(限流有
  A5 进程内 fail-safe 兜底、SSE 回落 live NATS),一 blip 就把全部实例踢出轮转反而制造整站
  故障,故 Redis 只上报不 gate。NATS 启动即连(fatal)不单列。
- 新 sundynix-shared/health 包:Serve/Handler 提供 /healthz(恒 200 liveness) + /readyz(由
  ready() 决定 readiness)。dispatcher(:8091, DISPATCHER_HEALTH_ADDR)与 mcp-go(:8092,
  MCP_GO_HEALTH_ADDR)各起一个,readiness = NATS 连接可用(bus.IsConnected)。接入各自优雅停机。
- bus 加 IsConnected()(nc.IsConnected 实时);dispatcher Subscriber 透传。

四模块 build+vet+test 绿;health 带 Handler 单测。探针端口仅内部用(对外仍只暴露 gateway)。
compose/k8s 的 healthcheck 编排属 C 层 ops,另做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:50:31 +08:00
Blizzard 4500335da7 refactor(store): 迁移路径务实硬化——advisory lock + 版本表 + 破坏性迁移移出启动路径(B1)
此前每次启动裸跑 AutoMigrate + 一串手写索引/回填,且启动路径上有两处 DROP TABLE
CASCADE 的 legacy 迁移;多实例并发启动无锁 → 并发 ALTER/建索引竞争,一方报错即掉降级。

务实硬化(不引外部工具,保留 gorm 结构体为源):
- PG advisory lock:整段迁移在 pg_advisory_lock 内串行,多实例同时启动只有一个进锁跑,
  其余阻塞等待。取锁 60s 超时兜底(取不到带告警继续,AutoMigrate/索引多幂等)。
- 破坏性 legacy 迁移移出默认路径:migrateLegacyIntIDs/migrateDocLinkToID(DROP TABLE
  CASCADE)默认不跑,仅 ALLOW_LEGACY_SCHEMA_MIGRATION=1 时执行;检测到旧 schema 但未开
  只告警不动手。现网早已是雪花 id 本就不触发,但从此不再是启动就可能 DROP。
- 版本化 runner:AutoMigrate 之外的步骤(3 个部分唯一索引 + NULL 余额回填)登记为
  schemaSteps,各跑一次并记入 sundynix_schema_migration 表,下次跳过;某步失败即停、
  不记录、下次重试。将来 AutoMigrate 做不了的破坏性/数据迁移在末尾追加新 id 即可。

pgsql.go 的迁移块收敛为一句 runMigrations(db)。带 runner 单测(跑一次/跳过/追加/失败即停)。
现网 DB 已有的索引/回填重跑无害(IF NOT EXISTS / WHERE IS NULL),跑后记录。build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:26:58 +08:00
Blizzard ac38d5e663 fix(prod): 后端生产级 A 类硬伤全清(7 项:授权/崩溃点/全表扫/限流/鉴权/限额)
部署前生产级审计(可靠性/数据层/安全三路)后,清掉 7 处代码级硬伤:

A1 后台定时器 goroutine 无 panic recover → 单个 DB panic 崩整个 gateway。加 safeGo/
   safeCall,包住订阅/掉单补偿/微信推送/探针 goroutine,单轮 tick 再兜一层。
A2 提示词控制面(建/激活/停用,热广播全服务)只 RequireAuth → 任意登录用户改全局提示词。
   三写端点+列表挂 RequireAdmin。
A3 HITL 审批端点无角色门 → viewer 可放行烧钱执行。加 RequireTenantRole(member)。
A4 审计/护栏列表 limit 无校验,limit=-1 让 gorm 取消 LIMIT 全表扫。加 clampLimit/
   clampOffset,AdminTasks/AdminSpaces 补上界。
A5 限流 Redis 一挂就完全放行(fail-open)。加进程内固定窗口兜底(fail-safe) + 登录/注册
   按 IP 专用严限流(10/min)。
A6 公开 by-id 端点(stream/exec/report导出/kb导入流)无鉴权无租户过滤。加
   AuthFromHeaderOrQuery(从 ?token= 取 JWT) + task/report 按 owner 归属校验;桌面端
   5 处 EventSource/下载 URL 经 tokenQuery 附 JWT。
A7 文件上传无大小上限(整文件进内存 OOM 面) → 50MB 闸(KB_MAX_UPLOAD_BYTES)+ LimitReader;
   http.Server 加 ReadHeaderTimeout/ReadTimeout/MaxHeaderBytes(不设 WriteTimeout 保 SSE)。

带单测:clampLimit/safeCall/procLimiter/AuthFromHeaderOrQuery/TaskOwner。
build+vet+全量 test 绿;desktop tsc 绿。B(迁移工具/实时探针/出网韧性/登录锁定/leader选举)
与 C(TLS/PG HA/K8s/备份自动化/可观测)分期后做,参照 production_readiness.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:04:47 +08:00
Blizzard ce7cca657e fix(backend): 补齐三处漏洞——暂停租户拦截/金额不符落审计终态/邀请码列表滤失效
P0 暂停租户是空开关:admin 能设 suspended,但 preflight 只查预算+余额、不看
租户 status → 暂停后照样能提交烧积分。加 TenantSuspended 校验(活跃租户 + 分叉时
的计费租户都拦),403 拒绝。

P1 金额不符只刷日志:回调/查单判了不符却没落审计、订单永远卡 pending 被补偿定时器
每轮重扫刷屏。加 disputed 终态 + MarkOrderDisputed(CAS 只挂一次) + 审计(首次写一次);
disputed 不在 pending 扫描内,停止无限重扫。admin /orders?status=disputed 可查。

P1 邀请码列表混入失效码:ListInvites 只按 status=active 过滤,过期/满员的码仍显示为
有效、误导邀请人。有效列表加 expires_at>now 且 used<max 过滤(RedeemInvite 本就会拒,
这里修的是展示一致性)。

三处均带 store 单测(TenantSuspended/MarkOrderDisputed CAS/ListInvites 过滤)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:20:34 +08:00
Blizzard bd829cfecb feat(invite): 租户成员二维码邀请(可复用团队码,扫码关注即入组)
owner/admin 在桌面端生成一张微信带参二维码发给团队,成员用微信扫码关注
即自动加入租户,并收到「 已加入团队【X】」被动回复。与登录二维码同一微信机制,
scene 加 inv_ 前缀分流;扫码入组走被动回复,不需要 access_token、不碰 IP 白名单。

- store: TenantInvite(可复用码=有效期+人数上限+可撤销三道闸);RedeemInvite 幂等入组、
  同一人重复扫不重复消耗名额、复活已移除者;带单测钉死过期/撤销/满员/幂等。
- handler: 建码/列表/撤销端点(RequireTenantRole admin);WxMPEvent inv_ 分支。
- 顺手修潜在生产 bug:微信用户此前都建成空邮箱,User.Email 整列唯一索引下第二个微信
  用户就撞唯一约束建号失败(登录"全通"只因当前仅一个微信用户)。改为按 openid 合成占位
  邮箱 wx-<openid>@wx.local,绕开冲突且不动索引。邀请功能会批量建微信用户,非修不可。
- desktop: 顶栏「邀请成员」入口(租户 admin/owner 可见) + 二维码弹窗(生成/大图展示/撤销)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:47:21 +08:00
Blizzard 68608c1592 feat(wechat): 支付成功后微信客服消息推回执(积分包/订阅购买)
用户扫码付款后(在 48h 互动窗口内),主动推一条客服消息回执:
到账积分/开通套餐 + 当前余额。只在 MarkOrderPaid changed=true 首次到账时推,
异步+超时隔离,失败只记日志、绝不影响入账。非微信用户自动跳过。

不做「周期刷新提醒」:客服消息受 48h 窗口限制,定时刷新那刻用户多半已超窗、
必然失败,那类隔天提醒须用模板消息(暂缓)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:23:41 +08:00
Blizzard 83269e067a feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)
用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里
回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单
限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:17:41 +08:00
Blizzard 9e2ff6007f docs(deploy): 微信 token 中控文档改回实际采用的 B 方案(宝塔 nginx + IP + 密钥)
用户腾讯云未配域名、用宝塔。文档从 frp stcp 改成实际走的:宝塔计划任务换 token
+ nginx 站点(IP)+ 密钥头。附强密钥提醒与 frp 隧道备选(token 走公网明文的固有代价)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:39:39 +08:00
Blizzard 0f2afdaac1 feat(admin): 微信用户观测页 + 唯一昵称 + 密钥明文回显(单管理员后台)
按需求四项:
1. 微信新用户昵称改「微信用户_XXXXX」:后缀从 openid 的 sha1 派生(去混淆字母表),
   openid 唯一 → 后缀实际不重复,且同 openid 每次一致(重登不换名)。用户可自行改名。
2. 后台加「平台 → 微信用户」列表:昵称 / openid(点击复制)/ 积分余额 / 加入时间。
   每人仍独立租户与积分(各买各的,确认过不共享积分池),此页只做统一观测。
3. 登录设置的 AppSecret 明文回显(不再只显示"已保存")。
4. 支付配置的 APIv3 密钥明文回显。
   —— 用户明确后台单人使用、RequireAdmin 已拦,接受这一安全降级;密文仍加密入库。

修一个 gorm Scan 坑:WechatUserRow 的 WechatOpenID/BalanceMicro 没加 column tag,
gorm 把 WechatOpenID 断成列名 wechat_open_id,与 SQL alias wechat_openid 对不上 →
openid 静默返回空。教训:Scan 到自定义结构 + SQL 用 alias 时,字段一律显式加 column tag。

本地验证:真库造两个微信用户,列表接口正确返回 openid(修 tag 前是空);
登录设置页 AppSecret 已是可见文本框;nickname 单测覆盖唯一/稳定/去混淆。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:36:27 +08:00
Blizzard cbd0a96ce7 docs(deploy): 微信 token 中控改用 frp stcp 隧道(不上公网,无需域名)
用户腾讯云未配域名、frp 是 toml。改成:腾讯云 token 服务只绑 127.0.0.1,
经 frp stcp(点对点加密隧道)让 132 拉取,token 全程不上公网、不用证书。

- 说明书给出 toml 版 frp 配置(腾讯云 [[proxies]] stcp + 132 [[visitors]])、
  cron 换 token 脚本、切换验证步骤。
- compose:gateway 加 extra_hosts host.docker.internal:host-gateway —— 容器里的
  127.0.0.1 是容器自己,token 落在宿主机 127.0.0.1:9099,须经 host.docker.internal 访问。

代码侧(PullToken + accessToken 中控分支)无改动,沿用上一提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:02:09 +08:00
Blizzard a1c68ec6b2 feat(auth): 微信 access_token 走中控服务器(腾讯云静态 IP 换 token)
隐患:gateway 换 access_token 时微信看到的是本地宽带出网 IP(106.58.232.43,
动态会变),一变白名单就失效、二维码建不出来(40164)。

按微信官方推荐的「中控服务器」架构解决:腾讯云静态 IP 统一换 token,gateway 拉取
使用。微信 IP 白名单只限制换 token 这一步,拿 token 建二维码不查 IP —— 所以
换 token 挪到中控、建二维码仍在 gateway 本地,白名单只填腾讯云 IP,永不失效。

- wechat.PullToken:从中控 HTTPS 端点拉 token(Bearer 密钥鉴权),坏响应/403/空
  token 一律报错不当成功;中控没给 expires_in 时按 7200 兜底。
- handler.accessToken:配了 WECHAT_TOKEN_URL 就只从中控拉、绝不自己 FetchAccessToken
  (微信要求单点刷新,多点各自换会互相顶掉 token);不配维持直连,零副作用可回退。
- compose:gateway 加 WECHAT_TOKEN_URL / WECHAT_TOKEN_SECRET(从宿主机 .env 注入)。
- deploy/wechat-token-zhongkong.md:腾讯云侧 cron 脚本 + nginx 配置 + 切换验证步骤。

本地验证(假中控 httptest + 真 gateway):建票时 Redis 缓存的是中控给的 token
(FAKE_TOKEN_FROM_ZHONGKONG),gateway 未直连微信换 token;随后拿该 token 调
qrcode/create(假 token 报 40001 属预期)——证明「中控换 token → 本地建二维码」链路成立。
4 组 PullToken 单测覆盖正常/403/坏JSON/兜底。

真机验证(部署后):把本地 IP 从白名单删掉、只留腾讯云 IP,扫码仍能登录即坐实
qrcode 不受 IP 限制;若建二维码报 40164 则退回 tinyproxy 正向代理备选。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:53:27 +08:00
Blizzard 04b5677605 feat(site): 官网直接登录 + 购买(不再单独部署 Web 面)
用户要求「直接在官网登录,别多搞一个 web 工程」。官网本就是 admin 前端的一部分
(embed 进 gateway),在它上面加登录 + 购买,仍是「一个前端」,零新增部署。
sundynix-web(薄 Web 面)不部署;账单/组织/团队等留桌面端。

定价页「购买」流程:
  未登录 → 弹微信扫码登录(带参二维码 + 关注/扫码事件)→ 登录后自动继续那笔购买
  已登录 → 直接弹微信支付二维码 → 轮询单态 → 到账
支付走后端现有链路(下单/回调/查单/掉单补偿全复用),与桌面端同一套。

站点用户 token 存独立 key(sdx_site_token),与运维后台的 sdx_admin_token
隔开——普通用户登录拿到的用户 JWT 不该与管理员令牌混用。

本地验证:未登录点购买 → 弹登录弹窗;未配微信时如实显示「微信登录未配置」
(部署配好即真二维码)。登录门 + 支付弹窗 UI 骨架与错误态均正确;真二维码/
真支付留待部署后。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:23:23 +08:00
Blizzard 883540bd7e feat(auth): 微信扫码登录改为「带参二维码 + 关注/扫码事件」(登录即涨粉)
上一版做成了网页授权(OAuth 允许页),方向错了。改成用户要的流程:
扫码 → 弹公众号关注页 → 关注即登录,服务号顺带涨粉。

流程:PC 建票 → 后端用 access_token 调「带参数二维码」接口(scene=ticket) →
展示微信二维码图 → 用户扫码关注 → 微信推 subscribe/SCAN 事件到 /wx/mp/callback →
按 openid 找/建用户 → ticket 置 authorized → PC 轮询拿 JWT。

明文模式(消息加解密):回调只验签名 sha1(sort(token,ts,nonce)),不做 AES。

关键实现点:
- access_token 缓存进 Redis(跨实例共享,避免重复拉取互相失效)+ 进程内锁双检;
- 事件同时处理 subscribe(未关注,EventKey 带 qrscene_ 前缀)与 SCAN(已关注,不带);
- 事件回调必须验签——否则任何人 POST 一个 openid 就能登录别人;
- 回调无论如何回 "success",否则微信重试并给用户弹"公众号故障";
- User.wechat_openid 用部分唯一索引(WHERE <> ''),避开存量空串互撞。

配置(appid/secret/token)后台可改、secret AES 加密入库。管理端「运维 → 登录设置」
列出还需在公众平台做的事(服务器 URL / Token 一致 / 明文模式 / IP 白名单)。

本地验证(真流程,非 mock):验签回 echostr 与微信算法一致;模拟 subscribe 事件
→ 建号 + 置票 → PC 轮询拿到 token+user → 库里确有该 openid 用户。真微信推真事件
留待部署后扫码。前端 web 登录页加「微信扫码/邮箱」双 tab,默认微信。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:58:31 +08:00
Blizzard 07955ddf07 feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感),
不接管消息推送,副作用最小。

流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 →
回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized →
PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。

- 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets;
- ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis);
- User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一:
  存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败
  —— 与之前 NULL 余额同类的坑,这次提前避开。

单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、
建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了,
留待部署后真机扫码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:44:00 +08:00
Blizzard a8a497b4ce feat(gateway): 支持微信域名校验文件(为网页授权登录铺路)
微信公众平台配置「JS接口安全域名 / 网页授权域名」时会下发 MP_verify_xxx.txt,
要求能从域名根目录直接访问。文件放宿主机 /home/workspace/wechat-verify,
只读挂进容器(不进镜像、不进 git —— 它随时可能重发,且属站点凭证类文件)。
未设 WECHAT_VERIFY_DIR 时这段完全不生效。

**第一版写成了独立路由 `/:mpfile`,直接把官网打挂**:它匹配所有单段路径,
于是 /pricing、/download 全变 404(实测确认)。改为并进 NoRoute 的 SPA 兜底里,
在"已排除 /api/ 与内嵌静态文件"之后、回退 index.html 之前处理。

文件名白名单:必须 MP_verify_ 前缀 + .txt 后缀、不含路径分隔符与 ..,
否则落 SPA 兜底 —— 避免把挂载目录变成任意文件下载口子。

回归验证:/ /pricing /download /admin 均 200,校验文件取到正确内容,
/passwd.txt 与路径穿越都只拿到 index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:20:02 +08:00
Blizzard b1fea23a0c feat(site): 官网定价页 —— 套餐由后台配置驱动,公开可见
官网加「定价」菜单与页面。数据来自 GET /api/v1/pricing(**不挂鉴权**):
运营改价、上下架、调发放节奏都在管理端完成,官网跟着变,不用发版;
未登录就能看到价格,这是转化的前提,也是官网存在的意义。

接口只吐在售项与展示字段,成本、权重、租户信息一律不出去。

结账仍在 Web 面完成(那里有登录态、租户上下文与支付轮询),官网只负责
「看价 → 去买」。同一套支付流程不在两处各实现一遍——这也是本次订阅开发
一直遵循的那条线:积分包与订阅共用一个支付弹窗,桌面端不重复购买入口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:59:58 +08:00
Blizzard a34d7045f1 feat(desktop): 用量页显示订阅状态与到期倒计时(购买仍归 Web 面)
桌面端只读展示:套餐、发放节奏、已发放次数、到期倒计时(≤7 天转橙、≤3 天转红),
「去续订」跳系统浏览器到 Web 面账单页。

刻意**不在桌面端做购买流程**。购买入口已在 Web 面,再复制一套扫码弹窗就违背了
「一个功能一个入口、一份数据一个查法」——也正是我在积分包/订阅之间特意合并弹窗
所避免的那种重复:真要两套,之前修过的轮询问题就得修两遍。

但"还剩几天到期"必须放在桌面端:这是用户实际干活的地方,而订阅到期即失效、
没有任何扣款或续费通知,看不见就等于没有。

真环境验证:桌面端用量页显示真实订阅(已发放 3 次)、余额 2,981.34 与账本一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:47:05 +08:00
Blizzard caa2602131 feat(web): 用户面订阅 —— 购买/续订入口 + 到期提醒
账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。
已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延
语义一致,免得用户以为会新开一条。

到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**,
用户只能从这里看见,不显眼等于没有。

支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn
处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修
两遍。

真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K
(NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:33:59 +08:00
Blizzard f8f7359723 feat(admin): 订阅管理页 + 修「余额列为 NULL 导致充值永不到账」
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。

顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招):

  credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是
  「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**:
  分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的,
  不是订阅引入的问题。

三处修:
  - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈;
  - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立);
  - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate
    尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。

回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给
模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。

真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额
2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:28:32 +08:00
Blizzard 80d4aaf4ba feat(billing): 订阅制后端 —— 手动购买 + 周期发放积分 + 到期失效
规格(按需求):用户扫码买一个订阅周期,有效期内每 N 天发一次积分,到期即失效,
不自动续费。N 与每次发放额度都在套餐里配,后台可改。

为什么不做自动续费:微信 Native 扫码支付没有代扣能力,真自动续费要走「委托代扣」
——另一套产品与资质。与其假装有,不如把"到期即失效"这个语义做扎实。

两个决定,都写进了代码注释:
- **发放语义是累加而非重置**。每次刷新写一条 grant 分录、余额累加。重置型
  (月度配额清零)会让「余额 = SUM(ledger)」这条对账不变量变复杂,且有误清
  用户自费积分的风险。
- **订阅开通放在 store.MarkOrderPaid 内**,而不是各调用方。回调与掉单补偿两条
  路都经过它,放这一处才没人能漏掉;按 orderID 幂等,重复调用无害。

复用而非另造:订阅单与积分包单走同一条支付链路(下单/回调/查单/掉单补偿),
只是 kind=sub 且 credits_micro=0——积分不在付款时给,由订阅按周期发。

定时器每 10 分钟扫一轮,语义与幂等都在 store.TickSubscription 里,与手动触发
共用,不会两处漂移。停机期间欠下的发放会一次性补齐。

8 组测试。其中一条当场抓到真 bug:开通时原本无条件发一笔,同一订单重复开通
(回调重推/查单赛跑)会因序号自增绕过幂等索引,白送积分。改为开通也走与定时器
同一套排期判断——排期天然幂等。教训:幂等键要锚在业务时间轴上,不能靠自增序号。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:11:34 +08:00
Blizzard 466959d3c3 fix: 静默吞错横扫 —— 数据落库/配置广播/审计失败不再无声
把检索链路那次的做法推到同类位置。判据是"丢了这个 error 的代价",只改代价
高的,不动刻意的 fire-and-forget。

改(丢了就是数据永久丢失或行为不可解释):
  - task_handler: 任务**输出**与**执行轨迹**的收尾落库。这是唯一的持久副本
    (Redis 流 10min TTL),失败则永远无法复盘,界面上只显示"这次运行没有
    轨迹"。轨迹的 json.Marshal 失败分支同样是静默跳过,一并补上。
  - admin.broadcastActive: 模型配置热更新广播。失败 = dispatcher/mcp-go 仍用
    旧配置,症状是"控制台改了模型却不生效",而操作者这边一切正常——正是今天
    排查半天的那类问题。
  - middleware/audit: 审计留痕写入。不阻断主流程是对的(业务已完成),但静默
    失败意味着敏感操作没有记录,且没人知道记录缺了,这是合规缺口。

不改(确认过是合理的):
  - dispatcher 的 PublishToken/PublishExec:往前端推流,丢一帧是 UI 瑕疵,
    且按 token 打日志会刷屏;
  - 计量回写 PublishUsage:本来就检查 error 并打日志,钱的路径是干净的。

另修一处误导文案:任务下钻的轨迹空态原本把原因说死为"早于该功能上线",
现在"落库失败"也是已知原因,文案改为并列并指向日志里的 [task] 告警。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:49:13 +08:00
Blizzard 518cddbd1c feat(admin): 检索试验台分路卡片直接说明"为什么空"
此前四张卡片一律写"这一路没召回",而人的视线落在卡片上、不在顶部状态行。
现在按该路诊断分别显示:未启用给出缺什么配置(琥珀)、报错给出错误原文(红)、
真没匹配才说没召回。

真环境验证(截图为证):向量="该知识库在 Milvus 中没有向量(未入库或集合被
重建过)"、图谱="未启用:Neo4j 未连接或未配置"、全文="索引里确实没有匹配内容"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:29:04 +08:00
Blizzard 7c8c723245 fix(admin): 诊断解析放错了函数 + 补 kb_search 形状测试
上一个提交里,诊断解析被塞进了用户面的 KbSearch,而真正传 diag 的
AdminKbSearch 还在用老代码 —— 于是试验台永远拿不到 routes。写法上是
字符串替换只改了文件里第一处匹配,而这两个 handler 的收尾代码一模一样。
用户面已还原(它不传 diag,拿到的本就是裸数组,多一层解析是错的)。

补 3 组 mcp 层测试钉死 kb_search 的两种返回形状:
  - 不带 diag(生产调用:dispatcher / Agent 工具链)→ 裸 JSON 数组,契约不变;
  - 带 diag → {hits, routes} 对象;
  - not ready 时不被总闸短路,仍逐路诊断。
这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若无条件生效,所有
调用方的解析都会静默失败。

真环境验证(本地 gateway + mcp-go + 真 PG/Milvus/bleve):
  vector    empty     该知识库在 Milvus 中没有向量(未入库或集合被重建过)
  fulltext  ok        15 命中
  graph     disabled  Neo4j 未连接或未配置
以前这三行都只是"0 条"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:24:49 +08:00