Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdd592d3f3 |
@@ -1,5 +1,11 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-23 — 新账户可以先问一个问题,档案能标明和你的关系
|
||||
|
||||
- 每个账户有一次不扣点的正式提问。问完才算用掉;中途停下、失败,或只是寒暄,这一次还在。选了比标准档更贵的模型时,只扣高出的那一部分。
|
||||
- 星盘档案里,别人可以标明是伴侣、孩子、父母、朋友或其他。没选过的显示「其他」。本人仍然只标「本人」。
|
||||
- Skill 版本不变。
|
||||
|
||||
## 2026-10-22 — 星盘大运右栏可以切到 Yogini
|
||||
|
||||
- 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」
|
||||
|
||||
@@ -72,6 +72,10 @@ _避免使用_:用户反馈状态、通知状态
|
||||
一张星盘档案对应的一个人。户主本人也是其中一个人。不要再叫「当前星盘」来指这个人。
|
||||
_避免使用_:当前星盘、关系、默认星盘
|
||||
|
||||
**关系(人物)**:
|
||||
这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。
|
||||
_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户
|
||||
|
||||
**星盘档案**:
|
||||
侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。
|
||||
_避免使用_:星盘库、星盘资料(设置里的旧面板)
|
||||
|
||||
@@ -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。
|
||||
- 没有单独造一套会员夹具去测「会员不占免费额度」;规则写在预扣函数里:来源是会员时不写入占用。
|
||||
@@ -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
@@ -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`。不新造色值。
|
||||
|
||||
|
||||
@@ -505,3 +505,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
不写「系统繁忙」「请稍后重试」。计算要等空位时,进度行只写「人有点多,排一下队」。这一轮最终没排上:「人有点多,这一轮没排上,点数已经退回。」对话太密:「这一会儿问得有点密,先停一下。」校正回合太密:「这一会儿校正问得有点密,先停一下。」这一小时报告太多:「这一小时的报告已经够多了,先停一下。」
|
||||
|
||||
## 人物关系(2026-10-23)
|
||||
|
||||
星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。
|
||||
|
||||
|
||||
@@ -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" ? "确认称呼" : "发送"}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+647
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user