feat: add invite rewards and redeem codes
This commit is contained in:
@@ -90,6 +90,20 @@ This document is the source of truth for backend RFC7807 `code` values consumed
|
||||
| code | status | meaning | frontend handling |
|
||||
|---|---:|---|---|
|
||||
| `INVITE_CODE_NOT_FOUND` | 404 | Invite code not found for current user | Show not-found message and trigger invite code bootstrap |
|
||||
| `INVITE_BIND_CODE_NOT_FOUND` | 404 | Invite code entered for binding does not exist | Show invalid-code message |
|
||||
| `INVITE_ALREADY_BOUND` | 409 | Current user already bound an inviter code | Disable bind UI and show bound state |
|
||||
| `INVITE_SELF_BIND_FORBIDDEN` | 409 | Current user attempted to bind their own invite code | Show cannot-bind-self message |
|
||||
| `INVITE_CODE_NOT_BINDABLE` | 409 | Invite code is disabled, expired, or has no active owner | Show code-unavailable message |
|
||||
| `INVITE_CODE_INVALID` | 422 | Invite code format is invalid | Prompt user to enter a valid code |
|
||||
|
||||
## Redeem Code
|
||||
|
||||
| code | status | meaning | frontend handling |
|
||||
|---|---:|---|---|
|
||||
| `REDEEM_CODE_NOT_FOUND` | 404 | Redeem code does not exist | Show not-found message |
|
||||
| `REDEEM_CODE_ALREADY_REDEEMED` | 409 | Redeem code has already been activated | Show already-redeemed message |
|
||||
| `REDEEM_CODE_DISABLED` | 409 | Redeem code is disabled or unavailable | Show unavailable message |
|
||||
| `REDEEM_CODE_INVALID` | 422 | Redeem code format is invalid | Prompt user to enter a valid code |
|
||||
|
||||
## Feedback
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ This protocol defines the canonical data contract for user profile, points accou
|
||||
|
||||
Protocol verification status:
|
||||
|
||||
- Last audited migration: `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py`
|
||||
- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py`
|
||||
- Current status: aligned with register bonus moved to application service
|
||||
- Last audited migration: `backend/alembic/versions/20260521_0002_invite_referrals_and_redeem_codes.py`
|
||||
- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/invite_referral.py`, `backend/src/models/redeem_code_batch.py`, `backend/src/models/redeem_code.py`, `backend/src/models/system_audit_log.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py`
|
||||
- Current status: aligned with referral rewards and redeem code activation
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -15,6 +15,10 @@ Protocol verification status:
|
||||
- `points_ledger`
|
||||
- `points_audit_ledger`
|
||||
- `register_bonus_claims`
|
||||
- `invite_referrals`
|
||||
- `redeem_code_batches`
|
||||
- `redeem_codes`
|
||||
- `system_audit_logs`
|
||||
- `sessions`
|
||||
- `messages`
|
||||
|
||||
@@ -23,6 +27,7 @@ Protocol verification status:
|
||||
- Current strategy: additive evolution.
|
||||
- Breaking changes (drop/rename/type change on core fields) require explicit migration + rollback notes.
|
||||
- `points_ledger.metadata.schema_version` is mandatory and current value is `1`.
|
||||
- Invite reward amount is configured by backend env `ERYAO_POINTS_POLICY__INVITE_REWARD_POINTS`.
|
||||
|
||||
## Runtime charging policy (chat)
|
||||
|
||||
@@ -53,6 +58,14 @@ Protocol verification status:
|
||||
|
||||
Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`).
|
||||
|
||||
### `adjust` metadata ext.reason conventions
|
||||
|
||||
For referral rewards and redeem codes, `change_type='adjust'` must use one of:
|
||||
|
||||
- `invite_reward_inviter`
|
||||
- `invite_reward_invitee`
|
||||
- `redeem_code_activation`
|
||||
|
||||
## Table contract
|
||||
|
||||
### profiles
|
||||
@@ -129,6 +142,87 @@ Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`).
|
||||
- `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery
|
||||
- `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits)
|
||||
|
||||
### invite_referrals
|
||||
|
||||
- PK: `id`
|
||||
- FK:
|
||||
- `inviter_user_id -> profiles.id`
|
||||
- `invitee_user_id -> profiles.id`
|
||||
- `invite_code_id -> invite_codes.id`
|
||||
- `first_creem_transaction_id -> creem_transactions.id` (`on delete set null`)
|
||||
- Core fields:
|
||||
- `invite_code_snapshot`
|
||||
- `bound_at`
|
||||
- `first_creem_paid_at`
|
||||
- `inviter_reward_event_id`
|
||||
- `invitee_reward_event_id`
|
||||
- `inviter_reward_granted_at`
|
||||
- `invitee_reward_granted_at`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- Constraints:
|
||||
- one invitee can only appear once
|
||||
- inviter cannot equal invitee
|
||||
- invite code snapshot is immutable after bind
|
||||
- Notes:
|
||||
- historical bindings should be backfilled from `profiles.referred_by`
|
||||
- reward grant is idempotent per side via unique event ids
|
||||
|
||||
### redeem_code_batches
|
||||
|
||||
- PK: `id`
|
||||
- Core fields:
|
||||
- `batch_key`
|
||||
- `created_by`
|
||||
- `notes`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- Constraints:
|
||||
- `batch_key` unique
|
||||
|
||||
### redeem_codes
|
||||
|
||||
- PK: `id`
|
||||
- FK:
|
||||
- `batch_id -> redeem_code_batches.id`
|
||||
- `redeemed_by_user_id -> auth.users.id` (`on delete set null`)
|
||||
- Core fields:
|
||||
- `code`
|
||||
- `package_product_code`
|
||||
- `package_type`
|
||||
- `package_name_snapshot`
|
||||
- `credits`
|
||||
- `sort_order`
|
||||
- `status`
|
||||
- `redeemed_at`
|
||||
- `redeem_event_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- Constraints:
|
||||
- `code` unique
|
||||
- `status in ('active', 'redeemed', 'disabled')`
|
||||
- redeemed rows must have `redeemed_at`, `redeemed_by_user_id`, `redeem_event_id`
|
||||
- Notes:
|
||||
- only regular packages are eligible for batch generation in this feature
|
||||
- redeem codes do not qualify as CREEM payments for invite binding rewards
|
||||
|
||||
### system_audit_logs
|
||||
|
||||
- PK: `id`
|
||||
- Core fields:
|
||||
- `actor_user_id`
|
||||
- `target_user_id`
|
||||
- `action`
|
||||
- `entity_type`
|
||||
- `entity_id`
|
||||
- `metadata`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- Constraints:
|
||||
- metadata must be object
|
||||
- Notes:
|
||||
- used for invite bind, invite reward grant, redeem code batch generation, redeem code activation
|
||||
|
||||
#### points_ledger.metadata (schema_version=1)
|
||||
|
||||
Canonical shape:
|
||||
@@ -174,6 +268,7 @@ JSON constraints:
|
||||
- Application service (in `POST /auth/email-session`):
|
||||
- `grant_register_bonus_if_eligible()` restores `balance_snapshot` first when present; otherwise grants register bonus via `register_bonus_claims`
|
||||
- Bonus amount from `config.points_policy.register_bonus_points`
|
||||
- referral binding remains write-once after signup via API or historical trigger snapshot
|
||||
|
||||
### sessions
|
||||
|
||||
@@ -260,6 +355,13 @@ Returns the authenticated user's points ledger in reverse chronological order.
|
||||
}
|
||||
```
|
||||
|
||||
## Invite and redeem integration notes
|
||||
|
||||
- Invite binding is write-once, cannot be unbound, and is allowed regardless of previous completed `creem_transactions` rows.
|
||||
- Referral reward qualification is based on the first completed CREEM payment after the invite binding has been created.
|
||||
- Invite rewards are credited as `adjust` ledger rows with reason `invite_reward_inviter` / `invite_reward_invitee`.
|
||||
- Redeem code activation is credited as an `adjust` ledger row with reason `redeem_code_activation`.
|
||||
|
||||
**Fields:**
|
||||
- `items`: ledger rows ordered by `createdAt desc`
|
||||
- `direction`: `1` for income, `-1` for spending/deduction
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Invite Protocol (Frontend <-> Backend)
|
||||
|
||||
This document defines the invite code contract for authenticated users.
|
||||
This document defines the invite/referral contract for authenticated web users.
|
||||
|
||||
Protocol verification status:
|
||||
|
||||
- Backend route source: `backend/src/v1/invite/router.py`
|
||||
- Backend service source: `backend/src/v1/invite/service.py`
|
||||
- Backend schema source: `backend/src/v1/invite/schemas.py`
|
||||
- Frontend mapping source: `apps/lib/features/settings/data/apis/invite_api.dart`
|
||||
- Web mapping source: `web/src/lib/api.ts`
|
||||
|
||||
## Compatibility strategy
|
||||
|
||||
@@ -18,7 +18,7 @@ Protocol verification status:
|
||||
|
||||
### GET /api/v1/invite/me
|
||||
|
||||
Get the current user's invite code information.
|
||||
Get the current user's invite overview.
|
||||
|
||||
**Authorization**: Requires authenticated session. User identity from JWT `sub`.
|
||||
|
||||
@@ -26,15 +26,78 @@ Get the current user's invite code information.
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "ABC123XYZ",
|
||||
"used_count": 5
|
||||
"myCode": "ABC123",
|
||||
"binding": {
|
||||
"canBind": false,
|
||||
"boundInviteCode": "QWE789",
|
||||
"boundAt": "2026-05-21T10:30:00+00:00"
|
||||
},
|
||||
"summary": {
|
||||
"rewardPoints": 40,
|
||||
"invitedCount": 3,
|
||||
"rewardedCount": 2,
|
||||
"pendingCount": 1,
|
||||
"rewardedPoints": 80,
|
||||
"totalPotentialRewardPoints": 120
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"referralId": "5ed7c3f0-6d1c-4f3f-8b40-2cb537da53e6",
|
||||
"inviteCode": "ABC123",
|
||||
"boundAt": "2026-05-18T09:00:00+00:00",
|
||||
"firstCreemPaidAt": "2026-05-20T11:15:00+00:00",
|
||||
"rewardGranted": true,
|
||||
"rewardGrantedAt": "2026-05-20T11:15:02+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `code`: string, unique invite code assigned to the user
|
||||
- `used_count`: integer `>= 0`, number of times this code has been used
|
||||
- `myCode`: string, unique invite code assigned to the current user
|
||||
- `binding.canBind`: boolean, whether the current user can still bind another user's invite code
|
||||
- `binding.boundInviteCode`: string or `null`, bound inviter code snapshot
|
||||
- `binding.boundAt`: ISO 8601 datetime or `null`
|
||||
- `summary.rewardPoints`: integer `>= 0`, reward granted to inviter and invitee per qualified post-bind CREEM payment
|
||||
- `summary.invitedCount`: integer `>= 0`
|
||||
- `summary.rewardedCount`: integer `>= 0`
|
||||
- `summary.pendingCount`: integer `>= 0`, computed as `invitedCount - rewardedCount`
|
||||
- `summary.rewardedPoints`: integer `>= 0`, computed as `rewardedCount * rewardPoints`
|
||||
- `summary.totalPotentialRewardPoints`: integer `>= 0`, computed as `invitedCount * rewardPoints`
|
||||
- `items`: inviter-side referral list, newest first
|
||||
- `items[].firstCreemPaidAt`: present only when the invitee has completed the qualifying CREEM payment after binding
|
||||
- `items[].rewardGranted`: whether inviter-side invite reward has been credited
|
||||
- `items[].rewardGrantedAt`: present only when `rewardGranted=true`
|
||||
|
||||
### POST /api/v1/invite/bind
|
||||
|
||||
Bind another user's invite code to the current account.
|
||||
|
||||
This endpoint is write-once:
|
||||
|
||||
- a user can bind at most once;
|
||||
- binding cannot be removed;
|
||||
- self-binding is forbidden;
|
||||
- binding is allowed even if the current user already has completed CREEM payments.
|
||||
|
||||
**Authorization**: Requires authenticated session.
|
||||
|
||||
**Request**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "ABC123"
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `code`: string, exactly 6 uppercase alphanumeric invite characters after normalization
|
||||
|
||||
**Response (200)**:
|
||||
|
||||
Same shape as `GET /api/v1/invite/me` after the bind succeeds.
|
||||
|
||||
## Error contract linkage
|
||||
|
||||
@@ -42,8 +105,14 @@ Field rules:
|
||||
- Shared registry: `docs/protocols/common/http-error-codes.md`.
|
||||
- Error codes for this feature:
|
||||
- `INVITE_CODE_NOT_FOUND` (404): Invite code not found for current user
|
||||
- `INVITE_BIND_CODE_NOT_FOUND` (404): Invite code to bind does not exist
|
||||
- `INVITE_ALREADY_BOUND` (409): Current user already bound an inviter
|
||||
- `INVITE_SELF_BIND_FORBIDDEN` (409): Current user attempted to bind their own code
|
||||
- `INVITE_CODE_NOT_BINDABLE` (409): Invite code is disabled, expired, or has no owner
|
||||
- `INVITE_CODE_INVALID` (422): Invite code format is invalid
|
||||
|
||||
## Data model linkage
|
||||
|
||||
- Invite codes are stored in `invite_codes` table.
|
||||
- See `docs/protocols/common/user-points-chat-data-protocol.md` for `profiles.referred_by` field.
|
||||
- Referral bindings are stored in `invite_referrals`.
|
||||
- See `docs/protocols/common/user-points-chat-data-protocol.md` for `profiles.referred_by`, `invite_referrals`, `redeem_code_batches`, and `redeem_codes`.
|
||||
|
||||
@@ -7,6 +7,7 @@ Protocol verification status:
|
||||
- Backend route source: `backend/src/v1/points/router.py`
|
||||
- Backend service source: `backend/src/v1/points/service.py`
|
||||
- Response schema source: `backend/src/v1/points/schemas.py`
|
||||
- Related redeem protocol: `docs/protocols/points/points-redeem-code-protocol.md`
|
||||
|
||||
## Compatibility strategy
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Points Redeem Code Protocol (Frontend <-> Backend)
|
||||
|
||||
This document defines the redeem-code activation contract for authenticated web users.
|
||||
|
||||
Protocol verification status:
|
||||
|
||||
- Backend route source: `backend/src/v1/points/router.py`
|
||||
- Backend service source: `backend/src/v1/points/service.py`
|
||||
- Response schema source: `backend/src/v1/points/schemas.py`
|
||||
- Web mapping source: `web/src/lib/api.ts`
|
||||
|
||||
## Compatibility strategy
|
||||
|
||||
- Additive evolution only.
|
||||
- Existing read-only balance/package routes remain backward-compatible.
|
||||
|
||||
## Route
|
||||
|
||||
### POST /api/v1/points/redeem-codes/redeem
|
||||
|
||||
Redeem a one-time activation code and credit the matching package points to the current account.
|
||||
|
||||
**Authorization**: Requires authenticated session.
|
||||
|
||||
**Request**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "RX8P2N6K4JQW"
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `code`: string, normalized uppercase code
|
||||
|
||||
**Response (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"packageProductCode": "popular_pack",
|
||||
"packageName": "常用加量包",
|
||||
"creditsAdded": 210,
|
||||
"newBalance": 330,
|
||||
"redeemedAt": "2026-05-21T12:30:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `packageProductCode`: package mapping key from backend static package config
|
||||
- `packageName`: current package display label snapshot stored in the redeem code record
|
||||
- `creditsAdded`: integer `> 0`
|
||||
- `newBalance`: integer `>= 0`
|
||||
- `redeemedAt`: ISO 8601 datetime
|
||||
|
||||
## Business rules
|
||||
|
||||
- Redeem codes are one-time use.
|
||||
- Redeem codes do not count as a successful recharge for invite reward qualification.
|
||||
- Redeem crediting is written to points ledger and points audit ledger.
|
||||
- Redeem activation must also write a system audit log entry.
|
||||
|
||||
## Error contract linkage
|
||||
|
||||
- RFC7807 + extension `code`, optional `params`.
|
||||
- Shared registry: `docs/protocols/common/http-error-codes.md`.
|
||||
- Error codes for this feature:
|
||||
- `REDEEM_CODE_NOT_FOUND` (404): Redeem code does not exist
|
||||
- `REDEEM_CODE_ALREADY_REDEEMED` (409): Redeem code was already activated
|
||||
- `REDEEM_CODE_DISABLED` (409): Redeem code is disabled or unavailable
|
||||
- `REDEEM_CODE_INVALID` (422): Redeem code format is invalid
|
||||
|
||||
## Data model linkage
|
||||
|
||||
- Redeem code batches are stored in `redeem_code_batches`.
|
||||
- Redeem codes are stored in `redeem_codes`.
|
||||
- Package snapshots come from `backend/src/core/config/static/packages/mapping.yaml`.
|
||||
Reference in New Issue
Block a user