第 08 章

会员与点数

会员方案查询、订阅开通、点数余额查询,以及「点数优先、会员配额补足」的双层计费模型与并发安全机制。

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降级占位结果退还扣点」
扣减顺序(performDeduction 三分支)
if (balance >= requiredPoints) {
    点数账户全额扣除                                          // ① 余额充足:只扣点数
} else if (balance > 0) {
    点数账户扣光 + 会员配额补足 (required - balance)           // ② 混合扣减
} else if (membershipRemain >= requiredPoints) {
    会员配额全额扣除                                          // ③ 无点数:纯会员额度
} else {
    throw new InsufficientPointsException(
        "点数余额与会员配额不足,无法完成本次翻译");          // → HTTP 402
}

💰 计费发生在调用真实引擎之前(deductOrThrow); 若引擎降级为 Mock 占位结果,已扣点数自动全额退还(退还失败仅记日志,不影响响应)。

8.2 接口总览

GET/api/membership/plans会员方案列表(3 档)
POST/api/membership/subscribe开通 / 续费会员(含注册赠点)
GET/api/points/balance当前用户点数余额

🔐 本章三个接口均在 /api/** 拦截范围内,需要携带 Authorization(见第 02 章)。 订单类接口 /api/membership/order、/api/membership/order/** 与支付类接口 /api/payment/** 在认证白名单中,无需登录即可访问(详见第 09 章)。

8.3 会员方案(plans)

GET

/api/membership/plans

返回三档会员方案,价格与配额如下(方案在服务端硬编码组装,价格不随请求变化):

type 值方案名价格(元)时长周期配额(periodQuota)
0包月会员491 个月monthlyQuota × 1
1包季会员1393 个月monthlyQuota × 3
2包年会员29912 个月monthlyQuota × 12
响应 data(List<MembershipPlanResponse>)
[
  { "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 为准,勿混用。

POST

/api/membership/subscribe

直接开通会员(内部开通通道,无支付环节;线上购买流程走第 09 章订单支付,支付成功后由系统调用同一订阅逻辑)。

请求体(MembershipSubscribeRequest)
字段类型必填说明
userIdLong必填目标用户 ID(@NotNull)
typeint必填方案 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 点
响应 data(UserMembership 实体)
{
  "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 点数余额查询

GET

/api/points/balance

查询当前登录用户的点数余额。用户 ID 由服务端从 UserContext(JWT 解析结果)取得,请求无需任何参数。

响应 data(PointsBalanceResponse)
{ "balance": 4321 }

点数来源:注册赠送、会员开通赠点、退款补退等;消耗于文本翻译扣点。 会员剩余配额不在此接口返回——如需一并展示,可结合会员状态接口自行计算 剩余配额 = periodQuota - periodUsed。

8.5 会员有效期与配额消耗机制

有效会员判定(getActiveMembership)
取该用户 endAt 最新的一条会员记录,须同时满足:
  status == ACTIVE && endAt.isAfter(now)
不满足 → 视为无有效会员
配额扣减的并发安全(consumeQuota)
  1. 事务包裹 扣减在 TransactionTemplate 编程式事务中执行,保证「读余额 → 扣减 → 回写」原子性。
  2. 乐观锁竞争 UserMembership 带 @Version 字段,MyBatis-Plus updateById 以 version 条件更新;并发扣减时后写方更新行数为 0。
  3. 有限重试 更新失败自动重试,最多 3 次(MAX_RETRY=3);重试期间重新读取最新配额再判定。
  4. 超限失败 3 次重试仍失败 → 抛 IllegalStateException("操作繁忙,请稍后重试") → HTTP 409 ILLEGAL_STATE。
会员扣减异常对照表
场景异常消息HTTP
无有效会员或已过期当前无有效会员或会员已过期402 INSUFFICIENT_POINTS
会员周期配额不足会员周期配额不足,所需: X, 剩余: Y402 INSUFFICIENT_POINTS
点数与会员合计不足(文本翻译)点数余额与会员配额不足,无法完成本次翻译402 INSUFFICIENT_POINTS
乐观锁重试 3 次仍失败操作繁忙,请稍后重试409 ILLEGAL_STATE

8.6 相关配置项

配置键默认值说明
app.membership.monthly-quota5000每档会员的单月配额基数;包季 ×3、包年 ×12
app.membership.subscribe-bonus-points5000开通会员时一次性赠送的点数
app.points.text-chars-per-point1000文本翻译每点对应的字符数
app.points.document-points-per-page1文档翻译每页消耗点数

配置异常兜底: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"