订单接口最小化与私有文件访问加固

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