Files
junhong_cmp_fiber/internal/service/client_auth/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

1167 lines
43 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 client_auth 提供 C 端认证业务逻辑
// 包含资产验证、微信登录、手机号绑定与退出登录等能力
package client_auth
import (
"context"
stderrors "errors"
"regexp"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
associationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"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/sanitizer"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
assetTypeIotCard = "iot_card"
assetTypeDevice = "device"
appTypeOfficialAccount = "official_account"
appTypeMiniapp = "miniapp"
assetTokenExpireSeconds = 300
)
var identifierRegex = regexp.MustCompile(`^[A-Za-z0-9_-]{1,50}$`)
// Service C 端认证服务
type Service struct {
db *gorm.DB
openidStore *postgres.PersonalCustomerOpenIDStore
customerStore *postgres.PersonalCustomerStore
phoneStore *postgres.PersonalCustomerPhoneStore
associationStore *postgres.PhoneAssetAssociationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
wechatConfigService *wechatConfigSvc.Service
verificationService *verification.Service
jwtManager *auth.JWTManager
redis *redis.Client
logger *zap.Logger
wechatCache kernel.CacheInterface
customerBinding *customerBinding.Service
accessAudit accessauditapp.Writer
associationWriter *associationSvc.AssociationWriter
}
// New 创建 C 端认证服务实例
func New(
db *gorm.DB,
openidStore *postgres.PersonalCustomerOpenIDStore,
customerStore *postgres.PersonalCustomerStore,
phoneStore *postgres.PersonalCustomerPhoneStore,
associationStore *postgres.PhoneAssetAssociationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
wechatConfigService *wechatConfigSvc.Service,
verificationService *verification.Service,
jwtManager *auth.JWTManager,
redisClient *redis.Client,
logger *zap.Logger,
binding *customerBinding.Service,
accessAudit accessauditapp.Writer,
) *Service {
return &Service{
db: db,
openidStore: openidStore,
customerStore: customerStore,
phoneStore: phoneStore,
associationStore: associationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
wechatConfigService: wechatConfigService,
verificationService: verificationService,
jwtManager: jwtManager,
redis: redisClient,
logger: logger,
wechatCache: wechat.NewRedisCache(redisClient),
customerBinding: binding,
accessAudit: accessAudit,
associationWriter: associationSvc.NewAssociationWriter(associationStore, accessAudit),
}
}
type assetTokenClaims struct {
AssetType string `json:"asset_type"`
AssetID uint `json:"asset_id"`
jwt.RegisteredClaims
}
// VerifyAsset A1 验证资产并签发短期资产令牌
func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, clientIP string) (*dto.VerifyAssetResponse, error) {
if req == nil || !identifierRegex.MatchString(req.Identifier) {
return nil, errors.New(errors.CodeInvalidParam)
}
if err := s.checkAssetVerifyRateLimit(ctx, clientIP); err != nil {
return nil, err
}
assetType, assetID, shopID, err := s.resolveAsset(ctx, req.Identifier)
if err != nil {
return nil, err
}
// 如果是卡类型,检查是否绑定了设备
// TODO: resolveAsset 可返回完整卡对象以避免二次查询,后续优化
if assetType == assetTypeIotCard {
if bound, checkErr := s.checkCardBoundToDevice(ctx, assetID); checkErr != nil {
return nil, checkErr
} else if bound {
return nil, errors.New(errors.CodeForbidden, "该卡绑定了设备,请使用设备的虚拟号/设备号/IMEI/SN登录")
}
}
if err := s.ensureShopClientLoginAllowed(ctx, shopID); err != nil {
return nil, err
}
assetToken, err := s.signAssetToken(assetType, assetID)
if err != nil {
s.logger.Error("签发资产令牌失败", zap.Error(err))
return nil, errors.Wrap(errors.CodeInternalError, err, "签发资产令牌失败")
}
resp := &dto.VerifyAssetResponse{
AssetToken: assetToken,
ExpiresIn: assetTokenExpireSeconds,
}
wechatAuthorization, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
resp.OaAppID = wechatAuthorization.OaAppID
resp.MiniappAppID = wechatAuthorization.MiniappAppID
return resp, nil
}
// WechatLogin A2 公众号登录
func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
assetClaims, err := s.verifyAssetToken(req.AssetToken)
if err != nil {
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
if err != nil {
s.logger.Error("创建公众号实例失败", zap.Error(err))
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "微信公众号配置不可用")
}
oaService := wechat.NewOfficialAccountService(oaApp, s.logger)
userInfo, err := oaService.GetUserInfoDetailed(ctx, req.Code)
if err != nil {
return nil, err
}
customerID, isNewUser, err := s.loginByOpenID(
ctx,
assetClaims.AssetType,
assetClaims.AssetID,
wechatConfig.OaAppID,
userInfo.OpenID,
userInfo.UnionID,
userInfo.Nickname,
userInfo.Avatar,
appTypeOfficialAccount,
)
if err != nil {
return nil, err
}
token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID)
if err != nil {
return nil, err
}
s.logger.Info("公众号登录成功",
zap.Uint("customer_id", customerID),
zap.String("client_ip", clientIP),
)
return &dto.WechatLoginResponse{
Token: token,
NeedBindPhone: needBindPhone,
IsNewUser: isNewUser,
}, nil
}
// MiniappLogin A3 小程序登录
func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
assetClaims, err := s.verifyAssetToken(req.AssetToken)
if err != nil {
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
if err != nil {
s.logger.Error("创建小程序服务失败", zap.Error(err))
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "小程序配置不可用")
}
openID, unionID, _, err := miniService.Code2Session(ctx, req.Code)
if err != nil {
return nil, err
}
customerID, isNewUser, err := s.loginByOpenID(
ctx,
assetClaims.AssetType,
assetClaims.AssetID,
wechatConfig.MiniappAppID,
openID,
unionID,
req.Nickname,
req.AvatarURL,
appTypeMiniapp,
)
if err != nil {
return nil, err
}
token, needBindPhone, err := s.issueLoginToken(ctx, customerID, assetClaims.AssetType, assetClaims.AssetID)
if err != nil {
return nil, err
}
s.logger.Info("小程序登录成功",
zap.Uint("customer_id", customerID),
zap.String("client_ip", clientIP),
)
return &dto.WechatLoginResponse{
Token: token,
NeedBindPhone: needBindPhone,
IsNewUser: isNewUser,
}, nil
}
// SendCode A4 发送验证码
func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, clientIP string) (*dto.ClientSendCodeResponse, error) {
if req == nil || req.Phone == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
if err := s.checkSendCodeRateLimit(ctx, req.Phone, clientIP); err != nil {
return nil, err
}
if err := s.verificationService.SendCode(ctx, req.Phone); err != nil {
s.logger.Error("发送验证码失败", zap.String("phone", req.Phone), zap.Error(err))
return nil, errors.Wrap(errors.CodeSmsSendFailed, err, "发送验证码失败")
}
cooldownKey := constants.RedisClientSendCodePhoneLimitKey(req.Phone)
if err := s.redis.Set(ctx, cooldownKey, "1", 60*time.Second).Err(); err != nil {
s.logger.Error("设置验证码冷却键失败", zap.String("phone", req.Phone), zap.Error(err))
return nil, errors.Wrap(errors.CodeRedisError, err, "设置验证码冷却失败")
}
return &dto.ClientSendCodeResponse{CooldownSeconds: 60}, nil
}
// BindPhone A5 绑定手机号
// POST /api/c/v1/auth/bind-phone
// 无主手机号时建立账号手机号;已有主手机号且提交号码与主号一致、验证码有效时,
// 幂等建立当前访问资产与该手机号的关联且不修改账号手机号;提交号码与主号不一致仍拒绝。
// 请求不含当前访问资产身份时只完成账号手机号绑定,不建立关联。
func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType string, assetID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil || s.associationStore == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
primary, primaryErr := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if primaryErr == nil {
return s.bindExistingPrimaryPhone(ctx, customer, primary, assetType, assetID, req)
}
if primaryErr != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, primaryErr, "查询主手机号失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
appErr := verificationCodeFailure(err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
appErr := errors.New(errors.CodeAlreadyBoundPhone)
if existed.CustomerID != customerID {
appErr = errors.New(errors.CodePhoneAlreadyBound)
}
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
} else if err != gorm.ErrRecordNotFound {
appErr := errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, appErr)
return nil, appErr
}
now := time.Now()
record := &model.PersonalCustomerPhone{
CustomerID: customerID,
Phone: req.Phone,
IsPrimary: true,
VerifiedAt: &now,
Status: 1,
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 固定加锁次序先取手机号串行化点advisory再按 id ASC 锁行。
if err := s.lockPhoneScopesInOrder(ctx, tx, req.Phone); err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
var count int64
if err := tx.Model(&model.PersonalCustomerPhone{}).
Where("customer_id = ? AND is_primary = ? AND status = ?", customerID, true, 1).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if count > 0 {
return errors.New(errors.CodeAlreadyBoundPhone)
}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.Phone, 1).First(&existed).Error; err == nil {
if existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
}
return errors.New(errors.CodeAlreadyBoundPhone)
} else if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
}
if err := tx.Create(record).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
}
if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil,
map[string]any{"phone": sanitizer.MaskPhone(record.Phone)}, "手机号已绑定", constants.AuditResultSuccess,
)); err != nil {
return err
}
// 建联与账号手机号同事务:超限或写入失败时账号手机号一并回滚。
if _, err := s.establishAssociation(ctx, tx, customer, record.Phone, assetType, assetID); err != nil {
return err
}
return nil
})
if err != nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err)
return nil, err
}
return &dto.BindPhoneResponse{
Phone: req.Phone,
BoundAt: now.Format("2006-01-02 15:04:05"),
}, nil
}
// bindExistingPrimaryPhone 处理已有主手机号的绑定请求。
// 提交号码等于主号且验证码有效时幂等建立关联且不改账号手机号;号码不一致仍拒绝换号。
func (s *Service) bindExistingPrimaryPhone(
ctx context.Context,
customer *model.PersonalCustomer,
primary *model.PersonalCustomerPhone,
assetType string,
assetID uint,
req *dto.BindPhoneRequest,
) (*dto.BindPhoneResponse, error) {
if primary.Phone != req.Phone {
appErr := errors.New(errors.CodeAlreadyBoundPhone)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
appErr := verificationCodeFailure(err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
now := time.Now()
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 固定加锁次序先取手机号串行化点advisory再按 id ASC 锁行。
if err := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone); err != nil {
return err
}
if err := s.lockPhoneRowsInIDOrder(ctx, tx, []uint{primary.ID}); err != nil {
return err
}
var locked model.PersonalCustomerPhone
if err := tx.WithContext(ctx).
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customer.ID, true, 1).
First(&locked).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAlreadyBoundPhone)
}
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if locked.Phone != req.Phone {
// 并发换绑已把主号改成其他号码:提交号码不再等于账号手机号,按既有语义拒绝。
return errors.New(errors.CodeAlreadyBoundPhone)
}
_, err := s.establishAssociation(ctx, tx, customer, locked.Phone, assetType, assetID)
return err
})
if err != nil {
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, primary, err)
return nil, err
}
return &dto.BindPhoneResponse{
Phone: req.Phone,
BoundAt: now.Format("2006-01-02 15:04:05"),
}, nil
}
// ChangePhone A6 换绑手机号
func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.db == nil || s.accessAudit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
customer, err := s.customerStore.GetByID(ctx, customerID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeOldPhoneMismatch)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, nil, appErr)
return nil, appErr
}
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, nil, appErr)
return nil, appErr
}
if primary.Phone != req.OldPhone {
appErr := errors.New(errors.CodeOldPhoneMismatch)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
appErr := verificationCodeFailure(err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
appErr := verificationCodeFailure(err)
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
return nil, appErr
}
now := time.Now()
var beforeData map[string]any
var failurePhone *model.PersonalCustomerPhone
var migrationChanges []accessauditapp.PhoneAssetAssociationChange
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 统一加锁顺序:先按手机号行 id ASC 锁定旧、新手机号行,再由关联迁移按 id ASC 锁定两侧有效关系行。
// 换绑两行的加锁顺序与 bind-phone 的单行加锁一致,两条路径不会形成 A→B / B→A 死锁环。
if idErr := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone, req.NewPhone); idErr != nil {
return idErr
}
phoneIDs, idErr := s.phoneRowIDsByPhone(ctx, tx, primary.Phone, req.NewPhone)
if idErr != nil {
return idErr
}
if idErr := s.lockPhoneRowsInIDOrder(ctx, tx, phoneIDs); idErr != nil {
return idErr
}
// 手机号行已在本事务内锁定,此处只复核主号归属与号码,不重复加锁。
if err := tx.WithContext(ctx).
Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1).
First(primary).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeOldPhoneMismatch)
}
return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if primary.Phone != req.OldPhone {
return errors.New(errors.CodeOldPhoneMismatch)
}
current := *primary
failurePhone = &current
beforeData = map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)}
var existed model.PersonalCustomerPhone
if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID {
return errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
}
// 关联迁移先于账号手机号改写:超限或与既有关系冲突时整次失败,旧、新关系均保持原状。
changes, migrateErr := s.migrateAssociations(ctx, tx, primary.Phone, req.NewPhone)
if migrateErr != nil {
return migrateErr
}
migrationChanges = changes
if err := tx.Model(primary).Updates(map[string]any{
"phone": req.NewPhone,
"verified_at": now,
"updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
}
primary.Phone = req.NewPhone
primary.VerifiedAt = &now
if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit(
constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData,
map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)}, "手机号已更换", constants.AuditResultSuccess,
)); err != nil {
return err
}
if len(migrationChanges) == 0 {
return nil
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPhoneAssetAssociationMigrated,
Summary: "换绑手机号并迁移资产关联",
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PhoneAssociations: migrationChanges,
})
})
if err != nil {
if failurePhone == nil {
failurePhone = primary
}
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号失败", customer, failurePhone, err)
return nil, err
}
return &dto.ChangePhoneResponse{
Phone: req.NewPhone,
ChangedAt: now.Format("2006-01-02 15:04:05"),
}, nil
}
func personalPhoneAudit(
actionCode, summary string,
customer *model.PersonalCustomer,
phone *model.PersonalCustomerPhone,
beforeData, afterData map[string]any,
subjectSummary, result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, SubjectVisibility: constants.AuditSubjectDetail,
SubjectSummary: subjectSummary, SubjectData: afterData,
}
if phone != nil && phone.ID != 0 {
change.PersonalPhones = []accessauditapp.PersonalCustomerPhoneChange{{
Phone: phone, BeforeData: beforeData, AfterData: afterData,
}}
}
return change
}
func (s *Service) recordPersonalFailure(
ctx context.Context,
actionCode, summary string,
customer *model.PersonalCustomer,
phone *model.PersonalCustomerPhone,
originalErr error,
) {
if customer == nil || customer.ID == 0 {
return
}
subjectSummary := "个人身份资料操作失败"
change := personalPhoneAudit(actionCode, summary, customer, phone, nil, nil, subjectSummary, personalAuditFailureResult(originalErr))
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, change, originalErr)
}
func personalAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeCustomerNotFound,
errors.CodeAlreadyBoundPhone, errors.CodePhoneAlreadyBound, errors.CodeOldPhoneMismatch,
errors.CodeVerificationCodeInvalid:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
// verificationCodeFailure 把验证码校验失败转换为对外错误。
// 限流错误码必须原样保留:失败次数达到上限后的锁定是「稍后再试」而不是「验证码错误」,
// 客户端据此区分重新获取验证码与等待窗口结束,其余失败仍按验证码无效统一提示。
func verificationCodeFailure(err error) error {
var appErr *errors.AppError
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeTooManyRequests {
return appErr
}
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
// Logout A7 退出登录
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
if err := s.redis.Del(ctx, redisKey).Err(); err != nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "退出登录失败")
}
return &dto.LogoutResponse{Success: true}, nil
}
func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string) error {
if clientIP == "" {
return nil
}
key := constants.RedisClientAuthRateLimitIPKey(clientIP)
count, err := s.redis.Incr(ctx, key).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "校验资产限流失败")
}
if count == 1 {
if expErr := s.redis.Expire(ctx, key, 60*time.Second).Err(); expErr != nil {
return errors.Wrap(errors.CodeRedisError, expErr, "设置资产限流过期时间失败")
}
}
if count > 30 {
return errors.New(errors.CodeTooManyRequests)
}
return nil
}
func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, *uint, error) {
var card model.IotCard
if err := s.db.WithContext(ctx).
Where("iccid = ? OR virtual_no = ? OR msisdn = ?", identifier, identifier, identifier).
First(&card).Error; err == nil {
return assetTypeIotCard, card.ID, card.ShopID, nil
} else if err != gorm.ErrRecordNotFound {
return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
var device model.Device
if err := s.db.WithContext(ctx).
Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
First(&device).Error; err == nil {
return assetTypeDevice, device.ID, device.ShopID, nil
} else if err != gorm.ErrRecordNotFound {
return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
return "", 0, nil, errors.New(errors.CodeAssetNotFound)
}
// ensureShopClientLoginAllowed 在签发资产令牌前检查店铺的新登录限制。
func (s *Service) ensureShopClientLoginAllowed(ctx context.Context, shopID *uint) error {
if shopID == nil {
return nil
}
var shop model.Shop
err := s.db.WithContext(ctx).Select("client_login_disabled").First(&shop, *shopID).Error
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, "资产所属店铺不可用")
}
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺C端登录限制失败")
}
if shop.ClientLoginDisabled {
return errors.New(errors.CodeForbidden, "该店铺暂不允许C端登录")
}
return nil
}
func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) {
now := time.Now()
claims := &assetTokenClaims{
AssetType: assetType,
AssetID: assetID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
cfg := config.Get()
if cfg == nil || cfg.JWT.SecretKey == "" {
return "", errors.New(errors.CodeInternalError, "JWT 密钥未配置")
}
return token.SignedString([]byte(cfg.JWT.SecretKey + ":asset"))
}
func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) {
if assetToken == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
cfg := config.Get()
if cfg == nil || cfg.JWT.SecretKey == "" {
return nil, errors.New(errors.CodeInternalError, "JWT 密钥未配置")
}
parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New(errors.CodeInvalidToken)
}
return []byte(cfg.JWT.SecretKey + ":asset"), nil
})
if err != nil {
return nil, errors.New(errors.CodeInvalidToken)
}
claims, ok := parsed.Claims.(*assetTokenClaims)
if !ok || !parsed.Valid || claims.AssetID == 0 || claims.AssetType == "" {
return nil, errors.New(errors.CodeInvalidToken)
}
return claims, nil
}
func (s *Service) loginByOpenID(
ctx context.Context,
assetType string,
assetID uint,
appID string,
openID string,
unionID string,
nickname string,
avatar string,
appType string,
) (uint, bool, error) {
if s.db == nil || s.accessAudit == nil {
return 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
var (
customerID uint
isNewUser bool
identityAudit *accessauditapp.ChangeAudit
)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
cid, created, change, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
customerID = cid
identityAudit = change
isNewUser = created
if findErr != nil {
return findErr
}
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
return bindErr
}
if identityAudit != nil {
return s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit)
}
return nil
})
if err != nil {
if identityAudit != nil && customerID != 0 && !isNewUser {
if identityAudit.ActionCode == constants.AuditActionPersonalCustomerProfileUpdated {
identityAudit.Summary = "同步个人资料失败"
identityAudit.SubjectSummary = "个人资料同步失败"
} else {
identityAudit.Summary = "同步个人微信主体失败"
identityAudit.SubjectSummary = "微信登录身份同步失败"
}
identityAudit.Result = personalAuditFailureResult(err)
identityAudit.SubjectData = nil
identityAudit.PersonalOpenIDs = nil
restorePersonalCustomerSnapshot(identityAudit)
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, *identityAudit, err)
}
return 0, false, err
}
return customerID, isNewUser, nil
}
// findOrCreateCustomer 根据 OpenID/UnionID 查找或创建客户
func (s *Service) findOrCreateCustomer(
ctx context.Context,
tx *gorm.DB,
appID string,
openID string,
unionID string,
nickname string,
avatar string,
appType string,
) (uint, bool, *accessauditapp.ChangeAudit, error) {
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
if existed, err := openidStore.FindByAppIDAndOpenID(ctx, appID, openID); err == nil {
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
}
beforeData := personalCustomerProfileData(customer)
changed := false
if nickname != "" && customer.Nickname != nickname {
customer.Nickname = nickname
changed = true
}
if avatar != "" && customer.AvatarURL != avatar {
customer.AvatarURL = avatar
changed = true
}
var change *accessauditapp.ChangeAudit
if changed {
pending := personalProfileSyncAudit(customer, beforeData)
change = &pending
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return customer.ID, false, change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, change, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
}
if unionID != "" {
if existed, err := openidStore.FindByUnionID(ctx, unionID); err == nil {
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, nil, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, nil, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
change := personalWechatAudit(customer, nil, nil, nil, appID, appType, constants.AuditResultDenied)
return customer.ID, false, &change, errors.New(errors.CodeForbidden, "账号已被禁用")
}
beforeData := personalCustomerProfileData(customer)
record := &model.PersonalCustomerOpenID{
CustomerID: customer.ID,
AppID: appID,
OpenID: openID,
UnionID: unionID,
AppType: appType,
}
change := personalWechatAudit(customer, record, beforeData, nil, appID, appType, constants.AuditResultSuccess)
if createErr := openidStore.Create(ctx, record); createErr != nil {
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
}
if nickname != "" && customer.Nickname != nickname {
customer.Nickname = nickname
}
if avatar != "" && customer.AvatarURL != avatar {
customer.AvatarURL = avatar
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
return customer.ID, false, &change, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
change = personalWechatAudit(customer, record, beforeData, personalCustomerProfileData(customer), appID, appType, constants.AuditResultSuccess)
return customer.ID, false, &change, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
}
}
newCustomer := &model.PersonalCustomer{
WxOpenID: openID,
WxUnionID: unionID,
Nickname: nickname,
AvatarURL: avatar,
Status: 1,
}
if err := customerStore.Create(ctx, newCustomer); err != nil {
return 0, false, nil, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
}
record := &model.PersonalCustomerOpenID{
CustomerID: newCustomer.ID,
AppID: appID,
OpenID: openID,
UnionID: unionID,
AppType: appType,
}
change := personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
if err := openidStore.Create(ctx, record); err != nil {
return newCustomer.ID, true, &change, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
}
change = personalWechatAudit(newCustomer, record, nil, personalCustomerProfileData(newCustomer), appID, appType, constants.AuditResultSuccess)
return newCustomer.ID, true, &change, nil
}
func personalProfileSyncAudit(customer *model.PersonalCustomer, beforeData map[string]any) accessauditapp.ChangeAudit {
return accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerProfileUpdated, Summary: "同步个人资料", Result: constants.AuditResultSuccess,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: personalCustomerProfileData(customer),
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "个人资料已同步",
SubjectData: personalCustomerProfileData(customer),
}
}
func personalWechatAudit(
customer *model.PersonalCustomer,
openID *model.PersonalCustomerOpenID,
beforeData, afterData map[string]any,
appID, appType, result string,
) accessauditapp.ChangeAudit {
change := accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerWechatIdentityUpdated, Summary: "同步个人微信主体", Result: result,
OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname,
Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer,
PersonalCustomer: customer, BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectDetail, SubjectSummary: "微信登录身份已同步",
SubjectData: map[string]any{"app_id": appID, "app_type": appType},
}
if openID != nil && openID.ID != 0 {
change.PersonalOpenIDs = []accessauditapp.PersonalCustomerOpenIDChange{{
OpenID: openID, AfterData: map[string]any{"app_id": openID.AppID, "app_type": openID.AppType},
}}
}
return change
}
func personalCustomerProfileData(customer *model.PersonalCustomer) map[string]any {
return map[string]any{"nickname": customer.Nickname, "avatar_url": customer.AvatarURL}
}
func restorePersonalCustomerSnapshot(change *accessauditapp.ChangeAudit) {
if change.PersonalCustomer == nil || change.BeforeData == nil {
return
}
customer := *change.PersonalCustomer
if nickname, ok := change.BeforeData["nickname"].(string); ok {
customer.Nickname = nickname
}
if avatarURL, ok := change.BeforeData["avatar_url"].(string); ok {
customer.AvatarURL = avatarURL
}
change.PersonalCustomer = &customer
}
// checkCardBoundToDevice 检查卡是否绑定了设备
// 返回 true 表示已绑定设备false 表示未绑定
func (s *Service) checkCardBoundToDevice(ctx context.Context, cardID uint) (bool, error) {
var card model.IotCard
if err := s.db.WithContext(ctx).Select("is_standalone", "device_virtual_no").First(&card, cardID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, errors.New(errors.CodeAssetNotFound)
}
return false, errors.Wrap(errors.CodeInternalError, err, "查询卡绑定状态失败")
}
// is_standalone = false 表示已绑定设备,或者 device_virtual_no 不为空也表示已绑定
return !card.IsStandalone || card.DeviceVirtualNo != "", nil
}
// bindAsset 委托 CustomerBinding 模块处理客户与资产的绑定关系
func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID)
}
// issueLoginToken 签发登录令牌并判定是否需要手机号验证。
// 三支判定:无主手机号 → true有主手机号但未与当前访问资产存在有效关系 → true
// 已存在有效关系 → false。全局开关关闭时恒为 false且不查询、不创建、不删除任何关系。
// 关联查询使用当前访问资产的 asset_type/asset_id不使用 phone claimphone 是登录时快照,
// 换绑后到下次登录前仍是旧号。该字段只作前端提示,不改变任何资源授权。
func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) {
// 查询用户已绑定的主手机号,写入 JWT供后续接口直接从 context 取用
var boundPhone string
needBindPhone := false
cfg := config.Get()
requirePhoneBinding := true
if cfg != nil {
requirePhoneBinding = cfg.Client.RequirePhoneBinding
}
primaryPhone, phoneErr := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if phoneErr == nil {
boundPhone = primaryPhone.Phone
} else if phoneErr != gorm.ErrRecordNotFound {
return "", false, errors.Wrap(errors.CodeInternalError, phoneErr, "查询手机号绑定关系失败")
}
// 开关关闭必须完全短路:既不查询也不写任何关系,否则会出现「关闭开关却写库」的越权写入。
if requirePhoneBinding {
if boundPhone == "" {
needBindPhone = true
} else {
associated, err := s.associationStore.ExistsValid(ctx, boundPhone, assetType, assetID)
if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号资产关联失败")
}
needBindPhone = !associated
}
}
token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, boundPhone, assetType, assetID)
if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败")
}
claims, err := s.jwtManager.VerifyPersonalCustomerToken(token)
if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败")
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
ttl = 24 * time.Hour
}
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil {
return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败")
}
return token, needBindPhone, nil
}
// DevLogin 开发环境测试登录
// 根据资产标识符查找或创建测试客户并直接签发 JWT无需微信 OAuth
// ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用
func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) {
if s.db == nil || s.accessAudit == nil {
return "", 0, false, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置")
}
assetType, assetID, _, err := s.resolveAsset(ctx, identifier)
if err != nil {
return "", 0, false, err
}
var (
customerID uint
isNewUser bool
)
// 在事务中查找或创建测试客户并绑定资产
// 使用固定的开发测试 OpenIDdev_test_+identifier避免重复创建
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
devOpenID := "dev_test_" + identifier
devAppID := "dev_test_app"
cid, created, identityAudit, findErr := s.findOrCreateCustomer(ctx, tx, devAppID, devOpenID, "", "测试用户", "", "dev")
if findErr != nil {
return findErr
}
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
return bindErr
}
if identityAudit != nil {
if auditErr := s.accessAudit.WriteAccessChange(ctx, tx, *identityAudit); auditErr != nil {
return auditErr
}
}
customerID = cid
isNewUser = created
return nil
})
if err != nil {
return "", 0, false, err
}
token, _, err := s.issueLoginToken(ctx, customerID, assetType, assetID)
if err != nil {
return "", 0, false, err
}
s.logger.Info("开发环境测试登录成功",
zap.Uint("customer_id", customerID),
zap.String("identifier", identifier),
)
return token, customerID, isNewUser, nil
}
func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error {
phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone)
exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "检查手机号冷却失败")
}
if exists > 0 {
return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试")
}
ipKey := constants.RedisClientSendCodeIPHourKey(clientIP)
ipCount, err := s.redis.Incr(ctx, ipKey).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "检查 IP 限流失败")
}
if ipCount == 1 {
if expErr := s.redis.Expire(ctx, ipKey, time.Hour).Err(); expErr != nil {
return errors.Wrap(errors.CodeRedisError, expErr, "设置 IP 限流过期时间失败")
}
}
if ipCount > 20 {
return errors.New(errors.CodeTooManyRequests)
}
phoneDayKey := constants.RedisClientSendCodePhoneDayKey(phone)
phoneDayCount, err := s.redis.Incr(ctx, phoneDayKey).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "检查手机号日限流失败")
}
if phoneDayCount == 1 {
nextDay := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour)
ttl := time.Until(nextDay)
if expErr := s.redis.Expire(ctx, phoneDayKey, ttl).Err(); expErr != nil {
return errors.Wrap(errors.CodeRedisError, expErr, "设置手机号日限流过期时间失败")
}
}
if phoneDayCount > 10 {
return errors.New(errors.CodeTooManyRequests)
}
return nil
}