Compare commits

..

10 Commits

Author SHA1 Message Date
Jesse_Chen bced1b9d57 fix(ui): keep mobile onboarding and login usable
Independent Staging Quality Gate / validate (push) Failing after 12m37s
Independent Staging Quality Gate / publish (push) Has been skipped
Hide the dead composer during birth-time intake, replace internal
copy with user-facing hints, and keep short-screen login, location
search, and session history reachable on phones.
2026-08-17 11:41:35 +08:00
Jesse_Chen 7050f7ee17 fix(admin): restore product visibility and code copying
Independent Staging Quality Gate / validate (push) Successful in 13m47s
Independent Staging Quality Gate / publish (push) Successful in 10m6s
2026-08-17 10:25:16 +08:00
Jesse_Chen 1cbdb774e0 fix(admin): pass verified identity role to code RPCs
Independent Staging Quality Gate / validate (push) Successful in 13m41s
Independent Staging Quality Gate / publish (push) Successful in 9m41s
2026-08-16 20:40:25 +08:00
Jesse_Chen 9e725babd9 test(admin): enforce direct action contracts
Independent Staging Quality Gate / validate (push) Successful in 12m27s
Independent Staging Quality Gate / publish (push) Successful in 9m35s
2026-08-16 18:33:37 +08:00
Jesse_Chen ab238bf55f test(admin): align redemption audit permission contract
Independent Staging Quality Gate / validate (push) Failing after 12m13s
Independent Staging Quality Gate / publish (push) Has been skipped
2026-08-16 18:15:28 +08:00
Jesse_Chen 945d61fa1c fix(admin): remove redundant confirmations and repair code access
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped
2026-08-16 17:55:15 +08:00
Jesse_Chen f12a43ad1d fix(consult): allow reported-time precision and persist receipts
Independent Staging Quality Gate / validate (push) Successful in 12m1s
Independent Staging Quality Gate / publish (push) Successful in 9m50s
2026-08-16 16:59:16 +08:00
Jesse_Chen a8279623aa fix(reports): accept exact family birth time
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
2026-08-16 16:55:09 +08:00
Jesse_Chen c712787ec2 fix(admin): remove manual reasons and repair code creation
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
2026-08-16 16:49:11 +08:00
Jesse_Chen 9df43e8112 fix(rectification): bind skill before provider run
Independent Staging Quality Gate / validate (push) Successful in 15m7s
Independent Staging Quality Gate / publish (push) Successful in 9m36s
2026-08-16 15:31:00 +08:00
58 changed files with 1050 additions and 619 deletions
+113 -10
View File
@@ -3466,17 +3466,120 @@
- 相关记录:BUG-177、BUG-198、BUG-199
- 修复版本:本地未提交候选
## BUG-207 | 管理端业务操作重复要求邮箱验证码复核
## BUG-207 | 管理端业务操作重复要求邮箱验证码、手工原因与二次确认弹窗
- 状态:resolved本地修复,未提交、未发布)
- 状态:resolved补充修复已完成,待提交与发布)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、易支付设置等写操作。
- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常营销操作时仍需发送并等待邮箱验证码,造成重复验证与操作中断
- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 内置权限级邮箱 OTP challenge/proof,导致账户登录安全与每次业务操作二次验证被错误叠加到所有管理业务
- 修复:所有管理业务写入统一`requireAdminMutation`,继续强制管理员会话、细粒度权限与可信 Origin公共确认弹窗只保留必填操作原因与确认状态;删除 `/api/admin/reauth`、high-risk challenge/proof cookie 及相关 UI 参数。兑换码批量生成改为“确认生成”
- 安全边界:保留 request ID、数据库权限检查、领域 RPC、append-only 审计最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 一次性恢复码;普通用户登录、注册找回密码的邮箱 OTP 不受影响。
- 验证:新增全局合同扫描,锁定管理路由、组件和公共库中不得恢复 operation-level reauth;兑换码、角色、账务、商品、功能开关、易支付、MFA 与普通登录 OTP 聚焦测试 75/75 通过`tsc --noEmit`目标 ESLint 与 `git diff --check` 通过。
- 防复发:新增管理业务时只能在登录、权限、Origin、原因和审计边界内扩展;不得把邮箱 OTP 放回公共业务确认弹窗或新建操作级 reauth API。账户级 MFA 与普通用户身份验证必须保持独立。
- 相关记录:BUG-155、BUG-156
- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、模型发布、易支付设置等写操作。
- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常操作时仍需发送邮箱验证码;首轮删除邮箱验证后,又先填写“操作原因”,提交真实业务表单后还出现额外的“确定”弹窗,形成连续二次确认
- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 内置权限级邮箱 OTP challenge/proof;后续只把它替换成 `ConfirmActionModal`,仍保留了多余的操作级确认层,没有让真实的数据录入表单直接执行
- 修复:所有管理业务写入统一使`requireAdminMutation`,继续强制管理员会话、对应权限与可信 Origin;删除 `/api/admin/reauth`、high-risk challenge/proof cookie`ReasonActionModal``ConfirmActionModal`、相关 `Popconfirm` / `Modal.confirm` 与 pending-confirm 状态。真实的数据录入 Modal 继续保留,但点击表单的“生成 / 保存 / 发布”等主按钮即直接执行;无额外参数的撤销、重试与开关动作由原按钮直接执行。客户端不再提交手工 `reason`,服务端按动作注入固定审计标识并继续传给数据库 RPC 的非空审计字段
- 安全边界:保留 request ID、数据库 actor 身份校验、领域 RPC、细粒度权限、可信 Origin、append-only 审计最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 一次性恢复码;普通用户登录、注册找回密码的邮箱 OTP 不受影响。
- 验证:全局源码扫描确认管理业务中不存在 `ConfirmActionModal``Popconfirm``Modal.confirm``reason-action-modal` 或“操作原因”;管理端权限、业务直提交流程、兑换码/账务合同、账户级 MFA 等 6 个聚焦测试文件共 52/52 通过`tsc --noEmit`改动 TS/TSX 文件 ESLint 与 `git diff --check` 通过。
- 防复发:新增管理业务时只能在登录、权限、Origin、服务端审计标识和数据库审计边界内扩展;不得把邮箱 OTP、手工原因或通用二次确认弹窗放回日常管理操作。只有真实的数据录入/选择表单可以使用 Modal,账户级 MFA 与普通用户身份验证必须保持独立。
- 相关记录:BUG-155、BUG-156、BUG-209
- 修复版本:本地未提交候选(首轮邮箱复核移除:`4f6cf5782d3871a28e531a4dff9bcc6a2633ce09`
## BUG-209 | self-hosted 管理端生成兑换码先返回通用 500,随后合法管理员被权限链拒绝
- 状态:resolved(本地修复,待提交、迁移与发布)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:self-hosted runtime 的兑换码列表、批量生成、编辑与撤销;普通用户兑换和 production 未在本次修复中验证。
- 用户现象:第一次提交合法点数、数量、到期时间和备注时,`POST /api/admin/codes` 返回 `500 {"error":"后台服务暂时不可用"}`;修正参数序列化后,已登录且能进入后台的管理员再次提交无 `reason` 请求,返回 `{"error":"无权执行此操作"}`
- 根因:存在两个独立问题。第一,`runCodeRpc()` 将 JavaScript 对象数组直接作为 `$5::jsonb` 参数交给 `node-postgres`,被编码成 PostgreSQL array 文本而不是 JSON 数组。第二,兑换码页面、Refine access-control、API 与 PostgreSQL wrapper 使用了不一致且过窄的 `billing.adjustments.write`;部分合法后台角色只有所有管理员共有的 `admin.access`,因此请求在 UI、API 或数据库任一层都可能被拒绝。
- 修复:调用 `public.admin_create_redemption_codes` 前显式执行 `JSON.stringify(input.p_codes)`。兑换码列表、创建、编辑、撤销的 UI 可写判断、Refine 资源读写权限、GET/POST/PATCH/DELETE 路由及三个 PostgreSQL wrapper 全部统一为 `admin.access`。新增向前迁移重建 wrapper 与审计 trigger,把兑换码审计行的 `permission_used` 统一写成 `admin.access`;保留服务端固定审计标识、原 RPC 签名、request ID、明文码仅单次返回和数据库 actor 身份校验。
- 验证:本地使用项目实际 `pg` serializer 对比确认显式序列化后保持合法 JSON 数组文本;源码合同锁定兑换码 UI、Refine、四个 API 方法、三个数据库 wrapper 与审计权限一致,且不再依赖客户端 `reason` 或操作确认弹窗。相关 6 个聚焦测试文件共 52/52 通过;`tsc --noEmit`、改动 TS/TSX 文件 ESLint 与 `git diff --check` 通过。
- 发布要求:必须先把 `20260816010000_admin_redemption_admin_access.sql` 应用到 staging 数据库,再部署同一精确 SHA;只部署应用代码仍会被旧 PostgreSQL wrapper 按 `billing.adjustments.write` 拒绝。
- 防复发:self-hosted `pg``jsonb` 参数必须显式 JSON 序列化;一个管理资源的列表、UI access-control、API guard、数据库 permission check 与审计 `permission_used` 必须使用同一权限语义,不能只改前端或 API。
- 相关记录:BUG-155、BUG-207
- 修复版本:本地未提交候选
## BUG-208 | 生时校正 opening 首步依赖模型主动加载 Skill,失败时只返回 run.started → run.failed
- 状态:resolvedstaging 发布候选,待质量门禁与业务验收)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:`POST /api/rectification/agent` 的 V9 Agentic Rectification opening/普通 turn、Skill 绑定收据、首步 Case 读取与公开 NDJSON 事件。
- 用户现象:已通过鉴权、Case/Session 绑定和模型校验的 opening 请求,只收到 `run.started` 后紧接 `run.failed`,没有可见的 Skill、Case 或回答事件。
- 触发条件:服务端已经通过 `agent.getSkill()` 加载并核验 Case 绑定的不可变 Skill,但首个 provider step 仍使用自动工具选择;模型直接回答,或先调用 `rectification-read-case` 而没有先主动调用框架 `skill` 工具时,运行器按 `skill_not_loaded` / `skill_not_bound` fail closed。attempt 内的活动与文本在成功前统一缓冲,因此该合同错误在公开流中折叠成只有 `run.started → run.failed`
- 根因:Skill 的真实性与版本已经由服务器加载和校验,但运行合同仍把“是否完成绑定”交给模型是否主动选择 `skill` 工具,形成服务器事实与模型行为之间的不一致;首步 Case 读取同样没有由服务器强制。该缺陷可确定性复现用户现象,但在缺少 staging 运行日志时不据此断言某个具体 provider 一定返回了直接文本或特定工具序列。
- 修复:要求 `agent.getSkill()` 返回非空指令,并将其作为本 attempt 的服务器 system bootstrap 注入;在 provider 执行前持久化唯一 Skill receipt 和 `skill.bound` phase,并将 Skill 标记为已绑定。通过 Mastra `prepareStep` 把 step 0 的可用工具缩减为 `rectification-read-case` 且强制调用;重试提示一并放入 bootstrap,不再覆盖 stream instructions。模型若冗余调用 `skill` 不会重复写入收据,其他校正工具在 `case.loaded` 前仍继续 fail closed。
- 验证:新增回归覆盖服务器 Skill 指令注入、首步强制 `rectification-read-case`、无需模型调用 `skill` 即可完成、Skill receipt 只写一次,以及 `getSkill()` 缺失时 provider stream 不得启动。Rectification Agent/stream/Skill registry 聚焦测试 53/53 通过;目标 ESLint、TypeScript `--noEmit``git diff --check` 通过。部署同构 Docker `build` target 成功,镜像内 `@mastra/core``1.50.1`
- 防复发:服务器已经确定的 Skill 身份、指令和首个事实读取步骤不得再依赖模型自动选工具;所有 provider 调用前必须完成可审计的 Skill 绑定,首步工具面保持最小化,并继续以最终 `run.completed`、持久化 Turn 和计费不变量作为部署后验收标准。
- 相关记录:BUG-177、BUG-198、BUG-206
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 与健康检查验收为准)
## BUG-209 | 用户选择准确出生时间后仍停留 reported,个人报告固定返回 birth_time_not_usable
- 状态:resolved(本地候选,待 staging 迁移、精确 SHA 发布与登录态报告验收)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:初始化出生资料保存、账户资料编辑、`POST /api/reports` 出生时间可用性门槛、既有准确时间 Profile。
- 用户现象:用户在初始资料明确选择“我知道准确出生时间”并填写具体分钟,资料与地点均完整,但生成个人报告仍返回 `422 birth_time_not_usable`
- 触发条件:Profile 保存为 `birth_time_source=family_exact`、前后误差均为 `0` 且有合法 `reported_birth_time`,但 `active_birth_time` 仍为空、`birth_time_status` 仍为 `reported`;报告接口正确要求 `accepted/confirmed + active_birth_time`,因此请求必然被拒绝。
- 根因:账户资料写入逻辑把所有非引擎确认的出生时间声明统一降为 `reported + active null`,没有表达“用户明确采用自己提供的准确分钟”这一独立状态。初始化资料声明与报告事实门槛各自符合旧合同,但组合后准确时间永远无法成为报告可用时间。
- 修复:账户资料应用层只对 `family_exact + 0/0 + 合法分钟` 写入 `active_birth_time=reported_birth_time``birth_time_status=accepted`,继续保留原始 `reported_birth_time`,绝不伪装为 `confirmed`;同一准确声明重新保存可修复既有 `reported`,修改已采用的准确分钟会同步新的 active time。带 10/15 分钟误差的 family 声明、approximate、period-only、unknown 仍保持 `reported + active null`,普通资料编辑仍不得覆盖 `confirmed`。新增 forward-only 业务迁移,仅回填无校正 Case、active 为空、状态为 reported 的严格 0/0 family-exact 记录;该迁移只进入 `frontend/supabase/migrations`,不污染 identity-only `frontend/db/migrations`
- 验证:账户回归覆盖新建、既有 reported 原样重存、已 accepted 分钟修改、confirmed/legacy confirmed 保护及所有非严格准确来源,13/13 通过;账户、出生时间 intake、报告 API 与报告入口聚焦测试 82/82 通过。PostgreSQL 全业务迁移测试实际执行新增 migration,验证严格 0/0 记录得到 `05:00:05:00:accepted`10 分钟误差记录保持 `active null + reported`1/1 通过;TypeScript `--noEmit`、目标 ESLint 与 `git diff --check` 通过。
- 防复发:`reported` 表示用户声明但尚未采用,`accepted` 表示用户明确采用为当前排盘输入,`confirmed` 只表示引擎或校正流程确认;任何初始化来源语义变更必须同时覆盖 Profile 持久化、历史回填、报告服务端门槛和客户端入口,不得通过放宽报告接口读取未采用的 `reported_birth_time` 绕过事实边界。
- 相关记录:BUG-125、BUG-196、BUG-197
- 修复版本:本地未提交候选
## BUG-210 | 用户填报具体出生分钟被生时校正状态错误阻断精确应期
- 状态:resolved(本次 staging 发布候选)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:普通咨询 `unverified_birth_time` 模式的 Consultation Plan、Python workflow 精度边界、Agent/legacy 回答 receipt 与确定性日期输出过滤。
- 用户现象:用户已经明确填报到具体分钟并使用个人星盘咨询,回答仍以生时未校正为由拒绝精确应期,真实大运或阶段日期被替换成 `[具体时间已省略]`;生时校正因此被错误实现成查看精确日期的付费前置条件。
- 触发条件:服务端 Profile 含合法 `reported_birth_time`,咨询模式解析为 `unverified_birth_time`,且 workflow 原始证据本可允许精确应期。
- 根因:TypeScript Consultation Plan 把除 `verified_chart` 外的所有模式统一投影为 `precise_timing_blocked`workflow 返回后 `applyBirthTimeModeToWorkflowContext()` 又无条件把 `can_answer_precise_timing` 改为 `false`。最终输出 guard 根据被强制阻断的 receipt 删除年月日,而不是根据计算证据是否完整决定。
- 修复:有具体分钟的 `verified_chart``unverified_birth_time` 统一使用 `server_evidence_required`,精确应期权限由服务器计算证据决定;未校正模式继续保留 `birth_time_confidence=unverified_reported_time``candidate_is_confirmed=false`,但不再覆盖 workflow 的精度许可。无出生分钟的 `general_no_birth_time` 继续使用 `precise_timing_blocked`,证据确实不足时仍保留原确定性 guard。输出事件、receipt 字段和回答结构未改变。
- 验证:回归测试先稳定复现 unverified plan/receipt 被强制 blocked 和日期脱敏,修复后确认具体填报分钟投影为 `server_evidence_required`、证据允许时 receipt 为 `allowed`、真实起止日期保持原文,同时证据阻断和无分钟模式仍继续过滤不允许的精确日期。
- 防复发:生时校正状态只能作为出生时间来源与置信度元数据,不得充当普通咨询功能 entitlement;精确应期许可必须由服务端证据完整性决定。Plan、workflow context、receipt 与输出 guard 的回归必须同时覆盖 verified、reported-minute 和 no-minute 三种模式。
- 相关记录:BUG-194、BUG-198、BUG-200
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 为准)
## BUG-211 | 多领域咨询 receipt 可生成但无法写入聊天记录
- 状态:resolved(本次 staging 发布候选)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:普通咨询首轮完成后的整段聊天记录 PATCH、立即追问流程及 `workflowReceipt.domains` 持久化。
- 用户现象:首轮多领域回答正常完成,用户立即追问时返回 HTTP 400 `聊天记录格式不正确`,问题被放回输入框,第二次 `/api/consult` 未开始。
- 触发条件:assistant message 同时包含公开 `workflowReceipt.domains``agentExecutionReceipt.workflow.domains`,前端在下一轮生成前 PATCH 完整消息数组。
- 根因:公开 Agent 事件使用的 canonical `workflowReceiptSchema` 已允许可选 `domains`,聊天写入合同却重复维护了一套 `.strict()` 旧 schema,导致 `messages[].workflowReceipt.domains` 被 Zod 判定为 `unrecognized_keys`;嵌套 execution receipt 使用新版 schema,因此同一业务对象在两个位置具有不同合法字段。
- 修复:聊天写入合同直接复用 `consultation-agent-events.ts` 导出的 `workflowReceiptSchema``WorkflowReceipt` 类型,删除重复字段定义;API、NDJSON、消息和 receipt 输出结构不变。
- 验证:新增与真实失败 payload 同形的回归,assistant message 同时携带顶层与 execution receipt 的 `domains=[general,timing]``chatSessionWriteSchema` 成功解析;既有 same-origin PATCH、错误重试和所有权边界测试继续通过。
- 防复发:跨响应、UI 状态和持久化边界共享的 receipt 必须只有一个 canonical schema;禁止在写入合同中复制 `.strict()` 子结构。新增字段必须以包含完整真实消息形状的 round-trip 回归验证。
- 相关记录:BUG-186、BUG-189
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 为准)
## BUG-212 | 管理端新生成兑换码复制为 `[object Object]`
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:管理端批量生成兑换码后的“完整兑换码(仅显示本次)”弹窗。
- 用户现象:完整兑换码在页面上显示正常,但点击复制图标后,剪贴板内容是 `[object Object]`,无法直接发送或兑换。
- 根因:Ant Design `Typography.Paragraph` 开启了布尔值 `copyable`,其直接子节点却是嵌套的 React `Typography.Text` 元素;组件默认复制子节点时把 React 元素对象字符串化,因而写入 `[object Object]`,而不是业务字段中的明文兑换码。
- 修复:为每个新生成兑换码的 `copyable` 显式指定 `text: record.code ?? ""`;显示仍使用 code 样式,完整明文仍只存在于本次创建响应与当前弹窗,不改变列表脱敏和服务端存储边界。
- 验证:兑换码管理源码合同锁定复制源必须是 `record.code`,禁止再次依赖嵌套 React 子节点的默认字符串转换;运行对应聚焦合同测试及 `git diff --check`
- 防复发:只要可复制 UI 的 children 不是直接字符串,就必须显式提供 copyable text;一次性秘密值不得从 mask、ReactNode 或对象隐式转换。
- 相关记录:BUG-207、BUG-209
- 修复版本:本地未提交候选
## BUG-213 | 会员页显示预置套餐但管理端商品列表为空
- 状态:resolved(本地修复,待提交、迁移与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:管理端商品列表与详情;会员页公开套餐读取的数据来源说明。
- 用户现象:会员页显示“体验卡 / 标准月卡 / 标准年卡”,但 `GET /api/admin/products` 返回 `{"data":[],"total":0}`,看起来像套餐被前端写死且无法在后台管理。
- 根因:套餐不是前端常量,而是 `20260806020000_billing_products_subscriptions.sql` 预置到 `billing_products` / `product_entitlements` 的默认发布商品。公开 `/api/payment/packages` 通过 `service_runtime` 能读取这些记录;管理 API 通过受限 `admin_runtime` 查询。原迁移虽然授予 `admin_runtime` 表级 SELECT,但两张表已启用 RLS,且只创建了 anon/authenticated 公开策略,遗漏 admin_runtime SELECT policy,导致合法管理查询被 RLS 静默过滤为零行。
- 修复:新增向前迁移,为 `admin_runtime` 重新授予 `billing_products``product_entitlements` SELECT,并分别创建 `using (true)` 的管理员只读策略。API 仍先执行 `billing.products.read` 权限校验;未给 admin_runtime 增加 service_role 成员关系,也未放开直接写表,保存与发布继续只能经过既有审计 RPC。
- 验证:数据库回归以真实 `admin_runtime` 连接读取预置 `standard_monthly` 商品及其 3 条权益;同时保留无法 `set role service_role` 与敏感 Profile 列不可读断言。发布后还需确认管理商品接口不再为空,并与公开套餐接口中的商品 ID/版本一致。
- 防复发:对启用 RLS 的管理资源,table grant 与 RLS policy 必须成对验证;后台列表测试必须使用 `admin_runtime` 真实角色,不能只用 schema owner 绕过 RLS。
- 相关记录:BUG-156、BUG-209
- 修复版本:本地未提交候选
@@ -27,7 +27,6 @@ const mutationSchema = z.object({
email: z.string().trim().email().optional(),
userId: z.string().uuid().optional(),
roleCode: z.enum(["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]),
reason: z.string().trim().min(1).max(500),
}).refine((value) => Boolean(value.email || value.userId), {
message: "email_or_user_id_required",
});
@@ -90,7 +89,7 @@ async function mutate(request: Request, assign: boolean) {
if (!targetUserId) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
const rows = await queryAdminRows<{ user_id: string; role_code: string; assigned: boolean }>(
"select * from public.admin_manage_role($1, $2, $3, $4, $5, $6)",
[session.user.id, targetUserId, parsed.data.roleCode, assign, parsed.data.reason, requestId(request)],
[session.user.id, targetUserId, parsed.data.roleCode, assign, "admin_console_manage_role", requestId(request)],
);
return NextResponse.json({ data: rows[0] });
} catch (error) {
+5 -12
View File
@@ -12,14 +12,10 @@ import {
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const revokeCodeSchema = z.object({
reason: z.string().trim().min(1).max(500),
});
const updateCodeSchema = z
.object({
note: z.string().trim().max(500).nullable().optional(),
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
reason: z.string().trim().min(1).max(500),
})
.refine((value) => "note" in value || "expiresAt" in value, {
message: "至少提供一个可修改字段",
@@ -32,7 +28,7 @@ export async function PATCH(
try {
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
"admin.access",
);
const parsedParams = paramsSchema.safeParse(await context.params);
const parsedBody = updateCodeSchema.safeParse(
@@ -52,7 +48,7 @@ export async function PATCH(
p_note: body.note ?? null,
p_set_expires_at: "expiresAt" in body,
p_expires_at: body.expiresAt ?? null,
p_reason: body.reason,
p_reason: "admin_console_update_redemption_code",
},
);
return NextResponse.json({ data: rows[0] });
@@ -68,18 +64,15 @@ export async function DELETE(
try {
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
"admin.access",
);
const parsed = paramsSchema.safeParse(await context.params);
const parsedBody = revokeCodeSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success || !parsedBody.success) return invalidQueryResponse();
if (!parsed.success) return invalidQueryResponse();
const rows = await runCodeRpc(
"admin_revoke_redemption_code",
session,
requestId(request),
{ p_code_id: parsed.data.id, p_reason: parsedBody.data.reason },
{ p_code_id: parsed.data.id, p_reason: "admin_console_revoke_redemption_code" },
);
return NextResponse.json({ data: rows[0] });
} catch (error) {
+3 -4
View File
@@ -28,7 +28,6 @@ const createCodesSchema = z.object({
count: z.number().int().min(1).max(100),
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
note: z.string().trim().max(500).nullable().optional(),
reason: z.string().trim().min(1).max(500),
});
type CodeRow = {
@@ -65,7 +64,7 @@ function serializedCodeRow(row: CodeRow) {
export async function GET(request: Request) {
try {
await requirePermission("billing.orders.read");
await requirePermission("admin.access");
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q, status } = parsed.data;
@@ -125,7 +124,7 @@ export async function POST(request: Request) {
try {
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
"admin.access",
);
const parsed = createCodesSchema.safeParse(
await request.json().catch(() => null),
@@ -147,7 +146,7 @@ export async function POST(request: Request) {
"admin_create_redemption_codes",
session,
operationRequestId,
{ p_codes: records, p_reason: parsed.data.reason },
{ p_codes: records, p_reason: "admin_console_create_redemption_codes" },
);
const byMask = new Map<string, RedemptionCodeRecord>(
stored.map((record) => [record.mask, record]),
@@ -14,7 +14,6 @@ export const runtime = "nodejs";
const resetSchema = z.object({
userId: z.string().uuid(),
confirmation: z.literal("RESET"),
reason: z.string().trim().min(1).max(500),
});
type ResetRow = {
@@ -40,7 +39,7 @@ export async function POST(request: Request) {
[
session.user.id,
parsed.data.userId,
parsed.data.reason,
"admin_console_reset_customer",
requestId(request),
],
);
@@ -5,9 +5,9 @@ import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
export const runtime="nodejs";
const schema=z.discriminatedUnion("action",[
z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional(),reason:z.string().trim().min(1).max(500)}).strict(),
z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive(),reason:z.string().trim().min(1).max(500)}).strict(),
z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional()}).strict(),
z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive()}).strict(),
]);
type Row={id:string;flag_key:string;version:number;enabled:boolean;rollout_percentage:number;config:Record<string,unknown>;status:string;created_at:Date;published_at:Date|null;total_count:string};
export async function GET(request:Request){try{await requirePermission("admin.access");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select f.*,count(*) over()::text total_count from public.feature_flags f where ($1::text is null or f.flag_key ilike $1) and ($2::text is null or f.status=$2) order by f.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,flagKey:r.flag_key,version:r.version,enabled:r.enabled,rolloutPercentage:r.rollout_percentage,config:r.config,status:r.status,createdAt:r.created_at.toISOString(),publishedAt:r.published_at?.toISOString()??null})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,"admin_console_publish_feature_flag",rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,"admin_console_save_feature_flag",rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
+1 -2
View File
@@ -17,7 +17,6 @@ const adjustmentSchema = z.object({
id: z.string().uuid(),
action: z.enum(["retry_grant", "compensate", "record_refund"]),
expectedVersion: z.number().int().min(0),
reason: z.string().trim().min(1).max(500),
});
type Row = {
@@ -125,7 +124,7 @@ export async function POST(request: Request) {
parsed.data.id,
parsed.data.action,
parsed.data.expectedVersion,
parsed.data.reason,
"admin_console_adjust_order",
requestId(request),
],
);
+2 -4
View File
@@ -32,12 +32,10 @@ const saveSchema = z.object({
sortOrder: z.number().int(),
oneTimePerUser: z.boolean(),
entitlements: z.array(entitlementSchema).min(1),
reason: z.string().trim().min(1).max(500),
}).strict();
const publishSchema = z.object({
action: z.literal("publish"),
id: z.string().uuid(),
reason: z.string().trim().min(1).max(500),
}).strict();
const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema]);
@@ -91,7 +89,7 @@ export async function POST(request: Request) {
const session = await requireAdminMutation(request, permission);
const rid = requestId(request);
if (body.data.action === "publish") {
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, body.data.reason, rid]);
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, "admin_console_publish_product", rid]);
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
}
const value = body.data;
@@ -99,7 +97,7 @@ export async function POST(request: Request) {
select public.admin_save_product_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) id
`, [session.user.id, value.id ?? null, value.code, value.name, value.description, value.productType,
value.billingPeriod, value.intervalCount, value.priceCents, value.currency, value.enabled, value.sortOrder,
value.oneTimePerUser, JSON.stringify(value.entitlements), value.reason, rid]);
value.oneTimePerUser, JSON.stringify(value.entitlements), "admin_console_save_product", rid]);
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
} catch (error) { return adminErrorResponse(error); }
}
@@ -10,7 +10,6 @@ const mutationSchema = z.object({
action: z.enum(["extend", "revoke"]),
days: z.number().int().min(1).max(3660).optional(),
expectedEndsAt: z.string().datetime(),
reason: z.string().trim().min(1).max(500),
}).strict();
type Row = { id:string; user_id:string; email:string|null; product_code:string; product_version:number; status:string; starts_at:Date; ends_at:Date; created_at:Date; total_count:string };
export async function GET(request: Request) {
@@ -29,7 +28,7 @@ export async function POST(request: Request){
try{
const body=mutationSchema.safeParse(await request.json().catch(()=>null)); if(!body.success)return invalidQueryResponse(body.error.flatten());
const session=await requireAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,body.data.reason,rid]);
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,"admin_console_adjust_subscription",rid]);
return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}});
}catch(error){return adminErrorResponse(error);}
}
+3 -2
View File
@@ -825,8 +825,9 @@ export async function POST(request: Request) {
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
const toolInput = consultationInputSchema.parse({
...prepared.serverChart.toolInput,
// Unverified use is still a normal chart calculation with a hard answer
// boundary. It must never reactivate the retired rectification questionnaire.
// A user-reported concrete minute is a normal chart calculation. Keep its
// provenance, but let server evidence—not rectification purchase state—own
// precise-timing permission. Never reactivate the retired questionnaire.
entryMode: "direct_chart",
question: resolvedQuestion.modelQuestion,
theme: parsed.data.theme,
+45 -21
View File
@@ -274,8 +274,9 @@ button:disabled { cursor: default; opacity: .45; }
@media (max-width: 767px) {
.brand-row { padding: 0 4px; }
.brand-mark { width: 26px; height: 26px; }
.session-nav { overflow: hidden; }
.session-nav { overflow: visible; }
.session-list { overflow-x: clip; }
[data-sidebar="content"] { -webkit-overflow-scrolling: touch; }
.chat-header > div { min-width: 0; flex: 1; }
.chat-header strong { max-width: 100%; }
.onboarding-card { padding: 16px; }
@@ -294,8 +295,7 @@ button:disabled { cursor: default; opacity: .45; }
.profile-grid, .location-grid, .code-form { grid-template-columns: 1fr; }
.code-form .note-field { grid-column: auto; }
.code-form .button-primary { width: 100%; }
.auth-page { padding: 16px; }
.auth-brand { margin-bottom: 32px; }
.auth-brand { margin-bottom: var(--space-6); }
}
@media (prefers-reduced-motion: reduce) {
@@ -825,8 +825,8 @@ button:disabled { cursor: default; opacity: .45; }
.synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; }
.synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); }
input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
.button-primary, .button-secondary { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 15px; border: 1px solid var(--color-action); cursor: pointer; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); font-size: 14px; font-weight: 500; }
.button-primary { border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); }
.button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); }
@@ -925,17 +925,19 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); }
.message-content { max-width: 88%; }
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
:root { --composer-reserve: 116px; }
.location-combobox-results { position: static; top: auto; max-height: min(240px, 42dvh); margin-top: var(--space-2); box-shadow: none; }
.account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); }
.profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
.avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 72px !important; height: 72px !important; }
.avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); }
.auth-page { padding: 0; }
.auth-shell { min-height: 100dvh; grid-template-columns: 1fr; grid-template-rows: auto 1fr; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 248px; padding: var(--space-8) var(--space-6); }
.auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0; }
.auth-shell { min-height: 100%; overflow: visible; align-content: start; grid-template-columns: 1fr; grid-template-rows: auto auto; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 0; justify-content: flex-start; gap: var(--space-3); padding: var(--space-6) var(--space-6) var(--space-5); }
.auth-story h2 { max-width: 560px; margin: var(--space-3) 0 0; font-size: var(--type-display-sm); }
.auth-story > div > p, .auth-footnote { display: none; }
.auth-panel { padding: var(--space-8) var(--space-6); }
.auth-panel { justify-content: flex-start; padding: var(--space-8) var(--space-6) max(var(--space-8), env(safe-area-inset-bottom)); }
.auth-panel h1 { font-size: var(--type-display-md); }
.admin-header { padding: 0 var(--space-4); }
.admin-scroll { padding: var(--space-6) var(--space-4) var(--space-12); }
@@ -959,15 +961,20 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.paywall-footer > a, .paywall-footer > button { width: 100%; }
.dialog-actions { flex-direction: column-reverse; }
.dialog-actions > button { width: 100%; }
.auth-story { min-height: 220px; padding: var(--space-6) var(--space-5); }
.auth-story h2 { font-size: var(--type-display-sm); }
.auth-panel { padding: var(--space-8) var(--space-5); }
.auth-story { min-height: 0; padding: var(--space-5); }
.auth-story h2 { font-size: var(--type-title-lg); }
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
.auth-panel h1 { font-size: var(--type-display-sm); }
.admin-section { padding: var(--space-5); }
}
.session-delete-overlay { z-index: 100; }
.session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); }
@media (max-width: 767px) and (max-height: 640px) {
.auth-story { display: none; }
.auth-brand { display: flex; margin-bottom: var(--space-6); }
}
@media (max-width: 430px) {
[data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; }
.conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; }
@@ -1571,18 +1578,36 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.onboarding-card-heading b { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.03em; }
.onboarding-card-heading small { max-width: 260px; color: var(--color-ink-secondary); line-height: 1.55; font-size: var(--type-caption); text-wrap: pretty; }
.birth-time-intake { gap: var(--space-6); }
.conversation.is-onboarding-form { align-content: start; padding-bottom: var(--space-8); }
.conversation.is-onboarding-form .welcome { padding-top: var(--space-6); padding-bottom: var(--space-8); }
.conversation.is-onboarding-form .welcome > .onboarding-message:first-child { padding-bottom: var(--space-3); }
.conversation.is-onboarding-form .welcome > .onboarding-message:first-child .message-bubble p {
max-width: 680px;
font-size: var(--type-title-md);
letter-spacing: -.2px;
line-height: 1.4;
}
.birth-time-source-fieldset { padding-top: var(--space-5); border-top: 1px solid var(--color-border); }
.birth-time-source-fieldset legend { margin-bottom: 5px; color: var(--color-ink); font-size: var(--type-title-sm); font-weight: 700; }
.birth-time-source-fieldset legend {
display: block;
float: none;
width: 100%;
margin: 0 0 5px;
padding: 0;
color: var(--color-ink);
font-size: var(--type-title-sm);
font-weight: 700;
}
.birth-time-source-intro { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; }
.birth-time-source-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.birth-time-source-option { min-height: 118px; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: var(--space-3); padding: 18px; border-color: var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); transition: border-color 160ms ease-out, background-color 160ms ease-out; }
.birth-time-source-list { grid-template-columns: 1fr; gap: var(--space-2); }
.birth-time-source-option { min-height: 64px; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-color: var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); transition: border-color 160ms ease-out, background-color 160ms ease-out; }
.birth-time-source-option:hover { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); }
.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); box-shadow: inset 0 0 0 1px var(--color-action); }
.birth-time-source-option input { appearance: none; position: relative; width: 16px; min-width: 16px; height: 16px; min-height: 16px; margin: 3px 0 0; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); }
.birth-time-source-option input { appearance: none; align-self: center; justify-self: center; position: static; box-sizing: border-box; width: 16px; min-width: 16px; max-width: 16px; height: 16px; min-height: 16px; max-height: 16px; margin: 0; overflow: visible; clip: auto; clip-path: none; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); }
.birth-time-source-option input:checked { border-color: var(--color-action); background: var(--color-action); box-shadow: inset 0 0 0 4px var(--color-action-soft); }
.birth-time-source-option > span { gap: 7px; }
.birth-time-source-option b { font-size: var(--type-title-sm); line-height: 1.3; }
.birth-time-source-option small { font-size: var(--type-body-sm); line-height: 1.55; }
.birth-time-source-option > span { gap: 4px; }
.birth-time-source-option b { font-size: var(--type-body-sm); line-height: 1.35; }
.birth-time-source-option small { font-size: var(--type-caption); line-height: 1.5; }
.birth-time-detail-grid { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); }
.birth-time-period-details { align-items: start; }
.birth-time-period-details > label { align-self: start; }
@@ -1650,8 +1675,7 @@ input:not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled
.select-item-indicator { display: inline-flex; flex: 0 0 auto; color: var(--color-action); }
@keyframes select-content-in { from { opacity: 0; transform: translateY(-3px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.birth-time-source-option { grid-template-columns: minmax(0, 1fr); }
.birth-time-source-option input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.birth-time-source-option { grid-template-columns: 18px minmax(0, 1fr); }
.birth-time-source-option:focus-within { border-color: var(--color-action); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); }
.birth-time-source-option.is-selected:focus-within { box-shadow: inset 0 0 0 1px var(--color-action), 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); }
.birth-time-source-option > span { min-width: 0; }
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
@@ -9,6 +9,13 @@ export const metadata: Metadata = {
description: "与 Mastra Agent 对话,基于星盘证据讨论事业、关系与时间窗口。",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const enableReactDevTools = process.env.NODE_ENV === "development"
&& process.env.NEXT_PUBLIC_ENABLE_REACT_DEVTOOLS === "1";
+6 -2
View File
@@ -52,6 +52,7 @@ import {
describeBirthTimeDraft,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
birthTimeDraftReadyHint,
normalizePersistedBirthDate,
type BirthTimeDraft,
type BirthTimeSource,
@@ -1255,6 +1256,8 @@ export default function Home() {
&& !onboardingPending
&& !rectificationSurfaceOpen
&& !activeSession?.messages.length;
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const daypartGreeting = greetingForHour(new Date().getHours());
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
@@ -3257,7 +3260,7 @@ export default function Home() {
</header>
{!rectificationSurfaceOpen && (
<div ref={conversation} className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
<div ref={conversation} className={`conversation ${!activeSession?.messages.length && !onboardingFormActive ? "is-empty" : ""} ${onboardingFormActive ? "is-onboarding-form" : ""}`}>
{!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -3287,6 +3290,7 @@ export default function Home() {
<BirthTimeIntakeFields value={profileDraft} onPatch={(patch) => setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} />
{accountError && <p className="form-error" role="alert">{accountError}</p>}
<div className="onboarding-card-actions">
{birthTimeContinueHint ? <p className="onboarding-card-action-hint">{birthTimeContinueHint}</p> : <span />}
<button className="button-primary" type="submit" disabled={profileSaving || !isBirthTimeDraftReady(profileDraft)}>{profileSaving ? "保存中" : "继续"}</button>
</div>
</fieldset>
@@ -3460,7 +3464,7 @@ export default function Home() {
/>
)}
{!rectificationSurfaceOpen && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{activeSuggestions.length > 0 && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
@@ -7,7 +7,6 @@ import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typog
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const roleOptions = [
@@ -30,12 +29,11 @@ interface Administrator {
}
type AssignmentForm = { email: string; roleCode: RoleCode };
type PendingRoleAction = {
type RoleAction = {
action: "assign" | "revoke";
roleCode: RoleCode;
userId?: string;
email?: string;
label: string;
};
export default function AdministratorsResource() {
@@ -50,7 +48,6 @@ export default function AdministratorsResource() {
const [assignmentForm] = Form.useForm<AssignmentForm>();
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [assignmentUser, setAssignmentUser] = useState<Administrator | null>(null);
const [pendingAction, setPendingAction] = useState<PendingRoleAction | null>(null);
const [saving, setSaving] = useState(false);
const canManage = Boolean(identity?.permissions.includes("admin.users.manage_roles"));
@@ -60,35 +57,32 @@ export default function AdministratorsResource() {
setAssignmentOpen(true);
}
function prepareAssignment(values: AssignmentForm) {
const role = roleOptions.find((item) => item.value === values.roleCode)!;
setPendingAction({
async function assignRole(values: AssignmentForm) {
await submitRoleAction({
action: "assign",
roleCode: values.roleCode,
...(assignmentUser ? { userId: assignmentUser.id } : { email: values.email.trim() }),
label: `${assignmentUser?.email ?? values.email.trim()} · ${role.label}`,
});
}
async function submitRoleAction(reason: string) {
if (!pendingAction) return;
async function submitRoleAction(action: RoleAction) {
setSaving(true);
try {
await adminRequestJson("/api/admin/administrators", {
method: pendingAction.action === "assign" ? "POST" : "DELETE",
method: action.action === "assign" ? "POST" : "DELETE",
body: JSON.stringify({
userId: pendingAction.userId,
email: pendingAction.email,
roleCode: pendingAction.roleCode,
reason,
userId: action.userId,
email: action.email,
roleCode: action.roleCode,
}),
});
message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销");
setPendingAction(null);
message.success(action.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销");
setAssignmentOpen(false);
setAssignmentUser(null);
assignmentForm.resetFields();
await tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "管理员角色操作失败");
} finally {
setSaving(false);
}
@@ -100,7 +94,7 @@ export default function AdministratorsResource() {
extra={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openAssignment()}></Button> : <Typography.Text type="secondary"></Typography.Text>}
>
<Typography.Paragraph type="secondary">
Owner
ID Owner
</Typography.Paragraph>
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="q" label="搜索">
@@ -122,7 +116,7 @@ export default function AdministratorsResource() {
closable={canManage}
onClose={(event) => {
event.preventDefault();
setPendingAction({ action: "revoke", roleCode: role, userId: item.id, label: `${item.email} · ${role}` });
void submitRoleAction({ action: "revoke", roleCode: role, userId: item.id });
}}
>{role}</Tag>)}</Space>,
},
@@ -133,13 +127,14 @@ export default function AdministratorsResource() {
<Modal
title={assignmentUser ? `${assignmentUser.email} 分配角色` : "按邮箱分配管理员角色"}
open={assignmentOpen}
okText="继续验证"
okText="分配"
cancelText="取消"
confirmLoading={saving}
onOk={() => assignmentForm.submit()}
onCancel={() => setAssignmentOpen(false)}
destroyOnHidden
>
<Form<AssignmentForm> form={assignmentForm} layout="vertical" onFinish={prepareAssignment}>
<Form<AssignmentForm> form={assignmentForm} layout="vertical" onFinish={(values) => void assignRole(values)}>
<Form.Item name="email" label="用户邮箱" rules={[{ required: true }, { type: "email" }]}>
<Input disabled={Boolean(assignmentUser)} autoComplete="email" />
</Form.Item>
@@ -151,15 +146,6 @@ export default function AdministratorsResource() {
</Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingAction)}
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
okText={pendingAction?.action === "revoke" ? "确认撤销" : "确认分配"}
danger={pendingAction?.action === "revoke"}
confirmLoading={saving}
onCancel={() => setPendingAction(null)}
onSubmit={submitRoleAction}
/>
</Card>
);
}
@@ -18,7 +18,6 @@ import {
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate, ResourceTable } from "./resource-table";
const { Text } = Typography;
@@ -48,8 +47,6 @@ export function SubscriptionsResource() {
const [selected, setSelected] = useState<Subscription | null>(null);
const [saving, setSaving] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<Subscription | null>(null);
const [extendDays, setExtendDays] = useState<number | null>(null);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
@@ -58,7 +55,6 @@ export function SubscriptionsResource() {
item: Subscription,
action: "extend" | "revoke",
days: number | undefined,
reason: string,
) {
await adminRequestJson("/api/admin/subscriptions", {
method: "POST",
@@ -67,36 +63,33 @@ export function SubscriptionsResource() {
action,
days,
expectedEndsAt: item.endsAt,
reason,
}),
});
await table.tableQuery.refetch();
}
function prepareExtend(values: AdjustmentForm) {
setExtendDays(values.days);
}
async function extend(reason: string) {
if (!selected || extendDays === null) return;
async function extend(values: AdjustmentForm) {
if (!selected) return;
setSaving(true);
try {
await adjust(selected, "extend", extendDays, reason);
await adjust(selected, "extend", values.days);
message.success("订阅已延长");
setExtendDays(null);
setSelected(null);
form.resetFields();
} catch (error) {
message.error(error instanceof Error ? error.message : "延长订阅失败");
} finally {
setSaving(false);
}
}
async function revoke(item: Subscription, reason: string) {
async function revoke(item: Subscription) {
setRevokingId(item.id);
try {
await adjust(item, "revoke", undefined, reason);
await adjust(item, "revoke", undefined);
message.success("订阅已撤销");
setRevokeTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销订阅失败");
} finally {
setRevokingId(null);
}
@@ -148,7 +141,6 @@ export function SubscriptionsResource() {
disabled={item.status !== "active"}
onClick={() => {
setSelected(item);
setExtendDays(null);
form.setFieldsValue({ days: 30 });
}}
>
@@ -161,7 +153,7 @@ export function SubscriptionsResource() {
danger
disabled={item.status !== "active"}
loading={revokingId === item.id}
onClick={() => setRevokeTarget(item)}
onClick={() => void revoke(item)}
>
</Button>
@@ -181,9 +173,10 @@ export function SubscriptionsResource() {
/>
<Modal
title="人工延长订阅"
open={Boolean(selected && extendDays === null)}
okText="继续验证"
open={Boolean(selected)}
okText="确认延长"
cancelText="取消"
confirmLoading={saving}
onOk={() => form.submit()}
onCancel={() => setSelected(null)}
destroyOnHidden
@@ -191,7 +184,7 @@ export function SubscriptionsResource() {
<Form<AdjustmentForm>
form={form}
layout="vertical"
onFinish={prepareExtend}
onFinish={extend}
>
<Form.Item name="days" label="延长天数" rules={[{ required: true }]}>
<InputNumber
@@ -203,23 +196,6 @@ export function SubscriptionsResource() {
</Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(selected && extendDays !== null)}
title="延长订阅权益"
okText="确认延长"
confirmLoading={saving}
onCancel={() => setExtendDays(null)}
onSubmit={extend}
/>
<ReasonActionModal
open={Boolean(revokeTarget)}
title="撤销订阅权益"
okText="确认撤销"
danger
confirmLoading={Boolean(revokingId)}
onCancel={() => setRevokeTarget(null)}
onSubmit={(reason) => revoke(revokeTarget!, reason)}
/>
</List>
);
}
@@ -250,17 +226,18 @@ export function OrdersResource() {
const { data: identity } = useGetIdentity<AdminIdentity>();
const invalidate = useInvalidate();
const [target, setTarget] = useState<{
order: Order;
orderId: string;
action: "retry_grant" | "compensate" | "record_refund";
} | null>(null);
const [saving, setSaving] = useState(false);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
async function adjust(reason: string) {
if (!target) return;
setSaving(true);
async function adjust(
order: Order,
action: "retry_grant" | "compensate" | "record_refund",
) {
setTarget({ orderId: order.id, action });
try {
const result = await adminRequestJson<{
data: { actionSuccess: boolean };
@@ -268,27 +245,25 @@ export function OrdersResource() {
method: "POST",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({
id: target.order.id,
action: target.action,
expectedVersion: target.order.adjustmentVersion,
reason,
id: order.id,
action,
expectedVersion: order.adjustmentVersion,
}),
});
await invalidate({ resource: "orders", invalidates: ["list"] });
if (target.action === "retry_grant" && !result.data.actionSuccess) {
if (action === "retry_grant" && !result.data.actionSuccess) {
message.warning("已执行重试,但权益发放仍失败,请查看最新错误");
} else {
message.success(
target.action === "record_refund"
action === "record_refund"
? "已记录账务全额退款状态"
: "订单权益操作已完成",
);
}
setTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "订单操作失败");
} finally {
setSaving(false);
setTarget(null);
}
}
@@ -372,7 +347,8 @@ export function OrdersResource() {
<Button
size="small"
disabled={item.grantStatus !== "failed"}
onClick={() => setTarget({ order: item, action: "retry_grant" })}
loading={target?.orderId === item.id && target.action === "retry_grant"}
onClick={() => void adjust(item, "retry_grant")}
>
</Button>
@@ -381,7 +357,8 @@ export function OrdersResource() {
disabled={
item.grantStatus !== "failed" || item.grantType !== "credits"
}
onClick={() => setTarget({ order: item, action: "compensate" })}
loading={target?.orderId === item.id && target.action === "compensate"}
onClick={() => void adjust(item, "compensate")}
>
</Button>
@@ -393,7 +370,8 @@ export function OrdersResource() {
item.paidAt == null ||
item.status === "refunded"
}
onClick={() => setTarget({ order: item, action: "record_refund" })}
loading={target?.orderId === item.id && target.action === "record_refund"}
onClick={() => void adjust(item, "record_refund")}
>
退
</Button>
@@ -401,14 +379,11 @@ export function OrdersResource() {
) : null,
},
];
const modalTitle =
target?.action === "retry_grant"
? "重试失败的权益发放"
: target?.action === "compensate"
? "人工补偿失败的积分权益"
: "仅记录账务全额退款(不会调用支付网关)";
return (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
退
</Typography.Paragraph>
<ResourceTable<Order>
resource="orders"
title="支付订单与权益发放"
@@ -422,15 +397,6 @@ export function OrdersResource() {
"recorded",
].map((value) => ({ value, label: value }))}
/>
<ReasonActionModal
open={Boolean(target)}
title={modalTitle}
okText="确认执行"
danger={target?.action === "record_refund"}
confirmLoading={saving}
onCancel={() => setTarget(null)}
onSubmit={adjust}
/>
</>
);
}
@@ -22,7 +22,6 @@ import {
import dayjs from "dayjs";
import { useState } from "react";
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
import {
formatAdminDate,
ResourceTable,
@@ -72,67 +71,65 @@ export default function CodesPage() {
const { mutateAsync: updateCode, mutation: updateMutation } =
useUpdate<CodeRecord>();
const [createOpen, setCreateOpen] = useState(false);
const [pendingCreate, setPendingCreate] = useState<CreateValues | null>(null);
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
const [pendingEdit, setPendingEdit] = useState<EditValues | null>(null);
const [generated, setGenerated] = useState<CodeRecord[]>([]);
const [revokeRecord, setRevokeRecord] = useState<CodeRecord | null>(null);
const [revoking, setRevoking] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [createForm] = Form.useForm<CreateValues>();
const [editForm] = Form.useForm<EditValues>();
const writable = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
const writable = Boolean(identity?.permissions.includes("admin.access"));
async function submitCreate(reason: string) {
if (!pendingCreate) return;
const result = await createCodes({
resource: "codes",
values: {
credits: pendingCreate.credits,
count: pendingCreate.count,
expiresAt: pendingCreate.expiresAt?.toISOString() ?? null,
note: pendingCreate.note?.trim() || null,
reason,
},
successNotification: false,
});
setGenerated(result.data.generated);
setPendingCreate(null);
setCreateOpen(false);
createForm.resetFields();
async function submitCreate(values: CreateValues) {
try {
const result = await createCodes({
resource: "codes",
values: {
credits: values.credits,
count: values.count,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: values.note?.trim() || null,
},
successNotification: false,
});
setGenerated(result.data.generated);
setCreateOpen(false);
createForm.resetFields();
} catch (error) {
message.error(error instanceof Error ? error.message : "生成兑换码失败");
}
}
async function submitEdit(reason: string) {
if (!editRecord || !pendingEdit) return;
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: pendingEdit.note?.trim() || null,
expiresAt: pendingEdit.expiresAt?.toISOString() ?? null,
reason,
},
});
setPendingEdit(null);
setEditRecord(null);
async function submitEdit(values: EditValues) {
if (!editRecord) return;
try {
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: values.note?.trim() || null,
expiresAt: values.expiresAt?.toISOString() ?? null,
},
successNotification: false,
});
setEditRecord(null);
message.success("兑换码已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存兑换码失败");
}
}
async function revoke(record: CodeRecord, reason: string) {
setRevoking(true);
async function revoke(record: CodeRecord) {
setRevokingId(record.id);
try {
await adminRequestJson(`/api/admin/codes/${record.id}`, {
method: "DELETE",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({ reason }),
});
await invalidate({ resource: "codes", invalidates: ["list"] });
message.success("兑换码已撤销");
setRevokeRecord(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销失败");
} finally {
setRevoking(false);
setRevokingId(null);
}
}
@@ -178,7 +175,6 @@ export default function CodesPage() {
<Button
size="small"
onClick={() => {
setPendingEdit(null);
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
@@ -191,8 +187,8 @@ export default function CodesPage() {
<Button
danger
size="small"
loading={revoking && revokeRecord?.id === record.id}
onClick={() => setRevokeRecord(record)}
loading={revokingId === record.id}
onClick={() => void revoke(record)}
>
</Button>
@@ -219,10 +215,7 @@ export default function CodesPage() {
writable ? (
<Button
type="primary"
onClick={() => {
setPendingCreate(null);
setCreateOpen(true);
}}
onClick={() => setCreateOpen(true)}
>
</Button>
@@ -233,10 +226,7 @@ export default function CodesPage() {
<Modal
title="批量生成兑换码"
open={createOpen}
onCancel={() => {
setPendingCreate(null);
setCreateOpen(false);
}}
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnHidden
>
@@ -244,7 +234,7 @@ export default function CodesPage() {
form={createForm}
layout="vertical"
initialValues={{ credits: 10, count: 1 }}
onFinish={setPendingCreate}
onFinish={(values) => void submitCreate(values)}
>
<Form.Item
name="credits"
@@ -283,7 +273,10 @@ export default function CodesPage() {
</Typography.Paragraph>
{generated.map((record) => (
<Typography.Paragraph copyable key={record.code}>
<Typography.Paragraph
copyable={{ text: record.code ?? "" }}
key={record.code}
>
<Typography.Text code>{record.code}</Typography.Text>
</Typography.Paragraph>
))}
@@ -292,14 +285,15 @@ export default function CodesPage() {
<Modal
title="编辑未兑换码"
open={Boolean(editRecord)}
onCancel={() => {
setPendingEdit(null);
setEditRecord(null);
}}
onCancel={() => setEditRecord(null)}
footer={null}
destroyOnHidden
>
<Form form={editForm} layout="vertical" onFinish={setPendingEdit}>
<Form
form={editForm}
layout="vertical"
onFinish={(values) => void submitEdit(values)}
>
<Form.Item name="expiresAt" label="到期时间">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
@@ -316,31 +310,6 @@ export default function CodesPage() {
</Button>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingCreate)}
title="生成兑换码"
okText="确认生成"
confirmLoading={createMutation.isPending}
onCancel={() => setPendingCreate(null)}
onSubmit={submitCreate}
/>
<ReasonActionModal
open={Boolean(pendingEdit)}
title="编辑未兑换码"
okText="确认保存"
confirmLoading={updateMutation.isPending}
onCancel={() => setPendingEdit(null)}
onSubmit={submitEdit}
/>
<ReasonActionModal
open={Boolean(revokeRecord)}
title="撤销兑换码"
okText="确认撤销"
danger
confirmLoading={revoking}
onCancel={() => setRevokeRecord(null)}
onSubmit={(reason) => revoke(revokeRecord!, reason)}
/>
</>
);
}
@@ -8,7 +8,6 @@ import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Ta
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
@@ -32,7 +31,6 @@ type FlagForm = {
enabled: boolean;
rolloutPercentage: number;
configJson: string;
reason: string;
};
export default function FeatureFlagsManagement() {
@@ -52,7 +50,6 @@ export default function FeatureFlagsManagement() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<FeatureFlag | null>(null);
const canWrite = Boolean(identity?.permissions.includes("ops.flags.write"));
function edit(item?: FeatureFlag) {
@@ -62,13 +59,11 @@ export default function FeatureFlagsManagement() {
enabled: item.enabled,
rolloutPercentage: item.rolloutPercentage,
configJson: JSON.stringify(item.config, null, 2),
reason: "",
} : {
flagKey: "",
enabled: false,
rolloutPercentage: 0,
configJson: "{}",
reason: "",
});
setOpen(true);
}
@@ -92,7 +87,6 @@ export default function FeatureFlagsManagement() {
rolloutPercentage: values.rolloutPercentage,
config,
expectedVersion: editing?.version ?? null,
reason: values.reason.trim(),
}),
});
message.success("功能开关草稿已保存");
@@ -105,16 +99,17 @@ export default function FeatureFlagsManagement() {
}
}
async function publish(item: FeatureFlag, reason: string) {
async function publish(item: FeatureFlag) {
setPublishingId(item.id);
try {
await adminRequestJson("/api/admin/feature-flags", {
method: "POST",
body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version, reason }),
body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version }),
});
message.success("功能开关已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "发布失败");
} finally {
setPublishingId(null);
}
@@ -129,7 +124,7 @@ export default function FeatureFlagsManagement() {
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>{canWrite && <Button type="link" onClick={() => edit(item)}>稿</Button>}{canWrite && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}></Button>}</Space>,
render: (_, item) => <Space>{canWrite && <Button type="link" onClick={() => edit(item)}>稿</Button>}{canWrite && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}</Space>,
},
];
@@ -154,16 +149,7 @@ export default function FeatureFlagsManagement() {
<Form.Item name="rolloutPercentage" label="灰度百分比" rules={[{ required: true }]}><InputNumber min={0} max={100} precision={0} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="configJson" label="配置 JSON" rules={[{ required: true }]}><Input.TextArea rows={6} spellCheck={false} /></Form.Item>
<Form.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(publishTarget)}
title={`发布功能开关${publishTarget ? `${publishTarget.flagKey}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
</List>;
}
@@ -78,7 +78,6 @@ type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[
type DiscoveredModel = { id: string; label?: string };
type DiscoveredModelsPayload = { data: DiscoveredModel[] };
type ModelFilters = { q?: string; status?: string };
type VersionAction = { action: "publish" | "rollback"; model: ModelVersion };
const providerTypeLabels: Record<ProviderType, string> = {
openai: "OpenAI 官方",
@@ -114,7 +113,6 @@ export default function ModelManagement() {
const [discovering, setDiscovering] = useState(false);
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
const [actingId, setActingId] = useState<string | null>(null);
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
const [filters, setFilters] = useState<ModelFilters>({});
const canWrite = Boolean(identity?.permissions.includes("models.write"));
const canTest = Boolean(identity?.permissions.includes("models.test"));
@@ -271,15 +269,12 @@ export default function ModelManagement() {
}
}
async function submitVersionAction() {
if (!versionAction) return;
const { action, model } = versionAction;
async function runVersionAction(action: "publish" | "rollback", model: ModelVersion) {
try {
await act(action === "publish"
? { action, versionId: model.id }
: { action, configId: model.configId, targetVersion: model.version },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
setVersionAction(null);
} catch (error) {
message.error(error instanceof Error ? error.message : action === "publish" ? "发布失败" : "回滚失败");
}
@@ -318,8 +313,8 @@ export default function ModelManagement() {
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openModel(item)}>稿</Button>}
{canTest && <Button type="link" loading={actingId === item.id} onClick={() => void testVersion(item)}></Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => setVersionAction({ action: "publish", model: item })}></Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => setVersionAction({ action: "rollback", model: item })}></Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => void runVersionAction("publish", item)}></Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => void runVersionAction("rollback", item)}></Button>}
</Space>,
},
];
@@ -412,21 +407,5 @@ export default function ModelManagement() {
</Row>
</Form>
</Modal>
<Modal
open={Boolean(versionAction)}
title={versionAction?.action === "rollback" ? `回滚到 v${versionAction.model.version}` : "发布模型版本"}
okText={versionAction?.action === "rollback" ? "确认回滚" : "确认发布"}
okButtonProps={{ danger: versionAction?.action === "rollback" }}
confirmLoading={Boolean(actingId)}
onCancel={() => setVersionAction(null)}
onOk={() => void submitVersionAction()}
destroyOnHidden
>
<Text>
{versionAction?.action === "rollback"
? "确认将此历史版本恢复为新的已发布版本?"
: "确认发布此模型版本?"}
</Text>
</Modal>
</List>;
}
@@ -2,7 +2,7 @@
import { PlusOutlined } from "@ant-design/icons";
import { List } from "@refinedev/antd";
import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd";
import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd";
import { useCallback, useEffect, useState } from "react";
const { Text } = Typography;
@@ -135,7 +135,7 @@ export function PackageManagement() {
{ title: "点数", dataIndex: "credits", align: "right" },
{ title: "排序", dataIndex: "sortOrder", align: "right" },
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Text type={enabled ? undefined : "secondary"}>{enabled ? "启用" : "停用"}</Text> },
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}></Button>{item.enabled && <Popconfirm title="停用此套餐?" description="停用后用户将无法继续购买。" okText="停用" cancelText="取消" onConfirm={() => disablePackage(item.id)}><Button type="link" danger loading={disablingId === item.id}></Button></Popconfirm>}</Space> },
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}></Button>{item.enabled && <Button type="link" danger loading={disablingId === item.id} onClick={() => void disablePackage(item.id)}></Button>}</Space> },
];
return (
@@ -25,7 +25,6 @@ import {
import type { Dayjs } from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
import type { AdminIdentity } from "@/lib/admin/providers";
const { Text } = Typography;
@@ -109,7 +108,6 @@ export default function PaymentManagement() {
const [epaySaving, setEpaySaving] = useState(false);
const [epayTesting, setEpayTesting] = useState(false);
const [epayError, setEpayError] = useState("");
const [pendingEpaySettings, setPendingEpaySettings] = useState<EpaySettingsForm | null>(null);
const canAdjustBilling = Boolean(identity?.permissions.includes("billing.adjustments.write"));
const loadPayments = useCallback(async () => {
@@ -175,23 +173,19 @@ export default function PaymentManagement() {
}
}
function prepareEpaySettings(values: EpaySettingsForm) {
setPendingEpaySettings(values);
}
async function saveEpaySettings() {
if (!pendingEpaySettings) return;
async function saveEpaySettings(values: EpaySettingsForm) {
setEpaySaving(true);
try {
await responsePayload(await fetch("/api/admin/epay-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...pendingEpaySettings, newKey: pendingEpaySettings.newKey || undefined }),
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
}));
epayForm.setFieldValue("newKey", "");
message.success("Z-Pay(易支付)配置已保存");
await loadEpaySettings();
setPendingEpaySettings(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
} finally {
setEpaySaving(false);
}
@@ -237,7 +231,7 @@ export default function PaymentManagement() {
{epaySettings && <Text type="secondary">{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]} · {epaySettings.complete ? "配置完整" : "配置不完整"} · {epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"} · {epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Text>}
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}></Button>} />}
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={prepareEpaySettings} requiredMark="optional">
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
@@ -277,14 +271,6 @@ export default function PaymentManagement() {
</Space>
</Card>
</Space>
<ReasonActionModal
open={Boolean(pendingEpaySettings)}
title="保存易支付设置"
okText="确认保存"
confirmLoading={epaySaving}
onCancel={() => setPendingEpaySettings(null)}
onSubmit={saveEpaySettings}
/>
</List>
);
}
@@ -23,7 +23,6 @@ import {
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
@@ -91,7 +90,6 @@ type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "e
entitlementsJson: string;
};
type PendingProductSave = Record<string, unknown>;
type ProductFilters = { q?: string; status?: string };
const defaultEntitlements: Entitlement[] = [{
@@ -121,8 +119,6 @@ export default function ProductManagement() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<Product | null>(null);
const [pendingSave, setPendingSave] = useState<PendingProductSave | null>(null);
const canWrite = Boolean(identity?.permissions.includes("billing.products.write"));
const canPublish = Boolean(identity?.permissions.includes("billing.products.publish"));
@@ -159,7 +155,7 @@ export default function ProductManagement() {
setOpen(true);
}
function prepareSave(values: ProductForm) {
async function save(values: ProductForm) {
let entitlements: unknown;
try {
entitlements = JSON.parse(values.entitlementsJson);
@@ -167,52 +163,49 @@ export default function ProductManagement() {
message.error("权益 JSON 格式不正确");
return;
}
setPendingSave({
action: "save",
id: editing?.id ?? null,
code: values.code.trim(),
name: values.name.trim(),
description: values.description?.trim() ?? "",
productType: values.productType,
billingPeriod: values.billingPeriod,
intervalCount: values.intervalCount,
priceCents: Math.round(values.priceYuan * 100),
currency: values.currency.toUpperCase(),
enabled: values.enabled,
sortOrder: values.sortOrder,
oneTimePerUser: values.oneTimePerUser,
entitlements,
});
}
async function save(reason: string) {
if (!pendingSave) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ ...pendingSave, reason }),
body: JSON.stringify({
action: "save",
id: editing?.id ?? null,
code: values.code.trim(),
name: values.name.trim(),
description: values.description?.trim() ?? "",
productType: values.productType,
billingPeriod: values.billingPeriod,
intervalCount: values.intervalCount,
priceCents: Math.round(values.priceYuan * 100),
currency: values.currency.toUpperCase(),
enabled: values.enabled,
sortOrder: values.sortOrder,
oneTimePerUser: values.oneTimePerUser,
entitlements,
}),
});
message.success("商品草稿已保存");
setPendingSave(null);
setOpen(false);
form.resetFields();
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存商品草稿失败");
} finally {
setSaving(false);
}
}
async function publish(product: Product, reason: string) {
async function publish(product: Product) {
setPublishingId(product.id);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ action: "publish", id: product.id, reason }),
body: JSON.stringify({ action: "publish", id: product.id }),
});
message.success("商品已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "发布商品失败");
} finally {
setPublishingId(null);
}
@@ -235,7 +228,7 @@ export default function ProductManagement() {
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openProduct(item)}>稿</Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}></Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}
</Space>,
},
];
@@ -256,7 +249,7 @@ export default function ProductManagement() {
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
</Space>
<Modal title={editing ? `编辑 ${editing.name}` : "新建商品"} open={open} width={860} confirmLoading={saving} okText="保存草稿" cancelText="取消" onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
<Form<ProductForm> form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional">
<Form<ProductForm> form={form} layout="vertical" onFinish={save} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="code" label="商品代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_]{1,79}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="name" label="名称" rules={[{ required: true }, { max: 80 }]}><Input /></Form.Item></Col>
@@ -277,21 +270,5 @@ export default function ProductManagement() {
</Row>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingSave)}
title="保存商品草稿"
okText="确认保存"
confirmLoading={saving}
onCancel={() => setPendingSave(null)}
onSubmit={save}
/>
<ReasonActionModal
open={Boolean(publishTarget)}
title={`发布商品${publishTarget ? `${publishTarget.name}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
</List>;
}
@@ -1,76 +0,0 @@
"use client";
import { Alert, Form, Input, Modal } from "antd";
import { useState } from "react";
type ReasonActionModalProps = {
open: boolean;
title: string;
okText: string;
danger?: boolean;
confirmLoading?: boolean;
onCancel: () => void;
onSubmit: (reason: string) => Promise<void> | void;
};
type ReasonFormValues = {
reason: string;
};
export function ReasonActionModal({
open,
title,
okText,
danger = false,
confirmLoading = false,
onCancel,
onSubmit,
}: ReasonActionModalProps) {
const [form] = Form.useForm<ReasonFormValues>();
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string>();
async function submit(values: ReasonFormValues) {
setActionError(undefined);
setActionLoading(true);
try {
await onSubmit(values.reason.trim());
} catch (error) {
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
} finally {
setActionLoading(false);
}
}
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText="取消"
okButtonProps={{ danger }}
confirmLoading={confirmLoading || actionLoading}
onCancel={onCancel}
onOk={() => form.submit()}
afterOpenChange={(visible) => {
if (visible) form.resetFields();
setActionError(undefined);
}}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false} onFinish={submit}>
<Form.Item
label="操作原因"
name="reason"
rules={[
{ required: true, whitespace: true, message: "请输入操作原因" },
{ max: 500, message: "操作原因最多 500 字" },
]}
>
<Input.TextArea rows={3} maxLength={500} showCount autoFocus />
</Form.Item>
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
</Form>
</Modal>
);
}
@@ -4,8 +4,6 @@ import { useGetIdentity } from "@refinedev/core";
import { App, Button, Space, Typography, type TableColumnsType } from "antd";
import { useState } from "react";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
@@ -38,7 +36,6 @@ export default function UsersPage() {
const { data: identity } = useGetIdentity<AdminIdentity>();
const [revealed, setRevealed] = useState<Record<string, RevealedBirthData>>({});
const [revealingId, setRevealingId] = useState<string | null>(null);
const [resetTarget, setResetTarget] = useState<UserRecord | null>(null);
const [resetting, setResetting] = useState(false);
const canReset = Boolean(identity?.permissions.includes("admin.users.manage_roles"));
const canReveal = Boolean(identity?.permissions.includes("admin.customers.birth_data.read"));
@@ -58,8 +55,7 @@ export default function UsersPage() {
}
}
async function resetAccount(reason: string) {
if (!resetTarget) return;
async function resetAccount(user: UserRecord) {
setResetting(true);
try {
await adminRequestJson<{ data: { credits: number } }>(
@@ -67,22 +63,19 @@ export default function UsersPage() {
{
method: "POST",
body: JSON.stringify({
userId: resetTarget.id,
userId: user.id,
confirmation: "RESET",
reason,
}),
},
);
message.success(`已重置 ${resetTarget.email},登录身份、管理员角色积分保持不变`);
message.success(`已重置 ${user.email},登录身份、管理员角色积分及账务审计记录会保留`);
setRevealed((current) => {
const next = { ...current };
delete next[resetTarget.id];
delete next[user.id];
return next;
});
setResetTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "重置账号失败");
throw error;
} finally {
setResetting(false);
}
@@ -119,22 +112,11 @@ export default function UsersPage() {
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
...(canReset ? [{
title: "操作",
render: (_: unknown, item: UserRecord) => <Button danger size="small" onClick={() => setResetTarget(item)}>
render: (_: unknown, item: UserRecord) => <Button danger size="small" loading={resetting} onClick={() => void resetAccount(item)}>
</Button>,
}] : []),
];
return <>
<ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />
<ReasonActionModal
open={Boolean(resetTarget)}
title={`确认重置 ${resetTarget?.email ?? "该账号"} 的资料与会话?登录身份、管理员角色、积分及账务审计记录会保留。`}
okText="确认重置"
danger
confirmLoading={resetting}
onCancel={() => setResetTarget(null)}
onSubmit={resetAccount}
/>
</>;
return <ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />;
}
@@ -85,7 +85,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
<section className="birth-time-profile-result" aria-label="生时校正结果">
<div className="birth-time-profile-result-heading">
<span></span>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "引擎确认"}</strong>
<strong>{displayState.kind === "candidate" ? "候选时间" : displayState.kind === "accepted" ? "校正采用" : "已记录时间"}</strong>
</div>
<dl>
<div>
@@ -101,7 +101,7 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
<p>{birthTimeConsultationOptionsCopy(value)}</p>
)}
{displayState.kind === "accepted" && (
<p>使</p>
<p>使</p>
)}
</section>
)}
+46 -6
View File
@@ -228,25 +228,55 @@ export function applyAccountProfileConcurrencyGuards<
}
export type AccountBirthTimeApplicationPatch = Readonly<{
active_birth_time?: null;
birth_time_status?: "reported";
active_birth_time?: string | null;
birth_time_status?: "accepted" | "reported";
rectification_case_id?: null;
}>;
function normalizeApplicableBirthClock(value: string | null | undefined): string | null {
if (!value) return null;
if (isBirthClockTime(value)) return value;
return /^(?:[01]\d|2[0-3]):[0-5]\d:00(?:\.0+)?$/.test(value)
? value.slice(0, 5)
: null;
}
function resolveExactFamilyBirthTime(
current: AccountBirthTimeState | null,
patch: AccountProfilePatch,
): string | null {
const source = patch.birth_time_source !== undefined
? patch.birth_time_source
: current?.birth_time_source;
const before = patch.uncertainty_before_minutes !== undefined
? patch.uncertainty_before_minutes
: current?.uncertainty_before_minutes;
const after = patch.uncertainty_after_minutes !== undefined
? patch.uncertainty_after_minutes
: current?.uncertainty_after_minutes;
const reportedTime = patch.reported_birth_time !== undefined
? patch.reported_birth_time
: current?.reported_birth_time;
if (source !== "family_exact" || before !== 0 || after !== 0) return null;
return normalizeApplicableBirthClock(reportedTime);
}
export function resolveAccountBirthTimeApplicationPatch(
current: AccountBirthTimeState | null,
patch: AccountProfilePatch,
): AccountBirthTimeApplicationPatch {
const exactFamilyBirthTime = resolveExactFamilyBirthTime(current, patch);
if (!current) {
return patch.birth_time_source ? {
active_birth_time: null,
birth_time_status: "reported",
active_birth_time: exactFamilyBirthTime,
birth_time_status: exactFamilyBirthTime ? "accepted" : "reported",
rectification_case_id: null,
} : {};
}
const confirmed = current.birth_time_status === "confirmed"
|| (current.birth_time_status === null && isBirthClockTime(current.birth_time ?? ""));
|| (current.birth_time_status === null
&& normalizeApplicableBirthClock(current.birth_time) !== null);
if (confirmed) return {};
const declarationChanged = declarationFields.some((field) => (
@@ -257,7 +287,17 @@ export function resolveAccountBirthTimeApplicationPatch(
&& current.birth_time === null
&& current.rectification_case_id === null
&& Boolean(patch.birth_time_source);
if (!declarationChanged && !repairsMissingStatus) return {};
const repairsReportedExactTime = current.birth_time_status === "reported"
&& current.active_birth_time === null
&& current.rectification_case_id === null
&& exactFamilyBirthTime !== null
&& declarationFields.some((field) => patch[field] !== undefined);
if (!declarationChanged && !repairsMissingStatus && !repairsReportedExactTime) return {};
if (exactFamilyBirthTime) return {
active_birth_time: exactFamilyBirthTime,
birth_time_status: "accepted",
rectification_case_id: null,
};
return {
active_birth_time: null,
birth_time_status: "reported",
+3 -3
View File
@@ -91,14 +91,14 @@ export function mapCode(row: RpcCodeRow): RedemptionCodeRecord {
export async function runCodeRpc<T extends keyof CodeRpcArgs>(
functionName: T,
session: Pick<AdminSession, "user" | "roles">,
session: Pick<AdminSession, "user">,
id: string,
args: CodeRpcArgs[T],
): Promise<RedemptionCodeRecord[]> {
const common = [
session.user.id,
session.user.email,
session.roles[0] ?? "admin",
"admin",
id,
];
let rows: RpcCodeRow[] = [];
@@ -110,7 +110,7 @@ export async function runCodeRpc<T extends keyof CodeRpcArgs>(
`select * from public.admin_create_redemption_codes(
$1::uuid,$2::text,$3::text,$4::text,$5::jsonb,$6::text
)`,
[...common, input.p_codes, input.p_reason],
[...common, JSON.stringify(input.p_codes), input.p_reason],
);
break;
}
+1 -1
View File
@@ -187,7 +187,7 @@ const resourcePermissions: Record<string, { read: string; write?: string }> = {
},
roles: { read: "admin.users.read" },
customers: { read: "admin.customers.read" },
codes: { read: "billing.orders.read", write: "billing.adjustments.write" },
codes: { read: "admin.access", write: "admin.access" },
"credit-transactions": { read: "billing.orders.read" },
consultations: { read: "billing.orders.read" },
"audit-logs": { read: "audit.read" },
+11 -2
View File
@@ -83,8 +83,8 @@ export function formatBirthDate(value: Date): string {
}
export const birthTimeSourceOptions = [
{ value: "family_exact", label: "我知道准确出生时间", hint: "保存为初始化填报时间,不会自动标记为引擎确认" },
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
{ value: "family_exact", label: "我知道准确出生时间", hint: "按医院记录或家人记得的时间填写,精确到分钟" },
{ value: "period_only", label: "我不确定准确时间", hint: "只知道大概几点、上午下午,或完全不清楚" },
] as const;
export const birthTimeSourceDefaults = {
@@ -223,6 +223,15 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
}
}
export function birthTimeDraftReadyHint(draft: BirthTimeDraft) {
if (!parseBirthDate(draft.date)) return "请先选择出生日期";
if (!draft.birthTimeSource) return "请选择你对出生时间了解多少";
if (isBirthTimeDraftReady(draft)) return "";
if (draft.birthTimeSource === "period_only") return "请选择一个大致时段";
if (draft.birthTimeSource === "unknown") return "";
return "请填写时和分";
}
/**
* Whether the user has finished declaring what they actually know about birth time.
* This is an onboarding condition, not a claim that an exact chart minute is ready.
+2 -7
View File
@@ -1,4 +1,4 @@
import type { AgentExecutionReceipt, PublicActivityPhase } from "./consultation-agent-events.ts";
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
export type AgentActivityView = Readonly<{
phase: PublicActivityPhase;
@@ -11,12 +11,7 @@ export type ChatMessage = {
readonly suggestions?: readonly string[];
readonly techniqueTruth?: string;
readonly agentExecutionReceipt?: AgentExecutionReceipt;
readonly workflowReceipt?: {
readonly route: string;
readonly status: string;
readonly preciseTiming: string;
readonly missingLayers: readonly string[];
};
readonly workflowReceipt?: WorkflowReceipt;
};
export type ChatMessageView = ChatMessage & {
@@ -1,5 +1,10 @@
import { z } from "zod";
import { agentExecutionReceiptSchema, type AgentExecutionReceipt } from "./consultation-agent-events.ts";
import {
agentExecutionReceiptSchema,
workflowReceiptSchema,
type AgentExecutionReceipt,
type WorkflowReceipt,
} from "./consultation-agent-events.ts";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
const chatMessageSchema = z.object({
@@ -8,12 +13,7 @@ const chatMessageSchema = z.object({
suggestions: z.array(z.string().max(200)).max(3).optional(),
techniqueTruth: z.string().max(120).optional(),
agentExecutionReceipt: agentExecutionReceiptSchema.optional(),
workflowReceipt: z.object({
route: z.string().max(120),
status: z.string().max(120),
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
}).strict().optional(),
workflowReceipt: workflowReceiptSchema.optional(),
}).strict();
export const chatSessionWriteSchema = z.object({
@@ -44,12 +44,7 @@ export type ChatSessionWrite = Readonly<{
suggestions?: readonly string[];
techniqueTruth?: string;
agentExecutionReceipt?: AgentExecutionReceipt;
workflowReceipt?: Readonly<{
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
}>;
workflowReceipt?: WorkflowReceipt;
}>[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
+10 -3
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { consultationDomainSchema } from "./consultation-domain-registry.ts";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
export const publicActivityPhaseSchema = z.enum([
"loading-method",
@@ -9,14 +9,21 @@ export const publicActivityPhaseSchema = z.enum([
]);
export type PublicActivityPhase = z.infer<typeof publicActivityPhaseSchema>;
export const workflowReceiptSchema = z.object({
export type WorkflowReceipt = Readonly<{
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
domains?: readonly ConsultationDomain[];
}>;
export const workflowReceiptSchema: z.ZodType<WorkflowReceipt> = z.object({
route: z.string().max(120),
status: z.string().max(120),
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
domains: z.array(consultationDomainSchema).min(1).max(6).optional(),
}).strict();
export type WorkflowReceipt = z.infer<typeof workflowReceiptSchema>;
const executionStepSchema = z.object({
sequence: z.number().int().min(1).max(32),
@@ -34,7 +34,6 @@ export function applyBirthTimeModeToWorkflowContext<
...context.consumer_context,
answer_policy: {
...context.consumer_context.answer_policy,
can_answer_precise_timing: false,
birth_time_confidence: "unverified_reported_time",
candidate_is_confirmed: false,
},
+1 -1
View File
@@ -73,7 +73,7 @@ function deepFreeze<T>(value: T): DeepReadonly<T> {
}
function precisionBoundaryFor(mode: ConsultationBirthTimeMode) {
return mode === "verified_chart" ? "server_evidence_required" as const : "precise_timing_blocked" as const;
return mode === "general_no_birth_time" ? "precise_timing_blocked" as const : "server_evidence_required" as const;
}
export function validateConsultationPlan(
@@ -425,14 +425,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
return failedAttempt(attemptId, "skill_not_loaded");
}
const messages = buildAgentMessages(options, attemptNumber, dossier);
const rawSkillInstructions = (frameworkSkill as { instructions?: unknown }).instructions;
const skillInstructions = typeof rawSkillInstructions === "string"
? rawSkillInstructions.trim()
: "";
if (!skillInstructions) {
return failedAttempt(attemptId, "skill_not_loaded");
}
const messages = buildAgentMessages(options, attemptNumber, dossier, skillInstructions);
const maxSteps = resolveRectificationStepBudget(action);
const abortController = new AbortController();
const onAbort = () => abortController.abort();
signal?.addEventListener("abort", onAbort, { once: true });
const timeout = setTimeout(() => abortController.abort(), 105_000);
let skillBound = false;
let skillBound = true;
let caseLoaded = false;
let intentClassified = false;
let streamFailed = false;
@@ -457,10 +465,30 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
try {
await recordPhase("run.started");
await insertV9SkillRunReceipt(
accounting,
userId,
caseId,
turnId,
attemptId,
"turn",
skillPackage,
);
await recordPhase("skill.bound");
events.push({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const result = await (agent as unknown as {
stream(
messages: unknown[],
streamOptions: { maxSteps: number; abortSignal: AbortSignal; instructions?: string },
streamOptions: {
maxSteps: number;
abortSignal: AbortSignal;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: { type: "tool"; toolName: string };
} | undefined;
},
): Promise<{
fullStream: AsyncIterable<{
type: string;
@@ -472,9 +500,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
...(attemptNumber > 1 ? {
instructions: "严格按运行合同执行:先加载绑定 Skill,再读取 Case;不得复用上一次 attempt 的文本或工具状态。",
} : {}),
prepareStep: ({ stepNumber }) => stepNumber === 0
? {
activeTools: ["rectification-read-case"],
toolChoice: { type: "tool", toolName: "rectification-read-case" },
}
: undefined,
});
for await (const chunk of result.fullStream) {
@@ -503,7 +534,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
const phaseEvent = mapStreamChunkToPhase(chunk as never);
if (phaseEvent) {
if (phaseEvent.type === "skill.bound") {
if (phaseEvent.type === "skill.bound" && !skillBound) {
skillBound = true;
await insertV9SkillRunReceipt(
accounting,
@@ -662,20 +693,30 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
function buildAgentMessages(
options: V9AgentRunOptions,
_attempt: number,
attempt: number,
dossier: V9CaseDossier,
skillInstructions: string,
): unknown[] {
void _attempt;
const timeContext = options.timeContext
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
const caseContext = `【服务端 Case ID】${options.caseId}。所有 rectification 工具调用的 caseId 必须原样使用此值。`;
const bootstrap = {
role: "system",
content: [
"【服务器已绑定当前 Case 的精确 Skill】运行器已在本 attempt 内加载并核验下列指令;不要重复调用 skill。第一步必须调用 rectification-read-case。",
skillInstructions,
...(attempt > 1
? ["【重试约束】不得复用上一次 attempt 的文本或工具状态;从 rectification-read-case 重新读取服务器事实。"]
: []),
].join("\n\n"),
};
if (options.action === "opening") {
return [{
return [bootstrap, {
role: "user",
content: [timeContext, caseContext, openingBrief(dossier)].join("\n"),
}];
}
return [{
return [bootstrap, {
role: "user",
content: [timeContext, caseContext, options.message ?? ""].join("\n"),
}];
+1 -1
View File
@@ -61,7 +61,7 @@ export function resolveRectificationStepBudget(action: RectificationAgentAction)
const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑定 jyotish-birth-time-rectification Skill 的生时校正 Case。方法、OpeningPolicy、ConversationFocus、长会话摘要、批量证据和候选比较策略全部以本 Case 绑定的不可变 Skill 为准,不在系统提示中重写。
1. Case Skill rectification-read-case
1. attempt Case Skill skill rectification-read-case Case
2. CaseConversationFocusCaseConversationSummaryEvidenceCandidateTurnReceiptownership 使
3.
4. active focusId/evidenceId
@@ -0,0 +1,19 @@
begin;
-- A zero-uncertainty family declaration is user-adopted chart input, not an
-- engine-confirmed minute. Repair only untouched reported profiles and keep the
-- original declaration in reported_birth_time.
update public.profiles
set active_birth_time = reported_birth_time,
birth_time_status = 'accepted',
updated_at = pg_catalog.now()
where birth_time_source = 'family_exact'
and uncertainty_before_minutes = 0
and uncertainty_after_minutes = 0
and reported_birth_time is not null
and extract(second from reported_birth_time) = 0
and birth_time_status = 'reported'
and active_birth_time is null
and rectification_case_id is null;
commit;
@@ -0,0 +1,106 @@
-- Redemption-code management is an ordinary admin-console capability.
-- Keep actor verification, trusted request IDs and server-owned audit markers,
-- but do not reuse the narrower billing adjustment permission.
create or replace function public.require_admin_redemption_reason()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
v_reason text := nullif(btrim(current_setting('app.admin_redemption_reason', true)), '');
begin
if new.action in ('redemption_code.create','redemption_code.update','redemption_code.revoke') then
if v_reason is null or char_length(v_reason) > 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
new.permission_used := 'admin.access';
new.reason := v_reason;
end if;
return new;
end;
$$;
revoke all on function public.require_admin_redemption_reason()
from public, anon, authenticated, service_role;
create or replace function public.admin_create_redemption_codes(
p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_codes jsonb,p_reason text
)
returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz,
redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz)
language plpgsql security definer set search_path = ''
as $$
begin
if not public.admin_has_permission(p_actor_user_id,'admin.access') then
raise exception 'admin_permission_denied' using errcode='42501';
end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
perform set_config('app.admin_redemption_reason',btrim(p_reason),true);
return query select * from public.admin_create_redemption_codes(
p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_codes
);
end;
$$;
create or replace function public.admin_update_redemption_code(
p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_code_id uuid,
p_set_note boolean,p_note text,p_set_expires_at boolean,p_expires_at timestamptz,p_reason text
)
returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz,
redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz)
language plpgsql security definer set search_path = ''
as $$
begin
if not public.admin_has_permission(p_actor_user_id,'admin.access') then
raise exception 'admin_permission_denied' using errcode='42501';
end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
perform set_config('app.admin_redemption_reason',btrim(p_reason),true);
return query select * from public.admin_update_redemption_code(
p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_code_id,
p_set_note,p_note,p_set_expires_at,p_expires_at
);
end;
$$;
create or replace function public.admin_revoke_redemption_code(
p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_code_id uuid,p_reason text
)
returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz,
redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz)
language plpgsql security definer set search_path = ''
as $$
begin
if not public.admin_has_permission(p_actor_user_id,'admin.access') then
raise exception 'admin_permission_denied' using errcode='42501';
end if;
if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
perform set_config('app.admin_redemption_reason',btrim(p_reason),true);
return query select * from public.admin_revoke_redemption_code(
p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_code_id
);
end;
$$;
revoke all on function public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text),
public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text),
public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)
from public, anon, authenticated, service_role;
do $$
begin
if exists(select 1 from pg_roles where rolname='admin_runtime') then
grant execute on function public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text),
public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text),
public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)
to admin_runtime;
end if;
end
$$;
@@ -0,0 +1,20 @@
begin;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant select on table public.billing_products, public.product_entitlements
to admin_runtime;
drop policy if exists billing_products_admin_select on public.billing_products;
create policy billing_products_admin_select on public.billing_products
for select to admin_runtime using (true);
drop policy if exists product_entitlements_admin_select on public.product_entitlements;
create policy product_entitlements_admin_select on public.product_entitlements
for select to admin_runtime using (true);
end if;
end;
$$;
commit;
+123 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import {
accountProfilePatchSchema,
@@ -12,6 +12,10 @@ const reportedStatusMigration = readFileSync(
new URL("../supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql", import.meta.url),
"utf8",
);
const acceptedExactFamilyMigration = readFileSync(
new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url),
"utf8",
);
const productionMigrationWorkflow = readFileSync(
new URL("../../.github/workflows/apply-production-rectification-migrations.yml", import.meta.url),
"utf8",
@@ -224,6 +228,107 @@ test("ordinary declaration edits clear stale candidate application but never ove
}, edited), {});
});
test("zero-uncertainty family exact time becomes an accepted usable chart time", () => {
const exactDeclaration = {
birth_date: "1997-08-08",
reported_birth_time: "05:00",
birth_time_source: "family_exact",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
} as const;
const reportedExactProfile = {
...exactDeclaration,
reported_birth_time: "05:00:00",
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
} as const;
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, exactDeclaration), {
active_birth_time: "05:00",
birth_time_status: "accepted",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(reportedExactProfile, exactDeclaration), {
active_birth_time: "05:00",
birth_time_status: "accepted",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...reportedExactProfile,
active_birth_time: "05:00:00",
birth_time_status: "accepted",
}, {
...exactDeclaration,
reported_birth_time: "05:40",
}), {
active_birth_time: "05:40",
birth_time_status: "accepted",
rectification_case_id: null,
});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...reportedExactProfile,
active_birth_time: "05:00:00",
birth_time_status: "confirmed",
}, {
...exactDeclaration,
reported_birth_time: "05:40",
}), {});
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
...reportedExactProfile,
active_birth_time: "05:00:00",
birth_time: "05:00:00",
birth_time_status: null,
}, {
...exactDeclaration,
reported_birth_time: "05:40",
}), {});
});
test("only a strict family exact zero-uncertainty declaration is auto-accepted", () => {
const base = {
birth_date: "1997-08-08",
reported_birth_time: "05:00",
birth_time_source: "family_exact",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
} as const;
const declarations = [
{ ...base, uncertainty_before_minutes: 5, uncertainty_after_minutes: 5 },
{ ...base, uncertainty_before_minutes: 10, uncertainty_after_minutes: 10 },
{ ...base, uncertainty_before_minutes: 15, uncertainty_after_minutes: 15 },
{ ...base, birth_time_source: "approximate", uncertainty_before_minutes: 30, uncertainty_after_minutes: 30 },
{
...base,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "morning",
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
},
{
...base,
reported_birth_time: null,
birth_time_source: "unknown",
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
},
] as const;
for (const declaration of declarations) {
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, declaration), {
active_birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
});
}
});
test("reported birth-time status repair is forward-only and wired into production migration flow", () => {
assert.match(reportedStatusMigration, /birth_time_status is null/);
assert.match(reportedStatusMigration, /birth_time_status = 'reported'/);
@@ -235,6 +340,23 @@ test("reported birth-time status repair is forward-only and wired into productio
);
});
test("existing exact family declarations are forward-repaired without claiming confirmation", () => {
assert.match(acceptedExactFamilyMigration, /update public\.profiles/);
assert.match(acceptedExactFamilyMigration, /active_birth_time = reported_birth_time/);
assert.match(acceptedExactFamilyMigration, /birth_time_status = 'accepted'/);
assert.match(acceptedExactFamilyMigration, /birth_time_source = 'family_exact'/);
assert.match(acceptedExactFamilyMigration, /uncertainty_before_minutes = 0/);
assert.match(acceptedExactFamilyMigration, /uncertainty_after_minutes = 0/);
assert.match(acceptedExactFamilyMigration, /birth_time_status = 'reported'/);
assert.match(acceptedExactFamilyMigration, /active_birth_time is null/);
assert.match(acceptedExactFamilyMigration, /rectification_case_id is null/);
assert.doesNotMatch(acceptedExactFamilyMigration, /birth_time_status = 'confirmed'/);
assert.equal(
existsSync(new URL("../db/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url)),
false,
);
});
test("account PATCH uses the shared validator and never writes client birth_time over account truth", () => {
assert.match(source, /accountProfilePatchSchema\.safeParse/);
assert.match(source, /resolveAccountBirthTimeApplicationPatch/);
+2 -5
View File
@@ -25,7 +25,6 @@ const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensur
const administratorsRoute = readFileSync(new URL("../src/app/api/admin/administrators/route.ts", import.meta.url), "utf8");
const mfaRoute = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8");
const mfaSecurity = readFileSync(new URL("../src/components/admin/mfa-security.tsx", import.meta.url), "utf8");
const reasonActionModal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
const customersRoute = readFileSync(new URL("../src/app/api/admin/customers/route.ts", import.meta.url), "utf8");
const adminUser = readFileSync(new URL("../src/lib/supabase/admin.ts", import.meta.url), "utf8");
const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8");
@@ -50,7 +49,7 @@ test("admin APIs use persisted Better Auth roles with admin-only boundaries", ()
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
assert.match(authBoundary, /readAuthProvider\(\)\?\.trim\(\) !== "self-hosted"/);
assert.match(authBoundary, /后台服务暂时不可用", 503/);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/);
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
assert.doesNotMatch(codesRoute, /requireHighRiskAdminMutation/);
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
@@ -237,9 +236,7 @@ test("administrator writes keep permission and origin checks without operation-l
assert.doesNotMatch(adminHttp, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/);
assert.equal(existsSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url)), false);
assert.match(reasonActionModal, /label="操作原因"/);
assert.match(reasonActionModal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.doesNotMatch(reasonActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
assert.equal(existsSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url)), false);
assert.match(authFactory, /twoFactor\(/);
assert.match(authFactory, /schema: identityModelMapping\.twoFactor/);
+3 -6
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -75,13 +75,10 @@ test("MFA API keeps native Better Auth enrollment, recovery, rotation, and proof
assert.match(migration, /grant select, insert, update, delete on table identity\.two_factors[\s\S]*identity_runtime/);
});
test("business confirmation UI requires only an audit reason while account MFA remains separate", () => {
const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
test("business operation confirmation UI is removed while account MFA remains separate", () => {
const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8");
assert.match(modal, /label="操作原因"/);
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.doesNotMatch(modal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
assert.equal(existsSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url)), false);
assert.match(http, /requireAdminMutation/);
assert.doesNotMatch(http, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
});
@@ -71,7 +71,9 @@ test("model mutations omit client reasons while the server keeps fixed audit rea
assert.doesNotMatch(component, /reauthPermission=[^\n]*models\.|验证并(?:保存|获取)/);
assert.match(component, /open=\{providerOpen\} okText="保存"[\s\S]*onFinish=\{saveProvider\}/);
assert.doesNotMatch(component, /继续验证/);
assert.match(component, /<Modal[\s\S]*open=\{Boolean\(versionAction\)\}[\s\S]*onOk=\{\(\) => void submitVersionAction\(\)\}/);
assert.doesNotMatch(component, /versionAction|submitVersionAction/);
assert.match(component, /onClick=\{\(\) => void runVersionAction\("publish", item\)\}>发布<\/Button>/);
assert.match(component, /onClick=\{\(\) => void runVersionAction\("rollback", item\)\}>回滚到此版<\/Button>/);
assert.doesNotMatch(route, /reason:\s*z\.string/);
for (const reason of ["保存模型供应商", "保存模型草稿", "发布模型版本", "回滚模型版本"]) {
@@ -115,11 +117,11 @@ test("model management renders localized labels without changing stored enum val
test("global native input sizing excludes Ant Design picker internals", () => {
assert.match(
globals,
/input:not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 12px;/,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\), select[^\{]+\{ width: 100%; min-height: 44px; padding: 0 12px;/,
);
assert.match(
globals,
/input:not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\):disabled/,
/input:not\(\[type="radio"\]\):not\(\[type="checkbox"\]\):not\(\[class\^="ant-"\]\):not\(\[class\*=" ant-"\]\):not\(\.ant-picker input\):disabled/,
);
assert.doesNotMatch(globals, /(?:^|\n)input, select \{ width: 100%; min-height: 44px; padding: 0 12px;/);
});
@@ -38,22 +38,24 @@ test("operation-level admin email reauthentication is removed from routes and UI
}
});
test("admin mutations retain session, permission, trusted-origin, reason, request-id, and audit boundaries", () => {
test("admin mutations retain session, permission, trusted-origin, server audit, request-id, and audit boundaries", () => {
const http = source("src/lib/admin/http.ts");
const codesRoute = source("src/app/api/admin/codes/route.ts");
const codesHelper = source("src/lib/admin/codes.ts");
const codesUi = source("src/components/admin/codes-resource.tsx");
const modal = source("src/components/admin/reason-action-modal.tsx");
assert.equal(existsSync(new URL("src/components/admin/confirm-action-modal.tsx", root)), false);
assert.match(http, /requirePermission\(permission, request\.headers\)/);
assert.match(http, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
assert.match(codesRoute, /requireAdminMutation\([\s\S]*"billing\.adjustments\.write"/);
assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.match(codesRoute, /requireAdminMutation\([\s\S]*"admin\.access"/);
assert.doesNotMatch(codesRoute, /reason:\s*z\.string/);
assert.match(codesRoute, /p_reason:\s*"admin_console_create_redemption_codes"/);
assert.match(codesHelper, /JSON\.stringify\(input\.p_codes\)/);
assert.match(codesRoute, /requestId\(request\)/);
assert.match(codesRoute, /admin_create_redemption_codes/);
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
assert.match(codesUi, /okText="确认生成"/);
assert.match(modal, /label="操作原因"/);
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.match(codesUi, /permissions\.includes\("admin\.access"\)/);
assert.match(codesUi, /onFinish=\{\(values\) => void submitCreate\(values\)\}/);
assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/);
});
test("account-level TOTP MFA and customer login email OTP remain available", () => {
@@ -33,13 +33,13 @@ const mutationMappings = [
name: "兑换码创建",
ui: "src/components/admin/codes-resource.tsx",
api: "src/app/api/admin/codes/route.ts",
permission: "billing.adjustments.write",
permission: "admin.access",
},
{
name: "兑换码编辑与撤销",
ui: "src/components/admin/codes-resource.tsx",
api: "src/app/api/admin/codes/[id]/route.ts",
permission: "billing.adjustments.write",
permission: "admin.access",
},
{
name: "功能开关发布",
@@ -66,7 +66,7 @@ test("admin mutation UI permissions match ordinary API guards without operation
await t.test(mapping.name, () => {
const ui = source(mapping.ui);
const api = source(mapping.api);
assert.match(ui, /ReasonActionModal/);
assert.doesNotMatch(ui, /ConfirmActionModal|Popconfirm|Modal\.confirm/);
assert.doesNotMatch(ui, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(api, new RegExp(String.raw`requireAdminMutation\(\s*request,\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\s*\)`));
assert.doesNotMatch(api, /requireHighRiskAdminMutation/);
@@ -75,6 +75,17 @@ test("admin mutation UI permissions match ordinary API guards without operation
}
});
test("redemption-code list and mutations are available to every authenticated admin role", () => {
const providers = source("src/lib/admin/providers.ts");
const route = source("src/app/api/admin/codes/route.ts");
assert.match(
providers,
/codes:\s*\{\s*read:\s*"admin\.access",\s*write:\s*"admin\.access"\s*\}/,
);
assert.match(route, /await requirePermission\("admin\.access"\)/);
});
test("product management localizes product, billing and entitlement enums", () => {
const ui = source("src/components/admin/product-management.tsx");
@@ -115,18 +126,16 @@ test("model management uses ordinary admin mutation guards without reauth", () =
assert.doesNotMatch(ui, /验证并(?:保存|获取)/);
});
test("admin request failures are real Error instances and reason modal keeps failures visible", () => {
test("admin request failures are real Error instances and operation confirmation UI is absent", () => {
const providers = source("src/lib/admin/providers.ts");
const modal = source("src/components/admin/reason-action-modal.tsx");
assert.match(
providers,
/Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/,
);
assert.doesNotMatch(providers, /throw \{ message, statusCode/);
assert.match(modal, /catch \(error\)[\s\S]*setActionError\(error instanceof Error \? error\.message/);
assert.match(modal, /name="reason"[\s\S]*required: true, whitespace: true/);
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.doesNotMatch(modal, /reauth|邮箱验证码|发送验证码/);
for (const path of mutationMappings.map((mapping) => mapping.ui)) {
assert.doesNotMatch(source(path), /ConfirmActionModal|Popconfirm|Modal\.confirm/);
}
});
test("customer list is always masked and a single explicit reveal is audited each time", () => {
@@ -154,7 +163,8 @@ test("administrator UI supports all six roles and exposes last-owner protection"
for (const role of ["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]) {
assert.match(ui, new RegExp(`["']${role}["']`));
}
assert.match(ui, /method: pendingAction\.action === "assign" \? "POST" : "DELETE"/);
assert.match(ui, /method: action\.action === "assign" \? "POST" : "DELETE"/);
assert.doesNotMatch(ui, /pendingAction|ConfirmActionModal/);
const mutationSection = route.slice(route.indexOf("async function mutate"));
assert.match(mutationSection, /last_owner_protected/);
assert.match(mutationSection, /不能撤销最后一位 Owner,请先分配另一位 Owner/);
+1 -1
View File
@@ -22,7 +22,7 @@ test("admin surfaces await database-backed administrator checks", () => {
assert.match(adminSource, /export async function isAdminUser/);
assert.match(adminSource, /admin_has_permission\(\$1, 'admin\.access'\)/);
assert.match(sessionSource, /await requirePermission\("admin\.access"\)/);
assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/);
assert.doesNotMatch(codesSource, /requireHighRiskAdminMutation/);
assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/);
assert.match(accountSource, /isAdmin,/);
@@ -229,3 +229,15 @@ test("homepage and profile result copy use the source-aware consultation options
assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(profile\)/);
assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/);
});
test("onboarding form steps hide the dead composer and keep source choices in a single column", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(page, /const onboardingFormActive = !profileComplete && onboardingStep !== "name"/);
assert.match(page, /!rectificationSurfaceOpen && !onboardingFormActive && <div className=\{`composer-wrap/);
assert.match(page, /birthTimeDraftReadyHint\(profileDraft\)/);
assert.match(css, /\.birth-time-source-list \{ grid-template-columns: 1fr/);
assert.doesNotMatch(css, /\.birth-time-source-list \{ grid-template-columns: repeat\(2/);
assert.doesNotMatch(css, /\.birth-time-source-option input \{[^}]*clip-path: inset\(50%\)/);
});
+26
View File
@@ -5,6 +5,7 @@ import {
applyBirthTimeDraftPatch,
assistantIntentCopy,
birthTimeDisplayState,
birthTimeDraftReadyHint,
birthTimePersistenceValues,
birthTimeSourceDefaults,
birthTimeSourceOptions,
@@ -308,9 +309,34 @@ test("persisted birth dates normalize database ISO values without accepting inva
assert.equal(normalizePersistedBirthDate("1997-08-08junk"), "");
});
test("fresh intake copy stays user-facing and explains the next missing field", () => {
assert.equal(
birthTimeSourceOptions.find((option) => option.value === "family_exact")?.hint,
"按医院记录或家人记得的时间填写,精确到分钟",
);
assert.equal(
birthTimeSourceOptions.find((option) => option.value === "period_only")?.hint,
"只知道大概几点、上午下午,或完全不清楚",
);
assert.equal(birthTimeSourceOptions.every((option) => !/引擎确认|初始化填报/.test(option.hint)), true);
assert.equal(birthTimeDraftReadyHint({ ...emptyDraft, date: "" }), "请先选择出生日期");
assert.equal(birthTimeDraftReadyHint(emptyDraft), "请选择你对出生时间了解多少");
assert.equal(birthTimeDraftReadyHint({
...emptyDraft,
birthTimeSource: "family_exact",
uncertaintyBeforeMinutes: 0,
uncertaintyAfterMinutes: 0,
}), "请填写时和分");
assert.equal(birthTimeDraftReadyHint({
...emptyDraft,
birthTimeSource: "period_only",
}), "请选择一个大致时段");
});
test("fresh intake preserves exact, approximate-period, and unknown-time paths without auto-confirming", () => {
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /引擎确认/);
assert.deepEqual(
birthTimeSourceOptions.map((option) => option.value),
["family_exact", "period_only"],
+10 -2
View File
@@ -21,13 +21,21 @@ test("chat session schema preserves the safe agent execution receipt", () => {
runtime: "mastra-agentic" as const,
skill: { name: "jyotish-vedic-astrology" as const, loaded: true },
steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }],
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] },
techniqueTruth: "verified",
};
const workflowReceipt = {
route: "multi-domain",
status: "ready",
preciseTiming: "allowed",
missingLayers: [],
domains: ["general", "timing"] as const,
};
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", agentExecutionReceipt: receipt }],
messages: [{ role: "assistant", text: "回答", workflowReceipt, agentExecutionReceipt: receipt }],
});
assert.deepEqual(parsed.messages[0]?.workflowReceipt, workflowReceipt);
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
});
@@ -72,7 +72,7 @@ test("context-bound tool keeps legacy single theme compatibility and calculates
assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" });
assert.strictEqual(capturedPlan, plan);
assert.equal(state.consultationToolCallCount, 1);
assert.equal(state.workflowReceipt?.preciseTiming, "blocked");
assert.equal(state.workflowReceipt?.preciseTiming, "allowed");
assert.deepEqual(state.workflowReceipt?.domains, ["career"]);
assert.deepEqual((first as { domains?: string[] }).domains, ["career"]);
});
@@ -19,7 +19,7 @@ test("general-no-birth-time is an explicit server mode that never runs a chart w
assert.equal(shouldRunBirthChartWorkflow("verified_chart"), true);
});
test("unverified chart context can never become confirmed or retain precise timing permission", () => {
test("unverified chart context preserves evidence-owned precise timing permission while retaining provenance", () => {
const original = {
success: true,
consumer_context: {
@@ -35,21 +35,27 @@ test("unverified chart context can never become confirmed or retain precise timi
assert.equal(original.consumer_context.answer_policy.can_answer_precise_timing, true);
assert.deepEqual(guarded.consumer_context.answer_policy, {
can_answer_direction: true,
can_answer_precise_timing: false,
can_answer_precise_timing: true,
birth_time_confidence: "unverified_reported_time",
candidate_is_confirmed: false,
});
});
test("unverified answers keep the timing guard without a repetitive rectification warning", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const first = transform("2026年8月适合观察方向。");
const second = transform("你一定会升职。");
test("unverified answers preserve exact dates when server evidence allows precise timing", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", true);
const answer = transform("Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.doesNotMatch(first, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.match(first, /具体时间已省略/);
assert.doesNotMatch(second, /一定会升职/);
assert.doesNotMatch(second, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.equal(answer, "Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.doesNotMatch(answer, /具体时间已省略/);
});
test("evidence-blocked unverified answers still use the deterministic timing guard", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const answer = transform("2026年8月适合观察方向。");
assert.match(answer, /具体时间已省略/);
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
});
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
+3 -3
View File
@@ -35,12 +35,12 @@ test("rejects a theme outside the consultation allowlist", () => {
);
});
test("server birth-time mode fixes the precision boundary", () => {
test("reported concrete birth time uses the same evidence-owned precision boundary as a verified chart", () => {
assert.equal(createConsultationPlan({
userIntent: "未来什么时候适合行动",
theme: "timing",
consultationMode: "unverified_birth_time",
}).precisionBoundary, "precise_timing_blocked");
}).precisionBoundary, "server_evidence_required");
assert.equal(createConsultationPlan({
userIntent: "什么是上升星座",
theme: "general",
@@ -55,7 +55,7 @@ test("rejects mode or precision tampering", () => {
consultationMode: "unverified_birth_time",
});
assert.throws(
() => validateConsultationPlan({ ...plan, precisionBoundary: "server_evidence_required" }, {
() => validateConsultationPlan({ ...plan, precisionBoundary: "precise_timing_blocked" }, {
consultationMode: "unverified_birth_time",
modelCreditCost: 1,
}),
@@ -25,7 +25,7 @@ test("timing questions use a legal report theme and preserve a timing route hint
assert.ok(request.requiredLayers.includes("negative holdout gate"));
assert.equal(request.claimBoundary, "candidate_day_month_window_only_until_holdout_passes");
assert.equal(request.plan_version, "consultation-plan-v2");
assert.equal(request.precision_boundary, "precise_timing_blocked");
assert.equal(request.precision_boundary, "server_evidence_required");
assert.equal(request.timing_horizon, "next_12_months");
});
@@ -98,7 +98,7 @@ test("timing input projects only legal private workflow fields", async () => {
assert.equal(body.strict_workflow_route, "timing");
assert.ok(body.required_layers.includes("negative holdout gate"));
assert.equal(body.claim_boundary, "candidate_day_month_window_only_until_holdout_passes");
assert.equal(body.precision_boundary, "precise_timing_blocked");
assert.equal(body.precision_boundary, "server_evidence_required");
assert.deepEqual(body.requested_domains, ["timing"]);
assert.deepEqual(body.required_evidence_categories, ["natal_foundation", "timing", "validation"]);
assert.equal(input.question, "未来哪些阶段值得把握?");
@@ -473,7 +473,7 @@ test("billing order adjustments and redemption reasons are atomic and audited",
assert.equal(
sql(`select string_agg(action||':'||permission_used||':'||reason,'|' order by created_at)
from audit.admin_audit_logs where target_id='${codeId}'`),
"redemption_code.create:billing.adjustments.write:创建客服补偿码|redemption_code.update:billing.adjustments.write:修正兑换码备注|redemption_code.revoke:billing.adjustments.write:撤销未发放兑换码",
"redemption_code.create:admin.access:创建客服补偿码|redemption_code.update:admin.access:修正兑换码备注|redemption_code.revoke:admin.access:撤销未发放兑换码",
);
} finally {
await Promise.all([
@@ -162,6 +162,24 @@ test("billing, subscriptions, usage authorization, RBAC, and model publication r
assert.equal(sql("select pg_has_role('admin_runtime','service_role','MEMBER')"), "f");
assert.equal(sql("select has_function_privilege('admin_runtime','public.admin_permission_keys(uuid)','execute')"), "t");
assert.equal(sql("select has_column_privilege('admin_runtime','public.profiles','birth_date','select')"), "f");
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
"select name from public.billing_products where code='standard_monthly'",
),
"标准月卡",
"admin product lists must not be hidden by billing_products RLS",
);
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
"select count(*) from public.product_entitlements where product_id='00000000-0000-4000-8000-000000000902'",
),
"3",
"admin product details must not be hidden by product_entitlements RLS",
);
expectAdminRuntimeError("set role service_role", /permission denied to set role/);
expectSqlError(
`select * from public.admin_manage_role('${ids.owner}','${ids.owner}','owner',false,'不得移除最后 Owner','last-owner')`,
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
@@ -13,6 +14,10 @@ import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
const acceptedExactFamilyMigration = readFileSync(
new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url),
"utf8",
);
function rpcError(error: unknown): string {
if (!error || typeof error !== "object") return "";
@@ -71,6 +76,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260814020000_rectification_v10_runtime\.sql/);
assert.match(migration.stdout, /applied 20260814025000_personal_report_document_v2\.sql/);
assert.match(migration.stdout, /applied 20260814040000_personal_report_jobs_v2\.sql/);
assert.match(migration.stdout, /applied 20260816010000_accept_exact_family_birth_times\.sql/);
assert.equal(
fixture.psql(`
@@ -233,6 +239,58 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
`SET\n${userId}\nlocal-user@example.com`,
);
fixture.psql(`update public.profiles set birth_date = '1997-08-08' where id = '${userId}'`);
fixture.psql(`
update public.profiles
set reported_birth_time = '05:00',
active_birth_time = null,
birth_time_source = 'family_exact',
uncertainty_before_minutes = 0,
uncertainty_after_minutes = 0,
birth_time_status = 'reported',
rectification_case_id = null
where id = '${userId}'
`);
fixture.psql(acceptedExactFamilyMigration);
assert.equal(
fixture.psql(`
select to_char(reported_birth_time, 'HH24:MI') || ':' ||
to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status
from public.profiles
where id = '${userId}'
`),
"05:00:05:00:accepted",
);
fixture.psql(`
update public.profiles
set reported_birth_time = '05:00',
active_birth_time = null,
birth_time_source = 'family_exact',
uncertainty_before_minutes = 10,
uncertainty_after_minutes = 10,
birth_time_status = 'reported',
rectification_case_id = null
where id = '${userId}'
`);
fixture.psql(acceptedExactFamilyMigration);
assert.equal(
fixture.psql(`
select active_birth_time is null || ':' || birth_time_status
from public.profiles
where id = '${userId}'
`),
"true:reported",
);
fixture.psql(`
update public.profiles
set reported_birth_time = null,
active_birth_time = null,
birth_time_source = null,
uncertainty_before_minutes = null,
uncertainty_after_minutes = null,
birth_time_status = null,
rectification_case_id = null
where id = '${userId}'
`);
const local = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
@@ -20,6 +20,8 @@ const codeRoute = source("src/app/api/admin/codes/[id]/route.ts");
const codesHelper = source("src/lib/admin/codes.ts");
const billingOperationsUi = source("src/components/admin/billing-operations-resources.tsx");
const codesUi = source("src/components/admin/codes-resource.tsx");
const redemptionAccessMigration = source("supabase/migrations/20260816010000_admin_redemption_admin_access.sql");
const baseRedemptionMigration = source("supabase/migrations/20260805010000_reconcile_admin_redemption_audit.sql");
test("billing.subscriptions only gates new trial and subscription purchases", () => {
assert.match(packagesRoute, /loadRuntimeFeatureFlags\(\["billing\.subscriptions"\]\)/);
@@ -40,13 +42,13 @@ test("existing payment orders remain queryable and settleable when subscriptions
test("billing and operations writes use the shared ordinary mutation guard", () => {
assert.match(productsRoute, /requireAdminMutation\(request, permission\)/);
assert.match(productsRoute, /body\.data\.reason[\s\S]*requestId/);
assert.match(productsRoute, /admin_console_(?:save|publish)_product/);
assert.match(epaySettingsRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
assert.match(epaySettingsRoute, /admin_save_epay_settings/);
assert.match(subscriptionsRoute, /requireAdminMutation\(request,"billing\.adjustments\.write"\)/);
assert.match(subscriptionsRoute, /body\.data\.reason,rid/);
assert.match(subscriptionsRoute, /admin_console_adjust_subscription/);
assert.match(featureFlagsRoute, /requireAdminMutation\(request,"ops\.flags\.write"\)/);
assert.match(featureFlagsRoute, /admin_publish_feature_flag[\s\S]*b\.data\.reason,rid/);
assert.match(featureFlagsRoute, /admin_console_publish_feature_flag/);
for (const route of [productsRoute, epaySettingsRoute, subscriptionsRoute, featureFlagsRoute]) {
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
}
@@ -74,13 +76,14 @@ test("self-hosted payment catalog uses simple queries and immutable product snap
assert.match(createRoute, /product_snapshot:\s*productSnapshot/);
});
test("order adjustments require permission, reason, version, idempotency request id, and the domain RPC", () => {
test("order adjustments require permission, version, server audit reason, request id, and the domain RPC", () => {
assert.match(
ordersRoute,
/requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/,
);
assert.match(ordersRoute, /expectedVersion:\s*z\.number\(\)\.int\(\)\.min\(0\)/);
assert.match(ordersRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.doesNotMatch(ordersRoute, /reason:\s*z\.string/);
assert.match(ordersRoute, /admin_console_adjust_order/);
assert.match(ordersRoute, /queryAdminRows<AdjustmentRow>/);
assert.match(ordersRoute, /public\.admin_adjust_order\(/);
assert.match(ordersRoute, /requestId\(request\)/);
@@ -92,24 +95,34 @@ test("order adjustments require permission, reason, version, idempotency request
assert.match(billingOperationsUi, /不调用支付网关|仅记录账务/);
});
test("every redemption-code write requires a reason and forwards it to the audited RPC", () => {
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
test("redemption-code writes generate server audit reasons and encode JSONB for self-hosted pg", () => {
assert.match(codesRoute, /await requirePermission\("admin\.access"\)/);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/);
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.match(codesRoute, /p_reason:\s*parsed\.data\.reason/);
assert.match(codeRoute, /const revokeCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.match(codeRoute, /const updateCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.match(codeRoute, /p_reason:\s*body\.reason/);
assert.match(codeRoute, /p_reason:\s*parsedBody\.data\.reason/);
assert.doesNotMatch(codesRoute, /reason:\s*z\.string/);
assert.doesNotMatch(codeRoute, /reason:\s*z\.string/);
assert.match(codesRoute, /p_reason:\s*"admin_console_create_redemption_codes"/);
assert.match(codeRoute, /p_reason:\s*"admin_console_update_redemption_code"/);
assert.match(codeRoute, /p_reason:\s*"admin_console_revoke_redemption_code"/);
assert.match(codesHelper, /JSON\.stringify\(input\.p_codes\)/);
assert.match(codesHelper, /session\.user\.email,[\s\S]*"admin",/);
assert.doesNotMatch(codesHelper, /session\.roles\[0\]/);
assert.match(codesHelper, /queryAdminRows<RpcCodeRow>/);
assert.match(codesHelper, /public\.admin_create_redemption_codes\(/);
assert.match(codesHelper, /public\.admin_update_redemption_code\(/);
assert.match(codesHelper, /public\.admin_revoke_redemption_code\(/);
assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/);
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
assert.match(codesUi, /permissions\.includes\("admin\.access"\)/);
assert.doesNotMatch(codesUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onSubmit=\{submitCreate\}/);
assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onSubmit=\{submitEdit\}/);
assert.match(codesUi, /<ReasonActionModal[\s\S]*onSubmit=\{\(reason\) => revoke\(revokeRecord!, reason\)\}/);
assert.match(codesUi, /onFinish=\{\(values\) => void submitCreate\(values\)\}/);
assert.match(codesUi, /onFinish=\{\(values\) => void submitEdit\(values\)\}/);
assert.match(codesUi, /onClick=\{\(\) => void revoke\(record\)\}/);
assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/);
assert.match(codesUi, /copyable=\{\{ text: record\.code \?\? "" \}\}/);
assert.equal((redemptionAccessMigration.match(/admin_has_permission\(p_actor_user_id,'admin\.access'\)/g) ?? []).length, 3);
assert.match(redemptionAccessMigration, /new\.permission_used := 'admin\.access'/);
assert.match(baseRedemptionMigration, /admin_verified_actor_email/);
assert.match(redemptionAccessMigration, /grant execute on function public\.admin_create_redemption_codes/);
assert.doesNotMatch(redemptionAccessMigration, /billing\.adjustments\.write/);
});
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
test("mobile login can scroll and keeps the form reachable on a short screen", () => {
const mobileAuth = css.indexOf(".auth-page { height: 100dvh; overflow-x: hidden; overflow-y: auto;");
assert.ok(mobileAuth >= 0, "the login page must be the mobile scroll container");
assert.match(css.slice(mobileAuth, mobileAuth + 900), /\.auth-shell \{ min-height: 100%; overflow: visible;/);
assert.match(css, /@media \(max-width: 767px\) and \(max-height: 640px\) \{\s*\.auth-story \{ display: none; \}/);
assert.match(layout, /interactiveWidget:\s*"resizes-content"/);
});
test("mobile onboarding does not clip location results or session history", () => {
assert.match(css, /\.session-nav \{ overflow: visible; \}/);
assert.doesNotMatch(css, /\.session-nav \{ overflow: hidden; \}/);
assert.match(css, /\.location-combobox-results \{ position: static; top: auto;/);
});
+63 -34
View File
@@ -218,31 +218,58 @@ test("agent receives the exact server-owned case id for tool calls", async () =>
assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/);
});
test("first turn with no real skill evidence retries once then fails without saving success", async () => {
const { options, emitted, billing } = runOptions({
accounting: fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
}).client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好," }),
chunk("finish"),
]) as never,
test("server-loaded Skill is bound before the provider and the first model step is forced to read Case", async () => {
const skillInstructions = "immutable-skill-instructions-from-server";
let observedMessages: unknown[] = [];
let observedStreamOptions: {
prepareStep?: (input: { stepNumber: number }) => unknown;
} = {};
const agent = fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我先从一件你记得比较清楚的经历开始。" }),
chunk("finish"),
]);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => ({
...agent,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: skillInstructions }),
stream: async (messages: unknown[], streamOptions: typeof observedStreamOptions) => {
observedMessages = messages;
observedStreamOptions = streamOptions;
return agent.stream();
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.turnStatus, "retryable");
assert.equal(result.skillLoaded, false);
assert.equal(result.errorCode, "skill_not_bound");
assert.equal(billing.released, 1, "failed first turn must release usage");
assert.equal(billing.completed, 0);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.equal(result.ok, true);
assert.equal(result.skillLoaded, true);
assert.equal(result.errorCode, null);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
assert.match(JSON.stringify(observedMessages), /服务器已绑定当前 Case 的精确 Skill/);
assert.match(JSON.stringify(observedMessages), /不要重复调用 skill/);
assert.match(JSON.stringify(observedMessages), new RegExp(skillInstructions));
assert.deepEqual(await observedStreamOptions.prepareStep?.({ stepNumber: 0 }), {
activeTools: ["rectification-read-case"],
toolChoice: { type: "tool", toolName: "rectification-read-case" },
});
assert.equal(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }), undefined);
assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(
accounting.calls.filter((call) => call.fn === "insert_agentic_rectification_skill_run_receipt").length,
1,
);
});
test("first turn with a bound immutable Skill completes and persists receipts", async () => {
@@ -352,9 +379,8 @@ test("a repeated identical tool call is detected and aborts the turn", async ()
assert.equal(billing.released, 1);
});
test("a failed opening does not let the next turn skip the real skill gate", async () => {
// The dossier has one failed turn and no completed turn: the skill gate
// must still apply, so an agent that never invokes the skill tool fails.
test("a failed opening does not let the next turn skip the server Skill load gate", async () => {
let streamCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
@@ -373,17 +399,20 @@ test("a failed opening does not let the next turn skip the real skill gate", asy
});
const { options, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好," }),
chunk("finish"),
]) as never,
buildAgent: async () => ({
getSkill: async () => null,
stream: async () => {
streamCount += 1;
return { fullStream: (async function* () {})() };
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "skill_not_bound");
assert.equal(result.errorCode, "skill_not_loaded");
assert.equal(streamCount, 0);
assert.equal(billing.released, 1);
});