已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -23,6 +23,7 @@ import (
"golang.org/x/crypto/hkdf"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type walletOwner struct {
@@ -89,7 +90,7 @@ func SetPaymentPassword(client string) gin.HandlerFunc {
Code string `json:"code"`
RequestIdentity string `json:"request_identity"`
}
if ctx.ShouldBindJSON(&request) != nil {
if ctx.ShouldBindJSON(&request) != nil || !paymentPasswordDigits.MatchString(request.NewPassword) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -100,19 +101,38 @@ func SetPaymentPassword(client string) gin.HandlerFunc {
}
valid := wallet.PayPasswordHash == "" && VerifyCode(client, owner.Phone, "set_payment_password", request.RequestIdentity, request.Code)
if wallet.PayPasswordHash != "" {
valid = bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(request.CurrentPassword)) == nil ||
VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code)
// 验证码找回不尝试空旧密码,避免把找回动作计入支付密码输错次数。
if request.CurrentPassword != "" {
valid = VerifyPaymentPassword(owner.Identity, wallet, request.CurrentPassword)
}
if !valid {
valid = VerifyCode(client, owner.Phone, "reset_payment_password", request.RequestIdentity, request.Code)
}
}
if !valid {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if err := impl.DBService.Model(&wallet).Update("pay_password_hash", string(hash)).Error; err != nil {
hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"changed": true})
// 条件更新避免两个已验证请求互相覆盖;散列不得进入SQL日志。
result := impl.DBService.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Model(&wallet).
Where("pay_password_hash = ?", wallet.PayPasswordHash).Update("pay_password_hash", string(hash))
if result.Error != nil {
infra.Response.Error(ctx, result.Error)
return
}
if result.RowsAffected != 1 {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
// 经过旧密码或手机验证码验证后,解除本次主体的既有密码锁;失败不否认已完成的改密。
cleared := impl.RedisService != nil && impl.RedisService.Client != nil && impl.RedisService.Client.Del(impl.RedisService.Ctx,
impl.RedisService.BuildKey("payment-password-lock", owner.Identity), impl.RedisService.BuildKey("payment-password-failures", owner.Identity)).Err() == nil
infra.Response.Success(ctx, gin.H{"changed": true, "lock_cleared": cleared})
}
}
@@ -145,10 +165,19 @@ func CreateRecharge(client string) gin.HandlerFunc {
RechargeNo: RecordNo("RC"), RequestNo: request.RequestNo, Amount: request.Amount,
Channel: request.Channel, OwnerType: owner.Type, OwnerIdentity: owner.Identity,
}
if err := impl.DBService.Create(&order).Error; err != nil {
created := impl.DBService.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "request_no"}}, DoNothing: true}).Create(&order)
if created.Error != nil {
infra.Response.Error(ctx, created.Error)
return
}
if created.RowsAffected == 0 {
var existing models.WalletRechargeOrder
if impl.DBService.Where("request_no = ? AND owner_identity = ?", request.RequestNo, owner.Identity).First(&existing).Error != nil {
infra.Response.Error(ctx, err)
if impl.DBService.Where("request_no = ? AND owner_type = ? AND owner_identity = ?", request.RequestNo, owner.Type, owner.Identity).First(&existing).Error != nil {
infra.Response.Error(ctx, ErrRechargeRequestConflict)
return
}
if existing.Amount != request.Amount || existing.Channel != request.Channel {
infra.Response.Error(ctx, ErrRechargeRequestConflict)
return
}
order = existing
@@ -160,7 +189,11 @@ func CreateRecharge(client string) gin.HandlerFunc {
infra.Response.Error(ctx, payErr)
return
}
infra.Response.Success(ctx, payment.PublicResponse(payOrder))
response := payment.PublicResponse(payOrder)
response["recharge_identity"] = order.Identity
response["recharge_no"] = order.RechargeNo
response["recharge_status"] = order.RechargeStatus
infra.Response.Success(ctx, response)
return
}
infra.Response.Success(ctx, ResourceResponse(order))
@@ -181,7 +214,7 @@ func ConfirmMockRecharge(client string) gin.HandlerFunc {
var response models.WalletRechargeOrder
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("identity = ? AND owner_identity = ?", ctx.Param("identity"), owner.Identity).First(&response).Error; err != nil {
Where("identity = ? AND owner_type = ? AND owner_identity = ?", ctx.Param("identity"), owner.Type, owner.Identity).First(&response).Error; err != nil {
return err
}
if response.RechargeStatus == 23 {
@@ -253,13 +286,14 @@ func ListBanks(client string) gin.HandlerFunc {
return
}
var banks []models.WalletBank
if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).Find(&banks).Error; err != nil {
if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).
Order("is_default desc, created_at desc").Find(&banks).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
list := make([]gin.H, 0, len(banks))
for _, bank := range banks {
list = append(list, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "bank_name": bank.BankName, "card_owner": bank.CardOwner, "bank_type": bank.BankType})
list = append(list, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "bank_name": bank.BankName, "card_owner": bank.CardOwner, "bank_type": bank.BankType, "is_default": bank.IsDefault})
}
infra.Response.Success(ctx, list)
}
@@ -304,17 +338,53 @@ func BindBank(client string) gin.HandlerFunc {
}
idCipher, _, _ := protectField(request.IDCard)
phoneCipher, _, _ := protectField(request.Phone)
var activeCount int64
if err := impl.DBService.Model(&models.WalletBank{}).
Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).Count(&activeCount).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
bank := models.WalletBank{
Entity: NewEntity(StatusEnable), WalletBasicID: wallet.ID, CardNoCiphertext: cardCipher,
CardFingerprint: fingerprint, CardNoLast4: request.CardNo[len(request.CardNo)-4:],
BankName: request.BankName, CardOwner: request.CardOwner, IDCardCiphertext: idCipher,
PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank,
PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank, IsDefault: activeCount == 0,
}
if err := impl.DBService.Create(&bank).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4})
infra.Response.Success(ctx, gin.H{"identity": bank.Identity, "card_no_masked": "**** **** **** " + bank.CardNoLast4, "is_default": bank.IsDefault})
}
}
// SetDefaultBank 将本人已绑定卡设为默认到账卡,其他卡同时清除默认状态。
func SetDefaultBank(client string) gin.HandlerFunc {
return func(ctx *gin.Context) {
owner, ok := currentOwner(ctx, client)
if !ok {
return
}
wallet, err := ensureWallet(impl.DBService, owner)
if err != nil {
infra.Response.Error(ctx, err)
return
}
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var bank models.WalletBank
if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", ctx.Param("identity"), wallet.ID, StatusEnable).First(&bank).Error; err != nil {
return err
}
if err := tx.Model(&models.WalletBank{}).Where("wallet_basic_id = ?", wallet.ID).Update("is_default", false).Error; err != nil {
return err
}
return tx.Model(&bank).Update("is_default", true).Error
})
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"changed": true})
}
}
@@ -339,18 +409,38 @@ func UnbindBank(client string) gin.HandlerFunc {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
var bank models.WalletBank
if impl.DBService.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
var count int64
impl.DBService.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count)
if count > 0 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := impl.DBService.Model(&bank).Update("status", StatusArchived).Error; err != nil {
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var bank models.WalletBank
if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&models.WalletApplyCash{}).Where("wallet_bank_id = ? AND apply_status IN ?", bank.ID, []int{10, 18}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return gorm.ErrInvalidData
}
if err := tx.Model(&bank).Updates(map[string]any{"status": StatusArchived, "is_default": false}).Error; err != nil {
return err
}
if !bank.IsDefault {
return nil
}
var next models.WalletBank
if err := tx.Where("wallet_basic_id = ? AND status = ?", wallet.ID, StatusEnable).Order("created_at desc").First(&next).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return err
}
return tx.Model(&next).Update("is_default", true).Error
})
if err != nil {
if err == gorm.ErrInvalidData {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Error(ctx, err)
return
}
@@ -438,28 +528,6 @@ func walletProof(wallet models.WalletBasic, client, phone, purpose, password, re
VerifyCode(client, phone, purpose, requestIdentity, code)
}
// VerifyPaymentPassword 校验支付密码,并在 Redis 中累计失败次数、短时锁定。
func VerifyPaymentPassword(ownerIdentity string, wallet models.WalletBasic, password string) bool {
lockKey := impl.RedisService.BuildKey("payment-password-lock", ownerIdentity)
var locked bool
if impl.RedisService.Get(lockKey, &locked) == nil && locked {
return false
}
if wallet.PayPasswordHash != "" && bcrypt.CompareHashAndPassword([]byte(wallet.PayPasswordHash), []byte(password)) == nil {
_ = impl.RedisService.Delete(impl.RedisService.BuildKey("payment-password-failures", ownerIdentity))
return true
}
failureKey := impl.RedisService.BuildKey("payment-password-failures", ownerIdentity)
var failures int
_ = impl.RedisService.Get(failureKey, &failures)
failures++
_ = impl.RedisService.Set(failureKey, failures, 15*time.Minute)
if failures >= 5 {
_ = impl.RedisService.Set(lockKey, true, 15*time.Minute)
}
return false
}
func protectField(value string) (string, string, error) {
reader := hkdf.New(sha256.New, []byte(config.Spec.Global.FieldEncryptionKey), nil, []byte("heqi-wallet-field-v1"))
key := make([]byte, 64)