Files
yml2213 85332df2bd 订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计
- 用户 token 增加版本控制,冻结/改密/退出即时撤销会话
- 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie
- 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属
- 公开商品接口返回最小字段,隐藏号主身份与内部状态
- 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
2026-08-16 21:47:46 +08:00

395 lines
11 KiB
Go

package paymentaccount
import (
"context"
"encoding/json"
"errors"
"net/url"
"strings"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/crypto"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Repository struct {
db *gorm.DB
encryptor crypto.Encryptor
}
func NewRepository(db *gorm.DB, encryptor crypto.Encryptor) *Repository {
if encryptor == nil {
encryptor = &crypto.MockEncryptor{}
}
return &Repository{db: db, encryptor: encryptor}
}
func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) {
db := r.db.WithContext(ctx)
var total int64
if err := db.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND status = ?", userID, "active").
Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var accounts []model.UserPaymentAccount
if err := db.Where("user_id = ? AND status = ?", userID, "active").
Order("is_default DESC, created_at DESC").
Offset(offset).Limit(pageSize).
Find(&accounts).Error; err != nil {
return nil, err
}
items := make([]PaymentAccountDTO, 0, len(accounts))
for _, acc := range accounts {
dto, err := r.toDTO(acc)
if err != nil {
continue
}
items = append(items, *dto)
}
return &PaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (r *Repository) FindByID(ctx context.Context, userID, id uint64) (*PaymentAccountDTO, error) {
var account model.UserPaymentAccount
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountNotFound
}
return nil, err
}
return r.toDTO(account)
}
func (r *Repository) Create(ctx context.Context, userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) {
db := r.db.WithContext(ctx)
if err := r.validateCertificateURLs(ctx, userID, req.CertificateURLs, nil); err != nil {
return nil, err
}
// 加密账号
encryptedNo, err := r.encryptor.Encrypt(req.AccountNo)
if err != nil {
return nil, err
}
// 处理凭证URLs
var certURLs datatypes.JSON
if len(req.CertificateURLs) > 0 {
certURLs, _ = json.Marshal(req.CertificateURLs)
}
// 如果是第一个账号,自动设为默认
isDefault := false
var count int64
db.Model(&model.UserPaymentAccount{}).Where("user_id = ? AND status = ?", userID, "active").Count(&count)
if count == 0 {
isDefault = true
}
account := model.UserPaymentAccount{
UserID: userID,
AccountType: req.AccountType,
AccountName: req.AccountName,
AccountNo: encryptedNo,
BankName: req.BankName,
BankBranch: req.BankBranch,
CertificateURLs: certURLs,
IsDefault: isDefault,
Status: "active",
}
if err := db.Create(&account).Error; err != nil {
return nil, err
}
return r.FindByID(ctx, userID, account.ID)
}
func (r *Repository) Update(ctx context.Context, userID, id uint64, req UpdatePaymentAccountRequest) (*PaymentAccountDTO, error) {
db := r.db.WithContext(ctx)
var account model.UserPaymentAccount
if err := db.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountNotFound
}
return nil, err
}
updates := make(map[string]interface{})
if req.BankBranch != "" {
updates["bank_branch"] = req.BankBranch
}
if len(req.CertificateURLs) > 0 {
var existingURLs []string
_ = json.Unmarshal(account.CertificateURLs, &existingURLs)
if err := r.validateCertificateURLs(ctx, userID, req.CertificateURLs, existingURLs); err != nil {
return nil, err
}
certURLs, _ := json.Marshal(req.CertificateURLs)
updates["certificate_urls"] = certURLs
}
if req.IsDefault != nil && *req.IsDefault {
// 先取消其他默认账号
db.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND id != ?", userID, id).
Update("is_default", false)
updates["is_default"] = true
}
if len(updates) > 0 {
if err := db.Model(&account).Updates(updates).Error; err != nil {
return nil, err
}
}
return r.FindByID(ctx, userID, id)
}
// validateCertificateURLs 确保新增收款凭证来自当前用户上传的 payment-cert 对象。
// 已绑定在当前收款账号上的历史凭证允许保留,避免旧数据无法编辑。
func (r *Repository) validateCertificateURLs(ctx context.Context, userID uint64, values []string, existing []string) error {
if len(values) == 0 {
return nil
}
existingKeys := make(map[string]struct{}, len(existing))
for _, value := range existing {
if key, ok := paymentCertificateKey(value); ok {
existingKeys[key] = struct{}{}
}
}
for _, value := range values {
key, ok := paymentCertificateKey(value)
if !ok {
return ErrInvalidCertificateURL
}
if _, exists := existingKeys[key]; exists {
continue
}
var count int64
if err := r.db.WithContext(ctx).Table("file_upload_owners").
Where("user_id = ? AND object_key = ?", userID, key).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return ErrInvalidCertificateURL
}
}
return nil
}
func paymentCertificateKey(value string) (string, bool) {
parsed, err := url.Parse(value)
if err != nil {
return "", false
}
if parsed.Path != "/api/files/object" && parsed.Path != "/api/public/files/object" {
return "", false
}
key := parsed.Query().Get("key")
if !strings.HasPrefix(key, "payment-cert/") {
return "", false
}
return key, true
}
func (r *Repository) Delete(ctx context.Context, userID, id uint64) error {
db := r.db.WithContext(ctx)
var account model.UserPaymentAccount
if err := db.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAccountNotFound
}
return err
}
// 软删除
return db.Model(&account).Update("status", "disabled").Error
}
func (r *Repository) SetDefault(ctx context.Context, userID, id uint64) error {
// 验证账号存在
var account model.UserPaymentAccount
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").
First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAccountNotFound
}
return err
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 取消其他默认账号
if err := tx.Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND id != ?", userID, id).
Update("is_default", false).Error; err != nil {
return err
}
// 设置当前为默认
return tx.Model(&account).Update("is_default", true).Error
})
}
func (r *Repository) CountByUser(ctx context.Context, userID uint64) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.UserPaymentAccount{}).
Where("user_id = ? AND status = ?", userID, "active").
Count(&count).Error
return count, err
}
func (r *Repository) ValidateRealname(ctx context.Context, userID uint64, accountName string) error {
var user model.User
db := r.db.WithContext(ctx)
if err := db.First(&user, userID).Error; err != nil {
return err
}
if user.RealnameStatus != "verified" {
return ErrRealnameRequired
}
// 获取实名信息
var realname model.UserRealname
if err := db.Where("user_id = ? AND status = ?", userID, "verified").
First(&realname).Error; err != nil {
return ErrRealnameRequired
}
// 验证姓名匹配 - 使用加密字段进行精确匹配
if realname.EncryptedName != "" {
// 有加密字段,解密后精确匹配
decryptedName, err := r.encryptor.Decrypt(realname.EncryptedName)
if err != nil {
// 解密失败,降级到前缀匹配
return r.validateByMaskedName(realname.MaskedName, accountName)
}
// 精确匹配(去除空格)
if strings.ReplaceAll(decryptedName, " ", "") != strings.ReplaceAll(accountName, " ", "") {
return ErrAccountNameMismatch
}
} else {
// 没有加密字段(旧数据),使用前缀匹配
return r.validateByMaskedName(realname.MaskedName, accountName)
}
return nil
}
// 使用脱敏姓名进行前缀匹配(兼容旧数据)
func (r *Repository) validateByMaskedName(maskedName, accountName string) error {
maskedName = strings.ReplaceAll(maskedName, " ", "")
inputName := strings.ReplaceAll(accountName, " ", "")
if strings.Contains(maskedName, "*") {
// 提取非星号部分(通常是姓氏)
prefix := strings.Split(maskedName, "*")[0]
if prefix != "" && !strings.HasPrefix(inputName, prefix) {
return ErrAccountNameMismatch
}
} else {
// 完整匹配
if maskedName != inputName {
return ErrAccountNameMismatch
}
}
return nil
}
func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO, error) {
// 解密账号并脱敏
decrypted, err := r.encryptor.Decrypt(account.AccountNo)
if err != nil {
decrypted = account.AccountNo // 降级处理
}
maskedNo := maskAccountNo(decrypted, account.AccountType)
// 解析凭证URLs
var certURLs []string
if account.CertificateURLs != nil {
json.Unmarshal(account.CertificateURLs, &certURLs)
}
certURLs = privateCertificateURLs(certURLs)
return &PaymentAccountDTO{
ID: account.ID,
UserID: account.UserID,
AccountType: account.AccountType,
AccountName: account.AccountName,
AccountNo: maskedNo,
BankName: account.BankName,
BankBranch: account.BankBranch,
CertificateURLs: certURLs,
IsDefault: account.IsDefault,
Status: account.Status,
CreatedAt: account.CreatedAt,
UpdatedAt: account.UpdatedAt,
}, nil
}
// privateCertificateURLs 兼容历史公开收款凭证 URL,统一经私有对象接口读取。
func privateCertificateURLs(values []string) []string {
for index, value := range values {
parsed, err := url.Parse(value)
if err != nil || parsed.Path != "/api/public/files/object" {
continue
}
if !strings.HasPrefix(parsed.Query().Get("key"), "payment-cert/") {
continue
}
parsed.Path = "/api/files/object"
values[index] = parsed.String()
}
return values
}
// 账号脱敏
func maskAccountNo(accountNo, accountType string) string {
length := len(accountNo)
if length <= 4 {
return accountNo
}
switch accountType {
case "alipay", "wechat":
// 手机号或邮箱
if length == 11 {
return accountNo[:3] + "****" + accountNo[7:]
}
return accountNo[:2] + "****" + accountNo[length-2:]
case "bank":
// 银行卡号
if length > 8 {
return accountNo[:4] + "****" + accountNo[length-4:]
}
return accountNo[:2] + "****" + accountNo[length-2:]
}
return accountNo
}
// 获取解密后的账号(仅供内部使用,如提现申请时)
func (r *Repository) GetDecryptedAccountNo(ctx context.Context, userID, id uint64) (string, error) {
var account model.UserPaymentAccount
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&account).Error; err != nil {
return "", err
}
return r.encryptor.Decrypt(account.AccountNo)
}