会员与点数
会员方案查询、订阅开通、点数余额查询,以及「点数优先、会员配额补足」的双层计费模型与并发安全机制。
8.1 计费模型总览
系统采用「点数优先、会员配额补足」的双层计费:扣费时先消耗账户点数余额, 余额不足部分再从当期会员配额中扣减;两者合计仍不足时拒绝服务。 会员不是免计费通行证,而是每月额外的一笔大额「翻译额度」。
文本翻译(/api/translate) requiredPoints = max(1, ceil(字符数 / 1000)) // app.points.text-chars-per-point = 1000 文档翻译(/api/document) requiredPoints = 页数 × 1 // 1 点 / 页,启动任务时一次性预扣 翻译缓存命中 usedPoints = 0 // 命中 Redis 缓存不扣任何额度 Mock 降级结果 usedPoints = 0(自动全额退还) // 退还备注「Mock降级占位结果退还扣点」
if (balance >= requiredPoints) { 点数账户全额扣除 // ① 余额充足:只扣点数 } else if (balance > 0) { 点数账户扣光 + 会员配额补足 (required - balance) // ② 混合扣减 } else if (membershipRemain >= requiredPoints) { 会员配额全额扣除 // ③ 无点数:纯会员额度 } else { throw new InsufficientPointsException( "点数余额与会员配额不足,无法完成本次翻译"); // → HTTP 402 }
💰 计费发生在调用真实引擎之前(deductOrThrow);
若引擎降级为 Mock 占位结果,已扣点数自动全额退还(退还失败仅记日志,不影响响应)。
8.2 接口总览
🔐 本章三个接口均在 /api/** 拦截范围内,需要携带 Authorization(见第 02 章)。
订单类接口 /api/membership/order、/api/membership/order/** 与支付类接口
/api/payment/** 在认证白名单中,无需登录即可访问(详见第 09 章)。
8.3 会员方案(plans)
/api/membership/plans
返回三档会员方案,价格与配额如下(方案在服务端硬编码组装,价格不随请求变化):
| type 值 | 方案名 | 价格(元) | 时长 | 周期配额(periodQuota) |
|---|---|---|---|---|
0 | 包月会员 | 49 | 1 个月 | monthlyQuota × 1 |
1 | 包季会员 | 139 | 3 个月 | monthlyQuota × 3 |
2 | 包年会员 | 299 | 12 个月 | monthlyQuota × 12 |
[
{ "type": 0, "name": "包月会员", "price": 49, "monthlyQuota": 5000 },
{ "type": 1, "name": "包季会员", "price": 139, "monthlyQuota": 5000 },
{ "type": 2, "name": "包年会员", "price": 299, "monthlyQuota": 5000 }
]
⚠️ type 字段是枚举下标(ordinal),不是枚举内部 value:
0=包月 / 1=包季 / 2=包年。而 MembershipTypeEnum 自身的 value 字段为 1/2/3,
二者不一致,集成时务必以本表 ordinal 为准,勿混用。
/api/membership/subscribe
直接开通会员(内部开通通道,无支付环节;线上购买流程走第 09 章订单支付,支付成功后由系统调用同一订阅逻辑)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId | Long | 必填 | 目标用户 ID(@NotNull) |
type | int | 必填 | 方案 ordinal:0 包月 / 1 包季 / 2 包年(越界抛 IllegalArgumentException → 400) |
months = {包月: 1, 包季: 3, 包年: 12}[type]
periodQuota = monthlyQuota × months // 默认 5000 × months
startAt = now;endAt = now + months // 精确到月(plusMonths)
status = ACTIVE(1),periodUsed = 0
// 随后赠送点数(失败不影响开通):
pointsService.add(userId, subscribeBonusPoints, "会员开通赠送点数",
"membership:{membershipId}") // 默认 +5000 点
{
"id": 3, "userId": 1,
"type": "MONTHLY", // 枚举名
"status": "ACTIVE", // ACTIVE(1) 正常 / EXPIRED(2) 过期 / CANCELLED(3) 取消
"startAt": "2026-09-06T10:00:00",
"endAt": "2026-10-06T10:00:00",
"periodQuota": 5000, // 本周期总配额
"periodUsed": 0, // 本周期已用
"version": 0 // 乐观锁版本号
}
💰 赠点步骤包在 try/catch 中:加点失败仅记 warn 日志,会员照常开通,响应不受影响。
8.4 点数余额查询
/api/points/balance
查询当前登录用户的点数余额。用户 ID 由服务端从 UserContext(JWT 解析结果)取得,请求无需任何参数。
{ "balance": 4321 }
点数来源:注册赠送、会员开通赠点、退款补退等;消耗于文本翻译扣点。
会员剩余配额不在此接口返回——如需一并展示,可结合会员状态接口自行计算
剩余配额 = periodQuota - periodUsed。
8.5 会员有效期与配额消耗机制
取该用户 endAt 最新的一条会员记录,须同时满足: status == ACTIVE && endAt.isAfter(now) 不满足 → 视为无有效会员
- 事务包裹
扣减在
TransactionTemplate编程式事务中执行,保证「读余额 → 扣减 → 回写」原子性。 - 乐观锁竞争
UserMembership带@Version字段,MyBatis-PlusupdateById以 version 条件更新;并发扣减时后写方更新行数为 0。 - 有限重试 更新失败自动重试,最多 3 次(MAX_RETRY=3);重试期间重新读取最新配额再判定。
- 超限失败
3 次重试仍失败 → 抛
IllegalStateException("操作繁忙,请稍后重试")→ HTTP 409ILLEGAL_STATE。
| 场景 | 异常消息 | HTTP |
|---|---|---|
| 无有效会员或已过期 | 当前无有效会员或会员已过期 | 402 INSUFFICIENT_POINTS |
| 会员周期配额不足 | 会员周期配额不足,所需: X, 剩余: Y | 402 INSUFFICIENT_POINTS |
| 点数与会员合计不足(文本翻译) | 点数余额与会员配额不足,无法完成本次翻译 | 402 INSUFFICIENT_POINTS |
| 乐观锁重试 3 次仍失败 | 操作繁忙,请稍后重试 | 409 ILLEGAL_STATE |
8.6 相关配置项
| 配置键 | 默认值 | 说明 |
|---|---|---|
app.membership.monthly-quota | 5000 | 每档会员的单月配额基数;包季 ×3、包年 ×12 |
app.membership.subscribe-bonus-points | 5000 | 开通会员时一次性赠送的点数 |
app.points.text-chars-per-point | 1000 | 文本翻译每点对应的字符数 |
app.points.document-points-per-page | 1 | 文档翻译每页消耗点数 |
配置异常兜底:text-chars-per-point 未配置或 ≤ 0 时,文本计费退化为固定 1 点 / 次,不会导致翻译不可用。
8.7 调用示例
# 1. 查询会员方案 curl http://localhost:8090/api/membership/plans \ -H "Authorization: Bearer $TOKEN" # 2. 开通包月会员(type=0 为 ordinal) curl -X POST http://localhost:8090/api/membership/subscribe \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"userId":1,"type":0}' # 3. 查询点数余额 curl http://localhost:8090/api/points/balance \ -H "Authorization: Bearer $TOKEN"