feat: add invite rewards and redeem codes
This commit is contained in:
@@ -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":"验证卡密兑换后余额、流水入口和展示口径是否一致"}
|
||||
@@ -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 充值成功后的支付链路定义"}
|
||||
@@ -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`
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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. 问题类型不匹配(最重要):",
|
||||
" - 用户询问塔罗 -> 拒答,建议咨询塔罗师",
|
||||
|
||||
@@ -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 留空列表"
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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":
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
from v1.auth.service import AuthService
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
Executable
+19
@@ -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 "$@"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 模型披露
|
||||
|
||||
觅爻 MeeYao 的 AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
|
||||
---
|
||||
|
||||
## 开发者信息
|
||||
|
||||
**开发者**:Ann Lee
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
本应用提供与传统易经和六爻文化相关的 AI 辅助文化解读内容,仅供日常参考和文化赏析。
|
||||
|
||||
- AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
- 所有 AI 生成内容和文化参考资料仅供娱乐和个人参考目的。
|
||||
- 内容不得视为专业建议,包括但不限于金融、投资、法律、医疗、职业或商业决策。
|
||||
- 我不保证本应用内任何 AI 生成内容的准确性、完整性或实用性。
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 模型披露
|
||||
|
||||
覓爻 MeeYao 的 AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
|
||||
---
|
||||
|
||||
## 開發者信息
|
||||
|
||||
**開發者**:Ann Lee
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
本應用提供與傳統易經和六爻文化相關的 AI 輔助文化解讀內容,僅供日常參考和文化賞析。
|
||||
|
||||
- AI 解卦分析功能由 DeepSeek 的 deepseek-v4-flash 模型提供支持。
|
||||
- 所有 AI 生成內容和文化參考資料僅供娛樂和個人參考目的。
|
||||
- 內容不得視為專業建議,包括但不限於金融、投資、法律、醫療、職業或商業決策。
|
||||
- 我不保證本應用內任何 AI 生成內容的準確性、完整性或實用性。
|
||||
|
||||
@@ -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) {
|
||||
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
|
||||
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
|
||||
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
|
||||
<Route path={`/${locale}/settings/invite`} element={<InvitePage locale={locale} />} />
|
||||
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
|
||||
@@ -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<CopyState>(() => ({ copied: false, error: null }), []);
|
||||
const s = t(locale);
|
||||
const navigate = useNavigate();
|
||||
const inviteState = useInvite();
|
||||
const overview = inviteState.data;
|
||||
const [copyState, setCopyState] = useState<CopyState>(copyInitial);
|
||||
const [bindCode, setBindCode] = useState('');
|
||||
const [bindLoading, setBindLoading] = useState(false);
|
||||
const [bindError, setBindError] = useState<string | null>(null);
|
||||
const [redeemOpen, setRedeemOpen] = useState(false);
|
||||
const [redeemCode, setRedeemCode] = useState('');
|
||||
const [redeemLoading, setRedeemLoading] = useState(false);
|
||||
const [toast, setToast] = useState<ToastState | null>(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<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 <div className="flex min-h-[320px] items-center justify-center text-slate-500 text-sm">{s.loading}</div>;
|
||||
}
|
||||
|
||||
if (inviteState.error && !overview) {
|
||||
return <div className="rounded-2xl border border-red-200 bg-white p-5 text-red-600 text-sm">{s.loadFailed}</div>;
|
||||
}
|
||||
|
||||
const summary = overview?.summary;
|
||||
const progressText = summary ? `${summary.rewardedPoints}/${summary.totalPotentialRewardPoints}` : '0/0';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/${locale}/settings`)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white transition-colors hover:bg-slate-50"
|
||||
aria-label={s.cancel}
|
||||
>
|
||||
<span className="material-symbols-rounded text-lg text-slate-500">arrow_back</span>
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold text-slate-900">{s.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRedeemOpen(true);
|
||||
setToast(null);
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-slate-800"
|
||||
>
|
||||
<span className="material-symbols-rounded text-[18px]">confirmation_number</span>
|
||||
{s.redeem}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className={`fixed right-6 top-6 z-[60] max-w-[360px] rounded-xl border px-4 py-3 text-sm shadow-lg ${toast.type === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-600'}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400">{s.myCode}</p>
|
||||
<p className="mt-1 font-mono text-3xl font-bold tracking-wider text-slate-900">{overview?.myCode ?? '-'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCode}
|
||||
className="inline-flex h-10 items-center gap-1.5 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<span className="material-symbols-rounded text-[18px]">content_copy</span>
|
||||
{copyState.copied ? s.copied : s.copy}
|
||||
</button>
|
||||
</div>
|
||||
{copyState.error && <p className="mt-3 text-sm text-red-600">{copyState.error}</p>}
|
||||
|
||||
<div className="mt-6 border-t border-slate-100 pt-5">
|
||||
<h2 className="text-base font-bold text-slate-900">{s.bindTitle}</h2>
|
||||
{overview?.binding.boundInviteCode ? (
|
||||
<div className="mt-3 rounded-xl bg-slate-50 p-4">
|
||||
<p className="text-sm text-slate-500">{s.boundPrefix}</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold text-slate-900">{overview.binding.boundInviteCode}</p>
|
||||
<p className="mt-2 text-xs text-slate-400">{s.bindLocked}</p>
|
||||
</div>
|
||||
) : overview?.binding.canBind ? (
|
||||
<form onSubmit={submitBind} className="mt-3 flex flex-col gap-3">
|
||||
<input
|
||||
value={bindCode}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={bindLoading || !bindCode.trim()}
|
||||
className="inline-flex h-11 items-center justify-center rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{s.bindButton}
|
||||
</button>
|
||||
{bindError && <p className="text-sm text-red-600">{bindError}</p>}
|
||||
</form>
|
||||
) : (
|
||||
<p className="mt-3 rounded-xl bg-slate-50 p-4 text-sm text-slate-500">{s.bindUnavailable}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-slate-900">{s.rewardTitle}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{s.rewardProgress}: <span className="font-semibold text-slate-900">{progressText}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
[s.invited, summary?.invitedCount ?? 0],
|
||||
[s.rewarded, summary?.rewardedCount ?? 0],
|
||||
[s.pending, summary?.pendingCount ?? 0],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-[88px] rounded-xl bg-slate-50 px-3 py-2 text-center">
|
||||
<p className="text-lg font-bold text-slate-900">{value}</p>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-xl border border-slate-100">
|
||||
<div className="grid grid-cols-[1fr_120px_150px] bg-slate-50 px-4 py-3 text-xs font-semibold text-slate-500 max-md:hidden">
|
||||
<span>{s.boundAt}</span>
|
||||
<span>{s.rewarded}</span>
|
||||
<span>{s.paidAt}</span>
|
||||
</div>
|
||||
{overview?.items.length ? (
|
||||
overview.items.map((item) => (
|
||||
<div key={item.referralId} className="grid grid-cols-1 gap-2 border-t border-slate-100 px-4 py-3 text-sm md:grid-cols-[1fr_120px_150px] md:items-center">
|
||||
<div>
|
||||
<p className="text-slate-900">{formatDate(locale, item.boundAt)}</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-slate-400">{item.inviteCode}</p>
|
||||
</div>
|
||||
<span className={`inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-semibold ${item.rewardGranted ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'}`}>
|
||||
{item.rewardGranted ? s.paid : s.unpaid}
|
||||
</span>
|
||||
<span className="text-slate-500">{formatDate(locale, item.firstCreemPaidAt)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="border-t border-slate-100 px-4 py-10 text-center text-sm text-slate-400">{s.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-slate-900">{s.ruleTitle}</h2>
|
||||
<p className="mt-2 max-w-[760px] text-sm leading-6 text-slate-600">
|
||||
{s.ruleBind}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 px-4 py-3 text-sm font-semibold text-violet-700">
|
||||
+{summary?.rewardPoints ?? 40} / +{summary?.rewardPoints ?? 40}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
{[
|
||||
s.ruleReward.replace('{points}', String(summary?.rewardPoints ?? 40)),
|
||||
s.ruleExclude,
|
||||
s.ruleLock,
|
||||
].map((item) => (
|
||||
<div key={item} className="rounded-xl bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{redeemOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4">
|
||||
<div className="w-full max-w-[420px] rounded-2xl border border-slate-200 bg-white p-5 shadow-xl">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold text-slate-900">{s.redeemTitle}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRedeemOpen(false)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl hover:bg-slate-100"
|
||||
aria-label={s.cancel}
|
||||
>
|
||||
<span className="material-symbols-rounded text-[20px] text-slate-500">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submitRedeem} className="mt-4 flex flex-col gap-3">
|
||||
<input
|
||||
value={redeemCode}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRedeemOpen(false)}
|
||||
className="h-10 rounded-xl border border-slate-200 px-4 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{s.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={redeemLoading || !redeemCode.trim()}
|
||||
className="h-10 rounded-xl bg-slate-900 px-4 text-sm font-semibold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{s.confirmRedeem}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
||||
{/* Account Settings Panel */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{s.accountTitle}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3.5">
|
||||
{/* General Settings */}
|
||||
<a
|
||||
href={`/${locale}/settings/general`}
|
||||
@@ -148,6 +148,21 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Invite */}
|
||||
<a
|
||||
href={`/${locale}/settings/invite`}
|
||||
className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-rounded text-violet-600 text-xl">group_add</span>
|
||||
<div>
|
||||
<p className="text-slate-900 text-[15px] font-bold">
|
||||
{locale === 'en' ? 'Invites' : locale === 'zh_Hant' ? '我的邀請' : '我的邀请'}
|
||||
</p>
|
||||
<p className="text-slate-400 text-xs">
|
||||
{locale === 'en' ? 'Rewards, redeem codes' : locale === 'zh_Hant' ? '邀請獎勵、卡密兌換' : '邀请奖励、卡密兑换'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Account Data */}
|
||||
<a
|
||||
href={`/${locale}/profile`}
|
||||
|
||||
+49
-3
@@ -12,6 +12,52 @@ export function getLocaleFromUrl(url: URL): Locale {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
function normalizeLocaleTag(tag: string): string {
|
||||
return tag.trim().replace(/_/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
export function resolvePreferredLocale(
|
||||
preferredLanguages: readonly string[],
|
||||
fallbackLocale: Locale = defaultLocale,
|
||||
): Locale {
|
||||
for (const rawLanguage of preferredLanguages) {
|
||||
const language = normalizeLocaleTag(rawLanguage);
|
||||
if (!language) continue;
|
||||
|
||||
if (language.startsWith('zh')) {
|
||||
if (
|
||||
language.includes('-hant') ||
|
||||
language.includes('-tw') ||
|
||||
language.includes('-hk') ||
|
||||
language.includes('-mo')
|
||||
) {
|
||||
return 'zh_Hant';
|
||||
}
|
||||
return 'zh';
|
||||
}
|
||||
|
||||
if (language.startsWith('en')) {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackLocale;
|
||||
}
|
||||
|
||||
export function resolveLocaleFromAcceptLanguage(
|
||||
acceptLanguage: string | null,
|
||||
fallbackLocale: Locale = defaultLocale,
|
||||
): Locale {
|
||||
if (!acceptLanguage) return fallbackLocale;
|
||||
|
||||
const preferredLanguages = acceptLanguage
|
||||
.split(',')
|
||||
.map((entry) => entry.split(';')[0]?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
return resolvePreferredLocale(preferredLanguages, fallbackLocale);
|
||||
}
|
||||
|
||||
export function getLocaleLabel(locale: Locale): string {
|
||||
const labels: Record<Locale, string> = { zh: '简体中文', zh_Hant: '繁體中文', en: 'English' };
|
||||
return labels[locale];
|
||||
@@ -47,7 +93,7 @@ const translations: Record<Locale, Translations> = {
|
||||
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<Locale, Translations> = {
|
||||
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<Locale, Translations> = {
|
||||
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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<PointsBalance> {
|
||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||
}
|
||||
@@ -170,6 +209,24 @@ export function createCheckout(productCode: string): Promise<CreateCheckoutRespo
|
||||
});
|
||||
}
|
||||
|
||||
export function redeemCode(code: string): Promise<RedeemCodeResponse> {
|
||||
return authFetch<RedeemCodeResponse>(API_ROUTES.points.redeemCode, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getInviteOverview(): Promise<InviteOverview> {
|
||||
return authFetch<InviteOverview>(API_ROUTES.invite.me);
|
||||
}
|
||||
|
||||
export function bindInviteCode(code: string): Promise<InviteOverview> {
|
||||
return authFetch<InviteOverview>(API_ROUTES.invite.bind, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
|
||||
export interface NotificationPayloadNone {
|
||||
|
||||
+5
-5
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InviteOverview> {
|
||||
return query({
|
||||
key: inviteOverviewKey,
|
||||
ttlMs: INVITE_TTL,
|
||||
fetcher: getInviteOverview,
|
||||
staleWhileRevalidate: true,
|
||||
force,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvite(): ResourceState<InviteOverview> {
|
||||
return useResource({
|
||||
key: inviteOverviewKey,
|
||||
ttlMs: INVITE_TTL,
|
||||
fetcher: getInviteOverview,
|
||||
staleWhileRevalidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateInvite(): void {
|
||||
invalidate(inviteOverviewKey);
|
||||
}
|
||||
|
||||
export async function bindInviteCodeResource(code: string): Promise<InviteOverview> {
|
||||
const overview = await bindInviteCode(code);
|
||||
set(inviteOverviewKey, overview, INVITE_TTL);
|
||||
return overview;
|
||||
}
|
||||
|
||||
export async function redeemCodeResource(code: string): Promise<RedeemCodeResponse> {
|
||||
const result = await redeemCode(code);
|
||||
invalidatePoints();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getPackagesResource(force = false): Promise<PackagesResponse> {
|
||||
return query({
|
||||
key: packagesKey,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -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, '/'));
|
||||
---
|
||||
|
||||
@@ -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'));
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
Reference in New Issue
Block a user