fix(代理分销注册): 验证码改为落库成功后消费并细化失败原因
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s
公开扫码注册原先在审批准备前就消费短信验证码,落库前的任何失败都会烧掉验证码, 客户重试只能得到统一的“分销码不可用”,掩盖了真实失败原因。 - 验证码校验与消费拆分为 CheckCode 与 ConsumeCode,注册记录与审批实例落库成功后才原子消费; 校验不消费、消费一次性,落库前失败时同一验证码可直接重试 - 分销码无效、所属店铺已停用、上级店铺缺少启用的主账号、验证码错误分别返回各自错误码与提示 - 注册必填字段缺失与请求参数校验失败提示定位到具体字段 - 同步公开接口描述与 agent-distribution-withdrawal 主 Spec 行为契约
This commit is contained in:
@@ -17,10 +17,12 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。
|
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码接缝。
|
||||||
// 校验成功即消费验证码,同一验证码不可二次使用。
|
// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
|
||||||
|
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
|
||||||
type VerificationCodeVerifier interface {
|
type VerificationCodeVerifier interface {
|
||||||
VerifyCode(ctx context.Context, phone string, code string) error
|
CheckCode(ctx context.Context, phone string, code string) error
|
||||||
|
ConsumeCode(ctx context.Context, phone string, code string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。
|
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package distributionwithdrawal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
stderrors "errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ func NewRegistrationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register 创建待审批注册记录。
|
// Register 创建待审批注册记录。
|
||||||
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。
|
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。
|
||||||
|
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
|
||||||
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
|
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
|
||||||
func (s *RegistrationService) Register(
|
func (s *RegistrationService) Register(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -61,24 +63,19 @@ func (s *RegistrationService) Register(
|
|||||||
}
|
}
|
||||||
code = strings.TrimSpace(code)
|
code = strings.TrimSpace(code)
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
return nil, errors.New(errors.CodeInvalidParam, "短信验证码不能为空")
|
||||||
}
|
}
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
|
||||||
}
|
}
|
||||||
var parent *model.Shop
|
parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
|
||||||
if err := s.db.WithContext(ctx).
|
if err != nil {
|
||||||
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled).
|
return nil, err
|
||||||
First(&parent).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
|
// 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。
|
||||||
}
|
if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil {
|
||||||
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。
|
return nil, verificationFailure(err)
|
||||||
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
|
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
|
||||||
}
|
}
|
||||||
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
|
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,9 +129,39 @@ func (s *RegistrationService) Register(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
|
||||||
|
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
|
||||||
|
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
|
||||||
|
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
|
||||||
|
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
|
||||||
|
var parent model.Shop
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("distribution_code = ?", distributionCode).
|
||||||
|
First(&parent).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
|
||||||
|
}
|
||||||
|
if parent.Status != constants.ShopStatusEnabled {
|
||||||
|
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
|
||||||
|
}
|
||||||
|
return &parent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
|
||||||
|
func verificationFailure(err error) error {
|
||||||
|
var appErr *errors.AppError
|
||||||
|
if stderrors.As(err, &appErr) {
|
||||||
|
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
|
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
|
||||||
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
|
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
|
||||||
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
|
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
|
||||||
@@ -144,7 +171,7 @@ func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID
|
|||||||
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
|
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
|
||||||
First(&account).Error; err != nil {
|
First(&account).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,20 @@ func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, erro
|
|||||||
input.City = strings.TrimSpace(input.City)
|
input.City = strings.TrimSpace(input.City)
|
||||||
input.District = strings.TrimSpace(input.District)
|
input.District = strings.TrimSpace(input.District)
|
||||||
input.Address = strings.TrimSpace(input.Address)
|
input.Address = strings.TrimSpace(input.Address)
|
||||||
if input.DistributionCode == "" || input.Phone == "" || input.Username == "" ||
|
if input.DistributionCode == "" {
|
||||||
input.ShopName == "" || input.ShopCode == "" {
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
|
||||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用")
|
}
|
||||||
|
if input.Phone == "" {
|
||||||
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
|
||||||
|
}
|
||||||
|
if input.Username == "" {
|
||||||
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
|
||||||
|
}
|
||||||
|
if input.ShopName == "" {
|
||||||
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
|
||||||
|
}
|
||||||
|
if input.ShopCode == "" {
|
||||||
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺编号不能为空")
|
||||||
}
|
}
|
||||||
if len(input.Phone) != 11 {
|
if len(input.Phone) != 11 {
|
||||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")
|
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
|
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
|
||||||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
@@ -29,7 +30,8 @@ func NewAgentDistributionHandler(
|
|||||||
// RegisterAgentDistribution 提交代理扫码注册
|
// RegisterAgentDistribution 提交代理扫码注册
|
||||||
// POST /api/c/v1/agent-distribution-registrations
|
// POST /api/c/v1/agent-distribution-registrations
|
||||||
// 无需认证、JWT、角色或权限;只创建待审批注册记录,不返回任何账号凭证。
|
// 无需认证、JWT、角色或权限;只创建待审批注册记录,不返回任何账号凭证。
|
||||||
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库。
|
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自提示且不落库;
|
||||||
|
// 短信验证码在注册记录落库成功后消费,落库前的失败不消耗验证码。
|
||||||
func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error {
|
func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error {
|
||||||
if h.service == nil {
|
if h.service == nil {
|
||||||
return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
|
return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
|
||||||
@@ -42,7 +44,7 @@ func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error
|
|||||||
return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置")
|
return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置")
|
||||||
}
|
}
|
||||||
if err := h.validator.Struct(&req); err != nil {
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "注册参数不合法")
|
return errors.New(errors.CodeInvalidParam, validation.Message("注册参数不合法", &req, err))
|
||||||
}
|
}
|
||||||
result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{
|
result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{
|
||||||
DistributionCode: req.DistributionCode,
|
DistributionCode: req.DistributionCode,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func registerAgentDistributionPublicRoutes(router fiber.Router, handler *app.Age
|
|||||||
}
|
}
|
||||||
Register(router, doc, basePath, "POST", "/agent-distribution-registrations", handler.RegisterAgentDistribution, RouteSpec{
|
Register(router, doc, basePath, "POST", "/agent-distribution-registrations", handler.RegisterAgentDistribution, RouteSpec{
|
||||||
Summary: "代理扫码注册",
|
Summary: "代理扫码注册",
|
||||||
Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;无效分销码、分销码所属店铺已停用、验证码无效或已被消费统一返回“分销码不可用”,且不创建注册记录、店铺或账号。通过后仍需企业微信终审才会创建店铺与代理账号。",
|
Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费分别返回各自提示,且不创建注册记录、店铺或账号。短信验证码只在注册记录落库成功后消费,落库前的失败不消耗验证码,可用同一验证码直接重试。通过后仍需企业微信终审才会创建店铺与代理账号。",
|
||||||
Tags: []string{"个人客户 - 代理分销注册"},
|
Tags: []string{"个人客户 - 代理分销注册"},
|
||||||
Auth: false,
|
Auth: false,
|
||||||
Input: new(dto.CreateAgentDistributionRegistrationReq),
|
Input: new(dto.CreateAgentDistributionRegistrationReq),
|
||||||
|
|||||||
@@ -118,12 +118,20 @@ func (s *Service) SendCode(ctx context.Context, phone string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyCode 验证验证码
|
// verificationCodeConsumeScript 原子消费验证码:仅当键值仍等于已校验通过的验证码时删除。
|
||||||
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
|
// 并发的两次提交只有一次能消费成功,验证码不会被重复使用。
|
||||||
codeKey := constants.RedisVerificationCodeKey(phone)
|
var verificationCodeConsumeScript = redis.NewScript(`
|
||||||
|
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('DEL', KEYS[1])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
// CheckCode 校验验证码但不消费。
|
||||||
|
// 供消费必须晚于业务事实落库的链路复用;不消费时验证码仍受原有 5 分钟有效期约束。
|
||||||
|
func (s *Service) CheckCode(ctx context.Context, phone string, code string) error {
|
||||||
// 从 Redis 获取验证码
|
// 从 Redis 获取验证码
|
||||||
storedCode, err := s.redisClient.Get(ctx, codeKey).Result()
|
storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result()
|
||||||
if err == redis.Nil {
|
if err == redis.Nil {
|
||||||
s.logger.Warn("验证码不存在或已过期",
|
s.logger.Warn("验证码不存在或已过期",
|
||||||
zap.String("phone", phone),
|
zap.String("phone", phone),
|
||||||
@@ -146,15 +154,39 @@ func (s *Service) VerifyCode(ctx context.Context, phone string, code string) err
|
|||||||
return errors.New(errors.CodeInvalidParam, "验证码错误")
|
return errors.New(errors.CodeInvalidParam, "验证码错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证成功,删除验证码(防止重复使用)
|
return nil
|
||||||
err = s.redisClient.Del(ctx, codeKey).Err()
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
s.logger.Error("删除验证码失败",
|
s.logger.Error("消费验证码失败",
|
||||||
zap.String("phone", phone),
|
zap.String("phone", phone),
|
||||||
zap.Error(err),
|
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("验证码验证成功",
|
s.logger.Info("验证码验证成功",
|
||||||
zap.String("phone", phone),
|
zap.String("phone", phone),
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。
|
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。
|
||||||
|
|
||||||
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`,不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验、消费与限流规则。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
|
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`,不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验与限流规则,并 MUST 保证同一验证码至多被消费一次。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
|
||||||
|
|
||||||
无效分销码、分销码所属店铺已停用、验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 对外返回统一不可用结果。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。
|
分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 按失败原因返回各自可定位的错误码与提示,MUST NOT 统一为同一不可用结果。短信验证码 MUST 在注册记录与审批实例落库成功后才消费;落库前的任何失败 MUST NOT 消费验证码,客户 MUST 能用同一验证码直接重试。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。
|
||||||
|
|
||||||
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
|
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
|
||||||
|
|
||||||
@@ -30,6 +30,11 @@
|
|||||||
- **WHEN** 请求携带的短信验证码无效、过期或已被消费
|
- **WHEN** 请求携带的短信验证码无效、过期或已被消费
|
||||||
- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额
|
- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额
|
||||||
|
|
||||||
|
#### Scenario: 落库前失败不消耗验证码
|
||||||
|
|
||||||
|
- **WHEN** 短信验证码校验通过后,注册记录或审批实例创建失败
|
||||||
|
- **THEN** 系统保留该验证码,客户可用同一验证码重试,重试得到的仍是同一失败原因而非验证码失效
|
||||||
|
|
||||||
#### Scenario: 审批通过建立层级与业务员快照
|
#### Scenario: 审批通过建立层级与业务员快照
|
||||||
|
|
||||||
- **WHEN** 企业微信最终通过一条待审批注册记录
|
- **WHEN** 企业微信最终通过一条待审批注册记录
|
||||||
|
|||||||
Reference in New Issue
Block a user