From 673f8fed306dd776d4e822050e5822585f742a7d Mon Sep 17 00:00:00 2001 From: zl-q Date: Thu, 21 May 2026 16:26:58 +0800 Subject: [PATCH] feat: add invite rewards and redeem codes --- .../check.jsonl | 4 + .../implement.jsonl | 6 + .../prd.md | 254 ++++++++++ .../task.json | 40 ++ .../versions/20260411_0001_core_llm_schema.py | 84 +++- ...20260411_0002_users_chat_points_invites.py | 437 +++++++++++++---- .../versions/20260411_0003_notifications.py | 157 ++++-- .../20260415_0001_compliance_and_feedback.py | 98 +++- .../20260428_0004_apple_iap_final_head.py | 75 ++- .../20260511_0001_creem_transactions.py | 62 ++- ..._0002_invite_referrals_and_redeem_codes.py | 307 ++++++++++++ backend/src/core/agentscope/events/store.py | 6 +- .../core/agentscope/prompts/system_prompt.py | 4 +- .../core/agentscope/prompts/user_prompt.py | 12 +- backend/src/core/config/settings.py | 5 +- backend/src/models/__init__.py | 8 + backend/src/models/creem_transaction.py | 4 +- backend/src/models/invite_referral.py | 94 ++++ backend/src/models/notification.py | 10 +- backend/src/models/redeem_code.py | 66 +++ backend/src/models/redeem_code_batch.py | 23 + backend/src/models/system_audit_log.py | 54 ++ backend/src/schemas/domain/points.py | 6 + backend/src/v1/auth/dependencies.py | 1 - backend/src/v1/auth/router.py | 4 +- backend/src/v1/invite/repository.py | 123 ++++- backend/src/v1/invite/router.py | 14 +- backend/src/v1/invite/schemas.py | 54 +- backend/src/v1/invite/service.py | 196 +++++++- backend/src/v1/payments/apple_verifier.py | 4 +- backend/src/v1/payments/creem_client.py | 4 +- backend/src/v1/payments/creem_service.py | 21 +- backend/src/v1/payments/schemas.py | 8 +- backend/src/v1/payments/service.py | 15 +- backend/src/v1/points/adjustments.py | 73 +++ backend/src/v1/points/invite_rewards.py | 101 ++++ backend/src/v1/points/repository.py | 47 ++ backend/src/v1/points/router.py | 22 + backend/src/v1/points/schemas.py | 20 +- backend/src/v1/points/service.py | 124 ++++- .../tests/unit/test_invite_redeem_points.py | 194 ++++++++ docs/protocols/common/http-error-codes.md | 14 + .../common/user-points-chat-data-protocol.md | 108 +++- docs/protocols/invite/invite-protocol.md | 85 +++- .../points/points-balance-protocol.md | 1 + .../points/points-redeem-code-protocol.md | 78 +++ infra/scripts/generate-redeem-codes.sh | 19 + infra/scripts/generate_redeem_codes.py | 226 +++++++++ web/design/assets/legal/en/about_us.md | 6 - .../assets/legal/en/terms_of_service.md | 1 - web/design/assets/legal/zh/about_us.md | 6 - .../assets/legal/zh/terms_of_service.md | 1 - web/design/assets/legal/zh_Hant/about_us.md | 6 - .../assets/legal/zh_Hant/terms_of_service.md | 1 - web/src/components/DashboardApp.tsx | 3 + web/src/components/InvitePage.tsx | 461 ++++++++++++++++++ web/src/components/SettingsPage.tsx | 17 +- web/src/i18n/utils.ts | 52 +- web/src/lib/api-routes.ts | 5 + web/src/lib/api.ts | 57 +++ web/src/lib/auth.ts | 10 +- web/src/lib/resources.ts | 42 ++ web/src/pages/en/settings/invite.astro | 7 + web/src/pages/index.astro | 8 +- web/src/pages/login.astro | 9 + web/src/pages/zh/settings/invite.astro | 7 + web/src/pages/zh_Hant/settings/invite.astro | 7 + 67 files changed, 3813 insertions(+), 265 deletions(-) create mode 100644 .trellis/tasks/05-21-referral-recovery-and-redeem-cards/check.jsonl create mode 100644 .trellis/tasks/05-21-referral-recovery-and-redeem-cards/implement.jsonl create mode 100644 .trellis/tasks/05-21-referral-recovery-and-redeem-cards/prd.md create mode 100644 .trellis/tasks/05-21-referral-recovery-and-redeem-cards/task.json create mode 100644 backend/alembic/versions/20260521_0002_invite_referrals_and_redeem_codes.py create mode 100644 backend/src/models/invite_referral.py create mode 100644 backend/src/models/redeem_code.py create mode 100644 backend/src/models/redeem_code_batch.py create mode 100644 backend/src/models/system_audit_log.py create mode 100644 backend/src/v1/points/adjustments.py create mode 100644 backend/src/v1/points/invite_rewards.py create mode 100644 backend/tests/unit/test_invite_redeem_points.py create mode 100644 docs/protocols/points/points-redeem-code-protocol.md create mode 100755 infra/scripts/generate-redeem-codes.sh create mode 100644 infra/scripts/generate_redeem_codes.py create mode 100644 web/src/components/InvitePage.tsx create mode 100644 web/src/pages/en/settings/invite.astro create mode 100644 web/src/pages/login.astro create mode 100644 web/src/pages/zh/settings/invite.astro create mode 100644 web/src/pages/zh_Hant/settings/invite.astro diff --git a/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/check.jsonl b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/check.jsonl new file mode 100644 index 0000000..53b96c8 --- /dev/null +++ b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/check.jsonl @@ -0,0 +1,4 @@ +{"file":"docs/protocols/invite/invite-protocol.md","reason":"验证接口返回与前端展示字段是否与更新后的邀请协议一致"} +{"file":"docs/protocols/common/user-points-chat-data-protocol.md","reason":"验证新增表、账本、审计与绑定后 Creem 充值奖励幂等是否符合统一数据协议"} +{"file":"docs/protocols/common/http-error-codes.md","reason":"验证新增错误码是否补齐前后端映射"} +{"file":"docs/protocols/points/points-balance-protocol.md","reason":"验证卡密兑换后余额、流水入口和展示口径是否一致"} diff --git a/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/implement.jsonl b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/implement.jsonl new file mode 100644 index 0000000..9787054 --- /dev/null +++ b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/backend/database-guidelines.md","reason":"本任务涉及新表、迁移、账本与支付链路入库约束"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"该需求跨协议、后端、Flutter 页面与运营脚本,需要先定义边界与数据流"} +{"file":"docs/protocols/invite/invite-protocol.md","reason":"当前邀请协议只有查询接口,需要在此基础上扩展绑定与详情能力"} +{"file":"docs/protocols/common/user-points-chat-data-protocol.md","reason":"需要扩展邀请奖励、卡密兑换、积分账本和审计账本的数据契约"} +{"file":"docs/protocols/common/http-error-codes.md","reason":"需要新增邀请码绑定和卡密兑换错误码"} +{"file":"docs/protocols/payments/apple-iap-protocol.md","reason":"邀请奖励触发点依赖绑定后 Creem 充值成功后的支付链路定义"} diff --git a/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/prd.md b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/prd.md new file mode 100644 index 0000000..c7d684a --- /dev/null +++ b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/prd.md @@ -0,0 +1,254 @@ +# 恢复邀请体系并新增兑换卡密系统 + +## 1. 目标 + +在现有积分与支付体系上,恢复“我的邀请”业务闭环,并新增面向运营发放的卡密兑换体系。该任务覆盖协议、后端、web 前端、数据库迁移、运营脚本与审计留痕。 + +## 2. 当前现状(已确认) + +### 2.1 邀请体系 + +- 后端当前只有 `GET /api/v1/invite/me`,返回 `{ code, used_count }`。 +- `web/` 当前没有邀请页或卡密兑换入口,但已有 `SettingsPage`、`StorePage`、`api.ts`、`resources.ts` 等可复用页面与资源层。 +- 注册时数据库 trigger `initialize_profile_and_invite_code_on_signup()` 会: + - 为新用户生成自己的邀请码; + - 若 `auth.users.raw_user_meta_data.invite_code` 合法,则给对应 `invite_codes.used_count + 1`; + - 将 `profiles.referred_by` 写成邀请人的 `profiles.id`。 +- 当前邀请只记录“被使用次数”,不记录逐个受邀人的充值达成状态,也没有邀请奖励发放逻辑。 + +### 2.2 积分与支付体系 + +- 当前积分账本 `points_ledger` / `points_audit_ledger` 支持 `register / consume / adjust / purchase / refund`。 +- 支付套餐当前定义在 `backend/src/core/config/static/packages/mapping.yaml`: + - `new_user_pack`(starter) + - `starter_pack` + - `popular_pack` + - `premium_pack` +- Apple IAP 与 Creem 支付成功后,现有代码会写入积分账本与积分审计账本。 + +### 2.3 审计与卡密 + +- 当前仓库没有通用 `audit_logs` 或同类系统审计表落地实现。 +- 当前没有卡密表、卡密生成脚本、卡密兑换接口或兑换 UI。 + +## 3. 用户要求(原始需求整理) + +### 3.1 邀请恢复 + +- 恢复此前“藏起来”的邀请能力。 +- 允许用户绑定别人的邀请码。 +- 绑定后不可解绑。 +- 每个账号只能绑定一次。 +- 受邀人绑定邀请码后第一次 Creem 充值成功后: + - 邀请人获得奖励积分; + - 被邀请人获得奖励积分。 +- 奖励积分值必须通过环境变量配置,当前目标值为 `40`。 + +### 3.2 我的邀请页展示 + +- 能看到邀请人数。 +- 能看到每个受邀关系对应奖励是否到账。 +- 用户给出的目标示例是:页面应区分“已邀请人数”“已达成绑定后充值人数”“未达成绑定后充值人数”,以及奖励到账情况。 + +### 3.3 卡密系统 + +- 覆盖除“新手专享包”以外的三个套餐。 +- 按价格从低到高生成卡密数量: + - 最低价套餐:100 张 + - 中间套餐:40 张 + - 最高价套餐:20 张 +- 生成脚本放在 `infra/scripts` 下,由触发脚本执行。 +- 生成一份 Excel,包含每个卡密及其对应套餐信息。 +- 数据库允许新增表,用于分析卡密、套餐与激活状态。 +- 在“我的邀请”页面下新增“兑换卡密”入口,点击弹窗输入卡密。 +- 兑换成功后,显示“已激活某某套餐,获得 xx 积分”。 +- 积分流水表与系统审计表都要记录卡密兑换链路。 + +## 4. 需求拆解 + +### 4.1 邀请业务数据补全 + +现有 `profiles.referred_by` 只能表示“我被谁邀请”,`invite_codes.used_count` 只能表示“邀请码被使用几次”,都不足以支撑: + +- 单次绑定约束; +- 绑定后 Creem 充值达成判断; +- 奖励幂等发放; +- 页面按受邀人维度展示达成状态。 + +本任务需要新增显式邀请关系表,至少能表达: + +- 邀请人用户 ID; +- 受邀人用户 ID; +- 绑定时使用的邀请码; +- 绑定时间; +- 是否已完成绑定后 Creem 充值; +- 绑定后 Creem 充值对应支付流水 ID; +- 邀请人奖励是否已发放; +- 受邀人奖励是否已发放; +- 对应账本 event_id / 审计事件 ID; +- 幂等字段与唯一约束。 + +### 4.2 邀请奖励触发 + +触发点应绑定在“绑定邀请码后第一次 Creem 充值成功”这个业务事件,而不是注册时: + +- Apple IAP 成功入账; +- Creem 支付成功入账; +- 绑定后 Creem 充值成功判定必须以 `creem` 交易表中的成功记录为准。 + +已确认规则: + +- “绑定邀请码后第一次 Creem 充值成功”仅指真实付费购买成功; +- 必须在 `creem` 交易表存在成功记录,才算邀请绑定奖励达成; +- 卡密兑换不算作邀请绑定奖励达成; +- 邀请奖励只发一次,且邀请双方各一次。 + +### 4.3 邀请绑定入口 + +当前注册 trigger 支持“注册时带邀请码”,但需求要求恢复“绑定别人的代码”,且绑定后不可解绑、只能绑定一次。因此需要新增应用层接口,而不是只依赖注册 trigger: + +- 绑定邀请码接口; +- 查询我的邀请详情接口; +- 接口错误码与前端文案; +- 明确禁止重复绑定、禁止绑定自己的码、禁止绑定不存在或失效的邀请码。 + +### 4.4 卡密体系 + +需要新增面向运营的可追踪卡密: + +- 卡密主表; +- 套餐快照字段; +- 激活状态、激活人、激活时间; +- 生成批次; +- 导出字段; +- 幂等兑换保护。 + +建议卡密兑换本质上走“积分入账 + 审计 + 系统操作日志”一条完整链路,不直接绕过现有积分服务。 + +## 5. 协议与实现范围 + +### 5.1 协议文档(代码前必须更新) + +以下协议需要先更新,再改实现: + +- `docs/protocols/invite/invite-protocol.md` +- `docs/protocols/common/user-points-chat-data-protocol.md` +- `docs/protocols/common/http-error-codes.md` +- 如新增兑换接口,补充 `docs/protocols/points/points-balance-protocol.md` 或新增兑换协议文档 + +### 5.2 后端 + +- Alembic 迁移: + - 邀请关系表 + - 卡密表 + - 如采用通用系统审计表,则新增审计表 +- `core.config.settings`: + - 新增邀请奖励积分环境变量配置 +- 邀请服务: + - 绑定邀请码 + - 查询我的邀请详情(不仅仅是 `used_count`) + - 绑定后 Creem 充值奖励发放 +- 支付服务: + - Creem 绑定后 Creem 充值成功时触发邀请奖励检查 +- 卡密服务: + - 兑换接口 + - 幂等、校验、入账、审计 +- 审计: + - 积分审计账本落地卡密兑换 + - 系统审计表落地邀请绑定、邀请奖励发放、卡密生成、卡密兑换 + +### 5.3 前端(Web) + +- 在现有 `web` 设置体系中新增“我的邀请”入口或子页面: + - 绑定邀请码 + - 展示我的邀请码 + - 展示邀请人数 / 达成绑定后充值人数 / 待达成人数 + - 展示逐个受邀人的奖励状态 +- 在“我的邀请”下新增“兑换卡密” + - 点击弹窗输入 + - 成功后提示并刷新余额 / 邀请数据 +- 更新 web 端本地化文案、API 调用与资源失效逻辑 + +### 5.4 Infra / 运营 + +- 新建 `infra/scripts` 下卡密生成脚本 +- 支持一键生成三档卡密 +- 导出 Excel +- Excel 至少包含: + - 卡密 + - 套餐编码 + - 套餐名称/档位 + - 对应积分 + - 生成批次 + - 是否已兑换 + - 兑换人 + - 兑换时间 + +## 6. 约束与实现原则 + +- 不加兜底分支,不用静默降级来掩盖绑定失败、绑定后 Creem 充值奖励失败或卡密兑换失败。 +- 绑定邀请码必须是强约束: + - 一人最多绑定一次; + - 绑定后不可解绑; + - 禁止自绑定。 +- 邀请奖励与卡密兑换都必须走幂等事件 ID。 +- 积分账本与审计账本必须保持一致。 +- 若新增系统审计表,不能替代 `points_audit_ledger`,而应补充业务操作审计。 + +## 7. 已确认口径 + +### 7.1 邀请页展示口径 + +用户已确认示例应为: + +- 邀请了 `3` 个人; +- 奖励到账进度为 `80/120`; +- 表示其中 `2` 个人已完成绑定后 Creem 充值,`1` 个人未完成。 + +因此页面至少需要稳定表达: + +- 已邀请人数; +- 已达成绑定后充值人数; +- 未达成绑定后充值人数; +- 已到账邀请奖励 / 总可达邀请奖励。 + +按当前确认,若奖励环境变量值为 `40`,则: + +- 已到账邀请奖励 = `已达成绑定后充值人数 * 40` +- 总可达邀请奖励 = `已邀请人数 * 40` + +### 7.2 绑定后 Creem 充值与卡密口径 + +- 卡密兑换不算充值成功。 +- 邀请绑定奖励达成必须以 `creem` 成功交易记录为准。 + +## 8. 实施清单 + +- [ ] 更新邀请 / 积分 / 错误码协议文档 +- [ ] 设计邀请关系表、卡密表、系统审计表 +- [ ] 增加邀请奖励积分环境变量配置 +- [ ] 实现邀请码绑定接口与查询接口 +- [ ] 在 Creem 成功支付链路接入绑定后 Creem 充值邀请奖励 +- [ ] 实现卡密生成脚本与 Excel 导出 +- [ ] 实现卡密兑换接口与账本/审计入库 +- [ ] 完成 web 邀请页与兑换弹窗改造 +- [ ] 增加后端 / web 回归测试 + +## 9. 建议的相关文件 + +- 后端: + - `backend/src/v1/invite/**` + - `backend/src/v1/points/**` + - `backend/src/v1/payments/**` + - `backend/src/core/config/settings.py` + - `backend/alembic/versions/*` +- Web: + - `web/src/components/SettingsPage.tsx` + - `web/src/lib/api.ts` + - `web/src/lib/api-routes.ts` + - `web/src/lib/resources.ts` + - `web/src/i18n/utils.ts` +- 协议: + - `docs/protocols/invite/invite-protocol.md` + - `docs/protocols/common/user-points-chat-data-protocol.md` + - `docs/protocols/common/http-error-codes.md` diff --git a/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/task.json b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/task.json new file mode 100644 index 0000000..c32408c --- /dev/null +++ b/.trellis/tasks/05-21-referral-recovery-and-redeem-cards/task.json @@ -0,0 +1,40 @@ +{ + "id": "referral-recovery-and-redeem-cards", + "name": "referral-recovery-and-redeem-cards", + "title": "恢复邀请体系并新增兑换卡密系统", + "description": "恢复邀请绑定与绑定后 Creem 充值奖励链路,并新增运营卡密生成/兑换系统,要求补齐协议、数据库、积分账本、系统审计,以及 web 端邀请页与兑换入口。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-21", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "docs/protocols/invite/invite-protocol.md", + "docs/protocols/common/user-points-chat-data-protocol.md", + "docs/protocols/common/http-error-codes.md", + "backend/src/v1/invite/router.py", + "backend/src/v1/invite/service.py", + "backend/src/v1/points/service.py", + "backend/src/v1/payments/service.py", + "backend/src/v1/payments/creem_service.py", + "web/src/components/SettingsPage.tsx", + "web/src/lib/api.ts", + "web/src/lib/api-routes.ts", + "web/src/lib/resources.ts", + "web/src/i18n/utils.ts" + ], + "notes": "先完成协议与 PRD,再落地实现。只做 web 端,不改 Flutter。邀请绑定奖励达成以 creem 成功交易为准,卡密兑换不算充值成功。", + "meta": {} +} diff --git a/backend/alembic/versions/20260411_0001_core_llm_schema.py b/backend/alembic/versions/20260411_0001_core_llm_schema.py index 67708a0..4f2d742 100644 --- a/backend/alembic/versions/20260411_0001_core_llm_schema.py +++ b/backend/alembic/versions/20260411_0001_core_llm_schema.py @@ -27,8 +27,18 @@ def upgrade() -> None: sa.Column("name", sa.String(length=50), nullable=False), sa.Column("request_url", sa.String(length=255), nullable=False), sa.Column("avatar", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name"), @@ -40,10 +50,25 @@ def upgrade() -> None: sa.Column("id", sa.UUID(), nullable=False), sa.Column("factory_id", sa.UUID(), nullable=False), sa.Column("model_code", sa.String(length=50), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["factory_id"], ["llm_factory.id"], name="fk_llms_factory_id", ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["factory_id"], + ["llm_factory.id"], + name="fk_llms_factory_id", + ondelete="RESTRICT", + ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("model_code"), ) @@ -55,10 +80,27 @@ def upgrade() -> None: sa.Column("agent_type", sa.String(length=20), nullable=False), sa.Column("llm_id", sa.UUID(), nullable=False), sa.Column("status", sa.String(length=20), nullable=False), - sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint(["llm_id"], ["llms.id"], name="fk_system_agents_llm_id", ondelete="RESTRICT"), + sa.Column( + "config", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["llm_id"], ["llms.id"], name="fk_system_agents_llm_id", ondelete="RESTRICT" + ), sa.PrimaryKeyConstraint("agent_type"), ) _enable_service_only_rls("system_agents") @@ -82,17 +124,29 @@ def downgrade() -> None: def _enable_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") for role in ["anon", "authenticated"]: - op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") - op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) def _drop_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0002_users_chat_points_invites.py b/backend/alembic/versions/20260411_0002_users_chat_points_invites.py index 4481707..ef2a342 100644 --- a/backend/alembic/versions/20260411_0002_users_chat_points_invites.py +++ b/backend/alembic/versions/20260411_0002_users_chat_points_invites.py @@ -30,7 +30,9 @@ def upgrade() -> None: def downgrade() -> None: op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") - op.execute("DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()") + op.execute( + "DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()" + ) op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()") for table_name in [ @@ -61,18 +63,37 @@ def _create_profiles() -> None: sa.Column("username", sa.String(length=30), nullable=False), sa.Column("avatar_url", sa.Text(), nullable=True), sa.Column("bio", sa.String(length=200), nullable=True), - sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column( + "settings", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), sa.Column("referred_by", sa.UUID(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint("char_length(username) >= 1", name="ck_profiles_username_non_empty"), + sa.CheckConstraint( + "char_length(username) >= 1", name="ck_profiles_username_non_empty" + ), sa.ForeignKeyConstraint(["id"], ["auth.users.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["referred_by"], ["profiles.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) op.create_index("ix_profiles_username", "profiles", ["username"]) - op.create_index("ix_profiles_settings_gin", "profiles", ["settings"], postgresql_using="gin") + op.create_index( + "ix_profiles_settings_gin", "profiles", ["settings"], postgresql_using="gin" + ) op.create_index("ix_profiles_referred_by", "profiles", ["referred_by"]) _enable_service_only_rls("profiles") @@ -86,24 +107,60 @@ def _create_chat_tables() -> None: sa.Column("job_id", sa.UUID(), nullable=True), sa.Column("title", sa.String(length=255), nullable=True), sa.Column("status", sa.String(length=20), nullable=False), - sa.Column("last_activity_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("message_count", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("total_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("total_cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False), - sa.Column("state_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "last_activity_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "message_count", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "total_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "total_cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "state_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint("session_type in ('chat', 'automation')", name="ck_sessions_session_type"), - sa.CheckConstraint("status in ('pending', 'running', 'completed', 'failed')", name="ck_sessions_status"), - sa.CheckConstraint("message_count >= 0", name="ck_sessions_message_count_non_negative"), - sa.CheckConstraint("total_tokens >= 0", name="ck_sessions_total_tokens_non_negative"), - sa.CheckConstraint("total_cost >= 0", name="ck_sessions_total_cost_non_negative"), + sa.CheckConstraint( + "session_type in ('chat', 'automation')", name="ck_sessions_session_type" + ), + sa.CheckConstraint( + "status in ('pending', 'running', 'completed', 'failed')", + name="ck_sessions_status", + ), + sa.CheckConstraint( + "message_count >= 0", name="ck_sessions_message_count_non_negative" + ), + sa.CheckConstraint( + "total_tokens >= 0", name="ck_sessions_total_tokens_non_negative" + ), + sa.CheckConstraint( + "total_cost >= 0", name="ck_sessions_total_cost_non_negative" + ), sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), ) op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) - op.create_index("ix_sessions_user_activity", "sessions", ["user_id", "last_activity_at"]) + op.create_index( + "ix_sessions_user_activity", "sessions", ["user_id", "last_activity_at"] + ) _enable_service_only_rls("sessions") op.create_table( @@ -115,27 +172,61 @@ def _create_chat_tables() -> None: sa.Column("content", sa.Text(), nullable=False), sa.Column("model_code", sa.String(length=50), nullable=True), sa.Column("tool_name", sa.String(length=100), nullable=True), - sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False), + sa.Column( + "input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False + ), sa.Column("latency_ms", sa.Integer(), nullable=True), - sa.Column("visibility_mask", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column( + "visibility_mask", + sa.BigInteger(), + server_default=sa.text("0"), + nullable=False, + ), sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.CheckConstraint("seq > 0", name="ck_messages_seq_positive"), - sa.CheckConstraint("role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role"), - sa.CheckConstraint("input_tokens >= 0", name="ck_messages_input_tokens_non_negative"), - sa.CheckConstraint("output_tokens >= 0", name="ck_messages_output_tokens_non_negative"), + sa.CheckConstraint( + "role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role" + ), + sa.CheckConstraint( + "input_tokens >= 0", name="ck_messages_input_tokens_non_negative" + ), + sa.CheckConstraint( + "output_tokens >= 0", name="ck_messages_output_tokens_non_negative" + ), sa.CheckConstraint("cost >= 0", name="ck_messages_cost_non_negative"), - sa.CheckConstraint("latency_ms is null or latency_ms >= 0", name="ck_messages_latency_non_negative"), + sa.CheckConstraint( + "latency_ms is null or latency_ms >= 0", + name="ck_messages_latency_non_negative", + ), sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"), ) op.create_index("ix_messages_session_id", "messages", ["session_id"]) - op.create_index("ix_messages_session_seq_visibility", "messages", ["session_id", "seq", "visibility_mask"]) + op.create_index( + "ix_messages_session_seq_visibility", + "messages", + ["session_id", "seq", "visibility_mask"], + ) _enable_service_only_rls("messages") @@ -143,18 +234,53 @@ def _create_points_tables() -> None: op.create_table( "user_points", sa.Column("user_id", sa.UUID(), nullable=False), - sa.Column("balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False), - sa.Column("frozen_balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False), - sa.Column("lifetime_earned", sa.BigInteger(), server_default=sa.text("0"), nullable=False), - sa.Column("lifetime_spent", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column( + "balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "frozen_balance", + sa.BigInteger(), + server_default=sa.text("0"), + nullable=False, + ), + sa.Column( + "lifetime_earned", + sa.BigInteger(), + server_default=sa.text("0"), + nullable=False, + ), + sa.Column( + "lifetime_spent", + sa.BigInteger(), + server_default=sa.text("0"), + nullable=False, + ), sa.Column("version", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.CheckConstraint("balance >= 0", name="ck_user_points_balance_non_negative"), - sa.CheckConstraint("frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative"), - sa.CheckConstraint("lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative"), - sa.CheckConstraint("lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative"), - sa.CheckConstraint("frozen_balance <= balance", name="ck_user_points_frozen_le_balance"), + sa.CheckConstraint( + "frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative" + ), + sa.CheckConstraint( + "lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative" + ), + sa.CheckConstraint( + "lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative" + ), + sa.CheckConstraint( + "frozen_balance <= balance", name="ck_user_points_frozen_le_balance" + ), sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("user_id"), ) @@ -172,30 +298,89 @@ def _create_points_tables() -> None: sa.Column("biz_id", sa.UUID(), nullable=True), sa.Column("event_id", sa.String(length=64), nullable=False), sa.Column("operator_id", sa.UUID(), nullable=True), - sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.CheckConstraint("amount > 0", name="ck_points_ledger_amount_positive"), - sa.CheckConstraint("direction in (1, -1)", name="ck_points_ledger_direction_valid"), - sa.CheckConstraint("balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"), - sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_ledger_change_type"), - sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_ledger_biz_type"), - sa.CheckConstraint("((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or (change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or (change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", name="ck_points_ledger_biz_binding"), - sa.CheckConstraint("((change_type in ('register', 'purchase') and direction = 1) or (change_type in ('consume', 'refund') and direction = -1) or (change_type = 'adjust' and direction in (1, -1)))", name="ck_points_ledger_direction_by_change_type"), - sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object"), - sa.CheckConstraint("metadata->>'schema_version' = '1' and metadata->>'operator_type' in ('user', 'system', 'admin') and coalesce(metadata->>'run_id', '') <> '' and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", name="ck_points_ledger_metadata_common"), - sa.CheckConstraint("(change_type <> 'register' or not (metadata ? 'charge'))", name="ck_points_ledger_metadata_register_shape"), - sa.CheckConstraint("(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and (metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and (metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and (metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))", name="ck_points_ledger_metadata_consume_shape"), - sa.CheckConstraint("(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'reason') and coalesce(metadata #>> '{ext,reason}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape"), - sa.CheckConstraint("(change_type not in ('purchase', 'refund') or ((metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and (metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and coalesce(metadata #>> '{ext,source}', '') <> '' and coalesce(metadata #>> '{ext,platform}', '') <> '' and coalesce(metadata #>> '{ext,product_code}', '') <> '' and coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", name="ck_points_ledger_metadata_payment_shape"), - sa.CheckConstraint("(change_type <> 'refund' or ((metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", name="ck_points_ledger_metadata_refund_shape"), - sa.ForeignKeyConstraint(["operator_id"], ["auth.users.id"], ondelete="SET NULL"), + sa.CheckConstraint( + "direction in (1, -1)", name="ck_points_ledger_direction_valid" + ), + sa.CheckConstraint( + "balance_after >= 0", name="ck_points_ledger_balance_after_non_negative" + ), + sa.CheckConstraint( + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", + name="ck_points_ledger_change_type", + ), + sa.CheckConstraint( + "biz_type is null or biz_type in ('chat', 'payment')", + name="ck_points_ledger_biz_type", + ), + sa.CheckConstraint( + "((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or (change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or (change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", + name="ck_points_ledger_biz_binding", + ), + sa.CheckConstraint( + "((change_type in ('register', 'purchase') and direction = 1) or (change_type in ('consume', 'refund') and direction = -1) or (change_type = 'adjust' and direction in (1, -1)))", + name="ck_points_ledger_direction_by_change_type", + ), + sa.CheckConstraint( + "jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object" + ), + sa.CheckConstraint( + "metadata->>'schema_version' = '1' and metadata->>'operator_type' in ('user', 'system', 'admin') and coalesce(metadata->>'run_id', '') <> '' and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", + name="ck_points_ledger_metadata_common", + ), + sa.CheckConstraint( + "(change_type <> 'register' or not (metadata ? 'charge'))", + name="ck_points_ledger_metadata_register_shape", + ), + sa.CheckConstraint( + "(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and (metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and (metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and (metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))", + name="ck_points_ledger_metadata_consume_shape", + ), + sa.CheckConstraint( + "(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'reason') and coalesce(metadata #>> '{ext,reason}', '') <> ''))", + name="ck_points_ledger_metadata_adjust_shape", + ), + sa.CheckConstraint( + "(change_type not in ('purchase', 'refund') or ((metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and (metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and coalesce(metadata #>> '{ext,source}', '') <> '' and coalesce(metadata #>> '{ext,platform}', '') <> '' and coalesce(metadata #>> '{ext,product_code}', '') <> '' and coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", + name="ck_points_ledger_metadata_payment_shape", + ), + sa.CheckConstraint( + "(change_type <> 'refund' or ((metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", + name="ck_points_ledger_metadata_refund_shape", + ), + sa.ForeignKeyConstraint( + ["operator_id"], ["auth.users.id"], ondelete="SET NULL" + ), sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"), ) - op.create_index("ix_points_ledger_user_created_at", "points_ledger", ["user_id", sa.text("created_at DESC")]) - op.create_index("ix_points_ledger_biz_type_biz_id", "points_ledger", ["biz_type", "biz_id"]) + op.create_index( + "ix_points_ledger_user_created_at", + "points_ledger", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_points_ledger_biz_type_biz_id", "points_ledger", ["biz_type", "biz_id"] + ) _enable_service_only_rls("points_ledger") op.create_table( @@ -213,24 +398,71 @@ def _create_points_tables() -> None: sa.Column("billed_to", sa.String(length=16), nullable=False), sa.Column("run_id", sa.String(length=128), nullable=True), sa.Column("request_id", sa.String(length=128), nullable=True), - sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False), - sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.CheckConstraint("amount >= 0", name="ck_points_audit_ledger_amount_non_negative"), - sa.CheckConstraint("direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"), - sa.CheckConstraint("balance_after >= 0", name="ck_points_audit_ledger_balance_after_non_negative"), - sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_audit_ledger_change_type"), - sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_audit_ledger_biz_type"), - sa.CheckConstraint("billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to"), - sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_audit_ledger_metadata_object"), + sa.Column( + "input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "amount >= 0", name="ck_points_audit_ledger_amount_non_negative" + ), + sa.CheckConstraint( + "direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid" + ), + sa.CheckConstraint( + "balance_after >= 0", + name="ck_points_audit_ledger_balance_after_non_negative", + ), + sa.CheckConstraint( + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", + name="ck_points_audit_ledger_change_type", + ), + sa.CheckConstraint( + "biz_type is null or biz_type in ('chat', 'payment')", + name="ck_points_audit_ledger_biz_type", + ), + sa.CheckConstraint( + "billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to" + ), + sa.CheckConstraint( + "jsonb_typeof(metadata) = 'object'", + name="ck_points_audit_ledger_metadata_object", + ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"), ) - op.create_index("ix_points_audit_ledger_user_id_created_at", "points_audit_ledger", ["user_id_snapshot", sa.text("created_at DESC")]) - op.create_index("ix_points_audit_ledger_change_type_created_at", "points_audit_ledger", ["change_type", sa.text("created_at DESC")]) + op.create_index( + "ix_points_audit_ledger_user_id_created_at", + "points_audit_ledger", + ["user_id_snapshot", sa.text("created_at DESC")], + ) + op.create_index( + "ix_points_audit_ledger_change_type_created_at", + "points_audit_ledger", + ["change_type", sa.text("created_at DESC")], + ) _enable_service_only_rls("points_audit_ledger") op.create_table( @@ -241,12 +473,29 @@ def _create_points_tables() -> None: sa.Column("first_user_id_snapshot", sa.UUID(), nullable=True), sa.Column("balance_snapshot", sa.BigInteger(), nullable=True), sa.Column("grant_event_id", sa.String(length=64), nullable=False), - sa.Column("has_purchased_starter_pack", sa.Boolean(), server_default=sa.text("false"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "has_purchased_starter_pack", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"), - sa.UniqueConstraint("grant_event_id", name="uq_register_bonus_claims_grant_event_id"), + sa.UniqueConstraint( + "grant_event_id", name="uq_register_bonus_claims_grant_event_id" + ), ) _enable_service_only_rls("register_bonus_claims") @@ -269,7 +518,9 @@ def _create_invite_codes() -> None: """ ) op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)") - op.execute("CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'") + op.execute( + "CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'" + ) _enable_service_only_rls("invite_codes") @@ -370,23 +621,37 @@ def _create_signup_helpers() -> None: """ ) op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") - op.execute("CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()") + op.execute( + "CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()" + ) def _enable_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") for role in ["anon", "authenticated"]: - op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") - op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) def _drop_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0003_notifications.py b/backend/alembic/versions/20260411_0003_notifications.py index 6e441a7..8f86325 100644 --- a/backend/alembic/versions/20260411_0003_notifications.py +++ b/backend/alembic/versions/20260411_0003_notifications.py @@ -23,29 +23,90 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( "notifications", - sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), - sa.Column("type", sa.String(length=32), server_default=sa.text("'system'"), nullable=False), - sa.Column("source", sa.String(length=32), server_default=sa.text("'manual'"), nullable=False), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column( + "type", + sa.String(length=32), + server_default=sa.text("'system'"), + nullable=False, + ), + sa.Column( + "source", + sa.String(length=32), + server_default=sa.text("'manual'"), + nullable=False, + ), sa.Column("source_key", sa.String(length=128), nullable=True), sa.Column("source_version", sa.Integer(), nullable=True), sa.Column("content_hash", sa.String(length=64), nullable=True), - sa.Column("title", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), - sa.Column("body", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), - sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), - sa.Column("status", sa.String(length=16), server_default=sa.text("'published'"), nullable=False), - sa.Column("target_mode", sa.String(length=32), server_default=sa.text("'all_users'"), nullable=False), + sa.Column( + "title", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "body", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "payload", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "status", + sa.String(length=16), + server_default=sa.text("'published'"), + nullable=False, + ), + sa.Column( + "target_mode", + sa.String(length=32), + server_default=sa.text("'all_users'"), + nullable=False, + ), sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint("status IN ('draft', 'published', 'revoked')", name="ck_notifications_status"), - sa.CheckConstraint("target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')", name="ck_notifications_target_mode"), - sa.CheckConstraint("jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object"), + sa.CheckConstraint( + "status IN ('draft', 'published', 'revoked')", + name="ck_notifications_status", + ), + sa.CheckConstraint( + "target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')", + name="ck_notifications_target_mode", + ), + sa.CheckConstraint( + "jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object" + ), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_notifications_status_created_at", "notifications", ["status", sa.text("created_at DESC")]) - op.create_index("ix_notifications_published_at", "notifications", [sa.text("published_at DESC")]) + op.create_index( + "ix_notifications_status_created_at", + "notifications", + ["status", sa.text("created_at DESC")], + ) + op.create_index( + "ix_notifications_published_at", "notifications", [sa.text("published_at DESC")] + ) op.create_index( "uq_notifications_source_source_key", "notifications", @@ -57,20 +118,46 @@ def upgrade() -> None: op.create_table( "user_notifications", - sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), sa.Column("user_id", sa.UUID(), nullable=False), sa.Column("notification_id", sa.UUID(), nullable=False), - sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column( + "is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["notification_id"], ["notifications.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["notification_id"], ["notifications.id"], ondelete="CASCADE" + ), sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "notification_id", name="uq_user_notifications_user_notification"), + sa.UniqueConstraint( + "user_id", "notification_id", name="uq_user_notifications_user_notification" + ), + ) + op.create_index( + "ix_user_notifications_user_created_at", + "user_notifications", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_user_notifications_user_unread", + "user_notifications", + ["user_id", "is_read"], ) - op.create_index("ix_user_notifications_user_created_at", "user_notifications", ["user_id", sa.text("created_at DESC")]) - op.create_index("ix_user_notifications_user_unread", "user_notifications", ["user_id", "is_read"]) _enable_service_only_rls("user_notifications") @@ -85,17 +172,29 @@ def downgrade() -> None: def _enable_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") for role in ["anon", "authenticated"]: - op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") - op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) def _drop_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260415_0001_compliance_and_feedback.py b/backend/alembic/versions/20260415_0001_compliance_and_feedback.py index 382117b..1352ef7 100644 --- a/backend/alembic/versions/20260415_0001_compliance_and_feedback.py +++ b/backend/alembic/versions/20260415_0001_compliance_and_feedback.py @@ -42,27 +42,79 @@ def upgrade() -> None: sa.Column("total_latency_ms", sa.Integer(), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("anonymized_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "anonymized_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.PrimaryKeyConstraint("id"), ) - op.create_index("ix_anonymous_session_snapshots_anonymous_id", "anonymous_session_snapshots", ["anonymous_id"]) - op.create_index("ix_anonymous_session_snapshots_created_at", "anonymous_session_snapshots", ["created_at"]) - op.create_index("ix_anonymous_session_snapshots_question_type", "anonymous_session_snapshots", ["question_type"]) + op.create_index( + "ix_anonymous_session_snapshots_anonymous_id", + "anonymous_session_snapshots", + ["anonymous_id"], + ) + op.create_index( + "ix_anonymous_session_snapshots_created_at", + "anonymous_session_snapshots", + ["created_at"], + ) + op.create_index( + "ix_anonymous_session_snapshots_question_type", + "anonymous_session_snapshots", + ["question_type"], + ) _enable_service_role_all_rls("anonymous_session_snapshots") op.create_table( "user_feedback", - sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("feedback_type", sa.String(length=20), server_default=sa.text("'other'"), nullable=False), + sa.Column( + "feedback_type", + sa.String(length=20), + server_default=sa.text("'other'"), + nullable=False, + ), sa.Column("content", sa.Text(), nullable=False), - sa.Column("images", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False), - sa.Column("device_info", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column( + "images", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'[]'::jsonb"), + nullable=False, + ), + sa.Column( + "device_info", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), sa.Column("app_version", sa.String(length=20), nullable=False), sa.Column("os_version", sa.String(length=50), nullable=False), - sa.Column("status", sa.String(length=20), server_default=sa.text("'pending'"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "status", + sa.String(length=20), + server_default=sa.text("'pending'"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) @@ -70,12 +122,22 @@ def upgrade() -> None: op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"]) op.create_index("ix_user_feedback_status", "user_feedback", ["status"]) op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'") - op.execute("COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选不上传我的个人信息)'") - op.execute("COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other'") + op.execute( + "COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选不上传我的个人信息)'" + ) + op.execute( + "COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other'" + ) op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'") - op.execute("COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'") - op.execute("COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)'") - op.execute("COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'") + op.execute( + "COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'" + ) + op.execute( + "COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)'" + ) + op.execute( + "COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'" + ) _enable_service_role_all_rls("user_feedback") @@ -89,7 +151,9 @@ def downgrade() -> None: def _enable_service_role_all_rls(table_name: str) -> None: op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - op.execute(f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)") + op.execute( + f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)" + ) def _drop_service_role_all_rls(table_name: str) -> None: diff --git a/backend/alembic/versions/20260428_0004_apple_iap_final_head.py b/backend/alembic/versions/20260428_0004_apple_iap_final_head.py index 1182ada..e22e03a 100644 --- a/backend/alembic/versions/20260428_0004_apple_iap_final_head.py +++ b/backend/alembic/versions/20260428_0004_apple_iap_final_head.py @@ -42,18 +42,51 @@ def upgrade() -> None: sa.Column("price_milliunits", sa.BigInteger(), nullable=True), sa.Column("ledger_event_id", sa.String(length=64), nullable=True), sa.Column("signed_transaction_info", sa.Text(), nullable=False), - sa.Column("apple_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column( + "apple_payload", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), sa.Column("failure_code", sa.String(length=64), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.CheckConstraint("environment in ('Sandbox', 'Production')", name="ck_apple_iap_transactions_environment"), - sa.CheckConstraint("status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", name="ck_apple_iap_transactions_status"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "environment in ('Sandbox', 'Production')", + name="ck_apple_iap_transactions_environment", + ), + sa.CheckConstraint( + "status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", + name="ck_apple_iap_transactions_status", + ), sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"), - sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"), + sa.UniqueConstraint( + "transaction_id", name="uq_apple_iap_transactions_transaction_id" + ), + sa.UniqueConstraint( + "ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id" + ), + ) + op.create_index( + "ix_apple_iap_transactions_user_created_at", + "apple_iap_transactions", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_apple_iap_transactions_status_updated_at", + "apple_iap_transactions", + ["status", sa.text("updated_at DESC")], ) - op.create_index("ix_apple_iap_transactions_user_created_at", "apple_iap_transactions", ["user_id", sa.text("created_at DESC")]) - op.create_index("ix_apple_iap_transactions_status_updated_at", "apple_iap_transactions", ["status", sa.text("updated_at DESC")]) _enable_service_only_rls("apple_iap_transactions") @@ -65,17 +98,29 @@ def downgrade() -> None: def _enable_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") for role in ["anon", "authenticated"]: - op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") - op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) def _drop_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260511_0001_creem_transactions.py b/backend/alembic/versions/20260511_0001_creem_transactions.py index cd08344..461a601 100644 --- a/backend/alembic/versions/20260511_0001_creem_transactions.py +++ b/backend/alembic/versions/20260511_0001_creem_transactions.py @@ -31,16 +31,42 @@ def upgrade() -> None: sa.Column("credits", sa.BigInteger(), nullable=False), sa.Column("amount_cents", sa.BigInteger(), nullable=False), sa.Column("currency", sa.String(length=8), nullable=False), - sa.Column("creem_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column( + "creem_payload", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), sa.Column("ledger_event_id", sa.String(length=128), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.CheckConstraint("status in ('pending', 'completed', 'failed', 'refunded')", name="ck_creem_transactions_status"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "status in ('pending', 'completed', 'failed', 'refunded')", + name="ck_creem_transactions_status", + ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("checkout_id", name="uq_creem_transactions_checkout_id"), ) - op.create_index("ix_creem_transactions_user_created_at", "creem_transactions", ["user_id", sa.text("created_at DESC")]) - op.create_index("ix_creem_transactions_status_updated_at", "creem_transactions", ["status", sa.text("updated_at DESC")]) + op.create_index( + "ix_creem_transactions_user_created_at", + "creem_transactions", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_creem_transactions_status_updated_at", + "creem_transactions", + ["status", sa.text("updated_at DESC")], + ) _enable_service_only_rls("creem_transactions") @@ -52,17 +78,29 @@ def downgrade() -> None: def _enable_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") for role in ["anon", "authenticated"]: - op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") - op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") - op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) def _drop_service_only_rls(table_name: str) -> None: for role in ["anon", "authenticated"]: for action in ["select", "insert", "update", "delete"]: - op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260521_0002_invite_referrals_and_redeem_codes.py b/backend/alembic/versions/20260521_0002_invite_referrals_and_redeem_codes.py new file mode 100644 index 0000000..c201acf --- /dev/null +++ b/backend/alembic/versions/20260521_0002_invite_referrals_and_redeem_codes.py @@ -0,0 +1,307 @@ +"""Create invite referral, redeem code, and system audit tables. + +Revision ID: 20260521_0002 +Revises: 20260511_0001 +Create Date: 2026-05-21 16:00:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260521_0002" +down_revision: Union[str, Sequence[str], None] = "20260511_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "invite_referrals", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("inviter_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("invitee_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("invite_code_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("invite_code_snapshot", sa.String(length=6), nullable=False), + sa.Column( + "bound_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "first_creem_transaction_id", postgresql.UUID(as_uuid=True), nullable=True + ), + sa.Column("first_creem_paid_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("inviter_reward_event_id", sa.String(length=64), nullable=True), + sa.Column("invitee_reward_event_id", sa.String(length=64), nullable=True), + sa.Column( + "inviter_reward_granted_at", sa.DateTime(timezone=True), nullable=True + ), + sa.Column( + "invitee_reward_granted_at", sa.DateTime(timezone=True), nullable=True + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["first_creem_transaction_id"], + ["creem_transactions.id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["invite_code_id"], ["invite_codes.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint( + ["invitee_user_id"], ["profiles.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["inviter_user_id"], ["profiles.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.CheckConstraint( + "inviter_user_id <> invitee_user_id", name="ck_invite_referrals_not_self" + ), + sa.UniqueConstraint( + "invitee_user_id", name="uq_invite_referrals_invitee_user_id" + ), + sa.UniqueConstraint( + "inviter_reward_event_id", + name="uq_invite_referrals_inviter_reward_event_id", + ), + sa.UniqueConstraint( + "invitee_reward_event_id", + name="uq_invite_referrals_invitee_reward_event_id", + ), + ) + op.create_index( + "ix_invite_referrals_inviter_bound_at", + "invite_referrals", + ["inviter_user_id", sa.text("bound_at DESC")], + ) + op.create_index( + "ix_invite_referrals_invitee_bound_at", + "invite_referrals", + ["invitee_user_id", sa.text("bound_at DESC")], + ) + _enable_service_only_rls("invite_referrals") + + op.create_table( + "redeem_code_batches", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("batch_key", sa.String(length=64), nullable=False), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("batch_key", name="uq_redeem_code_batches_batch_key"), + ) + _enable_service_only_rls("redeem_code_batches") + + op.create_table( + "redeem_codes", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("batch_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("code", sa.String(length=32), nullable=False), + sa.Column("package_product_code", sa.String(length=32), nullable=False), + sa.Column("package_type", sa.String(length=16), nullable=False), + sa.Column("package_name_snapshot", sa.String(length=64), nullable=False), + sa.Column("credits", sa.BigInteger(), nullable=False), + sa.Column("sort_order", sa.BigInteger(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("redeemed_by_user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("redeem_event_id", sa.String(length=64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["batch_id"], ["redeem_code_batches.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["redeemed_by_user_id"], ["auth.users.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code"), + sa.CheckConstraint( + "status in ('active', 'redeemed', 'disabled')", + name="ck_redeem_codes_status", + ), + sa.CheckConstraint( + "((status = 'redeemed' and redeemed_by_user_id is not null and redeemed_at is not null and redeem_event_id is not null) or (status <> 'redeemed'))", + name="ck_redeem_codes_redeemed_shape", + ), + ) + op.create_index("ix_redeem_codes_code", "redeem_codes", ["code"], unique=True) + op.create_index( + "ix_redeem_codes_batch_status", "redeem_codes", ["batch_id", "status"] + ) + op.create_index( + "ix_redeem_codes_redeemed_by", + "redeem_codes", + ["redeemed_by_user_id", sa.text("redeemed_at DESC")], + ) + _enable_service_only_rls("redeem_codes") + + op.create_table( + "system_audit_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("actor_user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("target_user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("entity_type", sa.String(length=32), nullable=False), + sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.CheckConstraint( + "jsonb_typeof(metadata) = 'object'", + name="ck_system_audit_logs_metadata_object", + ), + ) + op.create_index( + "ix_system_audit_logs_action_created_at", + "system_audit_logs", + ["action", sa.text("created_at DESC")], + ) + op.create_index( + "ix_system_audit_logs_actor_created_at", + "system_audit_logs", + ["actor_user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_system_audit_logs_target_created_at", + "system_audit_logs", + ["target_user_id", sa.text("created_at DESC")], + ) + _enable_service_only_rls("system_audit_logs") + + op.execute( + """ + INSERT INTO invite_referrals ( + id, + inviter_user_id, + invitee_user_id, + invite_code_id, + invite_code_snapshot, + bound_at, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + p.referred_by, + p.id, + ic.id, + ic.code, + p.created_at, + p.created_at, + p.updated_at + FROM profiles p + JOIN LATERAL ( + SELECT id, code + FROM invite_codes + WHERE owner_id = p.referred_by + ORDER BY created_at DESC + LIMIT 1 + ) ic ON TRUE + WHERE p.referred_by IS NOT NULL + ON CONFLICT (invitee_user_id) DO NOTHING + """ + ) + +def downgrade() -> None: + _drop_service_only_rls("system_audit_logs") + op.drop_table("system_audit_logs") + + _drop_service_only_rls("redeem_codes") + op.drop_index("ix_redeem_codes_redeemed_by", table_name="redeem_codes") + op.drop_index("ix_redeem_codes_batch_status", table_name="redeem_codes") + op.drop_index("ix_redeem_codes_code", table_name="redeem_codes") + op.drop_table("redeem_codes") + + _drop_service_only_rls("redeem_code_batches") + op.drop_table("redeem_code_batches") + + _drop_service_only_rls("invite_referrals") + op.drop_index("ix_invite_referrals_invitee_bound_at", table_name="invite_referrals") + op.drop_index("ix_invite_referrals_inviter_bound_at", table_name="invite_referrals") + op.drop_table("invite_referrals") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index aa0121a..0a78afc 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -13,7 +13,11 @@ from core.logging import get_logger from schemas.agent.forwarded_props import RuntimeMode from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus from schemas.agent.system_agent import AgentType -from schemas.agent.runtime_models import FollowUpOutput, PersistedAgentOutput, ToolAgentOutput +from schemas.agent.runtime_models import ( + FollowUpOutput, + PersistedAgentOutput, + ToolAgentOutput, +) from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.domain.chat_message import AgentChatMessageMetadata from schemas.domain.chat_session import SessionStateSnapshot diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index 6a11ef9..94c284a 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -25,7 +25,7 @@ def _build_safety_section(*, language: str) -> str: "MANDATORY REFUSAL CONDITIONS", "═══════════════════════════════════════════════════════════════════════════════", "", - "You MUST refuse (set status=\"refused\") if ANY of these conditions are true:", + 'You MUST refuse (set status="refused") if ANY of these conditions are true:', "", "1. QUESTION TYPE MISMATCH (MOST IMPORTANT):", " - User asks about Tarot (塔罗) -> REFUSE, suggest Tarot reading", @@ -62,7 +62,7 @@ def _build_safety_section(*, language: str) -> str: "必须拒答的条件", "═══════════════════════════════════════════════════════════════════════════════", "", - "如果满足以下任一条件,必须拒绝(设置 status=\"refused\"):", + '如果满足以下任一条件,必须拒绝(设置 status="refused"):', "", "1. 问题类型不匹配(最重要):", " - 用户询问塔罗 -> 拒答,建议咨询塔罗师", diff --git a/backend/src/core/agentscope/prompts/user_prompt.py b/backend/src/core/agentscope/prompts/user_prompt.py index 7c19db0..a564d08 100644 --- a/backend/src/core/agentscope/prompts/user_prompt.py +++ b/backend/src/core/agentscope/prompts/user_prompt.py @@ -45,8 +45,8 @@ _SCOPE_INSTRUCTIONS = { "3. Question is about non-divination topics (programming, weather, etc.)\n" "\n" "WHEN REFUSING:\n" - "- Set status=\"refused\"\n" - "- Set sign_level=\"下下签\"\n" + '- Set status="refused"\n' + '- Set sign_level="下下签"\n' "- Set answer to a brief explanation of why you cannot answer (in English)\n" "- Leave conclusion, focus_points, advice, keywords as empty lists" ), @@ -57,8 +57,8 @@ _SCOPE_INSTRUCTIONS = { "3. 問題與占卜無關(編程、天氣等)\n" "\n" "拒答時:\n" - "- 設置 status=\"refused\"\n" - "- 設置 sign_level=\"下下签\"\n" + '- 設置 status="refused"\n' + '- 設置 sign_level="下下签"\n' "- 設置 answer 為拒答原因簡述(使用繁體中文)\n" "- conclusion、focus_points、advice、keywords 留空列表" ), @@ -69,8 +69,8 @@ _SCOPE_INSTRUCTIONS = { "3. 问题与占卜无关(编程、天气等)\n" "\n" "拒答时:\n" - "- 设置 status=\"refused\"\n" - "- 设置 sign_level=\"下下签\"\n" + '- 设置 status="refused"\n' + '- 设置 sign_level="下下签"\n' "- 设置 answer 为拒答原因简述(使用简体中文)\n" "- conclusion、focus_points、advice、keywords 留空列表" ), diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 6d7c2c4..a10a4ce 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -208,6 +208,7 @@ class AgentRuntimeSettings(BaseModel): class PointsPolicySettings(BaseModel): register_bonus_points: int = Field(default=60, ge=0, le=1_000_000) + invite_reward_points: int = Field(default=40, ge=0, le=1_000_000) register_bonus_hmac_key: SecretStr = SecretStr("") @model_validator(mode="after") @@ -224,7 +225,9 @@ class PointsPolicySettings(BaseModel): class AppleIapSettings(BaseModel): bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1) - root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" + root_cert_url: str = ( + "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" + ) jws_x5c_cert_url: str = "https://api.storekit.itunes.apple.com/v1/verificationKeys" environment: Literal["auto", "sandbox", "production"] = "auto" server_api_issuer_id: str | None = None diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index 69dbe90..403ef38 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -6,13 +6,17 @@ from .anonymous_session_snapshot import AnonymousSessionSnapshot from .apple_iap_transaction import AppleIapTransaction from .auth_user import AuthUser from .invite_code import InviteCode +from .invite_referral import InviteReferral from .llm import Llm from .llm_factory import LlmFactory from .points_audit_ledger import PointsAuditLedger from .points_ledger import PointsLedger from .profile import Profile +from .redeem_code import RedeemCode +from .redeem_code_batch import RedeemCodeBatch from .register_bonus_claims import RegisterBonusClaims from .notification import Notification +from .system_audit_log import SystemAuditLog from .system_agents import SystemAgents from .user_notification import UserNotification from .user_points import UserPoints @@ -24,13 +28,17 @@ __all__ = [ "AppleIapTransaction", "AuthUser", "InviteCode", + "InviteReferral", "Llm", "LlmFactory", "Notification", "PointsAuditLedger", "PointsLedger", "Profile", + "RedeemCode", + "RedeemCodeBatch", "RegisterBonusClaims", + "SystemAuditLog", "SystemAgents", "UserNotification", "UserPoints", diff --git a/backend/src/models/creem_transaction.py b/backend/src/models/creem_transaction.py index de551be..c1c14fe 100644 --- a/backend/src/models/creem_transaction.py +++ b/backend/src/models/creem_transaction.py @@ -31,9 +31,7 @@ class CreemTransaction(TimestampMixin, Base): "status in ('pending', 'completed', 'failed', 'refunded')", name="ck_creem_transactions_status", ), - UniqueConstraint( - "checkout_id", name="uq_creem_transactions_checkout_id" - ), + UniqueConstraint("checkout_id", name="uq_creem_transactions_checkout_id"), Index( "ix_creem_transactions_user_created_at", "user_id", diff --git a/backend/src/models/invite_referral.py b/backend/src/models/invite_referral.py new file mode 100644 index 0000000..36d6265 --- /dev/null +++ b/backend/src/models/invite_referral.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class InviteReferral(TimestampMixin, Base): + __tablename__ = "invite_referrals" + __table_args__ = ( + CheckConstraint( + "inviter_user_id <> invitee_user_id", name="ck_invite_referrals_not_self" + ), + UniqueConstraint("invitee_user_id", name="uq_invite_referrals_invitee_user_id"), + UniqueConstraint( + "inviter_reward_event_id", + name="uq_invite_referrals_inviter_reward_event_id", + ), + UniqueConstraint( + "invitee_reward_event_id", + name="uq_invite_referrals_invitee_reward_event_id", + ), + Index( + "ix_invite_referrals_inviter_bound_at", + "inviter_user_id", + text("bound_at DESC"), + ), + Index( + "ix_invite_referrals_invitee_bound_at", + "invitee_user_id", + text("bound_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + inviter_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("profiles.id", ondelete="CASCADE"), + nullable=False, + ) + invitee_user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("profiles.id", ondelete="CASCADE"), + nullable=False, + ) + invite_code_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("invite_codes.id", ondelete="SET NULL"), + nullable=True, + ) + invite_code_snapshot: Mapped[str] = mapped_column(String(6), nullable=False) + bound_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("now()"), + ) + first_creem_transaction_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("creem_transactions.id", ondelete="SET NULL"), + nullable=True, + ) + first_creem_paid_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + inviter_reward_event_id: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + invitee_reward_event_id: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + inviter_reward_granted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + invitee_reward_granted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py index c00c927..ef7c9a8 100644 --- a/backend/src/models/notification.py +++ b/backend/src/models/notification.py @@ -58,10 +58,16 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base): source_version: Mapped[int | None] = mapped_column(nullable=True) content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) title: Mapped[dict[str, str]] = mapped_column( - jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, + jsonb, + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, ) body: Mapped[dict[str, str]] = mapped_column( - jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, + jsonb, + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, ) payload: Mapped[dict[str, object]] = mapped_column( jsonb, diff --git a/backend/src/models/redeem_code.py b/backend/src/models/redeem_code.py new file mode 100644 index 0000000..877516e --- /dev/null +++ b/backend/src/models/redeem_code.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + text, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class RedeemCode(TimestampMixin, Base): + __tablename__ = "redeem_codes" + __table_args__ = ( + CheckConstraint( + "status in ('active', 'redeemed', 'disabled')", + name="ck_redeem_codes_status", + ), + CheckConstraint( + "((status = 'redeemed' and redeemed_by_user_id is not null and redeemed_at is not null and redeem_event_id is not null) " + "or (status <> 'redeemed'))", + name="ck_redeem_codes_redeemed_shape", + ), + Index("ix_redeem_codes_batch_status", "batch_id", "status"), + Index( + "ix_redeem_codes_redeemed_by", + "redeemed_by_user_id", + text("redeemed_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + batch_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("redeem_code_batches.id", ondelete="CASCADE"), + nullable=False, + ) + code: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, index=True + ) + package_product_code: Mapped[str] = mapped_column(String(32), nullable=False) + package_type: Mapped[str] = mapped_column(String(16), nullable=False) + package_name_snapshot: Mapped[str] = mapped_column(String(64), nullable=False) + credits: Mapped[int] = mapped_column(BigInteger, nullable=False) + sort_order: Mapped[int] = mapped_column(BigInteger, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + redeemed_by_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="SET NULL"), + nullable=True, + ) + redeemed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + redeem_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/backend/src/models/redeem_code_batch.py b/backend/src/models/redeem_code_batch.py new file mode 100644 index 0000000..6514361 --- /dev/null +++ b/backend/src/models/redeem_code_batch.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class RedeemCodeBatch(TimestampMixin, Base): + __tablename__ = "redeem_code_batches" + __table_args__ = ( + UniqueConstraint("batch_key", name="uq_redeem_code_batches_batch_key"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + batch_key: Mapped[str] = mapped_column(String(64), nullable=False) + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/src/models/system_audit_log.py b/backend/src/models/system_audit_log.py new file mode 100644 index 0000000..5906f74 --- /dev/null +++ b/backend/src/models/system_audit_log.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import CheckConstraint, Index, JSON, String, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class SystemAuditLog(TimestampMixin, Base): + __tablename__ = "system_audit_logs" + __table_args__ = ( + CheckConstraint( + "jsonb_typeof(metadata) = 'object'", + name="ck_system_audit_logs_metadata_object", + ), + Index( + "ix_system_audit_logs_action_created_at", "action", text("created_at DESC") + ), + Index( + "ix_system_audit_logs_actor_created_at", + "actor_user_id", + text("created_at DESC"), + ), + Index( + "ix_system_audit_logs_target_created_at", + "target_user_id", + text("created_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + actor_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + target_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + action: Mapped[str] = mapped_column(String(64), nullable=False) + entity_type: Mapped[str] = mapped_column(String(32), nullable=False) + entity_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + metadata_json: Mapped[dict[str, object]] = mapped_column( + "metadata", + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) diff --git a/backend/src/schemas/domain/points.py b/backend/src/schemas/domain/points.py index b78d63a..9278718 100644 --- a/backend/src/schemas/domain/points.py +++ b/backend/src/schemas/domain/points.py @@ -146,6 +146,12 @@ class AuditLedgerMetadata(BaseModel): usage_missing: bool | None = Field(default=None) failure_kind: Literal["failed", "canceled"] | None = Field(default=None) operator_id: str | None = Field(default=None, max_length=64) + referral_id: str | None = Field(default=None, max_length=64) + redeem_code_id: str | None = Field(default=None, max_length=64) + product_code: str | None = Field(default=None, max_length=64) + package_product_code: str | None = Field(default=None, max_length=64) + transaction_id: str | None = Field(default=None, max_length=128) + role: Literal["inviter", "invitee"] | None = Field(default=None) @model_validator(mode="after") def validate_at_least_source(self) -> "AuditLedgerMetadata": diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py index 40c821c..cf7774f 100644 --- a/backend/src/v1/auth/dependencies.py +++ b/backend/src/v1/auth/dependencies.py @@ -1,7 +1,6 @@ from __future__ import annotations - from v1.auth.gateway import SupabaseAuthGateway from v1.auth.service import AuthService diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 645349d..2f6a9a9 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -81,7 +81,9 @@ async def create_email_session( if profile is not None: settings: dict[str, object] = dict(profile.settings or {}) prefs_raw = settings.get("preferences", {}) - preferences: dict[str, object] = dict(prefs_raw) if isinstance(prefs_raw, dict) else {} + preferences: dict[str, object] = ( + dict(prefs_raw) if isinstance(prefs_raw, dict) else {} + ) if payload.language is not None: preferences["language"] = payload.language if payload.timezone is not None: diff --git a/backend/src/v1/invite/repository.py b/backend/src/v1/invite/repository.py index b01f6e7..5dc59b7 100644 --- a/backend/src/v1/invite/repository.py +++ b/backend/src/v1/invite/repository.py @@ -1,11 +1,17 @@ from __future__ import annotations +from datetime import datetime, timezone from uuid import UUID -from sqlalchemy import select +from sqlalchemy import func, select, update +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession +from models.creem_transaction import CreemTransaction from models.invite_code import InviteCode +from models.invite_referral import InviteReferral +from models.profile import Profile +from models.system_audit_log import SystemAuditLog class InviteCodeRepository: @@ -20,3 +26,118 @@ class InviteCodeRepository: .limit(1) ) return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_referral_by_invitee( + self, *, invitee_user_id: UUID + ) -> InviteReferral | None: + stmt = ( + select(InviteReferral) + .where(InviteReferral.invitee_user_id == invitee_user_id) + .limit(1) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_legacy_binding_by_invitee( + self, *, invitee_user_id: UUID + ) -> tuple[str, datetime | None] | None: + stmt = ( + select(InviteCode.code, Profile.updated_at) + .join(InviteCode, InviteCode.owner_id == Profile.referred_by) + .where(Profile.id == invitee_user_id, Profile.referred_by.is_not(None)) + .order_by(InviteCode.created_at.desc()) + .limit(1) + ) + row = (await self._session.execute(stmt)).first() + if row is None: + return None + return (str(row[0]), row[1]) + + async def list_referrals_by_inviter( + self, *, inviter_user_id: UUID + ) -> list[InviteReferral]: + stmt = ( + select(InviteReferral) + .where(InviteReferral.inviter_user_id == inviter_user_id) + .order_by(InviteReferral.bound_at.desc()) + ) + return list((await self._session.execute(stmt)).scalars().all()) + + async def get_bindable_code_for_update(self, *, code: str) -> InviteCode | None: + stmt = ( + select(InviteCode).where(InviteCode.code == code).limit(1).with_for_update() + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def has_completed_creem_payment(self, *, user_id: UUID) -> bool: + stmt = ( + select(CreemTransaction.id) + .where( + CreemTransaction.user_id == user_id, + CreemTransaction.status == "completed", + ) + .limit(1) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() is not None + + async def insert_referral( + self, + *, + inviter_user_id: UUID, + invitee_user_id: UUID, + invite_code_id: UUID, + invite_code_snapshot: str, + ) -> UUID | None: + stmt = ( + insert(InviteReferral) + .values( + inviter_user_id=inviter_user_id, + invitee_user_id=invitee_user_id, + invite_code_id=invite_code_id, + invite_code_snapshot=invite_code_snapshot, + ) + .on_conflict_do_nothing(index_elements=[InviteReferral.invitee_user_id]) + .returning(InviteReferral.id) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def mark_profile_referred_by( + self, + *, + invitee_user_id: UUID, + inviter_user_id: UUID, + ) -> None: + await self._session.execute( + update(Profile) + .where(Profile.id == invitee_user_id) + .values(referred_by=inviter_user_id, updated_at=func.now()) + ) + await self._session.flush() + + async def append_system_audit_log( + self, + *, + action: str, + entity_type: str, + entity_id: UUID | None, + actor_user_id: UUID | None = None, + target_user_id: UUID | None = None, + metadata: dict[str, object] | None = None, + ) -> None: + self._session.add( + SystemAuditLog( + actor_user_id=actor_user_id, + target_user_id=target_user_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + metadata_json=metadata or {}, + ) + ) + await self._session.flush() + + async def commit(self) -> None: + await self._session.commit() + + @staticmethod + def utcnow() -> datetime: + return datetime.now(timezone.utc) diff --git a/backend/src/v1/invite/router.py b/backend/src/v1/invite/router.py index 407a3f5..20eac53 100644 --- a/backend/src/v1/invite/router.py +++ b/backend/src/v1/invite/router.py @@ -9,7 +9,7 @@ from v1.invite.dependencies import ( get_current_user_for_invite, get_invite_code_service, ) -from v1.invite.schemas import MyInviteCodeResponse +from v1.invite.schemas import InviteBindRequest, MyInviteCodeResponse from v1.invite.service import InviteCodeService @@ -22,3 +22,15 @@ async def get_my_invite_code( service: Annotated[InviteCodeService, Depends(get_invite_code_service)], ) -> MyInviteCodeResponse: return await service.get_my_invite_code(user_id=current_user.id) + + +@router.post("/bind", response_model=MyInviteCodeResponse) +async def bind_invite_code( + payload: InviteBindRequest, + current_user: Annotated[CurrentUser, Depends(get_current_user_for_invite)], + service: Annotated[InviteCodeService, Depends(get_invite_code_service)], +) -> MyInviteCodeResponse: + return await service.bind_invite_code( + user_id=current_user.id, + raw_code=payload.code, + ) diff --git a/backend/src/v1/invite/schemas.py b/backend/src/v1/invite/schemas.py index 5f20082..d02d782 100644 --- a/backend/src/v1/invite/schemas.py +++ b/backend/src/v1/invite/schemas.py @@ -1,10 +1,56 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field + + +class InviteBindingInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, serialize_by_alias=True, extra="forbid" + ) + + can_bind: bool = Field(alias="canBind") + bound_invite_code: str | None = Field(alias="boundInviteCode", default=None) + bound_at: str | None = Field(alias="boundAt", default=None) + + +class InviteSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, serialize_by_alias=True, extra="forbid" + ) + + reward_points: int = Field(alias="rewardPoints", ge=0) + invited_count: int = Field(alias="invitedCount", ge=0) + rewarded_count: int = Field(alias="rewardedCount", ge=0) + pending_count: int = Field(alias="pendingCount", ge=0) + rewarded_points: int = Field(alias="rewardedPoints", ge=0) + total_potential_reward_points: int = Field(alias="totalPotentialRewardPoints", ge=0) + + +class InviteReferralItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, serialize_by_alias=True, extra="forbid" + ) + + referral_id: str = Field(alias="referralId") + invite_code: str = Field(alias="inviteCode") + bound_at: str = Field(alias="boundAt") + first_creem_paid_at: str | None = Field(alias="firstCreemPaidAt", default=None) + reward_granted: bool = Field(alias="rewardGranted") + reward_granted_at: str | None = Field(alias="rewardGrantedAt", default=None) class MyInviteCodeResponse(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict( + populate_by_name=True, serialize_by_alias=True, extra="forbid" + ) - code: str - used_count: int + my_code: str = Field(alias="myCode") + binding: InviteBindingInfo + summary: InviteSummary + items: list[InviteReferralItem] + + +class InviteBindRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + code: str = Field(min_length=1, max_length=32) diff --git a/backend/src/v1/invite/service.py b/backend/src/v1/invite/service.py index ec7963a..7ad51b2 100644 --- a/backend/src/v1/invite/service.py +++ b/backend/src/v1/invite/service.py @@ -1,11 +1,21 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timezone +import re from uuid import UUID +from core.config.settings import config from core.http.errors import ApiProblemError, problem_payload from v1.invite.repository import InviteCodeRepository -from v1.invite.schemas import MyInviteCodeResponse +from v1.invite.schemas import ( + InviteBindingInfo, + InviteReferralItem, + InviteSummary, + MyInviteCodeResponse, +) + +INVITE_CODE_PATTERN = re.compile(r"^[A-Z0-9]{6}$") @dataclass @@ -13,6 +23,124 @@ class InviteCodeService: repository: InviteCodeRepository async def get_my_invite_code(self, *, user_id: UUID) -> MyInviteCodeResponse: + return await self._build_overview(user_id=user_id) + + async def bind_invite_code( + self, + *, + user_id: UUID, + raw_code: str, + ) -> MyInviteCodeResponse: + code = raw_code.strip().upper() + if not INVITE_CODE_PATTERN.fullmatch(code): + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="INVITE_CODE_INVALID", + detail="Invite code format is invalid", + ), + ) + + existing = await self.repository.get_referral_by_invitee( + invitee_user_id=user_id + ) + legacy_binding = await self.repository.get_legacy_binding_by_invitee( + invitee_user_id=user_id + ) + if existing is not None or legacy_binding is not None: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="INVITE_ALREADY_BOUND", + detail="Current user already bound an invite code", + ), + ) + + invite_code = await self.repository.get_bindable_code_for_update(code=code) + if invite_code is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="INVITE_BIND_CODE_NOT_FOUND", + detail="Invite code does not exist", + ), + ) + if invite_code.owner_id is None or invite_code.status != "active": + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="INVITE_CODE_NOT_BINDABLE", + detail="Invite code is not bindable", + ), + ) + if invite_code.owner_id == user_id: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="INVITE_SELF_BIND_FORBIDDEN", + detail="Cannot bind your own invite code", + ), + ) + now = self.repository.utcnow() + if invite_code.expires_at is not None: + expires_at = invite_code.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at <= now: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="INVITE_CODE_NOT_BINDABLE", + detail="Invite code is expired", + ), + ) + if ( + invite_code.max_uses is not None + and invite_code.used_count >= invite_code.max_uses + ): + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="INVITE_CODE_NOT_BINDABLE", + detail="Invite code usage limit reached", + ), + ) + + referral_id = await self.repository.insert_referral( + inviter_user_id=invite_code.owner_id, + invitee_user_id=user_id, + invite_code_id=invite_code.id, + invite_code_snapshot=invite_code.code, + ) + if referral_id is None: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="INVITE_ALREADY_BOUND", + detail="Current user already bound an invite code", + ), + ) + + invite_code.used_count = int(invite_code.used_count) + 1 + await self.repository.mark_profile_referred_by( + invitee_user_id=user_id, + inviter_user_id=invite_code.owner_id, + ) + await self.repository.append_system_audit_log( + action="invite.bind", + entity_type="invite_referral", + entity_id=referral_id, + actor_user_id=user_id, + target_user_id=invite_code.owner_id, + metadata={ + "invite_code": invite_code.code, + "invite_code_id": str(invite_code.id), + }, + ) + await self.repository.commit() + return await self._build_overview(user_id=user_id) + + async def _build_overview(self, *, user_id: UUID) -> MyInviteCodeResponse: invite_code = await self.repository.get_by_owner_id(owner_id=user_id) if invite_code is None: raise ApiProblemError( @@ -22,7 +150,67 @@ class InviteCodeService: detail="Invite code not found for current user", ), ) - return MyInviteCodeResponse( - code=invite_code.code, - used_count=invite_code.used_count, + binding = await self.repository.get_referral_by_invitee(invitee_user_id=user_id) + legacy_binding = None + if binding is None: + legacy_binding = await self.repository.get_legacy_binding_by_invitee( + invitee_user_id=user_id + ) + bound_invite_code = ( + binding.invite_code_snapshot + if binding is not None + else legacy_binding[0] + if legacy_binding is not None + else None + ) + bound_at = ( + binding.bound_at + if binding is not None + else legacy_binding[1] + if legacy_binding is not None + else None + ) + can_bind = bound_invite_code is None + referrals = await self.repository.list_referrals_by_inviter( + inviter_user_id=user_id + ) + reward_points = int(config.points_policy.invite_reward_points) + rewarded_count = sum( + 1 for row in referrals if row.inviter_reward_granted_at is not None + ) + invited_count = len(referrals) + return MyInviteCodeResponse( + myCode=invite_code.code, + binding=InviteBindingInfo( + canBind=can_bind, + boundInviteCode=bound_invite_code, + boundAt=bound_at.isoformat() if bound_at else None, + ), + summary=InviteSummary( + rewardPoints=reward_points, + invitedCount=invited_count, + rewardedCount=rewarded_count, + pendingCount=max(invited_count - rewarded_count, 0), + rewardedPoints=rewarded_count * reward_points, + totalPotentialRewardPoints=invited_count * reward_points, + ), + items=[ + InviteReferralItem( + referralId=str(row.id), + inviteCode=row.invite_code_snapshot, + boundAt=row.bound_at.isoformat(), + firstCreemPaidAt=( + row.first_creem_paid_at.isoformat() + if row.first_creem_paid_at is not None + else None + ), + rewardGranted=row.inviter_reward_granted_at is not None, + rewardGrantedAt=( + row.inviter_reward_granted_at.isoformat() + if row.inviter_reward_granted_at is not None + else None + ), + ) + for row in referrals + ], ) diff --git a/backend/src/v1/payments/apple_verifier.py b/backend/src/v1/payments/apple_verifier.py index 1858705..4a6f76f 100644 --- a/backend/src/v1/payments/apple_verifier.py +++ b/backend/src/v1/payments/apple_verifier.py @@ -15,9 +15,7 @@ logger = logging.getLogger(__name__) _ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey) -_APPLE_ROOT_CA_G3_FINGERPRINT = ( - "b52cb02fd567e0359fe8fa4d4c41037970fe01b0" -) +_APPLE_ROOT_CA_G3_FINGERPRINT = "b52cb02fd567e0359fe8fa4d4c41037970fe01b0" @dataclass(frozen=True) diff --git a/backend/src/v1/payments/creem_client.py b/backend/src/v1/payments/creem_client.py index 5cdf235..7ee1646 100644 --- a/backend/src/v1/payments/creem_client.py +++ b/backend/src/v1/payments/creem_client.py @@ -30,7 +30,9 @@ class CreemCheckout: class CreemClient: def __init__(self) -> None: settings = config.creem - self._api_key = settings.api_key.get_secret_value() if settings.api_key else None + self._api_key = ( + settings.api_key.get_secret_value() if settings.api_key else None + ) self._base_url = settings.base_url.rstrip("/") self._timeout = httpx.Timeout(30.0, connect=5.0) diff --git a/backend/src/v1/payments/creem_service.py b/backend/src/v1/payments/creem_service.py index ce9be9e..28cb389 100644 --- a/backend/src/v1/payments/creem_service.py +++ b/backend/src/v1/payments/creem_service.py @@ -5,6 +5,7 @@ import hmac import json import logging from dataclasses import dataclass +from datetime import datetime, timezone from pathlib import Path from typing import Any from uuid import UUID, uuid4 @@ -21,6 +22,7 @@ from schemas.domain.points import ( from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from v1.payments.creem_client import CreemClient, CreemProduct from v1.payments.repository import PaymentRepository +from v1.points.invite_rewards import grant_invite_rewards_for_creem_payment from v1.points.repository import PointsRepository logger = logging.getLogger(__name__) @@ -44,8 +46,7 @@ def _load_creem_product_mappings() -> dict[str, CreemProductMapping]: return _creem_product_mappings_cache mapping_path = ( - Path(__file__).parent.parent.parent - / "core/config/static/packages/mapping.yaml" + Path(__file__).parent.parent.parent / "core/config/static/packages/mapping.yaml" ) with mapping_path.open("r", encoding="utf-8") as f: raw: Any = yaml.safe_load(f) or {} @@ -265,7 +266,9 @@ class CreemService: order_id = order_obj.get("id") if isinstance(order_obj, dict) else None customer_obj = obj.get("customer", {}) customer_id = customer_obj.get("id") if isinstance(customer_obj, dict) else None - metadata = obj.get("metadata", {}) + customer_email = ( + customer_obj.get("email", "") if isinstance(customer_obj, dict) else "" + ) txn = await self._payment_repo.get_creem_transaction_by_checkout_id( checkout_id=checkout_id @@ -336,6 +339,7 @@ class CreemService: txn.status = "completed" txn.ledger_event_id = event_id txn.creem_payload = obj + paid_at = datetime.now(timezone.utc) logger.info( "CREEM payment completed: user_id=%s checkout_id=%s credits=%d new_balance=%d", @@ -345,11 +349,18 @@ class CreemService: new_balance, ) + await grant_invite_rewards_for_creem_payment( + repository=self._points_repo, + invitee_user_id=user_id, + invitee_email=str(customer_email), + creem_transaction_id=txn.id, + paid_at=paid_at, + ) + mappings = _load_creem_product_mappings() mapping = mappings.get(txn.product_code) if mapping and mapping.type == "starter": - user_email = obj.get("customer", {}).get("email", "") - normalized_email = user_email.strip().lower() + normalized_email = str(customer_email).strip().lower() if normalized_email: email_hash = self._build_email_hash(normalized_email) _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( diff --git a/backend/src/v1/payments/schemas.py b/backend/src/v1/payments/schemas.py index 3db23ca..c3238d6 100644 --- a/backend/src/v1/payments/schemas.py +++ b/backend/src/v1/payments/schemas.py @@ -14,12 +14,8 @@ class VerifyTransactionRequest(BaseModel): alias="appStoreProductId", min_length=1, max_length=128 ) transaction_id: str = Field(alias="transactionId", min_length=1, max_length=64) - signed_transaction_info: str = Field( - alias="signedTransactionInfo", min_length=1 - ) - app_account_token: UUID | None = Field( - alias="appAccountToken", default=None - ) + signed_transaction_info: str = Field(alias="signedTransactionInfo", min_length=1) + app_account_token: UUID | None = Field(alias="appAccountToken", default=None) class VerifyTransactionResponse(BaseModel): diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py index 856207a..8312a7d 100644 --- a/backend/src/v1/payments/service.py +++ b/backend/src/v1/payments/service.py @@ -49,8 +49,7 @@ def _load_product_mappings() -> dict[str, ProductMapping]: return _product_mappings_cache mapping_path = ( - Path(__file__).parent.parent.parent - / "core/config/static/packages/mapping.yaml" + Path(__file__).parent.parent.parent / "core/config/static/packages/mapping.yaml" ) with mapping_path.open("r", encoding="utf-8") as f: raw: Any = yaml.safe_load(f) or {} @@ -60,7 +59,9 @@ def _load_product_mappings() -> dict[str, ProductMapping]: for code, entry in product_mappings.items(): mappings[str(code)] = ProductMapping( app_store_product_id=str(entry.get("app_store_product_id", "")), - creem_product_id=str(entry["creem_product_id"]) if entry.get("creem_product_id") else None, + creem_product_id=str(entry["creem_product_id"]) + if entry.get("creem_product_id") + else None, credits=int(entry["credits"]), type=str(entry["type"]), sort_order=int(entry.get("sort_order", 0)), @@ -335,7 +336,9 @@ class PaymentService: transaction_id=transaction_id ) if txn is None: - logger.warning("Refund requested for unknown transaction: %s", transaction_id) + logger.warning( + "Refund requested for unknown transaction: %s", transaction_id + ) return if txn.status not in ("granted",): @@ -466,7 +469,9 @@ class PaymentService: txn_data: Any = json.loads(txn_decoded) transaction_id = str(txn_data.get("transactionId", "")) except Exception: - logger.exception("Failed to decode signed transaction from notification") + logger.exception( + "Failed to decode signed transaction from notification" + ) logger.info( "Apple notification received: type=%s subtype=%s transaction_id=%s", diff --git a/backend/src/v1/points/adjustments.py b/backend/src/v1/points/adjustments.py new file mode 100644 index 0000000..dbe4453 --- /dev/null +++ b/backend/src/v1/points/adjustments.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from decimal import Decimal +from uuid import UUID + +from models.user_points import UserPoints +from schemas.domain.points import ( + AppendAuditLedgerCommand, + AdjustLedgerMetadata, + ApplyPointsChangeCommand, + AuditLedgerMetadata, +) +from schemas.enums import PointsChangeType, PointsOperatorType +from v1.points.repository import PointsRepository + + +async def apply_adjust_points( + *, + repository: PointsRepository, + user_id: UUID, + user_email: str, + event_id: str, + amount: int, + reason: str, + audit_metadata: AuditLedgerMetadata, + ledger_ext: dict[str, object], +) -> UserPoints: + if amount <= 0: + raise ValueError("adjust amount must be positive") + + account = await repository.get_or_create_user_points_for_update(user_id=user_id) + account.balance = int(account.balance) + amount + account.lifetime_earned = int(account.lifetime_earned) + amount + account.version = int(account.version) + 1 + + metadata = AdjustLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=event_id, + ext={ + **ledger_ext, + "reason": reason, + }, + ) + await repository.append_ledger( + command=ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.ADJUST, + event_id=event_id, + amount=amount, + direction=1, + operator_id=None, + metadata=metadata, + ), + balance_after=int(account.balance), + ) + await repository.append_audit_ledger( + command=AppendAuditLedgerCommand( + event_id=event_id, + user_id_snapshot=user_id, + user_email_snapshot=(user_email or "").strip().lower() or None, + change_type=PointsChangeType.ADJUST, + direction=1, + amount=amount, + balance_after=int(account.balance), + billed_to="user", + run_id=event_id, + input_tokens=0, + output_tokens=0, + cost=Decimal("0"), + metadata=audit_metadata, + ) + ) + return account diff --git a/backend/src/v1/points/invite_rewards.py b/backend/src/v1/points/invite_rewards.py new file mode 100644 index 0000000..55545e3 --- /dev/null +++ b/backend/src/v1/points/invite_rewards.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from core.config.settings import config +from schemas.domain.points import AuditLedgerMetadata +from v1.points.adjustments import apply_adjust_points +from v1.points.repository import PointsRepository + + +async def grant_invite_rewards_for_creem_payment( + *, + repository: PointsRepository, + invitee_user_id: UUID, + invitee_email: str, + creem_transaction_id: UUID, + paid_at: datetime, +) -> None: + reward_points = int(config.points_policy.invite_reward_points) + if reward_points <= 0: + return + + referral = await repository.get_referral_by_invitee_for_update( + invitee_user_id=invitee_user_id + ) + if referral is None: + return + if ( + referral.inviter_reward_granted_at is not None + or referral.invitee_reward_granted_at is not None + ): + return + + referral.first_creem_transaction_id = creem_transaction_id + referral.first_creem_paid_at = paid_at + + inviter_event_id = f"invite.reward.inviter:{referral.id}" + invitee_event_id = f"invite.reward.invitee:{referral.id}" + inviter_email = await repository.get_user_email(user_id=referral.inviter_user_id) + + inviter_account = await apply_adjust_points( + repository=repository, + user_id=referral.inviter_user_id, + user_email=inviter_email or "", + event_id=inviter_event_id, + amount=reward_points, + reason="invite_reward_inviter", + audit_metadata=AuditLedgerMetadata( + source="invite_reward_inviter", + referral_id=str(referral.id), + transaction_id=str(creem_transaction_id), + role="inviter", + ), + ledger_ext={ + "reason": "invite_reward_inviter", + "referral_id": str(referral.id), + "invitee_user_id": str(invitee_user_id), + "creem_transaction_id": str(creem_transaction_id), + }, + ) + invitee_account = await apply_adjust_points( + repository=repository, + user_id=invitee_user_id, + user_email=invitee_email, + event_id=invitee_event_id, + amount=reward_points, + reason="invite_reward_invitee", + audit_metadata=AuditLedgerMetadata( + source="invite_reward_invitee", + referral_id=str(referral.id), + transaction_id=str(creem_transaction_id), + role="invitee", + ), + ledger_ext={ + "reason": "invite_reward_invitee", + "referral_id": str(referral.id), + "inviter_user_id": str(referral.inviter_user_id), + "creem_transaction_id": str(creem_transaction_id), + }, + ) + + referral.inviter_reward_event_id = inviter_event_id + referral.invitee_reward_event_id = invitee_event_id + referral.inviter_reward_granted_at = paid_at + referral.invitee_reward_granted_at = paid_at + await repository.append_system_audit_log( + actor_user_id=None, + target_user_id=invitee_user_id, + action="invite.reward_grant", + entity_type="invite_referral", + entity_id=referral.id, + metadata={ + "creem_transaction_id": str(creem_transaction_id), + "reward_points": reward_points, + "inviter_user_id": str(referral.inviter_user_id), + "invitee_user_id": str(invitee_user_id), + "inviter_balance_after": int(inviter_account.balance), + "invitee_balance_after": int(invitee_account.balance), + }, + ) diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index 2264f79..64e72a6 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -9,10 +9,14 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from models.agent_chat_message import AgentChatMessage +from models.auth_user import AuthUser +from models.invite_referral import InviteReferral from models.points_audit_ledger import PointsAuditLedger from models.points_ledger import PointsLedger from models.profile import Profile from models.register_bonus_claims import RegisterBonusClaims +from models.redeem_code import RedeemCode +from models.system_audit_log import SystemAuditLog from models.user_points import UserPoints from schemas.domain.points import ( AppendAuditLedgerCommand, @@ -47,6 +51,24 @@ class PointsRepository: row = (await self._session.execute(stmt)).scalar_one_or_none() return row is not None + async def get_redeem_code_for_update(self, *, code: str) -> RedeemCode | None: + stmt = select(RedeemCode).where(RedeemCode.code == code).with_for_update() + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_referral_by_invitee_for_update( + self, *, invitee_user_id: UUID + ) -> InviteReferral | None: + stmt = ( + select(InviteReferral) + .where(InviteReferral.invitee_user_id == invitee_user_id) + .with_for_update() + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_user_email(self, *, user_id: UUID) -> str | None: + stmt = select(AuthUser.email).where(AuthUser.id == user_id).limit(1) + return (await self._session.execute(stmt)).scalar_one_or_none() + async def append_ledger( self, *, @@ -97,6 +119,28 @@ class PointsRepository: self._session.add(entry) await self._session.flush() + async def append_system_audit_log( + self, + *, + actor_user_id: UUID | None, + target_user_id: UUID | None, + action: str, + entity_type: str, + entity_id: UUID | None, + metadata: dict[str, object], + ) -> None: + self._session.add( + SystemAuditLog( + actor_user_id=actor_user_id, + target_user_id=target_user_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + metadata_json=metadata, + ) + ) + await self._session.flush() + async def get_run_usage_snapshot( self, *, @@ -226,3 +270,6 @@ class PointsRepository: has_more = len(rows) > limit items = rows[:limit] return (items, has_more) + + async def commit(self) -> None: + await self._session.commit() diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 4e732f1..e181a22 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -14,6 +14,8 @@ from v1.points.schemas import ( PointsBalanceResponse, LedgerListResponse, LedgerItem, + RedeemCodeRequest, + RedeemCodeResponse, ) from v1.points.service import PointsService from v1.users.dependencies import get_current_user @@ -108,3 +110,23 @@ async def get_points_ledger( nextCursor=next_cursor, hasMore=has_more, ) + + +@router.post("/redeem-codes/redeem", response_model=RedeemCodeResponse) +async def redeem_code( + payload: RedeemCodeRequest, + service: Annotated[PointsService, Depends(get_points_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> RedeemCodeResponse: + result = await service.redeem_code( + user_id=current_user.id, + user_email=current_user.email or "", + raw_code=payload.code, + ) + return RedeemCodeResponse( + packageProductCode=result.package_product_code, + packageName=result.package_name, + credits=result.credits, + balanceAfter=result.balance_after, + redeemedAt=result.redeemed_at.isoformat(), + ) diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 3b988b5..a5217d4 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -31,7 +31,9 @@ class PackageInfo(BaseModel): starter_eligible: bool = Field(alias="starterEligible") sort_order: int = Field(alias="sortOrder", ge=0) price_cents: int | None = Field(alias="priceCents", default=None, ge=0) - currency: str | None = Field(alias="currency", default=None, min_length=3, max_length=8) + currency: str | None = Field( + alias="currency", default=None, min_length=3, max_length=8 + ) class PackagesResponse(BaseModel): @@ -57,3 +59,19 @@ class LedgerListResponse(BaseModel): items: list[LedgerItem] next_cursor: str | None = Field(alias="nextCursor", default=None) has_more: bool = Field(alias="hasMore") + + +class RedeemCodeRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + code: str = Field(min_length=1, max_length=64) + + +class RedeemCodeResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + package_product_code: str = Field(alias="packageProductCode") + package_name: str = Field(alias="packageName") + credits: int = Field(ge=1) + balance_after: int = Field(alias="balanceAfter", ge=0) + redeemed_at: str = Field(alias="redeemedAt") diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 6e797bf..5030a37 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -1,10 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal import hashlib import hmac +import re from typing import TYPE_CHECKING, Literal from uuid import UUID, uuid4 @@ -22,6 +23,10 @@ from schemas.domain.points import ApplyPointsChangeCommand from v1.payments.service import _load_product_mappings from v1.payments.creem_service import _load_creem_product_mappings from v1.payments.creem_client import CreemClient +from v1.points.adjustments import apply_adjust_points +from v1.points.invite_rewards import ( + grant_invite_rewards_for_creem_payment as grant_invite_rewards_for_creem_payment_command, +) from v1.points.repository import PointsRepository from v1.points.schemas import LedgerItem @@ -29,6 +34,7 @@ if TYPE_CHECKING: pass RUN_POINTS_COST = 20 +REDEEM_CODE_PATTERN = re.compile(r"^[A-Z0-9]{8,32}$") @dataclass(frozen=True) @@ -64,6 +70,15 @@ class RegisterBonusResult: is_first_registration: bool = False +@dataclass(frozen=True) +class RedeemCodeResult: + package_product_code: str + package_name: str + credits: int + balance_after: int + redeemed_at: datetime + + @dataclass(frozen=True) class PackageInfoResult: product_code: str @@ -431,6 +446,113 @@ class PointsService: cost=usage_snapshot.cost, ) + async def redeem_code( + self, + *, + user_id: UUID, + user_email: str, + raw_code: str, + ) -> RedeemCodeResult: + code = raw_code.strip().upper() + if not REDEEM_CODE_PATTERN.fullmatch(code): + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="REDEEM_CODE_INVALID_FORMAT", + detail="Redeem code must be 8-32 uppercase letters or digits", + ), + ) + + redeem_code = await self._repository.get_redeem_code_for_update(code=code) + if redeem_code is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="REDEEM_CODE_NOT_FOUND", + detail="Redeem code not found", + ), + ) + if redeem_code.status == "redeemed": + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="REDEEM_CODE_ALREADY_REDEEMED", + detail="Redeem code has already been redeemed", + ), + ) + if redeem_code.status != "active": + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="REDEEM_CODE_DISABLED", + detail="Redeem code is not active", + ), + ) + + event_hash = hashlib.sha1(code.encode("utf-8")).hexdigest() + event_id = f"redeem.code:{event_hash}" + redeemed_at = datetime.now(timezone.utc) + account = await apply_adjust_points( + repository=self._repository, + user_id=user_id, + user_email=user_email, + event_id=event_id, + amount=int(redeem_code.credits), + reason="redeem_code_activation", + audit_metadata=AuditLedgerMetadata( + source="redeem_code_activation", + redeem_code_id=str(redeem_code.id), + package_product_code=redeem_code.package_product_code, + ), + ledger_ext={ + "reason": "redeem_code_activation", + "redeem_code_id": str(redeem_code.id), + "package_product_code": redeem_code.package_product_code, + "package_name": redeem_code.package_name_snapshot, + }, + ) + + redeem_code.status = "redeemed" + redeem_code.redeemed_by_user_id = user_id + redeem_code.redeemed_at = redeemed_at + redeem_code.redeem_event_id = event_id + await self._repository.append_system_audit_log( + actor_user_id=user_id, + target_user_id=user_id, + action="redeem_code.activate", + entity_type="redeem_code", + entity_id=redeem_code.id, + metadata={ + "package_product_code": redeem_code.package_product_code, + "credits": int(redeem_code.credits), + "event_id": event_id, + }, + ) + await self._repository.commit() + return RedeemCodeResult( + package_product_code=redeem_code.package_product_code, + package_name=redeem_code.package_name_snapshot, + credits=int(redeem_code.credits), + balance_after=int(account.balance), + redeemed_at=redeemed_at, + ) + + async def grant_invite_rewards_for_creem_payment( + self, + *, + invitee_user_id: UUID, + invitee_email: str, + creem_transaction_id: UUID, + paid_at: datetime, + ) -> None: + await grant_invite_rewards_for_creem_payment_command( + repository=self._repository, + invitee_user_id=invitee_user_id, + invitee_email=invitee_email, + creem_transaction_id=creem_transaction_id, + paid_at=paid_at, + ) + @staticmethod def _normalize_email(email: str) -> str: return email.strip().lower() diff --git a/backend/tests/unit/test_invite_redeem_points.py b/backend/tests/unit/test_invite_redeem_points.py new file mode 100644 index 0000000..4cc1ccb --- /dev/null +++ b/backend/tests/unit/test_invite_redeem_points.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest + +from v1.points.invite_rewards import grant_invite_rewards_for_creem_payment +from v1.points.service import PointsService + + +@dataclass +class _FakeAccount: + balance: int = 0 + frozen_balance: int = 0 + lifetime_earned: int = 0 + lifetime_spent: int = 0 + version: int = 0 + + +class _FakePointsRepository: + def __init__(self) -> None: + self.accounts: dict[UUID, _FakeAccount] = {} + self.redeem_code: SimpleNamespace | None = None + self.referral: SimpleNamespace | None = None + self.user_emails: dict[UUID, str] = {} + self.ledgers: list[object] = [] + self.audit_ledgers: list[object] = [] + self.system_audits: list[dict[str, object]] = [] + self.committed = False + + async def get_redeem_code_for_update(self, *, code: str) -> SimpleNamespace | None: + if self.redeem_code is None or self.redeem_code.code != code: + return None + return self.redeem_code + + async def get_or_create_user_points_for_update( + self, *, user_id: UUID + ) -> _FakeAccount: + return self.accounts.setdefault(user_id, _FakeAccount()) + + async def append_ledger(self, *, command: object, balance_after: int) -> None: + self.ledgers.append(command) + + async def append_audit_ledger(self, *, command: object) -> None: + self.audit_ledgers.append(command) + + async def append_system_audit_log( + self, + *, + actor_user_id: UUID | None, + target_user_id: UUID | None, + action: str, + entity_type: str, + entity_id: UUID | None, + metadata: dict[str, object], + ) -> None: + self.system_audits.append( + { + "actor_user_id": actor_user_id, + "target_user_id": target_user_id, + "action": action, + "entity_type": entity_type, + "entity_id": entity_id, + "metadata": metadata, + } + ) + + async def commit(self) -> None: + self.committed = True + + async def get_referral_by_invitee_for_update( + self, *, invitee_user_id: UUID + ) -> SimpleNamespace | None: + if self.referral is None or self.referral.invitee_user_id != invitee_user_id: + return None + return self.referral + + async def get_user_email(self, *, user_id: UUID) -> str | None: + return self.user_emails.get(user_id) + + +@pytest.mark.asyncio +async def test_redeem_code_credits_account_and_audits() -> None: + user_id = uuid4() + code_id = uuid4() + repo = _FakePointsRepository() + repo.redeem_code = SimpleNamespace( + id=code_id, + code="ABCD2345", + status="active", + credits=100, + package_product_code="starter_pack", + package_name_snapshot="starter_pack", + redeemed_by_user_id=None, + redeemed_at=None, + redeem_event_id=None, + ) + + result = await PointsService(repository=repo).redeem_code( + user_id=user_id, + user_email="buyer@example.com", + raw_code="abcd2345", + ) + + assert result.credits == 100 + assert result.balance_after == 100 + assert repo.accounts[user_id].balance == 100 + assert repo.redeem_code.status == "redeemed" + assert repo.redeem_code.redeemed_by_user_id == user_id + assert len(repo.ledgers) == 1 + assert len(repo.audit_ledgers) == 1 + assert repo.system_audits[0]["action"] == "redeem_code.activate" + assert repo.committed is True + + +@pytest.mark.asyncio +async def test_creem_payment_after_binding_grants_invite_rewards_to_both_users() -> ( + None +): + inviter_id = uuid4() + invitee_id = uuid4() + referral_id = uuid4() + creem_transaction_id = uuid4() + paid_at = datetime.now(timezone.utc) + repo = _FakePointsRepository() + repo.user_emails[inviter_id] = "inviter@example.com" + repo.referral = SimpleNamespace( + id=referral_id, + inviter_user_id=inviter_id, + invitee_user_id=invitee_id, + first_creem_transaction_id=None, + first_creem_paid_at=None, + inviter_reward_event_id=None, + invitee_reward_event_id=None, + inviter_reward_granted_at=None, + invitee_reward_granted_at=None, + ) + + await grant_invite_rewards_for_creem_payment( + repository=repo, + invitee_user_id=invitee_id, + invitee_email="invitee@example.com", + creem_transaction_id=creem_transaction_id, + paid_at=paid_at, + ) + + assert repo.accounts[inviter_id].balance == 40 + assert repo.accounts[invitee_id].balance == 40 + assert repo.referral.first_creem_transaction_id == creem_transaction_id + assert repo.referral.inviter_reward_granted_at == paid_at + assert repo.referral.invitee_reward_granted_at == paid_at + assert len(repo.ledgers) == 2 + assert len(repo.audit_ledgers) == 2 + assert repo.system_audits[0]["action"] == "invite.reward_grant" + + +@pytest.mark.asyncio +async def test_creem_payment_after_existing_completed_payment_still_grants_binding_reward() -> ( + None +): + inviter_id = uuid4() + invitee_id = uuid4() + repo = _FakePointsRepository() + repo.referral = SimpleNamespace( + id=uuid4(), + inviter_user_id=inviter_id, + invitee_user_id=invitee_id, + first_creem_transaction_id=None, + first_creem_paid_at=None, + inviter_reward_event_id=None, + invitee_reward_event_id=None, + inviter_reward_granted_at=None, + invitee_reward_granted_at=None, + ) + + creem_transaction_id = uuid4() + paid_at = datetime.now(timezone.utc) + await grant_invite_rewards_for_creem_payment( + repository=repo, + invitee_user_id=invitee_id, + invitee_email="invitee@example.com", + creem_transaction_id=creem_transaction_id, + paid_at=paid_at, + ) + + assert repo.accounts[inviter_id].balance == 40 + assert repo.accounts[invitee_id].balance == 40 + assert repo.referral.first_creem_transaction_id == creem_transaction_id + assert repo.referral.inviter_reward_granted_at == paid_at + assert len(repo.ledgers) == 2 + assert len(repo.audit_ledgers) == 2 diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index a276165..4b896c6 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -90,6 +90,20 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| | `INVITE_CODE_NOT_FOUND` | 404 | Invite code not found for current user | Show not-found message and trigger invite code bootstrap | +| `INVITE_BIND_CODE_NOT_FOUND` | 404 | Invite code entered for binding does not exist | Show invalid-code message | +| `INVITE_ALREADY_BOUND` | 409 | Current user already bound an inviter code | Disable bind UI and show bound state | +| `INVITE_SELF_BIND_FORBIDDEN` | 409 | Current user attempted to bind their own invite code | Show cannot-bind-self message | +| `INVITE_CODE_NOT_BINDABLE` | 409 | Invite code is disabled, expired, or has no active owner | Show code-unavailable message | +| `INVITE_CODE_INVALID` | 422 | Invite code format is invalid | Prompt user to enter a valid code | + +## Redeem Code + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `REDEEM_CODE_NOT_FOUND` | 404 | Redeem code does not exist | Show not-found message | +| `REDEEM_CODE_ALREADY_REDEEMED` | 409 | Redeem code has already been activated | Show already-redeemed message | +| `REDEEM_CODE_DISABLED` | 409 | Redeem code is disabled or unavailable | Show unavailable message | +| `REDEEM_CODE_INVALID` | 422 | Redeem code format is invalid | Prompt user to enter a valid code | ## Feedback diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index ea5bf8b..3387a19 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -4,9 +4,9 @@ This protocol defines the canonical data contract for user profile, points accou Protocol verification status: -- Last audited migration: `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py` -- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py` -- Current status: aligned with register bonus moved to application service +- Last audited migration: `backend/alembic/versions/20260521_0002_invite_referrals_and_redeem_codes.py` +- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/invite_referral.py`, `backend/src/models/redeem_code_batch.py`, `backend/src/models/redeem_code.py`, `backend/src/models/system_audit_log.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py` +- Current status: aligned with referral rewards and redeem code activation ## Scope @@ -15,6 +15,10 @@ Protocol verification status: - `points_ledger` - `points_audit_ledger` - `register_bonus_claims` +- `invite_referrals` +- `redeem_code_batches` +- `redeem_codes` +- `system_audit_logs` - `sessions` - `messages` @@ -23,6 +27,7 @@ Protocol verification status: - Current strategy: additive evolution. - Breaking changes (drop/rename/type change on core fields) require explicit migration + rollback notes. - `points_ledger.metadata.schema_version` is mandatory and current value is `1`. +- Invite reward amount is configured by backend env `ERYAO_POINTS_POLICY__INVITE_REWARD_POINTS`. ## Runtime charging policy (chat) @@ -53,6 +58,14 @@ Protocol verification status: Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`). +### `adjust` metadata ext.reason conventions + +For referral rewards and redeem codes, `change_type='adjust'` must use one of: + +- `invite_reward_inviter` +- `invite_reward_invitee` +- `redeem_code_activation` + ## Table contract ### profiles @@ -129,6 +142,87 @@ Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`). - `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery - `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits) +### invite_referrals + +- PK: `id` +- FK: + - `inviter_user_id -> profiles.id` + - `invitee_user_id -> profiles.id` + - `invite_code_id -> invite_codes.id` + - `first_creem_transaction_id -> creem_transactions.id` (`on delete set null`) +- Core fields: + - `invite_code_snapshot` + - `bound_at` + - `first_creem_paid_at` + - `inviter_reward_event_id` + - `invitee_reward_event_id` + - `inviter_reward_granted_at` + - `invitee_reward_granted_at` + - `created_at` + - `updated_at` +- Constraints: + - one invitee can only appear once + - inviter cannot equal invitee + - invite code snapshot is immutable after bind +- Notes: + - historical bindings should be backfilled from `profiles.referred_by` + - reward grant is idempotent per side via unique event ids + +### redeem_code_batches + +- PK: `id` +- Core fields: + - `batch_key` + - `created_by` + - `notes` + - `created_at` + - `updated_at` +- Constraints: + - `batch_key` unique + +### redeem_codes + +- PK: `id` +- FK: + - `batch_id -> redeem_code_batches.id` + - `redeemed_by_user_id -> auth.users.id` (`on delete set null`) +- Core fields: + - `code` + - `package_product_code` + - `package_type` + - `package_name_snapshot` + - `credits` + - `sort_order` + - `status` + - `redeemed_at` + - `redeem_event_id` + - `created_at` + - `updated_at` +- Constraints: + - `code` unique + - `status in ('active', 'redeemed', 'disabled')` + - redeemed rows must have `redeemed_at`, `redeemed_by_user_id`, `redeem_event_id` +- Notes: + - only regular packages are eligible for batch generation in this feature + - redeem codes do not qualify as CREEM payments for invite binding rewards + +### system_audit_logs + +- PK: `id` +- Core fields: + - `actor_user_id` + - `target_user_id` + - `action` + - `entity_type` + - `entity_id` + - `metadata` + - `created_at` + - `updated_at` +- Constraints: + - metadata must be object +- Notes: + - used for invite bind, invite reward grant, redeem code batch generation, redeem code activation + #### points_ledger.metadata (schema_version=1) Canonical shape: @@ -174,6 +268,7 @@ JSON constraints: - Application service (in `POST /auth/email-session`): - `grant_register_bonus_if_eligible()` restores `balance_snapshot` first when present; otherwise grants register bonus via `register_bonus_claims` - Bonus amount from `config.points_policy.register_bonus_points` + - referral binding remains write-once after signup via API or historical trigger snapshot ### sessions @@ -260,6 +355,13 @@ Returns the authenticated user's points ledger in reverse chronological order. } ``` +## Invite and redeem integration notes + +- Invite binding is write-once, cannot be unbound, and is allowed regardless of previous completed `creem_transactions` rows. +- Referral reward qualification is based on the first completed CREEM payment after the invite binding has been created. +- Invite rewards are credited as `adjust` ledger rows with reason `invite_reward_inviter` / `invite_reward_invitee`. +- Redeem code activation is credited as an `adjust` ledger row with reason `redeem_code_activation`. + **Fields:** - `items`: ledger rows ordered by `createdAt desc` - `direction`: `1` for income, `-1` for spending/deduction diff --git a/docs/protocols/invite/invite-protocol.md b/docs/protocols/invite/invite-protocol.md index 2c0b576..c5496ef 100644 --- a/docs/protocols/invite/invite-protocol.md +++ b/docs/protocols/invite/invite-protocol.md @@ -1,13 +1,13 @@ # Invite Protocol (Frontend <-> Backend) -This document defines the invite code contract for authenticated users. +This document defines the invite/referral contract for authenticated web users. Protocol verification status: - Backend route source: `backend/src/v1/invite/router.py` - Backend service source: `backend/src/v1/invite/service.py` - Backend schema source: `backend/src/v1/invite/schemas.py` -- Frontend mapping source: `apps/lib/features/settings/data/apis/invite_api.dart` +- Web mapping source: `web/src/lib/api.ts` ## Compatibility strategy @@ -18,7 +18,7 @@ Protocol verification status: ### GET /api/v1/invite/me -Get the current user's invite code information. +Get the current user's invite overview. **Authorization**: Requires authenticated session. User identity from JWT `sub`. @@ -26,15 +26,78 @@ Get the current user's invite code information. ```json { - "code": "ABC123XYZ", - "used_count": 5 + "myCode": "ABC123", + "binding": { + "canBind": false, + "boundInviteCode": "QWE789", + "boundAt": "2026-05-21T10:30:00+00:00" + }, + "summary": { + "rewardPoints": 40, + "invitedCount": 3, + "rewardedCount": 2, + "pendingCount": 1, + "rewardedPoints": 80, + "totalPotentialRewardPoints": 120 + }, + "items": [ + { + "referralId": "5ed7c3f0-6d1c-4f3f-8b40-2cb537da53e6", + "inviteCode": "ABC123", + "boundAt": "2026-05-18T09:00:00+00:00", + "firstCreemPaidAt": "2026-05-20T11:15:00+00:00", + "rewardGranted": true, + "rewardGrantedAt": "2026-05-20T11:15:02+00:00" + } + ] } ``` Field rules: -- `code`: string, unique invite code assigned to the user -- `used_count`: integer `>= 0`, number of times this code has been used +- `myCode`: string, unique invite code assigned to the current user +- `binding.canBind`: boolean, whether the current user can still bind another user's invite code +- `binding.boundInviteCode`: string or `null`, bound inviter code snapshot +- `binding.boundAt`: ISO 8601 datetime or `null` +- `summary.rewardPoints`: integer `>= 0`, reward granted to inviter and invitee per qualified post-bind CREEM payment +- `summary.invitedCount`: integer `>= 0` +- `summary.rewardedCount`: integer `>= 0` +- `summary.pendingCount`: integer `>= 0`, computed as `invitedCount - rewardedCount` +- `summary.rewardedPoints`: integer `>= 0`, computed as `rewardedCount * rewardPoints` +- `summary.totalPotentialRewardPoints`: integer `>= 0`, computed as `invitedCount * rewardPoints` +- `items`: inviter-side referral list, newest first +- `items[].firstCreemPaidAt`: present only when the invitee has completed the qualifying CREEM payment after binding +- `items[].rewardGranted`: whether inviter-side invite reward has been credited +- `items[].rewardGrantedAt`: present only when `rewardGranted=true` + +### POST /api/v1/invite/bind + +Bind another user's invite code to the current account. + +This endpoint is write-once: + +- a user can bind at most once; +- binding cannot be removed; +- self-binding is forbidden; +- binding is allowed even if the current user already has completed CREEM payments. + +**Authorization**: Requires authenticated session. + +**Request**: + +```json +{ + "code": "ABC123" +} +``` + +Field rules: + +- `code`: string, exactly 6 uppercase alphanumeric invite characters after normalization + +**Response (200)**: + +Same shape as `GET /api/v1/invite/me` after the bind succeeds. ## Error contract linkage @@ -42,8 +105,14 @@ Field rules: - Shared registry: `docs/protocols/common/http-error-codes.md`. - Error codes for this feature: - `INVITE_CODE_NOT_FOUND` (404): Invite code not found for current user + - `INVITE_BIND_CODE_NOT_FOUND` (404): Invite code to bind does not exist + - `INVITE_ALREADY_BOUND` (409): Current user already bound an inviter + - `INVITE_SELF_BIND_FORBIDDEN` (409): Current user attempted to bind their own code + - `INVITE_CODE_NOT_BINDABLE` (409): Invite code is disabled, expired, or has no owner + - `INVITE_CODE_INVALID` (422): Invite code format is invalid ## Data model linkage - Invite codes are stored in `invite_codes` table. -- See `docs/protocols/common/user-points-chat-data-protocol.md` for `profiles.referred_by` field. +- Referral bindings are stored in `invite_referrals`. +- See `docs/protocols/common/user-points-chat-data-protocol.md` for `profiles.referred_by`, `invite_referrals`, `redeem_code_batches`, and `redeem_codes`. diff --git a/docs/protocols/points/points-balance-protocol.md b/docs/protocols/points/points-balance-protocol.md index 4d07f5b..fab25a8 100644 --- a/docs/protocols/points/points-balance-protocol.md +++ b/docs/protocols/points/points-balance-protocol.md @@ -7,6 +7,7 @@ Protocol verification status: - Backend route source: `backend/src/v1/points/router.py` - Backend service source: `backend/src/v1/points/service.py` - Response schema source: `backend/src/v1/points/schemas.py` +- Related redeem protocol: `docs/protocols/points/points-redeem-code-protocol.md` ## Compatibility strategy diff --git a/docs/protocols/points/points-redeem-code-protocol.md b/docs/protocols/points/points-redeem-code-protocol.md new file mode 100644 index 0000000..86acb30 --- /dev/null +++ b/docs/protocols/points/points-redeem-code-protocol.md @@ -0,0 +1,78 @@ +# Points Redeem Code Protocol (Frontend <-> Backend) + +This document defines the redeem-code activation contract for authenticated web users. + +Protocol verification status: + +- Backend route source: `backend/src/v1/points/router.py` +- Backend service source: `backend/src/v1/points/service.py` +- Response schema source: `backend/src/v1/points/schemas.py` +- Web mapping source: `web/src/lib/api.ts` + +## Compatibility strategy + +- Additive evolution only. +- Existing read-only balance/package routes remain backward-compatible. + +## Route + +### POST /api/v1/points/redeem-codes/redeem + +Redeem a one-time activation code and credit the matching package points to the current account. + +**Authorization**: Requires authenticated session. + +**Request**: + +```json +{ + "code": "RX8P2N6K4JQW" +} +``` + +Field rules: + +- `code`: string, normalized uppercase code + +**Response (200)**: + +```json +{ + "packageProductCode": "popular_pack", + "packageName": "常用加量包", + "creditsAdded": 210, + "newBalance": 330, + "redeemedAt": "2026-05-21T12:30:00+00:00" +} +``` + +Field rules: + +- `packageProductCode`: package mapping key from backend static package config +- `packageName`: current package display label snapshot stored in the redeem code record +- `creditsAdded`: integer `> 0` +- `newBalance`: integer `>= 0` +- `redeemedAt`: ISO 8601 datetime + +## Business rules + +- Redeem codes are one-time use. +- Redeem codes do not count as a successful recharge for invite reward qualification. +- Redeem crediting is written to points ledger and points audit ledger. +- Redeem activation must also write a system audit log entry. + +## Error contract linkage + +- RFC7807 + extension `code`, optional `params`. +- Shared registry: `docs/protocols/common/http-error-codes.md`. +- Error codes for this feature: + - `REDEEM_CODE_NOT_FOUND` (404): Redeem code does not exist + - `REDEEM_CODE_ALREADY_REDEEMED` (409): Redeem code was already activated + - `REDEEM_CODE_DISABLED` (409): Redeem code is disabled or unavailable + - `REDEEM_CODE_INVALID` (422): Redeem code format is invalid + +## Data model linkage + +- Redeem code batches are stored in `redeem_code_batches`. +- Redeem codes are stored in `redeem_codes`. +- Package snapshots come from `backend/src/core/config/static/packages/mapping.yaml`. diff --git a/infra/scripts/generate-redeem-codes.sh b/infra/scripts/generate-redeem-codes.sh new file mode 100755 index 0000000..ffb9426 --- /dev/null +++ b/infra/scripts/generate-redeem-codes.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +ENV_FILE="$ROOT_DIR/.env" +ENV_LOADER="$ROOT_DIR/infra/scripts/lib/env.sh" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: env file not found at $ENV_FILE" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +. "$ENV_LOADER" +load_env_file "$ENV_FILE" +load_env_file "$ROOT_DIR/.env.local" + +cd "$ROOT_DIR" +PYTHONPATH=backend/src uv run python infra/scripts/generate_redeem_codes.py "$@" diff --git a/infra/scripts/generate_redeem_codes.py b/infra/scripts/generate_redeem_codes.py new file mode 100644 index 0000000..fdfee0e --- /dev/null +++ b/infra/scripts/generate_redeem_codes.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +import secrets +import string +from uuid import UUID + +from openpyxl import Workbook +from openpyxl.utils import get_column_letter +from sqlalchemy import select + +from core.db.session import AsyncSessionLocal +from models.redeem_code import RedeemCode +from models.redeem_code_batch import RedeemCodeBatch +from models.system_audit_log import SystemAuditLog +from v1.payments.service import ProductMapping, _load_product_mappings + +CODE_ALPHABET = "".join( + ch for ch in string.ascii_uppercase + string.digits if ch not in "0O1I" +) +DEFAULT_COUNTS = (100, 40, 20) +DEFAULT_CODE_LENGTH = 16 + + +@dataclass(frozen=True) +class PackageSpec: + product_code: str + name: str + credits: int + sort_order: int + mapping: ProductMapping + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate redeem codes for regular packages." + ) + parser.add_argument( + "--batch-key", + default=None, + help="Unique batch key. Defaults to redeem-YYYYmmddHHMMSS.", + ) + parser.add_argument( + "--created-by", + default="infra_script", + help="Operator name stored on the redeem code batch.", + ) + parser.add_argument("--notes", default=None, help="Optional batch notes.") + parser.add_argument( + "--output-dir", + default="var/redeem-codes", + help="Directory for the generated xlsx file.", + ) + return parser.parse_args() + + +def load_regular_packages() -> list[PackageSpec]: + mappings = _load_product_mappings() + packages = [ + PackageSpec( + product_code=code, + name=code, + credits=mapping.credits, + sort_order=mapping.sort_order, + mapping=mapping, + ) + for code, mapping in mappings.items() + if mapping.enabled and mapping.type != "starter" + ] + packages.sort(key=lambda item: item.sort_order) + if len(packages) != 3: + raise RuntimeError( + f"Expected exactly 3 non-starter packages, found {len(packages)}" + ) + return packages + + +def generate_codes(total: int, *, length: int = DEFAULT_CODE_LENGTH) -> list[str]: + codes: set[str] = set() + while len(codes) < total: + codes.add("".join(secrets.choice(CODE_ALPHABET) for _ in range(length))) + return sorted(codes) + + +def build_workbook( + *, + batch_id: UUID, + batch_key: str, + rows: list[RedeemCode], +) -> Workbook: + workbook = Workbook() + sheet = workbook.active + if sheet is None: + raise RuntimeError("Failed to create redeem code workbook sheet") + sheet.title = "redeem_codes" + sheet.append( + [ + "batch_id", + "batch_key", + "code_id", + "code", + "package_product_code", + "package_name", + "credits", + "status", + "redeemed_by_user_id", + "redeemed_at", + ] + ) + for row in rows: + sheet.append( + [ + str(batch_id), + batch_key, + str(row.id), + row.code, + row.package_product_code, + row.package_name_snapshot, + int(row.credits), + row.status, + "", + "", + ] + ) + + for column_index, column in enumerate(sheet.columns, start=1): + column_letter = get_column_letter(column_index) + max_length = max(len(str(cell.value or "")) for cell in column) + sheet.column_dimensions[column_letter].width = min(max(max_length + 2, 12), 42) + return workbook + + +async def main() -> None: + args = parse_args() + now = datetime.now(timezone.utc) + batch_key = args.batch_key or f"redeem-{now:%Y%m%d%H%M%S}" + output_dir = Path(args.output_dir) + output_path = output_dir / f"{batch_key}.xlsx" + if output_path.exists(): + raise RuntimeError(f"Output file already exists: {output_path}") + + packages = load_regular_packages() + total = sum(DEFAULT_COUNTS) + codes = generate_codes(total) + + async with AsyncSessionLocal() as session: + existing_codes = ( + ( + await session.execute( + select(RedeemCode.code).where(RedeemCode.code.in_(codes)) + ) + ) + .scalars() + .all() + ) + if existing_codes: + raise RuntimeError("Generated redeem code collision; rerun the script") + + batch = RedeemCodeBatch( + batch_key=batch_key, + created_by=args.created_by, + notes=args.notes, + ) + session.add(batch) + await session.flush() + + rows: list[RedeemCode] = [] + offset = 0 + for package, count in zip(packages, DEFAULT_COUNTS, strict=True): + for code in codes[offset : offset + count]: + row = RedeemCode( + batch_id=batch.id, + code=code, + package_product_code=package.product_code, + package_type=package.mapping.type, + package_name_snapshot=package.name, + credits=package.credits, + sort_order=package.sort_order, + status="active", + ) + session.add(row) + rows.append(row) + offset += count + + session.add( + SystemAuditLog( + actor_user_id=None, + target_user_id=None, + action="redeem_code.batch_generate", + entity_type="redeem_code_batch", + entity_id=batch.id, + metadata_json={ + "batch_key": batch_key, + "counts": { + package.product_code: count + for package, count in zip(packages, DEFAULT_COUNTS, strict=True) + }, + "total": total, + "output_path": str(output_path), + }, + ) + ) + + try: + await session.flush() + output_dir.mkdir(parents=True, exist_ok=True) + workbook = build_workbook(batch_id=batch.id, batch_key=batch_key, rows=rows) + workbook.save(output_path) + await session.commit() + except Exception: + await session.rollback() + if output_path.exists(): + output_path.unlink() + raise + + print(f"Generated {total} redeem codes") + print(f"Batch key: {batch_key}") + print(f"Excel: {output_path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/web/design/assets/legal/en/about_us.md b/web/design/assets/legal/en/about_us.md index 5a60573..7ecd245 100644 --- a/web/design/assets/legal/en/about_us.md +++ b/web/design/assets/legal/en/about_us.md @@ -8,12 +8,6 @@ MeeYao Divination is designed based on traditional oriental culture. Our core go --- -## AI Model Disclosure - -MeeYao Divination's AI Analysis feature is powered by DeepSeek's deepseek-v4-flash model. - ---- - ## Company Info **Developer:** Ann Lee diff --git a/web/design/assets/legal/en/terms_of_service.md b/web/design/assets/legal/en/terms_of_service.md index dbb28e3..213713f 100644 --- a/web/design/assets/legal/en/terms_of_service.md +++ b/web/design/assets/legal/en/terms_of_service.md @@ -25,7 +25,6 @@ You represent and warrant that you are at least 13 years of age to use this App. This App provides AI-assisted cultural interpretation content related to traditional I Ching and Six-Line culture, for daily reference and cultural appreciation only. -- The AI Analysis feature is powered by DeepSeek's deepseek-v4-flash model. - All AI-generated content and cultural reference materials are for entertainment and personal reference purposes solely. - Content shall not be regarded as professional advice, including without limitation finance, investment, law, medical treatment, career or business decision-making. - I do not guarantee the accuracy, completeness or practicality of any AI-generated content within the App. diff --git a/web/design/assets/legal/zh/about_us.md b/web/design/assets/legal/zh/about_us.md index 3ffa323..85d2226 100644 --- a/web/design/assets/legal/zh/about_us.md +++ b/web/design/assets/legal/zh/about_us.md @@ -8,12 +8,6 @@ --- -## AI 模型披露 - -觅爻 MeeYao 的 AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。 - ---- - ## 开发者信息 **开发者**:Ann Lee diff --git a/web/design/assets/legal/zh/terms_of_service.md b/web/design/assets/legal/zh/terms_of_service.md index 2ccccf9..68e3aa6 100644 --- a/web/design/assets/legal/zh/terms_of_service.md +++ b/web/design/assets/legal/zh/terms_of_service.md @@ -25,7 +25,6 @@ 本应用提供与传统易经和六爻文化相关的 AI 辅助文化解读内容,仅供日常参考和文化赏析。 -- AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。 - 所有 AI 生成内容和文化参考资料仅供娱乐和个人参考目的。 - 内容不得视为专业建议,包括但不限于金融、投资、法律、医疗、职业或商业决策。 - 我不保证本应用内任何 AI 生成内容的准确性、完整性或实用性。 diff --git a/web/design/assets/legal/zh_Hant/about_us.md b/web/design/assets/legal/zh_Hant/about_us.md index dc941d0..218fddd 100644 --- a/web/design/assets/legal/zh_Hant/about_us.md +++ b/web/design/assets/legal/zh_Hant/about_us.md @@ -8,12 +8,6 @@ --- -## AI 模型披露 - -覓爻 MeeYao 的 AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。 - ---- - ## 開發者信息 **開發者**:Ann Lee diff --git a/web/design/assets/legal/zh_Hant/terms_of_service.md b/web/design/assets/legal/zh_Hant/terms_of_service.md index 5f8c677..7e33a7f 100644 --- a/web/design/assets/legal/zh_Hant/terms_of_service.md +++ b/web/design/assets/legal/zh_Hant/terms_of_service.md @@ -25,7 +25,6 @@ 本應用提供與傳統易經和六爻文化相關的 AI 輔助文化解讀內容,僅供日常參考和文化賞析。 -- AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。 - 所有 AI 生成內容和文化參考資料僅供娛樂和個人參考目的。 - 內容不得視為專業建議,包括但不限於金融、投資、法律、醫療、職業或商業決策。 - 我不保證本應用內任何 AI 生成內容的準確性、完整性或實用性。 diff --git a/web/src/components/DashboardApp.tsx b/web/src/components/DashboardApp.tsx index c607fae..8958164 100644 --- a/web/src/components/DashboardApp.tsx +++ b/web/src/components/DashboardApp.tsx @@ -19,6 +19,7 @@ const ProfileDetailPage = lazy(() => import('./ProfileDetailPage')); const SettingsPage = lazy(() => import('./SettingsPage')); const GeneralSettingsPage = lazy(() => import('./GeneralSettingsPage')); const FeedbackPage = lazy(() => import('./FeedbackPage')); +const InvitePage = lazy(() => import('./InvitePage')); const ManualDivinationPage = lazy(() => import('./ManualDivinationPage')); const AutoDivinationPage = lazy(() => import('./AutoDivinationPage')); @@ -31,6 +32,7 @@ const APP_PATHS = [ '/settings', '/settings/general', '/settings/feedback', + '/settings/invite', '/divination/manual', '/divination/auto', '/divination/result', @@ -94,6 +96,7 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/InvitePage.tsx b/web/src/components/InvitePage.tsx new file mode 100644 index 0000000..e7c0412 --- /dev/null +++ b/web/src/components/InvitePage.tsx @@ -0,0 +1,461 @@ +import { useMemo, useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { bindInviteCodeResource, redeemCodeResource, useInvite } from '../lib/resources'; + +interface Props { + locale: string; +} + +interface CopyState { + copied: boolean; + error: string | null; +} + +interface ToastState { + type: 'success' | 'error'; + message: string; +} + +const TEXT = { + zh: { + title: '我的邀请', + myCode: '我的邀请码', + copy: '复制', + copied: '已复制', + copyFailed: '复制失败', + bindTitle: '绑定邀请人', + bindPlaceholder: '输入对方邀请码', + bindButton: '绑定', + boundPrefix: '已绑定邀请码', + bindLocked: '绑定后不可解绑,每个账号只能绑定一次。', + bindUnavailable: '当前账号不可再绑定邀请码。', + rewardTitle: '邀请奖励', + invited: '已邀请', + rewarded: '已到账', + pending: '待充值', + rewardProgress: '到账积分', + listTitle: '邀请记录', + empty: '暂无邀请记录', + paid: '已到账', + unpaid: '未到账', + boundAt: '绑定时间', + paidAt: '充值时间', + redeem: '兑换卡密', + redeemTitle: '兑换卡密', + redeemPlaceholder: '输入卡密', + cancel: '取消', + confirmRedeem: '确认兑换', + redeemSuccess: '已激活 {name},获得 {credits} 积分。', + redeemAlreadyUsed: '该卡密已被兑换。', + ruleTitle: '邀请机制', + ruleBind: '你邀请的人绑定你的邀请码后,邀请关系才会生效。', + ruleReward: '从绑定时间开始计算,对方之后完成的第一笔充值,会给你和对方各赠送 {points} 积分。', + ruleExclude: '绑定前已经完成的充值不会触发奖励;卡密兑换也不算充值。', + ruleLock: '每个账号只能绑定一次邀请码,绑定后不能解绑或更换。', + loading: '加载中...', + loadFailed: '加载失败', + }, + zh_Hant: { + title: '我的邀請', + myCode: '我的邀請碼', + copy: '複製', + copied: '已複製', + copyFailed: '複製失敗', + bindTitle: '綁定邀請人', + bindPlaceholder: '輸入對方邀請碼', + bindButton: '綁定', + boundPrefix: '已綁定邀請碼', + bindLocked: '綁定後不可解綁,每個賬號只能綁定一次。', + bindUnavailable: '當前賬號不可再綁定邀請碼。', + rewardTitle: '邀請獎勵', + invited: '已邀請', + rewarded: '已到賬', + pending: '待充值', + rewardProgress: '到賬積分', + listTitle: '邀請記錄', + empty: '暫無邀請記錄', + paid: '已到賬', + unpaid: '未到賬', + boundAt: '綁定時間', + paidAt: '充值時間', + redeem: '兌換卡密', + redeemTitle: '兌換卡密', + redeemPlaceholder: '輸入卡密', + cancel: '取消', + confirmRedeem: '確認兌換', + redeemSuccess: '已激活 {name},獲得 {credits} 積分。', + redeemAlreadyUsed: '該卡密已被兌換。', + ruleTitle: '邀請機制', + ruleBind: '你邀請的人綁定你的邀請碼後,邀請關係才會生效。', + ruleReward: '從綁定時間開始計算,對方之後完成的第一筆充值,會給你和對方各贈送 {points} 積分。', + ruleExclude: '綁定前已經完成的充值不會觸發獎勵;卡密兌換也不算充值。', + ruleLock: '每個賬號只能綁定一次邀請碼,綁定後不能解綁或更換。', + loading: '加載中...', + loadFailed: '加載失敗', + }, + en: { + title: 'My Invites', + myCode: 'My Invite Code', + copy: 'Copy', + copied: 'Copied', + copyFailed: 'Copy failed', + bindTitle: 'Bind Inviter', + bindPlaceholder: 'Enter invite code', + bindButton: 'Bind', + boundPrefix: 'Bound invite code', + bindLocked: 'Binding cannot be removed. Each account can bind once.', + bindUnavailable: 'This account can no longer bind an invite code.', + rewardTitle: 'Invite Rewards', + invited: 'Invited', + rewarded: 'Credited', + pending: 'Pending', + rewardProgress: 'Reward Credits', + listTitle: 'Invite Records', + empty: 'No invite records', + paid: 'Credited', + unpaid: 'Pending', + boundAt: 'Bound At', + paidAt: 'Paid At', + redeem: 'Redeem Code', + redeemTitle: 'Redeem Code', + redeemPlaceholder: 'Enter code', + cancel: 'Cancel', + confirmRedeem: 'Redeem', + redeemSuccess: '{name} activated. {credits} credits added.', + redeemAlreadyUsed: 'This code has already been redeemed.', + ruleTitle: 'Invite Rules', + ruleBind: 'An invite relationship starts only after your invitee binds your invite code.', + ruleReward: 'Starting from the bind time, the invitee’s next successful payment grants {points} credits to both accounts.', + ruleExclude: 'Payments completed before binding do not trigger the reward. Redeem codes do not count as payments.', + ruleLock: 'Each account can bind one invite code only. It cannot be removed or changed after binding.', + loading: 'Loading...', + loadFailed: 'Failed to load', + }, +} as const; + +function t(locale: string) { + if (locale === 'zh_Hant') return TEXT.zh_Hant; + if (locale === 'en') return TEXT.en; + return TEXT.zh; +} + +function localeTag(locale: string): string { + if (locale === 'zh_Hant') return 'zh-Hant'; + if (locale === 'zh') return 'zh-CN'; + return 'en-US'; +} + +function formatDate(locale: string, value: string | null): string { + if (!value) return '-'; + return new Intl.DateTimeFormat(localeTag(locale), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +} + +function errorCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null || !('code' in error)) return null; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : null; +} + +function errorDetail(error: unknown): string | null { + if (typeof error === 'object' && error !== null && 'detail' in error) { + const detail = (error as { detail?: unknown }).detail; + if (typeof detail === 'string' && detail.trim()) return detail; + } + return null; +} + +function errorText(error: unknown, fallback: string): string { + const detail = errorDetail(error); + if (detail) return detail; + if (error instanceof Error) return error.message; + return fallback; +} + +export default function InvitePage({ locale }: Props) { + const copyInitial = useMemo(() => ({ copied: false, error: null }), []); + const s = t(locale); + const navigate = useNavigate(); + const inviteState = useInvite(); + const overview = inviteState.data; + const [copyState, setCopyState] = useState(copyInitial); + const [bindCode, setBindCode] = useState(''); + const [bindLoading, setBindLoading] = useState(false); + const [bindError, setBindError] = useState(null); + const [redeemOpen, setRedeemOpen] = useState(false); + const [redeemCode, setRedeemCode] = useState(''); + const [redeemLoading, setRedeemLoading] = useState(false); + const [toast, setToast] = useState(null); + + const copyCode = async () => { + if (!overview?.myCode) return; + try { + await navigator.clipboard.writeText(overview.myCode); + setCopyState({ copied: true, error: null }); + } catch { + setCopyState({ copied: false, error: s.copyFailed }); + } + }; + + const submitBind = async (event: FormEvent) => { + event.preventDefault(); + if (!bindCode.trim() || bindLoading) return; + setBindLoading(true); + setBindError(null); + try { + await bindInviteCodeResource(bindCode); + setBindCode(''); + } catch (error) { + setBindError(errorText(error, s.bindUnavailable)); + } finally { + setBindLoading(false); + } + }; + + const submitRedeem = async (event: FormEvent) => { + event.preventDefault(); + if (!redeemCode.trim() || redeemLoading) return; + setRedeemLoading(true); + setToast(null); + try { + const result = await redeemCodeResource(redeemCode); + setRedeemCode(''); + setRedeemOpen(false); + setToast({ + type: 'success', + message: s.redeemSuccess + .replace('{name}', result.packageName) + .replace('{credits}', String(result.credits)), + }); + } catch (error) { + setRedeemOpen(false); + setToast({ + type: 'error', + message: errorCode(error) === 'REDEEM_CODE_ALREADY_REDEEMED' + ? s.redeemAlreadyUsed + : errorText(error, s.redeemTitle), + }); + } finally { + setRedeemLoading(false); + } + }; + + if (inviteState.loading && !overview) { + return
{s.loading}
; + } + + if (inviteState.error && !overview) { + return
{s.loadFailed}
; + } + + const summary = overview?.summary; + const progressText = summary ? `${summary.rewardedPoints}/${summary.totalPotentialRewardPoints}` : '0/0'; + + return ( +
+
+
+ +
+

{s.title}

+
+
+ +
+ + {toast && ( +
+ {toast.message} +
+ )} + +
+
+
+
+

{s.myCode}

+

{overview?.myCode ?? '-'}

+
+ +
+ {copyState.error &&

{copyState.error}

} + +
+

{s.bindTitle}

+ {overview?.binding.boundInviteCode ? ( +
+

{s.boundPrefix}

+

{overview.binding.boundInviteCode}

+

{s.bindLocked}

+
+ ) : overview?.binding.canBind ? ( +
+ setBindCode(event.target.value.toUpperCase())} + placeholder={s.bindPlaceholder} + maxLength={32} + className="h-11 rounded-xl border border-slate-200 bg-white px-3 font-mono text-sm uppercase outline-none transition-colors focus:border-violet-500" + /> + + {bindError &&

{bindError}

} +
+ ) : ( +

{s.bindUnavailable}

+ )} +
+
+ +
+
+
+

{s.rewardTitle}

+

+ {s.rewardProgress}: {progressText} +

+
+
+ {[ + [s.invited, summary?.invitedCount ?? 0], + [s.rewarded, summary?.rewardedCount ?? 0], + [s.pending, summary?.pendingCount ?? 0], + ].map(([label, value]) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+ +
+
+ {s.boundAt} + {s.rewarded} + {s.paidAt} +
+ {overview?.items.length ? ( + overview.items.map((item) => ( +
+
+

{formatDate(locale, item.boundAt)}

+

{item.inviteCode}

+
+ + {item.rewardGranted ? s.paid : s.unpaid} + + {formatDate(locale, item.firstCreemPaidAt)} +
+ )) + ) : ( +
{s.empty}
+ )} +
+
+
+ +
+
+
+

{s.ruleTitle}

+

+ {s.ruleBind} +

+
+
+ +{summary?.rewardPoints ?? 40} / +{summary?.rewardPoints ?? 40} +
+
+
+ {[ + s.ruleReward.replace('{points}', String(summary?.rewardPoints ?? 40)), + s.ruleExclude, + s.ruleLock, + ].map((item) => ( +
+ {item} +
+ ))} +
+
+ + {redeemOpen && ( +
+
+
+

{s.redeemTitle}

+ +
+
+ setRedeemCode(event.target.value.toUpperCase())} + placeholder={s.redeemPlaceholder} + maxLength={64} + className="h-12 rounded-xl border border-slate-200 px-3 font-mono text-sm uppercase outline-none transition-colors focus:border-violet-500" + /> +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index 2334ca5..966ef41 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -117,7 +117,7 @@ export default function SettingsPage({ locale, settings: s }: Props) { {/* Account Settings Panel */}

{s.accountTitle}

-
+
{/* General Settings */}
+ {/* Invite */} + + group_add +
+

+ {locale === 'en' ? 'Invites' : locale === 'zh_Hant' ? '我的邀請' : '我的邀请'} +

+

+ {locale === 'en' ? 'Rewards, redeem codes' : locale === 'zh_Hant' ? '邀請獎勵、卡密兌換' : '邀请奖励、卡密兑换'} +

+
+
{/* Account Data */} entry.split(';')[0]?.trim() ?? '') + .filter(Boolean); + + return resolvePreferredLocale(preferredLanguages, fallbackLocale); +} + export function getLocaleLabel(locale: Locale): string { const labels: Record = { zh: '简体中文', zh_Hant: '繁體中文', en: 'English' }; return labels[locale]; @@ -47,7 +93,7 @@ const translations: Record = { testimonials: { title: '用户心声', t1Quote: '在最迷茫的时候,觅爻给了我一个方向。不管结果如何,那种静下心来的过程本身就很有帮助。', t1Name: '林小姐 · 产品经理', t2Quote: '界面很清爽,没有乱七八糟的广告。每次签问都像是一次心灵的短暂旅行。', t2Name: '张先生 · 创业者', t3Quote: '我是一个程序员,原本不信这些。但试了几次后发现,这种随机性反而让我看到平时忽略的可能性。', t3Name: '王先生 · 软件工程师' }, cta: { title: '开始你的第一次签问', subtitle: '无需注册,立即体验。让古老的智慧,为现代的你指引方向。', button: '免费开始 →' }, footer: { brandName: '觅爻签问', desc: '以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。', col1Title: '产品', col1Link1: '功能介绍', col1Link2: '定价', col2Title: '支持', col2Link1: '帮助中心', col2Link2: '联系我们', col3Title: '法律', col3Link1: '隐私政策', col3Link2: '服务条款' }, - features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合 AI 智能分析提供深度卦象解读与建议。本功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' }, + features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合 AI 智能分析提供深度卦象解读与建议。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' }, pricing: { title: '选择适合你的套餐', subtitle: '灵活积分套餐,按需选择,随时可用', p1Name: '新人专享包', p1Badge: '限购一次', p1Price: '$1.00', p1Credits: '60 积分', p1Desc: '最适合初次体验', p2Name: '入门补充包', p2Price: '$4.99', p2Credits: '100 积分', p2Desc: '日常解卦补充', p2Detail: '适量点数补充,经济实惠之选', p3Name: '常用加量包', p3Badge: '推荐', p3Price: '$7.99', p3Credits: '210 积分', p3Desc: '最适合日常使用', p4Name: '高频进阶包', p4Price: '$12.99', p4Credits: '415 积分', p4Desc: '重度使用优选', p4Detail: '大量点数储备,超值单价', buyNow: '立即购买' }, login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' }, dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' }, @@ -68,7 +114,7 @@ const translations: Record = { testimonials: { title: '用戶心聲', t1Quote: '在最迷茫的時候,覓爻給了我一個方向。不管結果如何,那種靜下心來的過程本身就很有幫助。', t1Name: '林小姐 · 產品經理', t2Quote: '界面很清爽,沒有亂七八糟的廣告。每次簽問都像是一次心靈的短暫旅行。', t2Name: '張先生 · 創業者', t3Quote: '我是一個程序員,原本不信這些。但試了幾次後發現,這種隨機性反而讓我看到平時忽略的可能性。', t3Name: '王先生 · 軟件工程師' }, cta: { title: '開始你的第一次簽問', subtitle: '無需註冊,立即體驗。讓古老的智慧,為現代的你指引方向。', button: '免費開始 →' }, footer: { brandName: '覓爻簽問', desc: '以古老智慧,解讀今時困惑。讓每一次簽問,都成為與自己對話的機會。', col1Title: '產品', col1Link1: '功能介紹', col1Link2: '定價', col2Title: '支持', col2Link1: '幫助中心', col2Link2: '聯繫我們', col3Title: '法律', col3Link1: '隱私政策', col3Link2: '服務條款' }, - features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合 AI 智能分析提供深度卦象解讀與建議。本功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' }, + features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合 AI 智能分析提供深度卦象解讀與建議。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' }, pricing: { title: '選擇適合你的套餐', subtitle: '靈活積分套餐,按需選擇,隨時可用', p1Name: '新人專享包', p1Badge: '限購一次', p1Price: '$1.00', p1Credits: '60 積分', p1Desc: '最適合初次體驗', p2Name: '入門補充包', p2Price: '$4.99', p2Credits: '100 積分', p2Desc: '日常解卦補充', p2Detail: '適量點數補充,經濟實惠之選', p3Name: '常用加量包', p3Badge: '推薦', p3Price: '$7.99', p3Credits: '210 積分', p3Desc: '最適合日常使用', p4Name: '高頻進階包', p4Price: '$12.99', p4Credits: '415 積分', p4Desc: '重度使用優選', p4Detail: '大量點數儲備,超值單價', buyNow: '立即購買' }, login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' }, dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' }, @@ -89,7 +135,7 @@ const translations: Record = { testimonials: { title: 'What Users Say', t1Quote: 'When I was most lost, MeeYao gave me direction. Regardless of the result, the process of calming down was itself very helpful.', t1Name: 'Ms. Lin · Product Manager', t2Quote: 'The interface is clean, no annoying ads. Each divination feels like a brief journey for the soul.', t2Name: 'Mr. Zhang · Entrepreneur', t3Quote: "I'm a programmer and didn't believe in this stuff. But after trying it a few times, the randomness actually helped me see possibilities I'd been overlooking.", t3Name: 'Mr. Wang · Software Engineer' }, cta: { title: 'Begin Your First Divination', subtitle: 'No registration needed. Let ancient wisdom guide your modern life.', button: 'Start Free →' }, footer: { brandName: 'MeeYao Divination', desc: 'Using ancient wisdom to interpret modern confusion. Let every divination become a chance to dialogue with yourself.', col1Title: 'Product', col1Link1: 'Features', col1Link2: 'Pricing', col2Title: 'Support', col2Link1: 'Help Center', col2Link2: 'Contact Us', col3Title: 'Legal', col3Link1: 'Privacy Policy', col3Link2: 'Terms of Service' }, - features: { title: 'Features', subtitle: 'Ancient wisdom meets modern confusion, MeeYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions. This feature is powered by DeepSeek\'s deepseek-v4-flash model.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' }, + features: { title: 'Features', subtitle: 'Ancient wisdom meets modern confusion, MeeYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' }, pricing: { title: 'Choose Your Plan', subtitle: 'Flexible credit packages, pay as you go', p1Name: 'Starter Pack', p1Badge: 'Once Only', p1Price: '$1.00', p1Credits: '60 credits', p1Desc: 'Best for first-timers', p2Name: 'Basic Pack', p2Price: '$4.99', p2Credits: '100 credits', p2Desc: 'Daily supplement', p2Detail: 'Affordable credit refill', p3Name: 'Popular Pack', p3Badge: 'Popular', p3Price: '$7.99', p3Credits: '210 credits', p3Desc: 'Best for daily use', p4Name: 'Premium Pack', p4Price: '$12.99', p4Credits: '415 credits', p4Desc: 'Best value per credit', p4Detail: 'Bulk credits at best unit price', buyNow: 'Buy Now' }, login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' }, dashboard: { brandName: 'MeeYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' }, diff --git a/web/src/lib/api-routes.ts b/web/src/lib/api-routes.ts index 044761a..f206457 100644 --- a/web/src/lib/api-routes.ts +++ b/web/src/lib/api-routes.ts @@ -15,6 +15,11 @@ export const API_ROUTES = { points: { balance: '/api/v1/points/balance', packages: '/api/v1/points/packages', + redeemCode: '/api/v1/points/redeem-codes/redeem', + }, + invite: { + me: '/api/v1/invite/me', + bind: '/api/v1/invite/bind', }, payments: { creemCheckout: '/api/v1/payments/creem/checkouts', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0a06c29..078eabb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -151,6 +151,45 @@ export interface CreateCheckoutResponse { checkoutUrl: string; } +export interface RedeemCodeResponse { + packageProductCode: string; + packageName: string; + credits: number; + balanceAfter: number; + redeemedAt: string; +} + +export interface InviteBindingInfo { + canBind: boolean; + boundInviteCode: string | null; + boundAt: string | null; +} + +export interface InviteSummary { + rewardPoints: number; + invitedCount: number; + rewardedCount: number; + pendingCount: number; + rewardedPoints: number; + totalPotentialRewardPoints: number; +} + +export interface InviteReferralItem { + referralId: string; + inviteCode: string; + boundAt: string; + firstCreemPaidAt: string | null; + rewardGranted: boolean; + rewardGrantedAt: string | null; +} + +export interface InviteOverview { + myCode: string; + binding: InviteBindingInfo; + summary: InviteSummary; + items: InviteReferralItem[]; +} + export function getPointsBalance(): Promise { return authFetch(API_ROUTES.points.balance); } @@ -170,6 +209,24 @@ export function createCheckout(productCode: string): Promise { + return authFetch(API_ROUTES.points.redeemCode, { + method: 'POST', + body: JSON.stringify({ code }), + }); +} + +export function getInviteOverview(): Promise { + return authFetch(API_ROUTES.invite.me); +} + +export function bindInviteCode(code: string): Promise { + return authFetch(API_ROUTES.invite.bind, { + method: 'POST', + body: JSON.stringify({ code }), + }); +} + // --- Notifications --- export interface NotificationPayloadNone { diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 0caf13c..aa44d43 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -6,6 +6,7 @@ import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client'; import { API_ROUTES } from './api-routes'; import { clearAll as clearDataCache } from './data-client'; +import { resolvePreferredLocale } from '../i18n/utils'; const STORAGE_KEY = 'meeyao_auth'; @@ -112,14 +113,13 @@ function toAuthData(response: SessionResponse): AuthData { }; } -function getLocaleFromPath(): string { - if (typeof window === 'undefined') return 'zh'; - const match = window.location.pathname.match(/^\/(zh|zh_Hant|en)(?:\/|$)/); - return match ? match[1] : 'zh'; +function getPreferredBrowserLocale(): string { + if (typeof window === 'undefined') return 'en'; + return resolvePreferredLocale(window.navigator.languages); } export function loginPath(): string { - const locale = getLocaleFromPath(); + const locale = getPreferredBrowserLocale(); return `/${locale}/login`; } diff --git a/web/src/lib/resources.ts b/web/src/lib/resources.ts index 8b75177..874aefb 100644 --- a/web/src/lib/resources.ts +++ b/web/src/lib/resources.ts @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + bindInviteCode, getAgentHistory, getAgentHistoryByThread, + getInviteOverview, getNotifications, getPackages, getPointsBalance, @@ -9,16 +11,19 @@ import { getUserProfile, markAllNotificationsRead, markNotificationRead, + redeemCode, updateUserProfile, updateUserSettings, uploadAvatar, type HistorySnapshot, + type InviteOverview, type NotificationItem, type NotificationListResponse, type PackageInfo, type PackagesResponse, type PointsBalance, type ProfileSettings, + type RedeemCodeResponse, type UnreadCount, type UpdateProfileRequest, type UserProfile, @@ -37,6 +42,7 @@ import { const PROFILE_TTL = 5 * 60_000; const POINTS_TTL = 60_000; const PACKAGES_TTL = 30 * 60_000; +const INVITE_TTL = 60_000; const HISTORY_TTL = 60_000; const HISTORY_THREAD_TTL = 5 * 60_000; const NOTIFICATIONS_TTL = 60_000; @@ -45,6 +51,7 @@ const UNREAD_TTL = 30_000; export const profileKey = ['profile'] as const; export const pointsBalanceKey = ['points', 'balance'] as const; export const packagesKey = ['points', 'packages'] as const; +export const inviteOverviewKey = ['invite', 'overview'] as const; export const historyListKey = ['history', 'list'] as const; export const historySummaryKey = historyListKey; export const historyThreadKey = (threadId: string) => ['history', 'thread', threadId] as const; @@ -206,6 +213,41 @@ export function invalidatePoints(): void { invalidate(pointsBalanceKey); } +export function getInviteResource(force = false): Promise { + return query({ + key: inviteOverviewKey, + ttlMs: INVITE_TTL, + fetcher: getInviteOverview, + staleWhileRevalidate: true, + force, + }); +} + +export function useInvite(): ResourceState { + return useResource({ + key: inviteOverviewKey, + ttlMs: INVITE_TTL, + fetcher: getInviteOverview, + staleWhileRevalidate: true, + }); +} + +export function invalidateInvite(): void { + invalidate(inviteOverviewKey); +} + +export async function bindInviteCodeResource(code: string): Promise { + const overview = await bindInviteCode(code); + set(inviteOverviewKey, overview, INVITE_TTL); + return overview; +} + +export async function redeemCodeResource(code: string): Promise { + const result = await redeemCode(code); + invalidatePoints(); + return result; +} + export function getPackagesResource(force = false): Promise { return query({ key: packagesKey, diff --git a/web/src/pages/en/settings/invite.astro b/web/src/pages/en/settings/invite.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/settings/invite.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + + diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index e2a558a..553e552 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -1,3 +1,9 @@ --- -return Astro.redirect('/en/'); +import { localePath, resolveLocaleFromAcceptLanguage } from '../i18n/utils'; + +const locale = resolveLocaleFromAcceptLanguage( + Astro.request.headers.get('accept-language'), +); + +return Astro.redirect(localePath(locale, '/')); --- diff --git a/web/src/pages/login.astro b/web/src/pages/login.astro new file mode 100644 index 0000000..f3db4fb --- /dev/null +++ b/web/src/pages/login.astro @@ -0,0 +1,9 @@ +--- +import { localePath, resolveLocaleFromAcceptLanguage } from '../i18n/utils'; + +const locale = resolveLocaleFromAcceptLanguage( + Astro.request.headers.get('accept-language'), +); + +return Astro.redirect(localePath(locale, '/login')); +--- diff --git a/web/src/pages/zh/settings/invite.astro b/web/src/pages/zh/settings/invite.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/settings/invite.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + + diff --git a/web/src/pages/zh_Hant/settings/invite.astro b/web/src/pages/zh_Hant/settings/invite.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/settings/invite.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +