订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计 - 用户 token 增加版本控制,冻结/改密/退出即时撤销会话 - 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie - 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属 - 公开商品接口返回最小字段,隐藏号主身份与内部状态 - 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
This commit is contained in:
@@ -177,6 +177,8 @@ func writeError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "收款账号数量已达上限(最多5个)")
|
||||
case ErrCannotDeleteDefault:
|
||||
response.BadRequest(c, "无法删除默认账号")
|
||||
case ErrInvalidCertificateURL:
|
||||
response.BadRequest(c, "收款凭证必须使用本人上传的图片")
|
||||
default:
|
||||
response.InternalServerError(c, "操作失败")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
@@ -74,6 +75,9 @@ func (r *Repository) FindByID(ctx context.Context, userID, id uint64) (*PaymentA
|
||||
|
||||
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 {
|
||||
@@ -131,6 +135,11 @@ func (r *Repository) Update(ctx context.Context, userID, id uint64, req UpdatePa
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -152,6 +161,54 @@ func (r *Repository) Update(ctx context.Context, userID, id uint64, req UpdatePa
|
||||
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
|
||||
@@ -269,6 +326,7 @@ func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO
|
||||
if account.CertificateURLs != nil {
|
||||
json.Unmarshal(account.CertificateURLs, &certURLs)
|
||||
}
|
||||
certURLs = privateCertificateURLs(certURLs)
|
||||
|
||||
return &PaymentAccountDTO{
|
||||
ID: account.ID,
|
||||
@@ -286,6 +344,22 @@ func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO
|
||||
}, 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)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package paymentaccount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestPrivateCertificateURLsConvertsLegacyPublicURL(t *testing.T) {
|
||||
values := privateCertificateURLs([]string{
|
||||
"/api/public/files/object?key=payment-cert%2F2026%2Fqr.jpg",
|
||||
"/api/public/files/object?key=avatar%2Fuser.jpg",
|
||||
})
|
||||
if values[0] != "/api/files/object?key=payment-cert%2F2026%2Fqr.jpg" {
|
||||
t.Fatalf("payment certificate URL = %q", values[0])
|
||||
}
|
||||
if values[1] != "/api/public/files/object?key=avatar%2Fuser.jpg" {
|
||||
t.Fatalf("non-certificate URL changed = %q", values[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertificateURLsRequiresUploaderOwnership(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil {
|
||||
t.Fatalf("migrate upload owners error = %v", err)
|
||||
}
|
||||
if err := db.Create(&model.FileUploadOwner{UserID: 1, ObjectKey: "payment-cert/owned.jpg"}).Error; err != nil {
|
||||
t.Fatalf("create upload owner error = %v", err)
|
||||
}
|
||||
repo := NewRepository(db, nil)
|
||||
ownedURL := "/api/files/object?key=payment-cert%2Fowned.jpg"
|
||||
if err := repo.validateCertificateURLs(context.Background(), 1, []string{ownedURL}, nil); err != nil {
|
||||
t.Fatalf("owned certificate rejected: %v", err)
|
||||
}
|
||||
if err := repo.validateCertificateURLs(context.Background(), 2, []string{ownedURL}, nil); !errors.Is(err, ErrInvalidCertificateURL) {
|
||||
t.Fatalf("other user's certificate validation error = %v", err)
|
||||
}
|
||||
if err := repo.validateCertificateURLs(context.Background(), 1, []string{"/api/files/object?key=listing%2Fowned.jpg"}, nil); !errors.Is(err, ErrInvalidCertificateURL) {
|
||||
t.Fatalf("non-certificate file validation error = %v", err)
|
||||
}
|
||||
legacyURL := "/api/public/files/object?key=payment-cert%2Flegacy.jpg"
|
||||
if err := repo.validateCertificateURLs(context.Background(), 1, []string{legacyURL}, []string{legacyURL}); err != nil {
|
||||
t.Fatalf("existing legacy certificate rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var (
|
||||
ErrRealnameRequired = errors.New("realname verification required")
|
||||
ErrAccountLimit = errors.New("maximum payment accounts reached")
|
||||
ErrCannotDeleteDefault = errors.New("cannot delete default account")
|
||||
ErrInvalidCertificateURL = errors.New("invalid certificate url")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
||||
Reference in New Issue
Block a user