Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) fdd592d3f3 feat: 新账户首问免费,档案可标明关系
只做启程(一):同一笔预扣覆盖已公布的标准档,更贵的只扣差价;引导状态与人物关系只加列。tsc 与 lint 通过。test:db 91 项,失败 14,其中 13 条为 Windows 已知失败;校正遥测单独重跑通过。

Co-Authored-By: Grok (agent loop)
2026-10-23 00:34:28 +08:00
32 changed files with 1323 additions and 25 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-23 — 新账户可以先问一个问题,档案能标明和你的关系
- 每个账户有一次不扣点的正式提问。问完才算用掉;中途停下、失败,或只是寒暄,这一次还在。选了比标准档更贵的模型时,只扣高出的那一部分。
- 星盘档案里,别人可以标明是伴侣、孩子、父母、朋友或其他。没选过的显示「其他」。本人仍然只标「本人」。
- Skill 版本不变。
## 2026-10-22 — 星盘大运右栏可以切到 Yogini
- 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」
+4
View File
@@ -72,6 +72,10 @@ _避免使用_:用户反馈状态、通知状态
一张星盘档案对应的一个人。户主本人也是其中一个人。不要再叫「当前星盘」来指这个人。
_避免使用_:当前星盘、关系、默认星盘
**关系(人物)**:
这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。
_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户
**星盘档案**:
侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。
_避免使用_:星盘库、星盘资料(设置里的旧面板)
+16
View File
@@ -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`。
@@ -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。
- 没有单独造一套会员夹具去测「会员不占免费额度」;规则写在预扣函数里:来源是会员时不写入占用。
+1 -1
View File
@@ -37,7 +37,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `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` | (一)待验收;(二)(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U |
| `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前 | 待领取(循环) | 原型 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`(未推送) |
@@ -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 是你的」。
## 以后追加
- (二)先看谁的盘、起步三问
- (三)启程清单、一次性提示
- (四)动效,以及打开「减少动态效果」再走一遍
+5 -4
View File
@@ -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=<id>` 历史记录,浏览器返回和详情顶部的「‹ 返回」走同一条路回到列表;刷新详情地址直接打开那个人。桌面点人不压历史。
- **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`。不新造色值。
+4
View File
@@ -505,3 +505,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」
## 人物关系(2026-10-23)
星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。
+1 -1
View File
@@ -1275,7 +1275,7 @@ export default function Home() {
<ChatComposer
inputRef={composerInput}
inputLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription), subjectDeleted, synastryClosed })}
placeholder={ordinaryComposerPlaceholder({ locked: rectificationComposerLocked, hasAccount: Boolean(account), profileComplete: synastryChat || profileComplete, onboardingName: onboardingStep === "name", presetMessageFinished, creditsEmpty: Boolean(account && account.credits === 0 && !account.activeSubscription && !account.firstConsultFree), subjectDeleted, synastryClosed })}
maxLength={!synastryChat && !profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={synastryClosed || subjectDeleted || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || (!synastryChat && !profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!synastryChat && !profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
+7 -1
View File
@@ -28,6 +28,11 @@ function adminEntryUrl(): string {
: "/admin";
}
function firstConsultFreeFromProfile(profile: object): boolean {
return Object.prototype.hasOwnProperty.call(profile, "first_consult_free_used_at")
&& (profile as { first_consult_free_used_at?: unknown }).first_consult_free_used_at == null;
}
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "PGRST204"
@@ -52,7 +57,7 @@ export async function GET() {
const admin = createAdminSupabaseClient();
let { data: profile, error: profileError } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id,gender")
.select("credits,active_birth_time,birth_time_status,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,ayanamsa,name,birth_time,rectification_case_id,gender,first_consult_free_used_at")
.eq("id", userId)
.single();
@@ -168,6 +173,7 @@ export async function GET() {
},
chartBirth: resolveServerOwnedChartBirth(profile),
compatibility,
firstConsultFree: firstConsultFreeFromProfile(profile),
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
+5 -1
View File
@@ -25,10 +25,12 @@ type Row = {
model_steps: unknown;
classification_duration_ms: string | null;
answer_reasoning_ms: string | null;
billing_source: string | null;
free_credit_cost: string | null;
total_count: string;
};
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,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),
@@ -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);
}
}
@@ -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" },
]}
/>
);
}
@@ -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}
@@ -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<HTMLFormElement>) => 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 ? (
<PersonRelationField
idPrefix={nameInputId}
value={value.relation}
onChange={(relation) => onChange((current) => ({ ...current, relation }))}
/>
) : null}
{showGender ? (
<ProfileGenderField
idPrefix={nameInputId}
@@ -6,6 +6,7 @@ import { FileText, MessageSquareText, Orbit } from "lucide-react";
import { newChatHref } from "@/lib/chat-session-url";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { ChartProfileForm } from "@/components/chart-profile-form";
import { personRelationLabel } from "@/lib/person-relation";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { fetchAccount } from "@/lib/home-cloud-sync";
@@ -409,7 +410,9 @@ export function PeopleArchiveView({
<PeopleAvatar person={person} />
<span className="people-archive-row-name">
<b>{peopleDisplayName(person.profile, person.role)}</b>
{person.role === "self" ? <span className="people-archive-tag">本人</span> : null}
{person.role === "self"
? <span className="people-archive-tag">本人</span>
: <span className="people-archive-tag">{personRelationLabel(person.profile.relation)}</span>}
</span>
<small className="people-archive-row-meta">
{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({
<header className="people-archive-head">
<PeopleAvatar person={selected} large />
<div>
<h2>{peopleDisplayName(selected.profile, selected.role)}</h2>
<h2>{peopleDisplayName(selected.profile, selected.role)}{selected.role === "self" ? null : <span className="people-archive-tag">{personRelationLabel(selected.profile.relation)}</span>}</h2>
<p>{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}</p>
</div>
</header>
@@ -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 (
<div className="profile-gender-field">
<span className="profile-gender-title" id={titleId}>{PERSON_RELATION_TITLE}</span>
<div className="profile-gender-options" role="radiogroup" aria-labelledby={titleId}>
{PERSON_RELATIONS.map((option) => {
const selected = option === shown;
return (
<button
key={option}
className={`profile-gender-option${selected ? " is-selected" : ""}`}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(option)}
>
{PERSON_RELATION_LABELS[option]}
</button>
);
})}
</div>
</div>
);
}
+1 -1
View File
@@ -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;
}
+9
View File
@@ -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<string, string | number | null> = {
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<string, unknown>): 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 }
+4
View File
@@ -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 } : {}),
};
}
+5
View File
@@ -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";
+92
View File
@@ -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<string, unknown>;
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<string, unknown>;
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 };
}
+25
View File
@@ -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<Record<PersonRelation, string>> = {
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;
}
+6 -2
View File
@@ -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<string, unknown>;
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,
});
@@ -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;
@@ -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" \}\);/);
@@ -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("));
@@ -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();
}
});
@@ -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);
});
@@ -106,6 +106,30 @@ test("D2 list rows: avatar, name, 本人 tag, one monospace meta line, chevron",
assert.match(markup, /class="people-archive-hint">还可以保存 18 个人</);
});
test("someone else shows 其他 until a relation is saved; the form asks TA 是你的", () => {
assert.match(render(), /<b>示例乙<\/b><span class="people-archive-tag">其他<\/span>/);
const childPeople = [people[0]!, { ...people[1]!, profile: { ...otherProfile, relation: "child" as const } }];
assert.match(render({ people: childPeople }), /<b>示例乙<\/b><span class="people-archive-tag">孩子<\/span>/);
const detail = render({
view: "detail",
people: childPeople,
selected: childPeople[1],
selectedId: childPeople[1]!.id,
});
assert.match(detail, /<h2>示例乙<span class="people-archive-tag">孩子<\/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 selfEdit = render({ view: "detail", editing: true, draft: selfProfile });
assert.doesNotMatch(selfEdit, /TA 是你的/);
});
test("D2 the current person carries is-current, whoever it is", () => {
const selfCurrent = render();
assert.match(selfCurrent, /class="people-archive-row is-current[^"]*" aria-current="true"[\s\S]*示例甲/);
@@ -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", () => {
@@ -108,7 +108,13 @@ test("people edit form: the gender field is part of the form and writes the draf
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({
selected: people[1]!, selectedId: people[1]!.id, editing: true, draft: otherProfile,
})} />);
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", () => {