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
+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`.