当一个产品需求以“签到就能领,便宜就算了,还能免费领!!划算哭了”的形式提出来时,开发团队拿到手的并不是一句文案,而是一个需要拆解为规则、库存、发放、风控和账务的完整功能模块。这种每日签到打卡领取奖励的玩法在电商、社区、内容、工具类产品里非常普遍:用户每天进入页面点击一次签到,系统按连续签到天数发放积分、优惠券、实物礼品或会员体验,平台用奖励换日活跃和用户留存。如果只看页面交互,它是按钮、弹窗和日历;如果看后端链路,它是签到记录唯一性、连续周期计算、奖励发放幂等、库存扣减、防刷风控、跨天时区、异常恢复和对账监控等一系列问题。这篇文章以“签到免费领”类活动为案例,给出适用后端开发、全栈和测试同学的设计方案,包含数据表、接口代码、测试用例和排查清单。
1. 把产品文案拆成开发需求:签到、连续天数和免费奖励
1.1 文案背后至少要先回答五个问题
如果产品经理只给了一句“签到就能领,便宜就算了,还能免费领!!划算哭了”,开发不能直接开始写接口。需要先定义一个模型:活动有效期、参与用户范围、每日签到限制、连续签到计规则、奖励发放方。没有这些定义,数据库表和接口设计都会留下隐患。
第一个问题是活动周期。活动从哪天开始、哪天结束,决定了哪些签到记录属于本次活动,也决定活动结束后按钮是否还能点击。第二个问题是参与资格。新注册用户能不能领当天奖励,黑名单用户是否禁止参与,是运营规则,不是单纯 if 判断。第三个问题是每日限制。同一个用户一天最多签到一次,这是大多数签到活动的默认规则,但要明确落实到接口幂等和数据唯一约束上。第四个问题是连续签到怎么算。昨天没有签到,今天签到之后连续天数应该重置为 1,还是延续之前的累计值?这里最容易出现产品和技术理解不一致。第五个问题是奖励从哪来。积分、优惠券、实物商品由不同系统发放,后端要定义清楚调用关系和失败补偿。
这些问题的答案会直接改变数据表设计。比如“每天只能一次”要求数据库必须有唯一索引;“连续签到按 7 天循环”要求奖励规则表里要有周期天数概念;“免费领实物”要求奖励流水表能记录发放状态和重复领取限制。
1.2 奖励类型和成本模型必须提前确认
“免费领”是运营页面用的话术,成本是财务和风控需要跟踪的数据。积分会增加运营负债,优惠券会形成营销费用,实物商品要计入采购、物流和售后成本。所以在数据库设计之前,产品要先给每个连续天数对应的奖励类型、面值、库存和发放方式。
常见奖励类型可以按这个表格梳理:
| 奖励类型 | 发放动作 | 成本特点 | 主要对接系统 |
|---|---|---|---|
| 积分 | 增加用户积分余额 | 边际成本低,但总量负债高 | 积分账户 |
| 优惠券 | 创建用户优惠券 | 按核销口径计入营销费用 | 券中心 |
| 实物商品 | 创建赠品订单或奖品订单 | 采购、物流、售后成本高 | 订单中心 |
| 会员权益 | 延长会员到期时间 | 按权益期限摊销 | 会员系统 |
这张表的作用是让团队在写代码前知道:签到接口成功后,下一步是写用户积分,还是调用远程发奖服务,还是创建订单。如果奖励类型和发放动作不明确,就会出现“签到成功弹窗已经出现,但用户查不到优惠券”的客诉。
这里有一条工程红线:奖励规则不要写死在代码里。实际活动中,第 1 天送积分、第 7 天送实物,运营经常要调文案、调库存、调活动日期。如果这些值被 hardcode 到常量里,每次调整都要重新发版,还会造成历史活动无法回溯。推荐把活动、奖励规则、签到记录、发放流水拆开建表,让运营在后台配置,后端只负责消费配置和验证。
2. 数据库设计:把活动、规则、签到记录、发放流水分开建模
2.1 为什么不能只用一张签到表
如果只用一张表存“用户 ID + 日期 + 奖励”,初期看起来方便,后续会出现三个问题。
第一,奖励规则变更很难维护。某一天把第 3 天奖励从积分改成优惠券,历史记录里已有的奖励不一定需要改变,但老表结构无法区分“当时规则”和“当前规则”。第二,发放失败无法补偿。签到记录和奖励发放在同一行里,发放失败时只能把整条记录标记为失败,但用户已经看到“签到成功”,重试逻辑很别扭。第三,无法支撑运营审计。运营需要知道某个活动发了多少券、多少实物,单表里没有活动维度,只能靠日期倒推,一旦活动时间调整就查不清。
推荐至少拆出四张表:活动表、奖励规则表、用户签到记录表、奖励发放流水表。拆表的目的是让每一类数据的生命周期独立管理,也方便后续扩展到任务、抽奖等场景。
2.2 DDL 参考
下面给出一组 MySQL 通用表结构,实际项目需要根据团队命名规范和 ORM 习惯调整。
活动表:
CREATE TABLE signin_activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', activity_name VARCHAR(128) NOT NULL COMMENT '活动名称', start_date VARCHAR(16) NOT NULL COMMENT '活动开始日期,格式 yyyy-MM-dd', end_date VARCHAR(16) NOT NULL COMMENT '活动结束日期,格式 yyyy-MM-dd', cycle_days INT NOT NULL DEFAULT 7 COMMENT '连续签到循环周期,例如7天一个循环', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY idx_status_start_date (status, start_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到活动表';奖励规则表:
CREATE TABLE signin_reward_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', activity_id BIGINT NOT NULL COMMENT '活动ID', day_no INT NOT NULL COMMENT '连续签到第N天,从1开始', reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型:point/coupon/product/member', reward_name VARCHAR(128) NOT NULL COMMENT '奖励名称,用于页面展示', reward_value DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '奖励面值或积分值', total_stock INT NOT NULL DEFAULT 0 COMMENT '总库存,-1表示不限库存', remain_stock INT NOT NULL DEFAULT 0 COMMENT '剩余库存', status TINYINT NOT NULL DEFAULT 0 COMMENT '0停用 1启用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_activity_day (activity_id, day_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到奖励规则表';用户签到记录表:
CREATE TABLE user_signin_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', user_id BIGINT NOT NULL COMMENT '用户ID', activity_id BIGINT NOT NULL COMMENT '活动ID', biz_date VARCHAR(16) NOT NULL COMMENT '业务日期,格式 yyyy-MM-dd', cycle_no INT NOT NULL COMMENT '本次签到对应的循环天数', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_activity_bizdate (user_id, activity_id, biz_date), KEY idx_user_bizdate (user_id, biz_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户签到记录表';奖励发放流水表:
CREATE TABLE user_reward_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', user_id BIGINT NOT NULL COMMENT '用户ID', activity_id BIGINT NOT NULL COMMENT '活动ID', signin_record_id BIGINT NOT NULL COMMENT '签到记录ID,关联 user_signin_record.id', reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型', reward_name VARCHAR(128) NOT NULL COMMENT '奖励名称', reward_value DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '奖励面值或积分值', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发放 1发放成功 2发放失败 3已撤销', out_biz_no VARCHAR(64) NOT NULL COMMENT '幂等流水号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_signin_record (signin_record_id), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖励发放流水表';2.3 关键字段和索引说明
第一,user_signin_record上的联合唯一索引uk_user_activity_bizdate是整个防重复签到的底线。无论前端点击多少次、接口被重试多少次,数据库都会把同一天重复插入挡回去。开发时不要依赖先查再插,因为并发之间会有时间窗口。
第二,biz_date使用字符串而不是 datetime,目的是让业务日期显式化。业务日期应该由服务端计算,不能由客户端传过来。如果使用数据库的当前日期,在跨时区部署时会受数据库服务器时区影响,很容易出现用户看到的日期和服务端记录的日期不一致。
第三,user_reward_record用signin_record_id做唯一键,保证“一次签到只生成一次奖励流水”。如果因为代码 Bug 对同一条签到记录发了两次奖品,流水表会有重复的 signin_record_id,唯一索引会直接报错。对账时也可以用这个字段快速定位异常。
第四,out_biz_no也要保持唯一。它是调用积分系统、券中心时的全局流水号,可以用“活动 ID + 用户 ID + 业务日期 + 随机数”生成,避免不同渠道幂等号冲突。
3. 后端签到接口:幂等、连续周期和库存扣减
3.1 接口协议与返回码
最简接口只需要两个入参:活动 ID 和渠道标识。用户 ID 必须从登录态获取,不能由客户端传值,否则用户之间可以伪造。
请求示例:
{ "activityId": 10001, "channel": "mobile" }成功响应示例:
{ "code": 0, "message": "success", "data": { "signin": true, "continuousDays": 3, "reward": { "type": "COUPON", "name": "满10元减2元优惠券", "value": 2 } } }返回码建议统一,便于前端和日志判定:
| code | 含义 | 前端处理 |
|---|---|---|
| 0 | 签到成功 | 展示奖励弹窗,刷新签到状态 |
| 1001 | 当天已签到 | 提示“今日已打卡”,不重复发奖 |
| 1002 | 活动未开始 | 展示倒计时或活动公告 |
| 1003 | 活动已结束 | 关闭签到入口 |
| 1004 | 奖品库存不足 | 提示“奖品已领完”,通知运营补库存 |
| 1005 | 奖励规则配置缺失 | 提示“活动配置异常”,后端查配置表 |
| 1101 | 触发风控 | 提示需要完成绑定或验证后再参与 |
3.2 签到主流程的代码实现
以一个 Spring Boot 单体项目为例,核心服务逻辑可以按下面思路写。这里代码只展示关键步骤,不绑定具体 ORM。
@Service public class SigninServiceImpl implements SigninService { @Override public SigninResp sign(Long userId, Long activityId) { SigninActivity activity = activityCache.get(activityId); if (activity == null || activity.getStatus() != 1) { throw new BizException(ErrorCode.ACTIVITY_NOT_AVAILABLE); } String bizDate = bizDateService.currentBizDate(); if (bizDate.compareTo(activity.getStartDate()) < 0) { throw new BizException(ErrorCode.ACTIVITY_NOT_STARTED); } if (bizDate.compareTo(activity.getEndDate()) > 0) { throw new BizException(ErrorCode.ACTIVITY_ENDED); } int continuousDays = calcContinuousDays(userId, activityId, bizDate); UserSigninRecord record = UserSigninRecord.builder() .userId(userId) .activityId(activityId) .bizDate(bizDate) .cycleNo(continuousDays) .build(); try { signinRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException(ErrorCode.REPEAT_SIGNIN); } RewardRule rule = rewardRuleCache.get(activityId, continuousDays); if (rule == null || rule.getStatus() != 1) { throw new BizException(ErrorCode.REWARD_CONFIG_NOT_FOUND); } boolean stockOk = stockService.decrStock(rule.getId()); if (!stockOk) { throw new BizException(ErrorCode.REWARD_STOCK_EMPTY); } rewardService.createAndSend(userId, activityId, record.getId(), rule); return SigninResp.success(record, rule); } private int calcContinuousDays(Long userId, Long activityId, String bizDate) { String yesterday = DateUtils.minusDays(bizDate, 1); UserSigninRecord last = signinRecordMapper.selectByUserAndBizDate(userId, activityId, yesterday); int raw = (last == null) ? 1 : last.getCycleNo() + 1; int cycleDays = activityCache.get(activityId).getCycleDays(); return 1 + (raw - 1) % cycleDays; } }这里有几个关键点。
计算连续天数必须用“昨天”的签到记录,而不是“最近一次”的签到记录。如果用户前天签到、昨天没签到,今天再签到,连续天数应重置为 1;如果查询最近一条记录,得到的会是 2,这是最常见的逻辑错误。
插入签到记录要捕获DuplicateKeyException。这样即使两个并发请求同时进入,也不会出现同一天两条记录。
库存扣减不能先查remain_stock再在代码里判断后更新。两个请求同时读到时都可能认为有库存,最后把库存扣成负数。原子扣减的推荐写法放到第 5 章说明。
3.3 并发控制:Redis 防抖不能替代数据库唯一约束
有的团队会在签到接口前加一个 Redis 锁,用来防止用户疯狂点击:
String lockKey = "signin:lock:" + activityId + ":" + userId; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(ErrorCode.REPEAT_SIGNIN); } try { return doSign(userId, activityId); } finally { redisTemplate.delete(lockKey); }Redis 锁可以降低数据库压力,但它不能作为唯一保障。原因有三个:锁可能因为网络问题没有写入成功;锁过期时间设置不合理会提前失效;Redis 本身也可能发生主从切换。只要数据库有唯一索引,最坏情况下用户会看到“今天已签到”,但不会有重复奖励。Redis 防抖只是优化,数据库唯一约束才是正确性兜底。
还要注意,不要把整个签到流程包括奖励远程调用都包在 Redis 锁里。奖励发放如果调用远程服务,耗时可能从几百毫秒到几秒,锁被占用期间其他用户会被迫等待。正确做法是锁只保护本地写库和库存扣减,远程发奖放到幂等流水里异步处理。
3.4 奖励发放采用同步还是异步
奖励发放方式取决于业务规模。
如果系统是单库单体,积分发放直接走本地事务,可以在同一个事务方法里插入签到记录、更新库存、写入奖励流水并增加用户积分。整个流程要么全成功,要么全回滚。
如果奖励需要调用远程系统,比如第三方券中心、实物订单中心,远程调用时间不可控,就不适合放在本地数据库事务里。推荐顺序是:
- 在主事务里插入用户签到记录和奖励流水,奖励流水状态为“待发放”。
- 事务提交后,向消息队列发送发奖消息,异步调用远程系统。
- 远程系统返回成功后,更新奖励流水状态为“发放成功”。
- 后台定时任务扫描长时间处于“待发放”或“发放失败”的流水,重新发送并告警。
同步和异步的选择可以用下表判断:
| 场景 | 推荐方案 | 注意点 |
|---|---|---|
| 积分在本系统账本 | 本地事务同步发放 | 直接改账户余额时要加行锁或乐观锁 |
| 优惠券在券中心 | 异步发送 + 状态补偿 | 依赖券模板 ID 和发放接口幂等 |
| 实物商品在订单中心 | 异步创建奖品订单 | 需要等用户补充收货地址,可能要补一次确认页 |
| 会员权益在会员系统 | 异步发放 + 对账 | 关注权益开始时间和到期时间 |
4. 前端状态和运营后台:不只是一个按钮
4.1 签到页面的状态管理和接口联动
签到页面里,最容易出错的是按钮状态。前端不能只做“已签到置灰”和“未签到可点”两个状态,至少应该覆盖下面这些情况:
| 页面状态 | UI 表现 | 对应接口行为 |
|---|---|---|
| 未登录 | 点击后跳登录 | 接口不参与签到 |
| 活动未开始 | 展示倒计时,按钮不可点 | 不能调用签到接口 |
| 未签到 | 展示“签到领奖励”按钮 | 可调用签到接口 |
| 请求中 | 按钮 loading,禁止重复点击 | 服务端仍用唯一索引兜底 |
| 已签到 | 展示“今日已打卡” | 接口会返回已签到错误码,前端直接提示 |
| 活动已结束 | 展示活动结束文案 | 不调用签到接口 |
这里特别强调,判断业务日期必须以服务端返回为准。用户修改手机时间后,如果前端用本地时间计算“今天是第几天”,会出现签到处显示和实际发放奖励对不上的问题。服务端最好在签到状态接口里直接返回下次可签到时间,前端用它做倒计时。
4.2 运营后台的配置、审核和审计
运营后台要支持活动配置、奖励规则、库存调整和记录查询。实际项目中,运营改库存的动作不能直连数据库,应该走一个管理端接口,比如:
@PostMapping("/admin/reward/stocks/adjust") public Result<Void> adjustStock(@RequestBody StockAdjustReq req) { // 校验管理权限 // 记录操作日志 // 更新 signin_reward_rule.remain_stock }这个接口要考虑两点:第一是要有权限校验,不能暴露在公网直接访问;第二是每次调整要记录操作人、调整前库存、调整后库存和原因,后续对账时能追溯。
运营配置奖品文案时,后端要把文案和奖励规则 ID 绑定,避免出现页面展示“满10元减2元券”,实际发放的是积分这种数据错乱。建议后台在保存前做一次规则预览,明确显示每个连续天数的奖励类型、面值、剩余库存。
5. 免费领背后的防刷、防超发和对账
5.1 用户维度防刷
“免费领”场景下的核心风险不是单用户点很多次,而是批量注册、脚本调用、多端并行和接口重放。开发时至少要覆盖这些维度:
| 风险点 | 风控字段 | 处理动作 |
|---|---|---|
| 新注册小号批量领奖 | 注册时长、手机绑定、设备指纹 | 设置参与门槛,不满足时引导完成验证 |
| 脚本高频调用 | IP 频率、设备次数、行为特征 | 触发验证码或临时限制 |
| 接口重放 | 幂等流水号、数据库唯一索引 | 重复请求返回“已签到” |
| 用户改时间跨天 | 服务端统一业务日期 | 客户端不计算业务日期 |
风控规则不要写死,最好由运营平台或风控系统提供开关。开发环境可以只做最小校验,生产环境再开启更严格的策略。这里要注意,风控不是对用户做惩罚,而是对活动预算和用户体验的保护。
5.2 防超发:库存扣减必须原子化
实物奖品和限量优惠券都有库存上限。库存扣减推荐使用数据库原子更新:
UPDATE signin_reward_rule SET remain_stock = remain_stock - 1, updated_at = NOW() WHERE id = #{ruleId} AND remain_stock > 0;执行后判断受影响行数。如果行数为 1,说明扣减成功;如果行数为 0,说明库存已经不足。不要先查再改,也不要先在代码里把库存减到负数再回滚。
如果库存放在 Redis 里,Redis 的计数只能用于快速拦截,不能直接当作最终库存依据。因为 Redis 数据一旦丢失,扣减记录无法恢复,最终仍要落库到数据库,以数据库、订单流水或对账结果为准。
5.3 对账任务和监控
上线签到功能后,运维日志只能说明接口没有报错,不能说明“免费领”没有出错。建议每天跑一个对账任务:
- 按活动统计当天签到记录数,与奖励流水记录数对比。
- 检查是否有重复的签到记录或重复的奖励流水。
- 找出状态为“待发放”且创建时间超过 10 分钟的流水,触发重试。
- 对状态为“发放失败”的流水统计,超过阈值就告警。
监控指标要覆盖签到接口的 QPS、错误码分布、库存不足次数、发放失败率和对账差异数。特别是 code=1004,它代表库存已经空了,运营需要及时补货或调整活动规则。
6. 测试用例、上线清单和故障排查
6.1 功能和并发测试用例
常见测试用例可以按这个表格设计:
| 编号 | 用例名称 | 前置条件 | 预期结果 |
|---|---|---|---|
| 1 | 首次签到 | 新用户或昨天未签到 | 连续天数=1,正常返回奖励 |
| 2 | 当天重复签到 | 已签到一次 | 返回 1001,不重复发奖 |
| 3 | 连续签到第 2 天 | 昨天已签到 | 连续天数=2,返回第 2 天奖励 |
| 4 | 中断后签到 | 昨天未签到 | 连续天数=1,周期重置 |
| 5 | 活动未开始 | 当前日期小于开始日期 | 返回 1002 |
| 6 | 活动已结束 | 当前日期大于结束日期 | 返回 1003 |
| 7 | 奖品库存不足 | remain_stock=0 | 返回 1004,不生成奖励流水 |
| 8 | 并发重复提交 | 同一用户同一天同时请求两次 | 仅 1 条签到记录和 1 条奖励流水 |
| 9 | 奖励服务不可用 | 远程发奖超时 | 签到记录成功,奖励流水状态失败,触发重试 |
| 10 | 客户端时间被修改 | 用户把手机时间改到第二天 | 服务端仍按自己业务日期判断 |
其中第 8 个用例是必测项,需要在压测工具里模拟同一用户两个并发请求,验证数据库唯一索引是否生效。
6.2 上线前检查清单
发布前一天建议把下面清单逐项确认完:
- 建表脚本和唯一索引已经执行,不能在应用启动后再手工补索引。
- 活动表、奖励规则表已配置,并且每个连续天数都有对应奖励。
- 奖励系统联调通过,积分、券中心、订单中心有测试环境可回滚手段。
- 库存字段初始值正确,限量和不限量规则已区分。
- 监控面板、错误日志、告警规则已经配置。
- 对账脚本可以在测试环境跑通,并能输出差异报告。
- 准备好紧急下架开关。开关关闭后,前端隐藏签到入口,后端接口直接返回活动暂停。
6.3 高频问题的排查路径
场景一:用户签到后奖励一直不到账。
检查顺序是:先查签到记录有没有记录,再看奖励流水状态,最后看奖励系统的回调状态。常见原因是远程发奖接口超时后没有重试,或者重试机制没有实现。
场景二:同一个用户出现多条同一天签到记录。
运行 SQL 查询:
SELECT user_id, activity_id, biz_date, COUNT(*) FROM user_signin_record GROUP BY user_id, activity_id, biz_date HAVING COUNT(*) > 1;如果有多条记录,说明表缺少唯一索引,或者代码里绕过了唯一索引直接更新。处理方式是先清理重复数据,再上线补索引,最后排查客户端是否把签到逻辑走到了别的入口。
场景三:连续签到天数展示不对。
检查服务端计算逻辑到底用的是“昨天记录”还是“最近一条记录”。还要确认业务日期是否统一由服务端生成。如果服务器时区和数据库时区不一致,也可能出现记录落在了昨天。
场景四:跨天之后按钮状态没有刷新。
需要检查前端是否在进入页面时调用了“签到状态”接口,返回的下次可签到时间是否更新。如果前端只依赖本地时间,用户改一下时间就会看到错误状态。
7. 把签到能力沉淀成可复用的奖励系统
7.1 从签到模块到通用发奖能力
在实际项目里,签到、任务、抽奖经常是同一类玩法:用户完成一个动作,系统返回一个奖励。如果把签到代码和业务逻辑强行耦合,下一次做“邀请有礼”时就要复制一遍。推荐按“活动中心 + 奖励中心”拆分:
活动中心负责记录用户参与行为,比如签到记录、任务完成记录。奖励中心负责根据活动场景和规则匹配奖励,处理库存、发放、对账。签到模块只关心“连续天数怎么算、今天是否已参与”,发什么奖品由规则表决定。这样新增一个任务活动时,不需要重新写发奖逻辑。
7.2 工程红线清单
把前面几章的关键点整理成一份可复用清单:
- 业务日期由服务端计算,不信任客户端时间。
- “每天一次”用数据库唯一索引兜底。
- 奖励流水用签到记录 ID 做唯一键,防止重复发奖。
- 库存扣减用原子 UPDATE,不先查再改。
- 远程发奖不进本地数据库事务,用状态机 + 重试 + 对账。
- 活动配置、奖励规则、库存调整都进管理后台,不留直接改数据库的口子。
- 上线前必须验证并发重复提交、奖励服务不可用和跨天三个场景。
7.3 落地顺序
如果是从零开始,建议先跑通单机版:Spring Boot + MySQL,按第 2 章建表,按第 3 章写一个串行签到接口。先不要急着引入 Redis、消息队列和分布式事务,把“当天只能一次、连续天数正确、奖励流水可对账”跑稳定再扩展。之后再加 Redis 缓存和防抖,加异步发奖,最后接风控、审计和对账平台。
一个看上去只花五分钟的“免费领”功能,往往要在一周后再来面对它产生的脏数据。把这些检查项在开发阶段就落地,可以让活动本身保持真正划算,而不是让技术债和客诉替运营文案买单。