feat: add invite rewards and redeem codes

This commit is contained in:
zl-q
2026-05-21 16:26:58 +08:00
parent d712645754
commit 673f8fed30
67 changed files with 3813 additions and 265 deletions
@@ -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")
+5 -1
View File
@@ -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 留空列表"
),
+4 -1
View File
@@ -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
+8
View File
@@ -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",
+1 -3
View File
@@ -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",
+94
View File
@@ -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,
)
+8 -2
View File
@@ -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,
+66
View File
@@ -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)
+23
View File
@@ -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)
+54
View File
@@ -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,
)
+6
View File
@@ -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
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from v1.auth.gateway import SupabaseAuthGateway
from v1.auth.service import AuthService
+3 -1
View File
@@ -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:
+122 -1
View File
@@ -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)
+13 -1
View File
@@ -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,
)
+50 -4
View File
@@ -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)
+192 -4
View File
@@ -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
],
)
+1 -3
View File
@@ -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)
+3 -1
View File
@@ -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)
+16 -5
View File
@@ -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(
+2 -6
View File
@@ -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):
+10 -5
View File
@@ -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",
+73
View File
@@ -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
+101
View File
@@ -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),
},
)
+47
View File
@@ -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()
+22
View File
@@ -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(),
)
+19 -1
View File
@@ -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")
+123 -1
View File
@@ -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
+14
View File
@@ -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
+77 -8
View File
@@ -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`.
+19
View File
@@ -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 "$@"
+226
View File
@@ -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())
-6
View File
@@ -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.
-6
View File
@@ -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 生成內容的準確性、完整性或實用性。
+3
View File
@@ -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} />} />
+461
View File
@@ -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 invitees 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>
);
}
+16 -1
View File
@@ -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
View File
@@ -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' },
+5
View File
@@ -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',
+57
View File
@@ -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
View File
@@ -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`;
}
+42
View File
@@ -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,
+7
View File
@@ -0,0 +1,7 @@
---
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
---
<DashboardAppPage locale={locale} />
+7 -1
View File
@@ -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, '/'));
---
+9
View File
@@ -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'));
---
+7
View File
@@ -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} />