diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9bd01a..5d4b08ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-23 — 新账户可以先问一个问题,档案能标明和你的关系 + +- 每个账户有一次不扣点的正式提问。问完才算用掉;中途停下、失败,或只是寒暄,这一次还在。选了比标准档更贵的模型时,只扣高出的那一部分。 +- 星盘档案里,别人可以标明是伴侣、孩子、父母、朋友或其他。没选过的显示「其他」。本人仍然只标「本人」。 +- Skill 版本不变。 + ## 2026-10-22 — 星盘大运右栏可以切到 Yogini - 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」 diff --git a/CONTEXT.md b/CONTEXT.md index 5b5aacc9..bd076f42 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,6 +72,10 @@ _避免使用_:用户反馈状态、通知状态 一张星盘档案对应的一个人。户主本人也是其中一个人。不要再叫「当前星盘」来指这个人。 _避免使用_:当前星盘、关系、默认星盘 +**关系(人物)**: +这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。 +_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户 + **星盘档案**: 侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。 _避免使用_:星盘库、星盘资料(设置里的旧面板) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1b543a01..2f0a6610 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19253,3 +19253,19 @@ - 相关记录:无 - 复发自:无 - 修复版本:本提交 + +## BUG-1421 | 新账户第一次提问就被兑换窗拦住 + +- 状态:resolved +- 首次发现:2026-10-22 +- 最近更新:2026-10-23 +- 影响面:已登录、点数为 0、没有会员的新账户。第一次正式提问发不出去,直接打开兑换窗。 +- 用户现象:建完档之后,还没问成任何问题,输入框就已经按「没有点数」处理,发送时弹出兑换。 +- 触发条件:账户点数默认是 0,发送前只看「点数用完且没有会员」。 +- 根因:没有「每个账户一次、由服务端记账」的首问。前端和预扣都把 0 点当成必须先付费。 +- 修复:服务端在同一笔预扣里判断这一次是否还免费。标准档这一问不扣点;选了更贵的模型只扣高出标准档的差价。回答完成才算用掉。停止、失败或寒暄都把这一次还回来。账户接口只多给一个 `firstConsultFree`。前端在它为真时不拦这一问。老账户里已经有过一次完成的正式咨询,迁移时记为已用过。 +- 验证:`frontend/tests/database-onboarding-journey.test.ts`(0 点首问、并发只有一条免费、取消和寒暄后仍可用、高档只扣差价、已有正式咨询的老账户不再免费)。`frontend/tests/onboarding-journey-data.test.ts`。 +- 防复发:数据库测试里,没有公布标准档价格时仍按传入的点数预扣,避免把所有咨询都变成免费;并发两条时只有一条成功且余额仍是 0。 +- 相关记录:任务书 `TASK-onboarding-journey-20261022.md` T1。issue #95。T3、T4 的现象不在本条。 +- 复发自:无 +- 修复版本:分支 `codex/onboarding-1-data-20261022`。 diff --git a/docs/tasks/PROGRESS-onboarding-1-data-20261022.md b/docs/tasks/PROGRESS-onboarding-1-data-20261022.md new file mode 100644 index 00000000..1f69a00d --- /dev/null +++ b/docs/tasks/PROGRESS-onboarding-1-data-20261022.md @@ -0,0 +1,52 @@ +# PROGRESS · 新用户启程(一)数据层(2026-10-22) + +Issue #95。只做 T1 首问免费和 T2 引导状态、人物关系。T3–T7 不做。 + +## 基线 + +- 开工时 `origin/staging` = `dfa9acaf05089dbb94ee8afe34c1d5626ffb1b23`(任务书写的 `c1aa72fcb` 已过期)。 +- BUG-1420 已被「Bug 历史去重」占用。本轮首问免费记 **BUG-1421**。 + +## 做了什么 + +- 迁移 `20261022190000_onboarding_first_consult_and_relation.sql`:账户上记下首问是否用过、当前占用的请求、引导状态;别人的档案增加关系。只加列和约束,不删废列。 +- 预扣在同一笔事务里占用免费额度。回答完成才记为已用。取消或寒暄把占用还回去,不记成已用。 +- 账户接口增加 `firstConsultFree`。前端发送前和输入框占位,在它为真时不再当「没有点数」。 +- 已登录接口读写引导状态。只接受清单关闭时间、看过的提示、进过星盘页。提示 id 只收任务书 T6 那八个。未登录 401。写入用当前登录的人,不读请求体里的用户 id。 +- 档案的添加和编辑(档案页和星盘页编辑别人)加「TA 是你的」。本人不出现这项。空关系显示「其他」。 + +## 红线 3 怎么选 + +选了 **只扣差价**。免费覆盖的是已经公布的 `chat.standard` / `standard` 价格。传入的价格更高时,只扣多出来的部分。这样标准档仍是 0 点,更贵的模型也不会整问白送。 + +没有公布中的标准档价格时,**不免费**,仍按传入的点数扣。现有计费测试没有这条价格,因此不会被误改成免费。 + +会员已经包住这一问时,不占用免费额度,留给还没有会员的那一次。合盘报告会话不占用这份额度。 + +0 点流水不能写进点数流水(现有约束不允许金额为 0)。记在用量台账上,后台「资金来源」显示「首问免费」;差价大于 0 时显示「首问免费(差价 N 点)」。筛选里原来对不上的 complimentary 换成「首问免费」。 + +## 老账户 + +只把「已经有过一条完成的、且不是寒暄的咨询」标成用过。只有寒暄的账户仍然有这一次。迁移结束时跑一遍,测试可以再跑同一函数。 + +## 本人与关系 + +本人档案行已经被现有约束拒绝写入(`chart_subject_self_mirror_retired`)。关系只存在别人的行上。本人界面仍只标「本人」。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 通过,0 错 | +| lint | `frontend` 下 `npm run lint` | 0 error(原有 warning 未动) | +| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 跑了本轮改到的测试文件 | 通过 | +| 数据库 | `frontend` 下 `npm run test:db` | 91 项,通过 77,失败 14,取消 0。其中 13 条失败与 `tools/agent-loop/windows-known-failures.txt` 同名。多出的一条是校正遥测,见下方 | + +未跑前端全量 `npm test` 和 Python 快速门(本轮不改 Python)。 + +全套里多出的失败是「rectification telemetry: server-only write, dedupe, retention, aggregate-only admin read, account cascade」。迁移进程退出码 1,报错只有 database migration failed,用时约 10 秒,容器日志没有 SQL 错误。同一轮里本单自己的数据库测试通过。这条遥测单独再跑:通过 1,失败 0。没有把它写进已知失败清单。 + +## 偏离 + +- 任务书 BUG 起点 1420 已占用,顺延为 1421。 +- 没有单独造一套会员夹具去测「会员不占免费额度」;规则写在预扣函数里:来源是会员时不写入占用。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f0cadaaf..5a61f689 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -40,7 +40,7 @@ | `TASK-rectification-explicit-open-20261023.md` | — | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表 | 待执行(循环) | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-chart-table-span-20261023.md` | — | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发 | 待执行(循环) | 分支 `codex/chart-table-span-20261023` | | `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | -| `TASK-onboarding-journey-20261022.md` | — | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 起 | 待领取(循环) | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | +| `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022` | (一)已合入 staging;(二)(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | | `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd | | `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` | | —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) | diff --git a/docs/testing/onboarding-journey-20261022.md b/docs/testing/onboarding-journey-20261022.md new file mode 100644 index 00000000..cb7a1d9b --- /dev/null +++ b/docs/testing/onboarding-journey-20261022.md @@ -0,0 +1,25 @@ +# 新用户启程 · 真机清单(2026-10-22) + +后面的 issue 往这份清单末尾追加。本页先只覆盖数据层(issue #95,T1 首问免费、T2 关系与引导状态)。起步三问、启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。 + +用新注册的邮箱。不要把真实出生资料写进记录。 + +## 首问免费 + +1. 新账户点数是 0。建完档之后,第一次正式提问能发出去,点数仍是 0,不弹兑换窗。 +2. 这一问回答完成之后,再问一次:弹出原来的兑换窗,文案不变。 +3. 另开一个新账户。第一问发出去之后立刻停止。再问一次仍然不扣点。 +4. 另开一个新账户。第一问如果只被当成寒暄,问完之后正式提问仍然不扣点。 +5. 后台用量里,这次免费提问的资金来源显示「首问免费」。若当时选了更贵的模型,显示「首问免费(差价 N 点)」,账户只少这 N 点。 + +## 人物关系 + +1. 星盘档案里添加一个人,选「孩子」,保存。刷新之后,列表和详情的名字后面仍是「孩子」。 +2. 找一份关系还是空的旧档案,打开编辑,补上「父母」,保存后再打开仍是「父母」。没改过的空档案,名字后面显示「其他」。 +3. 本人这一行仍只显示「本人」,编辑本人时没有「TA 是你的」。 + +## 以后追加 + +- (二)先看谁的盘、起步三问 +- (三)启程清单、一次性提示 +- (四)动效,以及打开「减少动态效果」再走一遍 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b15398ac..edcf644a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1146,7 +1146,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 标题旁的人物切换仍是当前阅读/对话上下文,不替换户主资料,也不是「设为默认」。上次选择可同步预读,但账户与服务端目录绑定完成前,星盘、星历、报告和新建对话不发默认本人的首屏请求;目录暂时失败应可恢复,不假登出,不先闪别人的内容。档案增删改后切换器重新取目录;同一人物不重复广播切换。会话列表更新合并metadata,保留已加载消息、深链与分页外对话;可见列表仅属于当前人物,换账户不保留旧数据。 -档案数量仍本人加最多十九个他人(一共 20 人),无关系字段,本人不可删除。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。 +档案数量仍本人加最多十九个他人(一共 20 人),本人不可删除。别人的档案可以标明和户主的关系(伴侣、孩子、父母、朋友、其他);没填过的显示「其他」,本人不记这一项,仍只标「本人」。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。 「和TA对话」新会话绑定选中的人物;「和我合盘」中的我保持本人,新会话显式绑定本人但不改变全局选择。普通新建深链用已绑定的当前人物。今日星语的可用性与缓存指纹取当前人物,不能由户主是否有分钟压掉他人的卡片。星历暖进只复用同账户/同人物缓存,首次失败仍显示可重试的静态错误句,不新增spinner、骨架或「正在加载」。跨scope迟到响应不得覆盖当前视图或分页游标。 @@ -1158,11 +1158,12 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 - **小于 1024px:列表 → 详情。** 同一路由里用 `.people-archive[data-view]` 切换,只显示一栏。点人时压一条 `?person=` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。 - **1024px 及以上:两栏。** 左列表(18–22rem),右详情;「‹ 返回」隐藏。右栏正在看的那一行用中性浅底(`is-open`),强调色只给当前人物。 -- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。 +- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签;别人后跟同一枚标签,写关系,没填过写「其他」)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。 - **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成「最多保存 20 个人的星盘」并禁用。 - **人多时的滚动**(2026-10-17):`.people-archive-rows` 限高 `min(70vh, 640px)`,只在列表内部纵向滚动。标题旁和聊天里的人物菜单(`.chat-profile-picker-options`)限高 `min(50vh, 320px)`,同样只纵向滚动,名单放到 20 人也不把弹层撑出屏幕。两处都 `overflow-x: hidden`。 -- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。 -- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。 +- **详情卡**:头部 48px 头像 + 名字(别人的名字后跟关系标签,没填过写「其他」;本人的标题仍只有名字)+「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。他人已经保存过出生时间时,编辑表单锁住这一项,旁边只留「填报的出生时间保存后不能再改」,说明里不再重复。保存他人成功后清掉星盘页和星历页缓存(BUG-1411),再打开仍是刚保存的资料。 +- **编辑表单里的关系**(2026-10-23):只在编辑或添加别人时出现,放在出生地点之后、性别之前。标题「TA 是你的」,五个选项:伴侣、孩子、父母、朋友、其他。复用性别那一排的尺寸和样式,不新造颜色。没点过就保持空,页面上仍显示「其他」;点了才写入。本人的编辑表单没有这一项。 +- **编辑表单里的性别(选填)**(2026-09-27):放在关系之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。 - **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。 - **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 17939e16..8643a91a 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -505,3 +505,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」 +## 人物关系(2026-10-23) + +星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。 + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 07a9bd5c..7645c155 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -1275,7 +1275,7 @@ export default function Home() { 'cache'->>'readTokens' as cache_read_tokens,l.metadata->'modelSteps' as model_steps,l.metadata->'classification'->>'durationMs' as classification_duration_ms,l.metadata->'answer'->>'reasoning_ms' as answer_reasoning_ms,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`; +const LIST_SQL = `select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,l.metadata->'modelSteps' as model_steps,l.metadata->'classification'->>'durationMs' as classification_duration_ms,l.metadata->'answer'->>'reasoning_ms' as answer_reasoning_ms,l.metadata->>'billingSource' as billing_source,l.metadata->>'creditCost' as free_credit_cost,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2 or l.metadata->>'billingSource'=$2) order by l.created_at desc limit $3 offset $4`; export async function GET(request: Request) { try { @@ -59,6 +61,8 @@ export async function GET(request: Request) { durationMs: row.duration_ms, cacheReadTokens: row.cache_read_tokens == null ? null : Number(row.cache_read_tokens), timing: usageTimingFromLedger(row.model_steps, row.classification_duration_ms, row.answer_reasoning_ms), + billingSource: row.billing_source, + freeCreditCost: row.free_credit_cost == null ? null : Number(row.free_credit_cost), createdAt: row.created_at.toISOString(), })), total: Number(rows[0]?.total_count ?? 0), diff --git a/frontend/src/app/api/onboarding-state/route.ts b/frontend/src/app/api/onboarding-state/route.ts new file mode 100644 index 00000000..dc89283c --- /dev/null +++ b/frontend/src/app/api/onboarding-state/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { parseOnboardingPatch, parseOnboardingState } from "@/lib/onboarding-state"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +function unavailable(error: unknown) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "引导状态暂时不可用" }, { status: 500 }); +} + +async function signedInUser() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return { supabase, user: null }; + return { supabase, user }; +} + +export async function GET() { + try { + const { supabase, user } = await signedInUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const { data, error } = await supabase + .from("profiles") + .select("onboarding_state") + .eq("id", user.id) + .single(); + if (error || !data) return NextResponse.json({ error: "暂时无法读取引导状态" }, { status: 500 }); + return NextResponse.json({ state: parseOnboardingState(data.onboarding_state) }); + } catch (error) { + return unavailable(error); + } +} + +export async function PATCH(request: Request) { + try { + const { user } = await signedInUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const parsed = parseOnboardingPatch(await request.json().catch(() => null)); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }); + const admin = createAdminSupabaseClient(); + const { data, error } = await admin.rpc("save_onboarding_state", { + p_user_id: user.id, + p_patch: parsed.patch, + }); + if (error) return NextResponse.json({ error: "暂时无法保存引导状态" }, { status: 500 }); + const row = (Array.isArray(data) ? data[0] : data) as { success?: boolean; state?: unknown; error_code?: string } | null; + if (!row?.success) { + return NextResponse.json( + { error: "引导状态没有保存", code: row?.error_code ?? "invalid_state" }, + { status: row?.error_code === "unauthorized" ? 401 : 400 }, + ); + } + return NextResponse.json({ state: parseOnboardingState(row.state) }); + } catch (error) { + return unavailable(error); + } +} diff --git a/frontend/src/components/admin/billing-operations-resources.tsx b/frontend/src/components/admin/billing-operations-resources.tsx index 6dd7b959..666a0160 100644 --- a/frontend/src/components/admin/billing-operations-resources.tsx +++ b/frontend/src/components/admin/billing-operations-resources.tsx @@ -418,6 +418,8 @@ type Usage = { durationMs: number | null; cacheReadTokens: number | null; timing: UsageTimingView | null; + billingSource: string | null; + freeCreditCost: number | null; createdAt: string; }; @@ -436,7 +438,11 @@ export function UsageResource() { { title: "资金来源", dataIndex: "source", - render: (value) => value, + render: (_value, item) => { + if (item.billingSource !== "first_consult_free") return item.source; + const difference = item.freeCreditCost ?? 0; + return difference > 0 ? `首问免费(差价 ${difference} 点)` : "首问免费"; + }, }, { title: "模型", @@ -485,12 +491,12 @@ export function UsageResource() { title="用量、Token 与成本" columns={columns} statusOptions={[ - "subscription", - "credits", - "complimentary", - "chat.standard", - "rectification", - ].map((value) => ({ value, label: value }))} + { value: "subscription", label: "subscription" }, + { value: "credits", label: "credits" }, + { value: "first_consult_free", label: "首问免费" }, + { value: "chat.standard", label: "chat.standard" }, + { value: "rectification", label: "rectification" }, + ]} /> ); } diff --git a/frontend/src/components/chart-page/chart-birth-editor.tsx b/frontend/src/components/chart-page/chart-birth-editor.tsx index 5bd69c43..5557e6ae 100644 --- a/frontend/src/components/chart-page/chart-birth-editor.tsx +++ b/frontend/src/components/chart-page/chart-birth-editor.tsx @@ -88,6 +88,7 @@ export function ChartBirthEditorForm({ nameInputId="chart-birth-edit-name" showAyanamsa showGender + showRelation={!self} lockReportedBirthTime={!self && reportedTimeStored(baseline)} notice={birthFactsChanged(baseline, draft) ? CHART_BIRTH_EDIT_NOTICE : null} onSubmit={onSubmit} diff --git a/frontend/src/components/chart-profile-form.tsx b/frontend/src/components/chart-profile-form.tsx index 78606ec7..c55a2f07 100644 --- a/frontend/src/components/chart-profile-form.tsx +++ b/frontend/src/components/chart-profile-form.tsx @@ -3,6 +3,7 @@ import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react"; import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field"; import { ProfileFields } from "@/components/profile-fields"; +import { PersonRelationField } from "@/components/person-relation-field"; import { ProfileGenderField } from "@/components/profile-gender-field"; import { resolveAyanamsa } from "@/lib/ayanamsa"; import type { Profile } from "@/lib/home-types"; @@ -16,6 +17,8 @@ export type ChartProfileFormProps = { readonly showAyanamsa?: boolean; /** Optional gender (女 / 男 / 不填); saved with the form. */ readonly showGender?: boolean; + /** Household link. Only for someone else; self does not store relation. */ + readonly showRelation?: boolean; readonly onSubmit: (event: FormEvent) => void; readonly onCancel?: () => void; readonly cancelLabel?: string; @@ -34,6 +37,7 @@ export function ChartProfileForm({ nameInputId, showAyanamsa = false, showGender = false, + showRelation = false, onSubmit, onCancel, cancelLabel = "取消", @@ -51,6 +55,13 @@ export function ChartProfileForm({ nameInputId={nameInputId} lockReportedBirthTime={lockReportedBirthTime} /> + {showRelation ? ( + onChange((current) => ({ ...current, relation }))} + /> + ) : null} {showGender ? ( {peopleDisplayName(person.profile, person.role)} - {person.role === "self" ? 本人 : null} + {person.role === "self" + ? 本人 + : {personRelationLabel(person.profile.relation)}} {person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)} @@ -439,6 +442,7 @@ export function PeopleArchiveView({ nameInputId="people-archive-name" showAyanamsa showGender + showRelation={!editingSelf} lockReportedBirthTime={lockReportedBirthTime} onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))} onCancel={onCancelEdit} @@ -450,7 +454,7 @@ export function PeopleArchiveView({
-

{peopleDisplayName(selected.profile, selected.role)}

+

{peopleDisplayName(selected.profile, selected.role)}{selected.role === "self" ? null : {personRelationLabel(selected.profile.relation)}}

{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}

diff --git a/frontend/src/components/person-relation-field.tsx b/frontend/src/components/person-relation-field.tsx new file mode 100644 index 00000000..09b084fd --- /dev/null +++ b/frontend/src/components/person-relation-field.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { + PERSON_RELATIONS, + PERSON_RELATION_LABELS, + PERSON_RELATION_TITLE, + type PersonRelation, +} from "@/lib/person-relation"; + +/** + * 「TA 是你的」 on someone else's chart. An empty stored value looks like 其他 + * and is written only when that choice is clicked. + */ +export function PersonRelationField({ + value, + onChange, + idPrefix, +}: { + readonly value: PersonRelation | null | undefined; + readonly onChange: (next: PersonRelation) => void; + readonly idPrefix: string; +}) { + const titleId = `${idPrefix}-relation-title`; + const shown = value ?? "other"; + return ( +
+ {PERSON_RELATION_TITLE} +
+ {PERSON_RELATIONS.map((option) => { + const selected = option === shown; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index ee06a93c..f8f19442 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -615,7 +615,7 @@ export function useConsultationRun(params: ConsultationRunParams) { ? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null } : initialConsultationRoute; - if (account.credits <= 0 && !account.activeSubscription) { + if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) { if (!openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" }); return false; } diff --git a/frontend/src/lib/chart-subject-write.ts b/frontend/src/lib/chart-subject-write.ts index 9248cbfb..d99c7a0b 100644 --- a/frontend/src/lib/chart-subject-write.ts +++ b/frontend/src/lib/chart-subject-write.ts @@ -1,4 +1,5 @@ import { isAyanamsaName, resolveAyanamsa } from "./ayanamsa.ts"; +import { parsePersonRelation } from "./person-relation.ts"; import { parseProfileGender } from "./profile-gender.ts"; import { MAX_OTHER_SUBJECTS } from "./subject-birth.ts"; import { asRecord as recordOf } from "@/lib/guards"; @@ -89,6 +90,12 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart if (genderSent && profile.gender != null && profile.gender !== "" && !gender) { return { ok: false, error: "性别只能选女、男或不填" }; } + // Same omit-when-absent rule as gender, so an old client does not clear relation. + const relationSent = Object.prototype.hasOwnProperty.call(profile, "relation"); + const relation = parsePersonRelation(profile.relation); + if (relationSent && profile.relation != null && profile.relation !== "" && !relation) { + return { ok: false, error: "关系只能选伴侣、孩子、父母、朋友或其他" }; + } const columns: Record = { name, birth_date: birthDate, @@ -110,6 +117,7 @@ export function parseChartSubjectWrite(value: unknown): { ok: true; write: Chart ayanamsa, birth_time_status: status, ...(genderSent ? { gender } : {}), + ...(relationSent ? { relation } : {}), }; const mirror = { name, @@ -174,6 +182,7 @@ export function libraryProfileFromSubjectRow(row: Record): Reco timezoneOffset: typeof row.timezone_offset === "number" ? row.timezone_offset : null, ayanamsa: resolveAyanamsa(row), ...(parseProfileGender(row.gender) ? { gender: parseProfileGender(row.gender) } : {}), + ...("relation" in row ? { relation: parsePersonRelation(row.relation) } : {}), ...(typeof row.active_birth_date === "string" ? { activeDate: row.active_birth_date.slice(0, 10) } : {}), ...(typeof row.active_birth_timezone_offset === "number" ? { activeTimezoneOffset: row.active_birth_timezone_offset } diff --git a/frontend/src/lib/home-profile.ts b/frontend/src/lib/home-profile.ts index 5ca86d05..1a9f40c7 100644 --- a/frontend/src/lib/home-profile.ts +++ b/frontend/src/lib/home-profile.ts @@ -26,6 +26,7 @@ import { import { resolveAyanamsa } from "@/lib/ayanamsa"; import { loadedChinaCountry } from "@/lib/china-locations-client"; import { effectiveBirthDate } from "@/lib/effective-birth-date"; +import { parsePersonRelation } from "@/lib/person-relation"; import { parseProfileGender } from "@/lib/profile-gender"; /** Needs the city table loaded (china-locations-client.ts); undefined until then. */ @@ -321,6 +322,9 @@ export function readProfile(value: unknown): Profile { timezoneOffset, ayanamsa: resolveAyanamsa(profile), ...(parseProfileGender(profile.gender) ? { gender: parseProfileGender(profile.gender) } : {}), + ...(Object.prototype.hasOwnProperty.call(profile, "relation") + ? { relation: parsePersonRelation(profile.relation) } + : {}), ...(chartRelationship ? { chartRelationship } : {}), }; } diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index c7ecbfbe..6b0f6c0a 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -2,6 +2,7 @@ import type { BeamAvatar } from "@/lib/beam-avatar"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import type { BirthTimeDraft } from "@/lib/birth-time-intake-model"; import { DEFAULT_AYANAMSA, type AyanamsaName } from "@/lib/ayanamsa"; +import type { PersonRelation } from "@/lib/person-relation"; import type { ProfileGender } from "@/lib/profile-gender"; import type { AgentActivityView, ChatMessage } from "@/lib/chat-message-view"; import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline"; @@ -34,6 +35,8 @@ export type Profile = BirthTimeDraft & { ayanamsa: AyanamsaName; /** Optional; absent or null = not filled (TASK-consult-gender-optional-20260927). */ gender?: ProfileGender | null; + /** Household link for someone else. Absent or null displays as 其他 and is not the old chartRelationship vocabulary. */ + relation?: PersonRelation | null; chartRelationship?: ChartRelationship; }; export type ChartRelationship = "self" | "partner" | "family" | "friend" | "client" | "other"; @@ -144,6 +147,8 @@ export type Account = { profile: unknown; chartBirth?: { fingerprint?: string | null } | null; compatibility?: boolean; + /** True while this account's one free standard question is still available. Missing means the paywall stays. */ + firstConsultFree?: boolean; }; export type OnboardingStep = "name" | "birth" | "place" | "rectification"; export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout"; diff --git a/frontend/src/lib/onboarding-state.ts b/frontend/src/lib/onboarding-state.ts new file mode 100644 index 00000000..6029c520 --- /dev/null +++ b/frontend/src/lib/onboarding-state.ts @@ -0,0 +1,92 @@ +/** Server-owned onboarding flags. The only keys the checklist and hints may store. */ + +export const ONBOARDING_STATE_KEYS = ["checklistDismissedAt", "hintsSeen", "chartVisitedAt"] as const; + +export const ONBOARDING_HINT_IDS = [ + "follow-up", + "sidebar-chart", + "tab-chart", + "tab-info", + "tab-dasha", + "tab-west", + "syn-ready", + "ephemeris", +] as const; + +export type OnboardingHintId = (typeof ONBOARDING_HINT_IDS)[number]; + +export type OnboardingState = { + readonly checklistDismissedAt?: string | null; + readonly hintsSeen?: readonly OnboardingHintId[]; + readonly chartVisitedAt?: string | null; +}; + +const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/; + +function isHintId(value: unknown): value is OnboardingHintId { + return ONBOARDING_HINT_IDS.some((id) => id === value); +} + +function timestamp(value: unknown): string | null | undefined { + if (value == null) return null; + if (typeof value !== "string" || !TIMESTAMP.test(value) || Number.isNaN(Date.parse(value))) return undefined; + return value; +} + +export function parseOnboardingState(value: unknown): OnboardingState { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const record = value as Record; + const state: { + checklistDismissedAt?: string | null; + hintsSeen?: OnboardingHintId[]; + chartVisitedAt?: string | null; + } = {}; + if ("checklistDismissedAt" in record) { + const parsed = timestamp(record.checklistDismissedAt); + if (parsed !== undefined) state.checklistDismissedAt = parsed; + } + if ("chartVisitedAt" in record) { + const parsed = timestamp(record.chartVisitedAt); + if (parsed !== undefined) state.chartVisitedAt = parsed; + } + if (Array.isArray(record.hintsSeen)) { + const hints = record.hintsSeen.filter(isHintId); + if (hints.length === record.hintsSeen.length) state.hintsSeen = hints; + } + return state; +} + +export function parseOnboardingPatch( + value: unknown, +): { ok: true; patch: OnboardingState } | { ok: false; error: string } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, error: "引导状态格式不正确" }; + } + const record = value as Record; + const unknownKey = Object.keys(record).find((key) => !ONBOARDING_STATE_KEYS.some((allowed) => allowed === key)); + if (unknownKey) return { ok: false, error: "只能记下清单、提示和是否进过星盘页" }; + if (Object.keys(record).length === 0) return { ok: false, error: "没有要保存的引导状态" }; + + const patch: { + checklistDismissedAt?: string | null; + hintsSeen?: OnboardingHintId[]; + chartVisitedAt?: string | null; + } = {}; + if ("checklistDismissedAt" in record) { + const parsed = timestamp(record.checklistDismissedAt); + if (parsed === undefined) return { ok: false, error: "时间格式不正确" }; + patch.checklistDismissedAt = parsed; + } + if ("chartVisitedAt" in record) { + const parsed = timestamp(record.chartVisitedAt); + if (parsed === undefined) return { ok: false, error: "时间格式不正确" }; + patch.chartVisitedAt = parsed; + } + if ("hintsSeen" in record) { + if (!Array.isArray(record.hintsSeen) || record.hintsSeen.some((item) => !isHintId(item))) { + return { ok: false, error: "这个提示不在名单里" }; + } + patch.hintsSeen = record.hintsSeen.filter(isHintId); + } + return { ok: true, patch }; +} diff --git a/frontend/src/lib/person-relation.ts b/frontend/src/lib/person-relation.ts new file mode 100644 index 00000000..940eb2c6 --- /dev/null +++ b/frontend/src/lib/person-relation.ts @@ -0,0 +1,25 @@ +/** Household link on someone else's chart. Self is not stored here. */ + +export const PERSON_RELATIONS = ["partner", "child", "parent", "friend", "other"] as const; + +export type PersonRelation = (typeof PERSON_RELATIONS)[number]; + +export const PERSON_RELATION_LABELS: Readonly> = { + partner: "伴侣", + child: "孩子", + parent: "父母", + friend: "朋友", + other: "其他", +}; + +export const PERSON_RELATION_TITLE = "TA 是你的"; + +export function parsePersonRelation(value: unknown): PersonRelation | null { + return PERSON_RELATIONS.find((item) => item === value) ?? null; +} + +/** A missing value displays as 其他. The column stays null until the person picks one. */ +export function personRelationLabel(value: unknown): string { + const relation = parsePersonRelation(value); + return relation ? PERSON_RELATION_LABELS[relation] : PERSON_RELATION_LABELS.other; +} diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts index 71204a2c..c22f9149 100644 --- a/frontend/src/lib/subject-birth.ts +++ b/frontend/src/lib/subject-birth.ts @@ -7,6 +7,7 @@ * to the household profile. */ +import { parsePersonRelation, type PersonRelation } from "./person-relation.ts"; import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts"; import { ACCOUNT_BIRTH_SELECT, @@ -37,9 +38,9 @@ export class ConsultationSubjectError extends Error { export const SELF_SUBJECT_ID = "self"; -export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender"; +export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation"; -export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,profile,updated_at"; +export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at"; export const MAX_OTHER_SUBJECTS = 19; export const MAX_SAVED_SUBJECTS = 20; @@ -48,6 +49,7 @@ export type ResolvedSubjectBirth = Readonly<{ subjectId: string; role: "self" | "other"; name: string; + relation: PersonRelation | null; row: Record; chartable: boolean; }>; @@ -140,6 +142,7 @@ export async function resolveSubjectBirth(input: { subjectId: SELF_SUBJECT_ID, role: "self", name: text(row.name), + relation: null, row, chartable, }); @@ -195,6 +198,7 @@ export async function resolveSubjectBirth(input: { subjectId, role: "other", name: text(accountRow.name), + relation: parsePersonRelation(row.relation), row: accountRow, chartable, }); diff --git a/frontend/supabase/migrations/20261022190000_onboarding_first_consult_and_relation.sql b/frontend/supabase/migrations/20261022190000_onboarding_first_consult_and_relation.sql new file mode 100644 index 00000000..ec577113 --- /dev/null +++ b/frontend/supabase/migrations/20261022190000_onboarding_first_consult_and_relation.sql @@ -0,0 +1,647 @@ +begin; + +-- Issue #95 T1+T2. Add-only. The one free standard question is held at reserve +-- and consumed only when a non-smalltalk answer completes. Old accounts that +-- already finished a non-smalltalk consult are not given another free one. + +alter table public.profiles + add column if not exists first_consult_free_used_at timestamptz, + add column if not exists first_consult_free_request_id text, + add column if not exists onboarding_state jsonb; + +alter table public.profiles + drop constraint if exists profiles_onboarding_state_object; +alter table public.profiles + add constraint profiles_onboarding_state_object + check (onboarding_state is null or jsonb_typeof(onboarding_state) = 'object'); + +alter table public.chart_profiles + add column if not exists relation text; + +alter table public.chart_profiles + drop constraint if exists chart_profiles_relation_check; +alter table public.chart_profiles + add constraint chart_profiles_relation_check + check (relation is null or relation in ('partner', 'child', 'parent', 'friend', 'other')); + +comment on column public.profiles.first_consult_free_used_at is + 'Set when the one free standard question is completed. Null means it is still available.'; +comment on column public.profiles.first_consult_free_request_id is + 'Request id currently holding the free question. Cleared on cancel or smalltalk.'; +comment on column public.chart_profiles.relation is + 'partner, child, parent, friend, or other. Null on old rows; the self profile does not use this column.'; + +-- The browser may read its own profile. Only the service route may write onboarding_state. +grant select ( + onboarding_state, + first_consult_free_used_at, + first_consult_free_request_id +) on table public.profiles to service_role; + +grant insert (relation) on table public.chart_profiles to authenticated; +grant update (relation) on table public.chart_profiles to authenticated; +grant select (relation) on table public.chart_profiles to service_role; + +create or replace function public.backfill_first_consult_free() +returns integer +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_count integer; +begin + update public.profiles as profile + set first_consult_free_used_at = coalesce(( + select min(request.updated_at) + from public.consultation_requests as request + where request.user_id = profile.id + and request.status = 'completed' + and coalesce(request.response_message->>'responseKind', '') <> 'smalltalk' + ), clock_timestamp()) + where profile.first_consult_free_used_at is null + and exists ( + select 1 + from public.consultation_requests as request + where request.user_id = profile.id + and request.status = 'completed' + and coalesce(request.response_message->>'responseKind', '') <> 'smalltalk' + ); + get diagnostics v_count = row_count; + return v_count; +end; +$$; + +revoke all on function public.backfill_first_consult_free() from public, anon, authenticated; + +select public.backfill_first_consult_free(); + +create or replace function public.release_first_consult_free_hold(p_user_id uuid, p_request_id text) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +begin + update public.profiles as profile + set first_consult_free_request_id = null + where profile.id = p_user_id + and profile.first_consult_free_used_at is null + and profile.first_consult_free_request_id = btrim(coalesce(p_request_id, '')); +end; +$$; + +revoke all on function public.release_first_consult_free_hold(uuid, text) from public, anon, authenticated; + +create or replace function public.consume_first_consult_free(p_user_id uuid, p_request_id text) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_request_id text := btrim(coalesce(p_request_id, '')); + v_amount integer; +begin + update public.profiles as profile + set first_consult_free_used_at = clock_timestamp() + where profile.id = p_user_id + and profile.first_consult_free_used_at is null + and profile.first_consult_free_request_id = v_request_id; + if not found then + return; + end if; + select reservation.credit_amount into v_amount + from public.usage_reservations as reservation + where reservation.user_id = p_user_id + and reservation.request_id = v_request_id; + update public.usage_ledger as ledger + set metadata = coalesce(ledger.metadata, '{}'::jsonb) || jsonb_build_object( + 'billingSource', 'first_consult_free', + 'creditCost', coalesce(v_amount, 0) + ) + where ledger.user_id = p_user_id + and ledger.request_id = v_request_id; +end; +$$; + +revoke all on function public.consume_first_consult_free(uuid, text) from public, anon, authenticated; + +create or replace function public.save_onboarding_state(p_user_id uuid, p_patch jsonb) +returns table(success boolean, state jsonb, error_code text) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_current jsonb; + v_next jsonb; + v_key text; + v_value jsonb; +begin + if p_user_id is null then + return query select false, null::jsonb, 'unauthorized'::text; + return; + end if; + if jsonb_typeof(p_patch) is distinct from 'object' then + return query select false, null::jsonb, 'invalid_state'::text; + return; + end if; + if exists ( + select 1 + from jsonb_object_keys(p_patch) as key + where key not in ('checklistDismissedAt', 'hintsSeen', 'chartVisitedAt') + ) then + return query select false, null::jsonb, 'invalid_key'::text; + return; + end if; + + if p_patch ? 'hintsSeen' then + if jsonb_typeof(p_patch->'hintsSeen') is distinct from 'array' then + return query select false, null::jsonb, 'invalid_hints'::text; + return; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_patch->'hintsSeen') as elem + where jsonb_typeof(elem) is distinct from 'string' + or (elem #>> '{}') not in ( + 'follow-up', 'sidebar-chart', 'tab-chart', 'tab-info', + 'tab-dasha', 'tab-west', 'syn-ready', 'ephemeris' + ) + ) then + return query select false, null::jsonb, 'invalid_hint'::text; + return; + end if; + end if; + + foreach v_key in array array['checklistDismissedAt', 'chartVisitedAt'] loop + if p_patch ? v_key then + v_value := p_patch->v_key; + if jsonb_typeof(v_value) is distinct from 'null' and jsonb_typeof(v_value) is distinct from 'string' then + return query select false, null::jsonb, 'invalid_timestamp'::text; + return; + end if; + if jsonb_typeof(v_value) = 'string' then + begin + perform (v_value #>> '{}')::timestamptz; + exception + when others then + return query select false, null::jsonb, 'invalid_timestamp'::text; + return; + end; + end if; + end if; + end loop; + + select profile.onboarding_state into v_current + from public.profiles as profile + where profile.id = p_user_id + for update; + if not found then + return query select false, null::jsonb, 'profile_missing'::text; + return; + end if; + v_next := coalesce(v_current, '{}'::jsonb) || p_patch; + update public.profiles as profile + set onboarding_state = v_next + where profile.id = p_user_id + returning profile.onboarding_state into v_next; + return query select true, v_next, null::text; +end; +$$; + +revoke all on function public.save_onboarding_state(uuid, jsonb) from public, anon, authenticated; +grant execute on function public.save_onboarding_state(uuid, jsonb) to service_role; + +create or replace function public.reserve_consultation_usage( + p_user_id uuid, + p_request_id text, + p_session_id uuid, + p_requested_model_id text, + p_credit_cost integer +) +returns table( + success boolean, + reservation_id uuid, + source text, + credits integer, + subscription_id uuid, + reason text, + retry_after_seconds integer +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_authorization record; + v_session_type text; + v_profile public.profiles%rowtype; + v_standard integer; + v_charge integer; + v_free boolean := false; +begin + if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then + return query select false, null::uuid, null::text, null::integer, + null::uuid, 'invalid_request'::text, null::integer; + return; + end if; + + perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0)); + + select session.session_type into v_session_type + from public.chat_sessions as session + where session.id = p_session_id + and session.user_id = p_user_id + and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null)); + if not found then + return query select false, null::uuid, null::text, + (select profile.credits from public.profiles as profile where profile.id = p_user_id), + null::uuid, 'session_missing'::text, null::integer; + return; + end if; + + if exists ( + select 1 + from public.consultation_requests as request + where request.user_id = p_user_id + and request.request_id = btrim(p_request_id) + ) then + return query select false, null::uuid, null::text, + (select profile.credits from public.profiles as profile where profile.id = p_user_id), + null::uuid, 'request_conflict'::text, null::integer; + return; + end if; + + v_charge := p_credit_cost; + if v_session_type = 'consultation' then + select profile.* into v_profile + from public.profiles as profile + where profile.id = p_user_id + for update; + if found + and v_profile.first_consult_free_used_at is null + and ( + v_profile.first_consult_free_request_id is null + or v_profile.first_consult_free_request_id = btrim(p_request_id) + ) then + -- Difference only: the free question covers the published standard price. + -- No published standard price means do not waive. + select pricing.credit_cost into v_standard + from public.feature_pricing as pricing + where pricing.feature_key = 'chat.standard' + and pricing.model_tier = 'standard' + and pricing.status = 'published' + and pricing.enabled + and (pricing.effective_from is null or pricing.effective_from <= clock_timestamp()) + and (pricing.effective_to is null or pricing.effective_to > clock_timestamp()) + order by pricing.version desc + limit 1; + if v_standard is not null and p_credit_cost >= 0 then + v_charge := greatest(0, p_credit_cost - v_standard); + v_free := true; + end if; + end if; + end if; + + select * into v_authorization + from public.authorize_usage( + p_user_id, + 'chat.standard', + p_requested_model_id, + btrim(p_request_id), + v_charge + ); + + if coalesce(v_authorization.success, false) then + insert into public.consultation_requests(user_id, request_id, session_id, status) + values (p_user_id, btrim(p_request_id), p_session_id, 'reserved'); + if v_free and v_authorization.source is distinct from 'subscription' then + update public.profiles as profile + set first_consult_free_request_id = btrim(p_request_id) + where profile.id = p_user_id + and profile.first_consult_free_used_at is null + and ( + profile.first_consult_free_request_id is null + or profile.first_consult_free_request_id = btrim(p_request_id) + ); + if not found then + raise exception 'first_consult_free_hold_lost'; + end if; + end if; + end if; + + return query select + v_authorization.success, + v_authorization.reservation_id, + v_authorization.source, + v_authorization.credits, + v_authorization.subscription_id, + v_authorization.reason, + v_authorization.retry_after_seconds; +end; +$$; + +revoke all on function public.reserve_consultation_usage(uuid, text, uuid, text, integer) + from public, anon, authenticated; +grant execute on function public.reserve_consultation_usage(uuid, text, uuid, text, integer) + to service_role; +do $$ begin + if exists(select 1 from pg_roles where rolname = 'admin_runtime') then + grant execute on function public.reserve_consultation_usage(uuid, text, uuid, text, integer) + to admin_runtime; + end if; +end $$; + +create or replace function public.cancel_consultation_credit(p_user_id uuid, p_request_id text) +returns table(success boolean, credits integer, error_code text) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_request public.consultation_requests%rowtype; + v_release record; + v_balance integer; +begin + if p_user_id is null or btrim(coalesce(p_request_id, '')) = '' then + return query select false, null::integer, 'invalid_request'::text; + return; + end if; + + perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0)); + + select request.* into v_request + from public.consultation_requests as request + where request.user_id = p_user_id + and request.request_id = btrim(p_request_id) + for update; + + if not found then + return query select false, null::integer, 'request_missing'::text; + return; + end if; + + select profile.credits into v_balance + from public.profiles as profile + where profile.id = p_user_id; + + if v_request.status = 'completed' then + return query select false, v_balance, 'request_completed'::text; + return; + end if; + if v_request.status = 'cancelled' then + perform public.release_first_consult_free_hold(p_user_id, p_request_id); + return query select true, v_balance, null::text; + return; + end if; + if v_request.status <> 'reserved' then + return query select false, v_balance, 'invalid_request_status'::text; + return; + end if; + + select * into v_release + from public.release_usage(p_user_id, btrim(p_request_id), 'consultation_cancelled'); + if not coalesce(v_release.success, false) then + return query select false, v_release.credits, v_release.error_code; + return; + end if; + + update public.consultation_requests + set status = 'cancelled', updated_at = clock_timestamp() + where user_id = p_user_id + and request_id = btrim(p_request_id) + and status = 'reserved'; + perform public.release_first_consult_free_hold(p_user_id, p_request_id); + + return query select true, v_release.credits, null::text; +end; +$$; + +revoke all on function public.cancel_consultation_credit(uuid, text) + from public, anon, authenticated; +grant execute on function public.cancel_consultation_credit(uuid, text) + to service_role; +do $$ begin + if exists(select 1 from pg_roles where rolname = 'admin_runtime') then + grant execute on function public.cancel_consultation_credit(uuid, text) + to admin_runtime; + end if; +end $$; + +create or replace function public.complete_consultation_response( + p_user_id uuid, + p_request_id text, + p_session_id uuid, + p_response_message jsonb, + p_actual_usage jsonb +) +returns table(success boolean, credits integer, error_code text) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_request public.consultation_requests%rowtype; + v_settlement record; + v_balance integer; +begin + if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then + return query select false, null::integer, 'invalid_request'::text; + return; + end if; + if jsonb_typeof(p_response_message) <> 'object' + or p_response_message->>'role' <> 'assistant' + or btrim(coalesce(p_response_message->>'text', '')) = '' then + return query select false, null::integer, 'invalid_response_message'::text; + return; + end if; + + perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0)); + + select request.* into v_request + from public.consultation_requests as request + where request.user_id = p_user_id + and request.request_id = btrim(p_request_id) + and request.session_id = p_session_id + for update; + + if not found then + return query select false, null::integer, 'request_missing'::text; + return; + end if; + if v_request.status = 'cancelled' then + return query select false, null::integer, 'request_cancelled'::text; + return; + end if; + if v_request.status = 'completed' then + select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id; + return query select v_request.response_message = p_response_message, v_balance, + case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end; + return; + end if; + + update public.chat_sessions as session + set messages = session.messages || jsonb_build_array(p_response_message), + updated_at = clock_timestamp() + where session.id = p_session_id + and session.user_id = p_user_id + and (session.session_type = 'consultation' or (session.session_type = 'synastry_report' and session.synastry_report_id is not null)); + if not found then + return query select false, null::integer, 'session_missing'::text; + return; + end if; + + update public.consultation_requests + set response_message = p_response_message, + updated_at = clock_timestamp() + where user_id = p_user_id and request_id = btrim(p_request_id); + + select * into v_settlement + from public.complete_usage(p_user_id, btrim(p_request_id), coalesce(p_actual_usage, '{}'::jsonb)); + if not coalesce(v_settlement.success, false) then + raise exception 'consultation_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown'); + end if; + + perform public.consume_first_consult_free(p_user_id, p_request_id); + + update public.consultation_requests + set status = 'completed', updated_at = clock_timestamp() + where user_id = p_user_id and request_id = btrim(p_request_id); + + return query select true, v_settlement.credits, null::text; +end; +$$; + +revoke all on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb) + from public, anon, authenticated; +grant execute on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb) + to service_role; +do $$ begin + if exists(select 1 from pg_roles where rolname = 'admin_runtime') then + grant execute on function public.complete_consultation_response(uuid, text, uuid, jsonb, jsonb) + to admin_runtime; + end if; +end $$; + +create or replace function public.complete_consultation_free( + p_user_id uuid, + p_request_id text, + p_session_id uuid, + p_response_message jsonb, + p_actual_usage jsonb +) +returns table(success boolean, credits integer, error_code text) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_request public.consultation_requests%rowtype; + v_res public.usage_reservations%rowtype; + v_settlement record; + v_balance integer; +begin + if p_user_id is null or p_session_id is null or btrim(coalesce(p_request_id, '')) = '' then + return query select false, null::integer, 'invalid_request'::text; + return; + end if; + if jsonb_typeof(p_response_message) is distinct from 'object' + or p_response_message->>'role' is distinct from 'assistant' + or p_response_message->>'responseKind' is distinct from 'smalltalk' + or jsonb_typeof(p_response_message->'text') is distinct from 'string' + or btrim(coalesce(p_response_message->>'text', '')) = '' + or length(p_response_message->>'text') > 20 + or (p_response_message - array['role', 'text', 'responseKind']) <> '{}'::jsonb then + return query select false, null::integer, 'invalid_response_message'::text; + return; + end if; + if jsonb_typeof(p_actual_usage) is distinct from 'object' then + return query select false, null::integer, 'invalid_actual_usage'::text; + return; + end if; + + perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || btrim(p_request_id), 0)); + select request.* into v_request + from public.consultation_requests as request + where request.user_id = p_user_id + and request.request_id = btrim(p_request_id) + and request.session_id = p_session_id + for update; + if not found then + return query select false, null::integer, 'request_missing'::text; + return; + end if; + if v_request.status = 'cancelled' then + return query select false, null::integer, 'request_cancelled'::text; + return; + end if; + if v_request.status = 'completed' then + select profile.credits into v_balance from public.profiles as profile where profile.id = p_user_id; + return query select coalesce(v_request.response_message = p_response_message, false), v_balance, + case when v_request.response_message = p_response_message then null::text else 'response_conflict'::text end; + return; + end if; + if v_request.status <> 'reserved' then + return query select false, null::integer, 'invalid_request_status'::text; + return; + end if; + + select reservation.* into v_res from public.usage_reservations as reservation + where reservation.user_id = p_user_id and reservation.request_id = btrim(p_request_id) + for update; + if not found or v_res.status <> 'reserved' or v_res.feature_key <> 'chat.standard' then + return query select false, null::integer, 'invalid_reservation'::text; + return; + end if; + select profile.credits into v_balance from public.profiles as profile + where profile.id = p_user_id for update; + if not found then + return query select false, null::integer, 'profile_missing'::text; + return; + end if; + + update public.chat_sessions as session + set messages = session.messages || jsonb_build_array(p_response_message), updated_at = clock_timestamp() + where session.id = p_session_id and session.user_id = p_user_id and session.session_type = 'consultation'; + if not found then + return query select false, null::integer, 'session_missing'::text; + return; + end if; + + select * into v_settlement from public.complete_usage( + p_user_id, btrim(p_request_id), + p_actual_usage || jsonb_build_object('metadata', coalesce(p_actual_usage->'metadata', '{}'::jsonb) + || jsonb_build_object('responseKind', 'smalltalk', 'freeCompletion', true)) + ); + if not coalesce(v_settlement.success, false) then + raise exception 'consultation_free_usage_settlement_failed:%', coalesce(v_settlement.error_code, 'unknown'); + end if; + + if v_res.source = 'credits' and v_res.credit_amount > 0 then + update public.profiles as profile + set credits = profile.credits + v_res.credit_amount, updated_at = clock_timestamp() + where profile.id = p_user_id returning profile.credits into v_balance; + insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id, model) + values(p_user_id, 'refund', v_res.credit_amount, v_balance, v_res.request_id, v_res.requested_model_id); + end if; + update public.usage_reservations + set status = 'released', released_at = clock_timestamp(), release_reason = 'consultation_smalltalk_free' + where id = v_res.id; + update public.consultation_requests + set status = 'completed', response_message = p_response_message, updated_at = clock_timestamp() + where user_id = p_user_id and request_id = btrim(p_request_id); + -- Smalltalk does not use the free question. + perform public.release_first_consult_free_hold(p_user_id, p_request_id); + + return query select true, v_balance, null::text; +end; +$$; + +revoke all on function public.complete_consultation_free(uuid, text, uuid, jsonb, jsonb) + from public, anon, authenticated; +grant execute on function public.complete_consultation_free(uuid, text, uuid, jsonb, jsonb) + to service_role; + +commit; diff --git a/frontend/tests/birth-sky-polish-20260928.test.ts b/frontend/tests/birth-sky-polish-20260928.test.ts index ddaaa83a..dbf3314c 100644 --- a/frontend/tests/birth-sky-polish-20260928.test.ts +++ b/frontend/tests/birth-sky-polish-20260928.test.ts @@ -54,8 +54,11 @@ test("the paywall only opens from a refused send; the typed question stays in th assert.doesNotMatch(page, /setOnboardingPaywallOpen\(true\)/); assert.doesNotMatch(page, /!starterHomeVisible\s*\n\s*\|\| !account/); // Pre-flight balance check: before send() clears the draft, paywall first, else billing. - const guard = hook.slice(hook.indexOf("if (account.credits <= 0 && !account.activeSubscription) {")); - assert.match(guard, /^if \(account\.credits <= 0 && !account\.activeSubscription\) \{\s*if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);\s*return false;/); + // 原值: if (account.credits <= 0 && !account.activeSubscription) + // 新值: 同一条件再加 && !account.firstConsultFree + // 原因: 首问免费只看服务端给的布尔值,0 点且仍有这一次时不再先弹兑换窗(#95 T1) + const guard = hook.slice(hook.indexOf("if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {")); + assert.match(guard, /^if \(account\.credits <= 0 && !account\.activeSubscription && !account\.firstConsultFree\) \{\s*if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);\s*return false;/); assert.ok(hook.indexOf("if (!openRedeemPaywallOnce())") < hook.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");")); // 402: same order; the existing rollback restores the draft (reserve did not commit). assert.match(hook, /if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/); diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 4d28c855..d8801369 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -59,9 +59,12 @@ test("in-app destinations navigate client-side so the chat survives the round tr }); test("both insufficient-credit paths are soft so the typed question is not thrown away", () => { + // 原值: if (account.credits <= 0 && !account.activeSubscription) { + // 新值: if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) { + // 原因: 首问免费只看服务端给的布尔值,0 点且仍有这一次时不再先弹兑换窗(#95 T1) const creditGuard = sourceBetween( pageSource, - "if (account.credits <= 0 && !account.activeSubscription) {", + "if (account.credits <= 0 && !account.activeSubscription && !account.firstConsultFree) {", "const [year, month, day]", ); const sendSource = pageSource.slice(pageSource.indexOf(" async function send(")); diff --git a/frontend/tests/database-onboarding-journey.test.ts b/frontend/tests/database-onboarding-journey.test.ts new file mode 100644 index 00000000..55c6df81 --- /dev/null +++ b/frontend/tests/database-onboarding-journey.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import pg from "pg"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; +const sessionA = "99999999-9999-4999-8999-999999999981"; +const sessionB = "99999999-9999-4999-8999-999999999982"; +const sessionC = "99999999-9999-4999-8999-999999999983"; +const chartOld = "99999999-9999-4999-8999-999999999984"; +const chartChild = "99999999-9999-4999-8999-999999999985"; + +test("first consult is free once, onboarding state is service-owned, and relation is constrained", { skip: docker ? false : "docker unavailable" }, async () => { + const fixture = startPostgresFixture(); + try { + const migration = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, + }); + assert.equal(migration.status, 0, migration.stderr); + fixture.psql(`insert into identity.users(name,email,email_verified,email_verified_at) values + ('Fictional A','onboarding-a@example.com',true,now()), + ('Fictional B','onboarding-b@example.com',true,now()), + ('Fictional C','onboarding-c@example.com',true,now()), + ('Fictional D','onboarding-d@example.com',true,now());`); + const user = (email: string) => fixture.psql(`select id from identity.users where email='${email}'`); + const a = user("onboarding-a@example.com"); + const b = user("onboarding-b@example.com"); + const c = user("onboarding-c@example.com"); + const d = user("onboarding-d@example.com"); + const session = (id: string, owner: string) => + `insert into public.chat_sessions(id,user_id,title,theme,model_id,messages,session_type,updated_at) values ('${id}','${owner}','test','general','test-model','[]','consultation',now());`; + fixture.psql(`update public.profiles set credits=0 where id in ('${a}','${c}'); + update public.profiles set credits=5 where id='${b}'; + ${session(sessionA, a)} + ${session(sessionB, b)} + ${session(sessionC, c)}`); + + for (const column of ["onboarding_state", "first_consult_free_used_at", "first_consult_free_request_id"]) { + assert.equal(fixture.psql(`select has_column_privilege('authenticated','public.profiles','${column}','update')`), "f", column); + assert.equal(fixture.psql(`select has_column_privilege('service_role','public.profiles','${column}','select')`), "t", column); + } + assert.equal(fixture.psql("select has_function_privilege('authenticated','public.save_onboarding_state(uuid,jsonb)','execute')"), "f"); + assert.equal(fixture.psql("select has_function_privilege('service_role','public.save_onboarding_state(uuid,jsonb)','execute')"), "t"); + assert.throws(() => fixture.psql(`set role authenticated; update public.profiles set onboarding_state='{}'::jsonb where id='${a}'`)); + + const save = (patch: string) => fixture.psql( + `select success::text || ':' || coalesce(error_code,'ok') || ':' || coalesce(state->>'chartVisitedAt','') || ':' || coalesce(state->'hintsSeen'->>0,'') from public.save_onboarding_state('${a}','${patch}'::jsonb)`, + ); + assert.equal(save('{"chartVisitedAt":"2026-10-22T00:00:00Z"}'), "true:ok:2026-10-22T00:00:00Z:"); + assert.equal(save('{"hintsSeen":["follow-up"]}'), "true:ok:2026-10-22T00:00:00Z:follow-up"); + assert.equal(save('{"nope":true}'), "f:invalid_key::"); + assert.equal(save('{"hintsSeen":["not-a-hint"]}'), "f:invalid_hint::"); + assert.equal(fixture.psql(`select onboarding_state->>'chartVisitedAt' from public.profiles where id='${a}'`), "2026-10-22T00:00:00Z"); + assert.equal(fixture.psql(`select save_onboarding_state.success::text from public.save_onboarding_state('${b}','{"hintsSeen":["tab-chart"]}'::jsonb)`), "true"); + assert.equal(fixture.psql(`select onboarding_state->>'chartVisitedAt' from public.profiles where id='${a}'`), "2026-10-22T00:00:00Z"); + + assert.throws(() => fixture.psql(`insert into public.chart_profiles(id,user_id,role,profile,name,relation) values ('${chartChild}','${a}','other','{}','示例乙','cousin')`)); + fixture.psql(`insert into public.chart_profiles(id,user_id,role,profile,name) values ('${chartOld}','${a}','other','{}','示例丙')`); + assert.equal(fixture.psql(`select coalesce(relation,'null') from public.chart_profiles where id='${chartOld}'`), "null"); + fixture.psql(`update public.chart_profiles set relation='child' where id='${chartOld}'`); + assert.equal(fixture.psql(`select relation from public.chart_profiles where id='${chartOld}'`), "child"); + assert.throws(() => fixture.psql(`insert into public.chart_profiles(user_id,role,profile,name,relation) values ('${a}','self','{}','示例甲','partner')`)); + + fixture.psql(`insert into public.consultation_requests(user_id,request_id,status,response_message) values + ('${c}','old-done','completed','{"role":"assistant","text":"done","responseKind":"reading"}'), + ('${d}','old-chat','completed','{"role":"assistant","text":"你好","responseKind":"smalltalk"}');`); + assert.equal(fixture.psql("select public.backfill_first_consult_free()"), "1"); + assert.equal(fixture.psql("select public.backfill_first_consult_free()"), "0"); + assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${c}'`), "f"); + assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${d}'`), "true"); + assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "true"); + + const reserve = (owner: string, id: string, sid: string, cost: number) => + fixture.psql(`select success::text || ':' || coalesce(reason,'ok') from public.reserve_consultation_usage('${owner}','${id}','${sid}','test-model',${cost})`); + assert.equal(reserve(a, "no-price", sessionA, 1), "f:insufficient_credits"); + fixture.psql(`insert into public.feature_pricing(feature_key,model_tier,credit_cost,version,status,enabled) values ('chat.standard','standard',3,1,'published',true)`); + assert.equal(reserve(c, "old-account", sessionC, 3), "f:insufficient_credits"); + + const clients = [ + new pg.Client({ connectionString: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }), + new pg.Client({ connectionString: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }), + ]; + try { + await Promise.all(clients.map((client) => client.connect())); + const raced = await Promise.all([ + clients[0]!.query(`select success from public.reserve_consultation_usage('${a}','race-1','${sessionA}','test-model',3)`), + clients[1]!.query(`select success from public.reserve_consultation_usage('${a}','race-2','${sessionA}','test-model',3)`), + ]); + const wins = raced.filter((result) => result.rows[0]?.success === true); + assert.equal(wins.length, 1); + } finally { + await Promise.all(clients.map((client) => client.end())); + } + assert.equal(fixture.psql(`select credits::text from public.profiles where id='${a}'`), "0"); + assert.equal(fixture.psql(`select count(*) from public.credit_transactions where user_id='${a}' and request_id in ('race-1','race-2')`), "0"); + const held = fixture.psql(`select first_consult_free_request_id from public.profiles where id='${a}'`); + assert.match(held, /^race-[12]$/); + assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "true"); + assert.equal(fixture.psql(`select success::text from public.cancel_consultation_credit('${a}','${held}')`), "true"); + assert.equal(fixture.psql(`select coalesce(first_consult_free_request_id,'null') || ':' || (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "null:true"); + + assert.equal(reserve(a, "smalltalk", sessionA, 3), "true:ok"); + assert.equal( + fixture.psql(`select success::text from public.complete_consultation_free('${a}','smalltalk','${sessionA}','{"role":"assistant","text":"你好","responseKind":"smalltalk"}','{}')`), + "true", + ); + assert.equal(fixture.psql(`select coalesce(first_consult_free_request_id,'null') || ':' || (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "null:true"); + + assert.equal(reserve(a, "standard", sessionA, 3), "true:ok"); + assert.equal( + fixture.psql(`select success::text from public.complete_consultation_response('${a}','standard','${sessionA}','{"role":"assistant","text":"a full answer"}','{}')`), + "true", + ); + assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${a}'`), "f"); + assert.equal(fixture.psql(`select credits::text from public.profiles where id='${a}'`), "0"); + assert.equal(fixture.psql(`select (metadata->>'billingSource') || ':' || (metadata->>'creditCost') from public.usage_ledger where user_id='${a}' and request_id='standard'`), "first_consult_free:0"); + assert.equal(reserve(a, "second", sessionA, 3), "f:insufficient_credits"); + + assert.equal(reserve(b, "premium-chat", sessionB, 5), "true:ok"); + assert.equal(fixture.psql(`select credits::text from public.profiles where id='${b}'`), "3"); + assert.equal( + fixture.psql(`select success::text from public.complete_consultation_free('${b}','premium-chat','${sessionB}','{"role":"assistant","text":"你好","responseKind":"smalltalk"}','{}')`), + "true", + ); + assert.equal(fixture.psql(`select credits::text || ':' || (first_consult_free_used_at is null)::text from public.profiles where id='${b}'`), "5:true"); + assert.equal(reserve(b, "premium-paid", sessionB, 5), "true:ok"); + assert.equal(fixture.psql(`select credits::text from public.profiles where id='${b}'`), "3"); + assert.equal(fixture.psql(`select amount::text from public.credit_transactions where user_id='${b}' and request_id='premium-paid' and transaction_type='reserve'`), "-2"); + assert.equal( + fixture.psql(`select success::text from public.complete_consultation_response('${b}','premium-paid','${sessionB}','{"role":"assistant","text":"a premium answer"}','{}')`), + "true", + ); + assert.equal(fixture.psql(`select (first_consult_free_used_at is null)::text from public.profiles where id='${b}'`), "f"); + assert.equal(fixture.psql(`select (metadata->>'billingSource') || ':' || (metadata->>'creditCost') from public.usage_ledger where user_id='${b}' and request_id='premium-paid'`), "first_consult_free:2"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/onboarding-journey-data.test.ts b/frontend/tests/onboarding-journey-data.test.ts new file mode 100644 index 00000000..a8922869 --- /dev/null +++ b/frontend/tests/onboarding-journey-data.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { parseChartSubjectWrite, libraryProfileFromSubjectRow } from "../src/lib/chart-subject-write.ts"; +import { readProfile } from "../src/lib/home-profile.ts"; +import { parseOnboardingPatch, parseOnboardingState } from "../src/lib/onboarding-state.ts"; +import { parsePersonRelation, personRelationLabel } from "../src/lib/person-relation.ts"; + +const route = readFileSync(new URL("../src/app/api/onboarding-state/route.ts", import.meta.url), "utf8"); +const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); + +test("person relation accepts only the five household values", () => { + assert.equal(parsePersonRelation("child"), "child"); + assert.equal(parsePersonRelation("family"), null); + assert.equal(parsePersonRelation(null), null); + assert.equal(personRelationLabel(null), "其他"); + assert.equal(personRelationLabel("parent"), "父母"); + assert.equal(personRelationLabel("other"), "其他"); +}); + +test("a chart write omits relation unless the client sent the key", () => { + const omitted = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12" }); + assert.equal(omitted.ok, true); + if (!omitted.ok) return; + assert.equal("relation" in omitted.write.columns, false); + + const child = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12", relation: "child" }); + assert.equal(child.ok, true); + if (!child.ok) return; + assert.equal(child.write.columns.relation, "child"); + + const cleared = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12", relation: null }); + assert.equal(cleared.ok, true); + if (!cleared.ok) return; + assert.equal(cleared.write.columns.relation, null); + + const illegal = parseChartSubjectWrite({ name: "示例乙", date: "1992-03-12", relation: "client" }); + assert.equal(illegal.ok, false); +}); + +test("an old chart row with a null relation still reads", () => { + const profile = libraryProfileFromSubjectRow({ name: "示例乙", relation: null, profile: {} }); + assert.equal(profile.relation, null); + assert.equal(readProfile(profile).relation, null); + assert.equal("relation" in readProfile({ name: "示例乙" }), false); +}); + +test("onboarding state keeps only the three keys and the hint list", () => { + const saved = parseOnboardingPatch({ + chartVisitedAt: "2026-10-22T00:00:00Z", + hintsSeen: ["follow-up", "tab-chart"], + }); + assert.equal(saved.ok, true); + assert.equal(parseOnboardingPatch({ userId: "someone-else" }).ok, false); + assert.equal(parseOnboardingPatch({ hintsSeen: ["not-a-hint"] }).ok, false); + assert.equal(parseOnboardingPatch({ checklistDismissedAt: "tomorrow" }).ok, false); + assert.deepEqual(parseOnboardingState({ + chartVisitedAt: "2026-10-22T00:00:00Z", + hintsSeen: ["follow-up"], + extra: true, + }), { + chartVisitedAt: "2026-10-22T00:00:00Z", + hintsSeen: ["follow-up"], + }); +}); + +test("the onboarding route is login-only and writes the session user", () => { + assert.match(route, /请先登录/); + assert.match(route, /status: 401/); + assert.match(route, /p_user_id: user\.id/); + assert.doesNotMatch(route, /userId|body\.id|p_user_id: parsed/); +}); + +test("account GET exposes firstConsultFree from the column, and a missing column is not free", () => { + assert.match(account, /first_consult_free_used_at/); + assert.match(account, /firstConsultFree: firstConsultFreeFromProfile\(profile\)/); + assert.match(account, /hasOwnProperty\.call\(profile, "first_consult_free_used_at"\)/); + const getSource = account.slice(account.indexOf("export async function GET"), account.indexOf("export async function PATCH")); + const fallbacks = getSource.slice(getSource.indexOf("isMissingProfileColumn")); + assert.equal(fallbacks.includes("first_consult_free_used_at"), false); +}); diff --git a/frontend/tests/people-archive-view.test.tsx b/frontend/tests/people-archive-view.test.tsx index caff8fe9..bd21e605 100644 --- a/frontend/tests/people-archive-view.test.tsx +++ b/frontend/tests/people-archive-view.test.tsx @@ -106,6 +106,30 @@ test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron", assert.match(markup, /class="people-archive-hint">还可以保存 18 个人 { + assert.match(render(), /示例乙<\/b>其他<\/span>/); + const childPeople = [people[0]!, { ...people[1]!, profile: { ...otherProfile, relation: "child" as const } }]; + assert.match(render({ people: childPeople }), /示例乙<\/b>孩子<\/span>/); + const detail = render({ + view: "detail", + people: childPeople, + selected: childPeople[1], + selectedId: childPeople[1]!.id, + }); + assert.match(detail, /

示例乙孩子<\/span><\/h2>/); + const editing = render({ + view: "detail", + selected: people[1], + selectedId: people[1]!.id, + editing: true, + draft: { ...otherProfile, relation: "child" }, + }); + assert.match(editing, /TA 是你的/); + assert.match(editing, />孩子 { const selfCurrent = render(); assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/); diff --git a/frontend/tests/profile-gender-20260927.test.ts b/frontend/tests/profile-gender-20260927.test.ts index ebc953a4..507dfb30 100644 --- a/frontend/tests/profile-gender-20260927.test.ts +++ b/frontend/tests/profile-gender-20260927.test.ts @@ -85,7 +85,10 @@ test("account PATCH accepts gender alone, clears it with null, and rejects anyth assert.equal(accountProfilePatchSchema.safeParse({ gender: "女" }).success, false); const route = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); assert.match(route, /payload\.gender !== undefined \? \{ gender: payload\.gender \}/); - assert.match(route, /rectification_case_id,gender"\)/, "GET returns the stored gender"); + // 原值: /rectification_case_id,gender"\)/ + // 新值: /rectification_case_id,gender,first_consult_free_used_at"\)/ + // 原因: 账户 GET 在 gender 后多读首问是否已用,用来返回 firstConsultFree(#95 T1) + assert.match(route, /rectification_case_id,gender,first_consult_free_used_at"\)/, "GET returns the stored gender and the free-question flag"); }); test("people writes carry gender only when sent; reads expose it only when stored", () => { diff --git a/frontend/tests/profile-gender-ui-20260927.test.tsx b/frontend/tests/profile-gender-ui-20260927.test.tsx index 35730c1d..c99eb5b8 100644 --- a/frontend/tests/profile-gender-ui-20260927.test.tsx +++ b/frontend/tests/profile-gender-ui-20260927.test.tsx @@ -108,7 +108,13 @@ test("people edit form: the gender field is part of the form and writes the draf const editing = renderToStaticMarkup(); - assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]); + // 原值: ["女:false", "男:true", "不填:false"] + // 新值: 性别单选前面多一组「TA 是你的」(空关系显示为已选「其他」) + // 原因: 档案编辑他人时要能补关系(#95 T2);性别单选本身没改 + assert.deepEqual(radios(editing), [ + "伴侣:false", "孩子:false", "父母:false", "朋友:false", "其他:true", + "女:false", "男:true", "不填:false", + ]); }); test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => {