Files
junhong_cmp_fiber/internal/service/verification/service.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

297 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package verification 提供验证码管理的业务逻辑服务
// 包含短信验证码生成、发送、验证等功能
package verification
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/sms"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// Service 验证码服务
type Service struct {
redisClient *redis.Client
smsClient *sms.Client
logger *zap.Logger
}
// NewService 创建验证码服务实例
func NewService(redisClient *redis.Client, smsClient *sms.Client, logger *zap.Logger) *Service {
return &Service{
redisClient: redisClient,
smsClient: smsClient,
logger: logger,
}
}
// SendCode 发送验证码
func (s *Service) SendCode(ctx context.Context, phone string) error {
// 检查短信服务是否可用
if s.smsClient == nil {
s.logger.Error("短信服务未配置", zap.String("phone", phone))
return errors.New(errors.CodeServiceUnavailable)
}
// 检查发送频率限制
limitKey := constants.RedisVerificationCodeLimitKey(phone)
exists, err := s.redisClient.Exists(ctx, limitKey).Result()
if err != nil {
s.logger.Error("检查验证码发送频率限制失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "检查验证码发送频率限制失败")
}
if exists > 0 {
s.logger.Warn("验证码发送过于频繁",
zap.String("phone", phone),
)
return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试")
}
// 生成随机验证码
code, err := s.generateCode()
if err != nil {
s.logger.Error("生成验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "生成验证码失败")
}
// 构造短信内容
cfg := config.Get()
content := fmt.Sprintf("您的验证码是%s%d分钟内有效", code, int(constants.VerificationCodeExpiration.Minutes()))
// 发送短信
_, err = s.smsClient.SendMessage(ctx, content, []string{phone})
if err != nil {
s.logger.Error("发送验证码短信失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "发送验证码短信失败")
}
// 存储验证码到 Redis
codeKey := constants.RedisVerificationCodeKey(phone)
err = s.redisClient.Set(ctx, codeKey, code, constants.VerificationCodeExpiration).Err()
if err != nil {
s.logger.Error("存储验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "存储验证码失败")
}
// 设置发送频率限制
err = s.redisClient.Set(ctx, limitKey, "1", constants.VerificationCodeRateLimit).Err()
if err != nil {
s.logger.Error("设置验证码发送频率限制失败",
zap.String("phone", phone),
zap.Error(err),
)
// 这个错误不影响主流程,只记录日志
}
s.logger.Info("验证码发送成功",
zap.String("phone", phone),
)
// 避免在日志中暴露验证码(仅在开发环境下记录)
if cfg.Logging.Development {
s.logger.Debug("验证码内容(仅开发环境)",
zap.String("phone", phone),
zap.String("code", code),
)
}
return nil
}
// verificationCodeConsumeScript 原子消费验证码:仅当键值仍等于已校验通过的验证码时删除。
// 并发的两次提交只有一次能消费成功,验证码不会被重复使用。
var verificationCodeConsumeScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
// verificationFailureWriteTimeout 是失败计数写入的独立超时:写入与请求生命周期解耦,避免客户端断开绕过计数。
const verificationFailureWriteTimeout = 5 * time.Second
// verificationFailureCountScript 原子累加手机号维度的校验失败计数,并在同一步刷新计数窗口。
// INCR 与 PEXPIRE 必须在同一脚本内完成:拆成 SETNX 再 INCR 会让并发提交在两次调用之间丢失过期时间,
// 计数键永久驻留即等于手机号被永久锁定;同时 INCR 本身原子,并发提交不会丢失一次失败计数。
// 每次失败都刷新窗口(滑动窗口):锁定期间的提交在比对前即被拒绝且不再计数,因此锁定不会续期,
// 锁定上限为一个窗口,到期自动恢复。
var verificationFailureCountScript = redis.NewScript(`
local count = redis.call('INCR', KEYS[1])
redis.call('PEXPIRE', KEYS[1], ARGV[1])
return count
`)
// verificationFailureCount 读取手机号当前窗口内的校验失败计数;键不存在表示窗口内尚未失败。
func (s *Service) verificationFailureCount(ctx context.Context, phone string) (int64, error) {
value, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeFailKey(phone)).Result()
if err == redis.Nil {
return 0, nil
}
if err != nil {
return 0, err
}
count, err := strconv.ParseInt(value, 10, 64)
if err != nil {
// 计数键被外部写入非数字值:按窗口内无计数处理并记录,不作为限流依据。
return 0, fmt.Errorf("校验失败计数取值非法: %w", err)
}
return count, nil
}
// recordVerificationFailure 累加一次校验失败;计数写入失败只记录并放行,不改变本次校验结果。
func (s *Service) recordVerificationFailure(ctx context.Context, phone string) {
// 计数写入不随请求上下文取消而丢失:客户端提前断开不应成为绕过失败次数限制的通道。
countCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), verificationFailureWriteTimeout)
defer cancel()
_, err := verificationFailureCountScript.Run(
countCtx, s.redisClient, []string{constants.RedisVerificationCodeFailKey(phone)},
constants.VerificationCodeFailureWindow.Milliseconds(),
).Int64()
if err != nil {
s.logger.Error("累加验证码校验失败计数失败,按放行处理",
zap.String("phone", phone),
zap.Error(err),
)
}
}
// clearVerificationFailures 在校验成功后清零手机号的失败计数;清零失败只记录,不影响校验成功结果。
func (s *Service) clearVerificationFailures(ctx context.Context, phone string) {
if err := s.redisClient.Del(ctx, constants.RedisVerificationCodeFailKey(phone)).Err(); err != nil {
s.logger.Error("清零验证码校验失败计数失败,按成功放行",
zap.String("phone", phone),
zap.Error(err),
)
}
}
// CheckCode 校验验证码但不消费。
// 供消费必须晚于业务事实落库的链路复用;不消费时验证码仍受原有 5 分钟有效期约束。
// 失败次数限制在验证码比对之前生效:窗口内失败达到上限后,锁定期间的校验一律拒绝(即使验证码正确),
// 锁定随窗口到期自动解除;窗口内校验成功清零计数;校验失败不消费验证码。
// 计数读写故障一律放行并记录MUST NOT 因限流计数故障阻断注册、绑定、换绑、换证与登录的成功路径。
func (s *Service) CheckCode(ctx context.Context, phone string, code string) error {
failureCount, err := s.verificationFailureCount(ctx, phone)
if err != nil {
s.logger.Error("读取验证码校验失败计数失败,按放行处理",
zap.String("phone", phone),
zap.Error(err),
)
}
if failureCount >= constants.VerificationCodeMaxFailures {
s.logger.Warn("验证码校验失败次数达到上限,窗口内拒绝校验",
zap.String("phone", phone),
)
// 提示不含验证码正确性、剩余失败次数与内部键名。
return errors.New(errors.CodeTooManyRequests, constants.VerificationCodeLockedMessage)
}
// 从 Redis 获取验证码
storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result()
if err == redis.Nil {
s.logger.Warn("验证码不存在或已过期",
zap.String("phone", phone),
)
// 已无可用验证码的提交同样是校验失败:计入失败次数,但不产生任何业务事实。
s.recordVerificationFailure(ctx, phone)
return errors.New(errors.CodeInvalidParam, "验证码不存在或已过期")
}
if err != nil {
s.logger.Error("获取验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "获取验证码失败")
}
// 验证码比对
if storedCode != code {
s.logger.Warn("验证码错误",
zap.String("phone", phone),
)
s.recordVerificationFailure(ctx, phone)
return errors.New(errors.CodeInvalidParam, "验证码错误")
}
s.clearVerificationFailures(ctx, phone)
return nil
}
// ConsumeCode 消费已校验通过的验证码;验证码已失效或已被其它请求消费时返回错误并记录日志。
func (s *Service) ConsumeCode(ctx context.Context, phone string, code string) error {
consumed, err := verificationCodeConsumeScript.Run(
ctx, s.redisClient, []string{constants.RedisVerificationCodeKey(phone)}, code,
).Int64()
if err != nil {
s.logger.Error("消费验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "消费验证码失败")
}
if consumed == 0 {
s.logger.Warn("验证码已失效或已被消费",
zap.String("phone", phone),
)
return errors.New(errors.CodeInvalidParam, "验证码已失效或已被消费")
}
return nil
}
// VerifyCode 验证并消费验证码,校验成功即消费。
// 仅适用于失败后必须重新获取验证码的链路;失败可能晚于消费的链路改用 CheckCode 与 ConsumeCode。
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
if err := s.CheckCode(ctx, phone, code); err != nil {
return err
}
// 消费失败不影响主流程ConsumeCode 内部已记录日志,与既有链路保持一致。
_ = s.ConsumeCode(ctx, phone, code)
s.logger.Info("验证码验证成功",
zap.String("phone", phone),
)
return nil
}
// generateCode 生成随机验证码
func (s *Service) generateCode() (string, error) {
// 生成 6 位数字验证码
const digits = "0123456789"
code := make([]byte, constants.VerificationCodeLength)
for i := range code {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
if err != nil {
return "", err
}
code[i] = digits[num.Int64()]
}
return string(code), nil
}