邀请码 API
邀请码管理接口,包括公开验证/绑定接口和需认证的用户接口。此为可插拔功能模块。
路由前缀:/invite-codes
源码:apps/backend/src/routes/invite-codes.ts
认证
接口分为两类:
- 公开接口(
/validate、/bind、/settings):无需认证 - 用户接口(
/my/*):需要 JWT 认证
Authorization: Bearer <JWT>公开接口
1. 验证邀请码
POST /invite-codes/validate验证邀请码是否可用(注册流程中调用,无需登录)。若邀请码功能关闭,直接返回通过。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 是 | 邀请码 |
email | string | 是 | 注册邮箱 |
响应 200 OK(可用):
{
"valid": true
}响应 200 OK(功能关闭):
{
"valid": true,
"message": "Invite code not required"
}响应 200 OK(同邮箱重复验证):
{
"valid": true,
"alreadyBound": true
}响应 200 OK(不可用):
{
"valid": false,
"error": "INVALID_CODE"
}error 错误码:
| error | 说明 |
|---|---|
MISSING_PARAMS | 缺少 code 或 email |
INVALID_CODE | 邀请码不存在 |
CODE_ALREADY_USED | 邀请码已被使用 |
CODE_EXPIRED | 邀请码已过期 |
EMAIL_ALREADY_REGISTERED | 邮箱已注册 |
INTERNAL_ERROR | 服务端错误 |
HTTP 错误码:
| HTTP | 说明 |
|---|---|
| 400 | 缺少参数 |
| 500 | 服务端错误 |
2. 绑定邀请码
POST /invite-codes/bind将邀请码绑定到邮箱(注册流程中调用,无需登录)。原子性操作,仅未使用且未过期的邀请码才能绑定。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 是 | 邀请码 |
email | string | 是 | 注册邮箱 |
响应 200 OK(成功):
{
"success": true
}响应 200 OK(功能关闭):
{
"success": true,
"message": "Invite code not required"
}错误码:
| HTTP | error | 说明 |
|---|---|---|
| 400 | MISSING_PARAMS | 缺少 code 或 email |
| 400 | BIND_FAILED | 绑定失败(码已使用或过期) |
| 500 | INTERNAL_ERROR | 服务端错误 |
3. 获取邀请码配置
GET /invite-codes/settings获取邀请码功能的公开配置信息。
响应 200 OK:
{
"enabled": true,
"creditsPerCode": 1000
}| 字段 | 说明 |
|---|---|
enabled | 邀请码功能是否启用 |
creditsPerCode | 每个邀请码奖励积分数(用户可见单位) |
错误码:
| HTTP | 说明 |
|---|---|
| 500 | 获取配置失败,返回 { "enabled": false } |
用户接口(需 JWT 认证)
4. 获取我的邀请码列表
GET /invite-codes/my/codes获取当前用户的所有邀请码(包括已使用的),按创建时间倒序。
used_by_display 是服务端生成的 { type, masked }。type 为 display_name | nickname | email | phone | name | id | none,按此顺序降级。 可读名称取 profiles.display_name,微信昵称取 user_social_identities.nickname; 自动生成的邮箱前缀、微信名称、默认用户名称及 UUID 延后到联系方式之后。 邀请码历史邮箱为空、无效或为微信内部占位地址时,服务端只按当前列表关联的使用者 ID 补查 auth 账号,覆盖后绑定邮箱; 按 ID 去重,最多 5 个并发,不扫描全部账号。后绑定联系方式按数据库客户端隔离缓存 60 秒,每个客户端最多 2000 条;首次读取仍按缺失账号数查询。邮箱和手机保持服务端脱敏(包括填入名字字段的联系方式),微信占位邮箱不作为联系方式。 联系方式和名称均不可用时,id 返回该邀请码所关联使用者的短 ID(前 8 位 + 省略号 + 后 4 位),作为最后一项身份兜底;仅邀请码拥有者可见。 只有用户 ID 也缺失时才返回 none,前端显示「使用者信息不可用」,不显示 -。
used_at 是已使用表的使用时间,expires_at 只用于未使用表。 表格占容器宽度 100%;未使用表不显示使用者列,已使用表限制使用者列宽为 280px,时间列 200px, 以省略号及 tooltip 展示长内容。直接显示名字,不添加来源前缀。
响应 200 OK:
{
"data": [
{
"id": "uuid",
"code": "ABCD1234",
"credits_milli": 1000000,
"used_by_email": null,
"used_by_display": { "type": "none", "masked": "-" },
"used_at": null,
"expires_at": "2026-04-06T00:00:00.000Z",
"created_at": "2026-03-07T00:00:00.000Z"
}
]
}
credits_milli为毫积分单位(1 积分 = 1000 毫积分)。
错误码:
| HTTP | 错误 | 说明 |
|---|---|---|
| 401 | Unauthorized | 未认证 |
| 500 | Failed to fetch codes | 查询失败 |
| 500 | Internal server error | 服务端错误 |
5. 处理邀请码积分发放
POST /invite-codes/my/redeem注册完成后处理邀请码积分发放。前端在用户首次登录后调用此接口:
- 查找绑定到该邮箱的邀请码
- 发放积分给邀请人和被邀请人
- 为新用户生成邀请码
响应 200 OK:
{
"success": true
}错误码:
| HTTP | 错误 | 说明 |
|---|---|---|
| 401 | Unauthorized | 未认证或无邮箱信息 |
| 500 | Internal server error | 服务端错误 |
6. 生成邀请码
POST /invite-codes/my/generate为当前用户生成邀请码,自动补齐到配置数量(默认每用户 5 个)。如果已有足够的邀请码,不会重复生成。
邀请码格式:8 位全大写字母 + 数字(排除易混淆字符 0OIL1),有效期默认 30 天。
响应 200 OK(生成了新码):
{
"generated": 3
}响应 200 OK(已有足够):
{
"generated": 0,
"message": "Already have enough codes"
}错误码:
| HTTP | 错误 | 说明 |
|---|---|---|
| 401 | Unauthorized | 未认证 |
| 500 | Failed to generate codes | 生成失败 |
| 500 | Internal server error | 服务端错误 |
邀请人奖励(2026-06-10 起)
邀请码被成功使用(新用户完成注册关联)后,系统自动:
- 给邀请人发放奖励积分:额度由
site_settings的invite.inviter_reward配置 ({ "creditsMilli": 100000 },毫积分),未配置时默认 100 积分;设0关闭奖励。 流水transaction_type = 'invite_bonus',metadata.role = 'inviter'(与被邀请人 的invitee区分)。 - 发送「邀请码被使用」站内通知:Novu workflow
invite-code-used(需在 Novu 后台 配置模板,启动期 preflight 会校验),按邀请码 id 幂等,与奖励是否发放无关。
幂等保障:奖励与通知仅在邀请码 used_by_user_id 从空翻转为已关联时触发一次。
GET /invite-codes/my/activation
读取当前登录用户的邀请码激活状态。
POST /invite-codes/my/activate
为当前登录用户激活邀请码权益。请求体提供邀请码;重复激活或邀请码无效时返回对应业务错误。
额度与购买(需 JWT 认证)
免费额度用尽后,团队 Owner / 管理员可以用团队积分购买邀请码。价格、有效期与上限全部由站点配置 (site_settings 的 registration.invite_code)在数据库事务内解析,接口入参不含价格。
GET /invite-codes/my/quota
额度概览与购买报价,前端据此决定按钮语义与是否显示购买入口。所有计数与 quoteVersion 由数据库 get_invite_purchase_context 计算,与购买 RPC 同一份口径。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
teamId | string | 否 | 目标团队。传入时服务端先校验当前用户是否为该团队 Owner / 管理员;无权时不把 teamId 传给查询(团队余额保持 null),避免向任意登录用户泄漏团队余额 |
响应 200 OK:
{
"success": true,
"data": {
"freeQuotaTotal": 5,
"freeQuotaUsed": 5,
"freeQuotaRemaining": 0,
"activeUnusedCount": 2,
"purchase": {
"enabled": true,
"priceCredits": 100,
"maxPerRequest": 20,
"maxUnusedHolding": 50,
"expirationDays": 30,
"teamBalanceMilliCredits": 10000000
},
"quoteVersion": "v1|enabled=1|price=100|maxReq=20|maxHold=50|expDays=30|freeQuota=5",
"canIssueUnlimited": false
}
}| 字段 | 说明 |
|---|---|
freeQuotaTotal / freeQuotaUsed / freeQuotaRemaining | 免费额度(终身口径,按 source='free' 计数);剩余数下限为 0 |
activeUnusedCount | 当前有效且未使用的邀请码数量(含购买的码,为持有上限口径) |
purchase | 购买配置快照;enabled、单价、单次上限、持有上限、有效期由站点配置决定,未配置时数值字段为 null、enabled 为 false |
purchase.teamBalanceMilliCredits | 目标团队积分余额(毫积分);未传 teamId 或无购买权时为 null |
quoteVersion | 报价版本指纹;购买时须原样回传 |
canIssueUnlimited | 当前用户是否持有 site:invite_codes 站点权限(可无限发码) |
错误码:
| HTTP | 错误 | 说明 |
|---|---|---|
| 401 | Unauthorized | 未认证 |
| 500 | config_invalid | 站点配置非法(响应附 field 字段指出问题配置) |
| 500 | Internal server error | 服务端错误 |
POST /invite-codes/my/purchase
用团队积分购买邀请码。仅团队 Owner / 管理员可调用——团队积分是共享资产,只有管理角色能把它换成 归个人所有的邀请码。
入参不含价格:价格与有效期由 RPC 在事务内从锁定的配置解析,是唯一权威来源。前端把 GET /my/quota 拿到的 quoteVersion 原样回传,RPC 事务内再比对一次,避免「用户确认的是旧价、 扣款按新价」。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
teamId | string | 是 | 扣款的团队 |
quantity | number | 是 | 购买数量(正整数,且不超过配置的单次上限) |
requestId | string | 是 | 幂等键(与 userId 组合成数据库幂等键,不含 teamId/quantity;重复请求返回同一结果) |
quoteVersion | string | 是 | GET /my/quota 返回的报价版本 |
{
"teamId": "团队 UUID",
"quantity": 5,
"requestId": "客户端生成的唯一请求 ID",
"quoteVersion": "v1|enabled=1|price=100|maxReq=20|maxHold=50|expDays=30|freeQuota=5"
}响应 200 OK:
{
"success": true,
"data": {
"codes": ["ABCD2345", "EFGH6789"],
"transactionId": "流水 UUID",
"balanceAfterMilli": 9950000,
"unitPriceMilli": 100000,
"totalMilli": 500000,
"expiresAt": "2026-09-28T00:00:00.000Z",
"idempotent": false
}
}| 字段 | 说明 |
|---|---|
codes | 新购买的邀请码明文 |
balanceAfterMilli | 扣款后团队余额(毫积分) |
unitPriceMilli / totalMilli | 单价与总价(毫积分) |
expiresAt | 本批邀请码过期时间(按配置的有效期天数计算) |
idempotent | 命中幂等重放时为 true(不重复扣费) |
错误码:
| HTTP | error | 说明 |
|---|---|---|
| 400 | MISSING_PARAMS | 缺少 teamId / requestId / quoteVersion |
| 400 | invalid_quantity | quantity 非正整数 |
| 400 | purchase_disabled | 站点未开启购买 |
| 400 | quantity_exceeds_limit | 超过单次购买上限 |
| 400 | holding_limit_exceeded | 超过持有上限(活跃未用码过多) |
| 400 | free_quota_remaining | 免费额度尚未用完,不允许购买 |
| 402 | insufficient_credits | 团队积分余额不足(充值后可重试) |
| 403 | forbidden | 当前用户不是该团队 Owner / 管理员 |
| 409 | quote_stale | 报价已过期,需重新拉取 quota 后确认 |
| 409 | idempotency_key_conflict | 幂等键重复但参数(数量等)不一致 |
| 409 | activation_not_atomic | 邀请码功能尚未切换到原子激活路径 |
| 500 | config_invalid / balance_inconsistent / balance_not_found / internal_error | 平台侧问题 |
| 503 | code_collision | 生成邀请码撞码,事务已整体回滚,可直接重试 |
失败响应带 detail 对象,按错误类型包含 reason、balance、required、free_remaining、 active_unused、max_holding 等上下文字段。