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

- 订单列表使用独立最小 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
@@ -216,6 +216,10 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
beforeRisk := user.RiskStatus
user.Status = status
user.RiskStatus = riskStatus
if user.TokenVersion < 1 {
user.TokenVersion = 1
}
user.TokenVersion++
if err := tx.Save(&user).Error; err != nil {
return err
}
+29 -1
View File
@@ -111,6 +111,7 @@ func (h *Handler) Login(c *gin.Context) {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -131,11 +132,12 @@ func (h *Handler) Refresh(c *gin.Context) {
response.BadRequest(c, "refresh_token 不能为空")
return
}
tokens, err := h.service.RefreshToken(req.RefreshToken)
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
response.Unauthorized(c, "刷新令牌无效或已过期")
return
}
setAccessCookie(c, tokens.AccessToken)
response.OK(c, tokens)
}
@@ -149,6 +151,16 @@ func (h *Handler) Refresh(c *gin.Context) {
// @Security BearerAuth
// @Router /auth/logout [post]
func (h *Handler) Logout(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
if err := h.service.Logout(c.Request.Context(), userID); err != nil {
writeAuthError(c, err)
return
}
clearAccessCookie(c)
response.OK(c, gin.H{"logged_out": true})
}
@@ -163,6 +175,7 @@ func (h *Handler) PasswordLogin(c *gin.Context) {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -177,6 +190,7 @@ func (h *Handler) Register(c *gin.Context) {
writeAuthError(c, err)
return
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
}
@@ -253,3 +267,17 @@ func currentUserID(c *gin.Context) (uint64, bool) {
userID, ok := val.(uint64)
return userID, ok
}
func setAccessCookie(c *gin.Context, token string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true)
}
func clearAccessCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true)
}
func requestUsesHTTPS(c *gin.Context) bool {
return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
+27 -1
View File
@@ -27,6 +27,21 @@ func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User,
return &user, nil
}
// FindActiveForToken 校验用户仍可用且令牌版本未被撤销。
func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.User, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
user, err := r.FindByID(ctx, id)
if err != nil {
return nil, err
}
if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion {
return nil, ErrUserDisabled
}
return user, nil
}
func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) {
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
"nickname": nickname,
@@ -46,6 +61,7 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
TokenVersion: 1,
LastLoginAt: &now,
}
@@ -73,7 +89,16 @@ func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.
}
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"password_hash": hash,
"token_version": gorm.Expr("token_version + 1"),
}).Error
}
// RevokeTokens 使当前用户的所有 access/refresh token 立即失效。
func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
@@ -86,6 +111,7 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string,
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
TokenVersion: 1,
LastLoginAt: &now,
}
+23 -5
View File
@@ -180,7 +180,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
return LoginResult{}, ErrUserDisabled
}
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
tokens, err := s.issueTokenPair(user)
if err != nil {
return LoginResult{}, err
}
@@ -189,12 +189,30 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
return LoginResult{User: user, Tokens: tokens}, nil
}
func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) {
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) {
if s.users == nil {
return TokenPair{}, ErrDependencyUnavailable
}
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
if err != nil {
return TokenPair{}, err
}
return s.jwt.GeneratePair(claims.UserID, claims.Phone)
user, err := s.users.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
if err != nil {
return TokenPair{}, err
}
return s.issueTokenPair(user)
}
func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) {
return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion)
}
func (s *Service) Logout(ctx context.Context, userID uint64) error {
if s.users == nil {
return ErrDependencyUnavailable
}
return s.users.RevokeTokens(ctx, userID)
}
func codeKey(phone string) string {
@@ -264,7 +282,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
tokens, err := s.issueTokenPair(user)
if err != nil {
return LoginResult{}, err
}
@@ -369,7 +387,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
return LoginResult{}, ErrUserDisabled
}
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
tokens, err := s.issueTokenPair(user)
if err != nil {
return LoginResult{}, err
}
+9
View File
@@ -8,4 +8,13 @@ type UploadDTO struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
objectKeys []string
}
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
func (d *UploadDTO) ObjectKeys() []string {
if d == nil {
return nil
}
return append([]string(nil), d.objectKeys...)
}
+56 -5
View File
@@ -1,22 +1,38 @@
package file
import (
"context"
"errors"
"net/http"
"strings"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type Handler struct {
service *Service
storage *Storage
service *Service
storage *Storage
objectAuthorizer ObjectAuthorizer
uploadOwnerRecorder UploadOwnerRecorder
}
func NewHandler(service *Service, storage *Storage) *Handler {
return &Handler{service: service, storage: storage}
// ObjectAuthorizer 校验用户是否可读取指定私有对象。
type ObjectAuthorizer func(ctx context.Context, userID uint64, key string) (bool, error)
// UploadOwnerRecorder 保存用户上传私有文件的归属。
type UploadOwnerRecorder func(ctx context.Context, userID uint64, objectKeys []string) error
func NewHandler(service *Service, storage *Storage, objectAuthorizer ObjectAuthorizer, uploadOwnerRecorder UploadOwnerRecorder) *Handler {
return &Handler{
service: service,
storage: storage,
objectAuthorizer: objectAuthorizer,
uploadOwnerRecorder: uploadOwnerRecorder,
}
}
func (h *Handler) Upload(c *gin.Context) {
@@ -44,6 +60,19 @@ func (h *Handler) Upload(c *gin.Context) {
writeFileError(c, err)
return
}
if userID, ok := c.Get("user_id"); ok && h.uploadOwnerRecorder != nil {
id, valid := userID.(uint64)
if !valid || h.uploadOwnerRecorder(c.Request.Context(), id, item.ObjectKeys()) != nil {
// 归属记录失败时补偿删除刚写入的对象,避免用户重试产生孤儿文件。
if h.storage != nil {
if removeErr := h.storage.RemoveObjects(c.Request.Context(), item.ObjectKeys()); removeErr != nil {
logging.FromContext(c.Request.Context()).Warn("补偿删除上传对象失败", zap.Error(removeErr))
}
}
response.ServiceUnavailable(c, "文件归属记录失败")
return
}
}
response.Created(c, item)
}
@@ -68,7 +97,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
if publicOnly &&
!strings.HasPrefix(key, "home-banner/") &&
!strings.HasPrefix(key, "avatar/") &&
!strings.HasPrefix(key, "payment-cert/") &&
!strings.HasPrefix(key, "announcement/") &&
!strings.HasPrefix(key, "mohong/") &&
!strings.HasPrefix(key, "crash/") &&
@@ -77,6 +105,29 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return
}
if !publicOnly {
if _, isAdmin := c.Get("admin_id"); !isAdmin {
userID, ok := c.Get("user_id")
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := userID.(uint64)
if !ok || h.objectAuthorizer == nil {
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
return
}
allowed, err := h.objectAuthorizer(c.Request.Context(), id, key)
if err != nil {
response.ServiceUnavailable(c, "文件权限校验失败")
return
}
if !allowed {
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件")
return
}
}
}
object, err := h.storage.Get(c.Request.Context(), key)
if err != nil {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
+4 -1
View File
@@ -61,6 +61,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
}
var thumbnailURL string
var mediumURL string
objectKeys := []string{key}
for _, variant := range generateImageVariants(key, data, contentType) {
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
"source-object": key,
@@ -68,6 +69,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
if err != nil {
continue
}
objectKeys = append(objectKeys, variant.Key)
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
thumbnailURL = fileURLForScene(scene, variant.Key)
}
@@ -84,6 +86,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
Filename: req.Header.Filename,
ContentType: contentType,
Size: int64(len(data)),
objectKeys: objectKeys,
}, nil
}
@@ -106,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
}
return fileURL
+30
View File
@@ -157,6 +157,36 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
}, nil
}
// RemoveObjects 删除主存储与镜像中的对象,返回第一个错误;对象不存在视为成功。
func (s *Storage) RemoveObjects(ctx context.Context, keys []string) error {
var firstErr error
for _, key := range keys {
if key == "" {
continue
}
if err := s.removeObject(ctx, key); err != nil && firstErr == nil {
firstErr = err
}
if s.mirror != nil {
if err := s.mirror.removeObject(ctx, key); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
func (s *Storage) removeObject(ctx context.Context, key string) error {
if err := s.ensureBucketReady(ctx); err != nil {
return err
}
err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{})
if minio.ToErrorResponse(err).Code == "NoSuchKey" {
return nil
}
return err
}
func (s *Storage) ensureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
+32 -5
View File
@@ -43,6 +43,33 @@ type ListingDTO struct {
UpdatedAt time.Time `json:"updated_at"`
}
// PublicListingListItemDTO 是首页商品卡片的最小公开数据。
// 号主身份、账号内部 ID、审核和结算字段仅限号主或后台接口返回。
type PublicListingListItemDTO struct {
ID uint64 `json:"id"`
ListingNo string `json:"listing_no"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
AssetSummary map[string]any `json:"asset_summary,omitempty"`
CoverURL string `json:"cover_url"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
IsAccelerated bool `json:"is_accelerated_sale"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PublicListingDetailDTO 是公开商品详情数据,不包含号主身份或运营内部状态。
type PublicListingDetailDTO struct {
PublicListingListItemDTO
Description string `json:"description"`
ScreenshotURLS []string `json:"screenshot_urls"`
}
type CreateRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
@@ -159,11 +186,11 @@ type NumberRange struct {
}
type PublicListResult struct {
Items []ListingDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
ZoneCounts map[string]int64 `json:"zone_counts"`
Items []PublicListingListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
ZoneCounts map[string]int64 `json:"zone_counts"`
}
type AdminActionRequest struct {
@@ -71,9 +71,100 @@ func applyPublicListingURLs(item *ListingDTO) {
if item.AssetSummary != nil {
delete(item.AssetSummary, "import_meta")
delete(item.AssetSummary, "price_breakdown")
// 截图分组可能保留原始对象 URL,公开详情统一使用受控图片接口。
delete(item.AssetSummary, "screenshot_groups")
}
}
func publicListingListItems(items []ListingDTO) []PublicListingListItemDTO {
result := make([]PublicListingListItemDTO, 0, len(items))
for _, item := range items {
result = append(result, item.toPublicListItem())
}
return result
}
func (item ListingDTO) toPublicListItem() PublicListingListItemDTO {
return PublicListingListItemDTO{
ID: item.ID,
ListingNo: item.ListingNo,
Title: item.Title,
GameName: item.GameName,
ServerRegion: item.ServerRegion,
LoginPlatform: item.LoginPlatform,
RankLevel: item.RankLevel,
HafCoinAmount: item.HafCoinAmount,
AssetSummary: publicListAssetSummary(item.AssetSummary),
CoverURL: item.CoverURL,
PriceCent: item.PriceCent,
DepositAmountCent: item.DepositAmountCent,
IsAccelerated: item.IsAccelerated,
PublishedAt: item.PublishedAt,
CreatedAt: item.CreatedAt,
}
}
func (item ListingDTO) toPublicDetail() PublicListingDetailDTO {
listItem := item.toPublicListItem()
listItem.AssetSummary = publicDetailAssetSummary(item.AssetSummary)
return PublicListingDetailDTO{
PublicListingListItemDTO: listItem,
Description: item.Description,
ScreenshotURLS: item.ScreenshotURLS,
}
}
// publicListAssetSummary 仅保留首页卡片和筛选展示所需资产字段。
func publicListAssetSummary(summary map[string]any) map[string]any {
return selectPublicAssetSummary(summary, []string{
"season_insurance",
"stamina_level",
"load_level",
"resources",
"skin_groups",
"total_asset_wan",
"online_time",
"can_change_game_name",
"all_hero",
"daily_loss_m",
"publish_ratio",
})
}
// publicDetailAssetSummary 仅保留公开详情明确展示的资产字段。
func publicDetailAssetSummary(summary map[string]any) map[string]any {
return selectPublicAssetSummary(summary, []string{
"season_insurance",
"stamina_level",
"load_level",
"resources",
"skin_groups",
"total_asset_wan",
"online_time",
"can_change_game_name",
"all_hero",
"daily_loss_m",
"publish_ratio",
"secret_kd",
"fire_level",
"common_regions",
"ban_record",
})
}
func selectPublicAssetSummary(summary map[string]any, keys []string) map[string]any {
if len(summary) == 0 {
return nil
}
result := make(map[string]any, len(keys))
for _, key := range keys {
if value, ok := summary[key]; ok {
result[key] = value
}
}
return result
}
func applySellerListingPrice(item *ListingDTO) {
if item == nil {
return
@@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
items = items[start:end]
}
return &PublicListResult{
Items: items,
Items: publicListingListItems(items),
Total: total,
Page: page,
PageSize: pageSize,
@@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
return nil, err
}
return &PublicListResult{
Items: publicListings(rowsToDTO(rows)),
Items: publicListingListItems(publicListings(rowsToDTO(rows))),
Total: total,
Page: page,
PageSize: pageSize,
@@ -219,13 +219,14 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
return copied
}
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
if err != nil {
return nil, err
}
applyPublicListingURLs(dto)
return dto, nil
item := dto.toPublicDetail()
return &item, nil
}
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
@@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e
return s.repo.ListMine(ctx, ownerID)
}
func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) {
func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -329,8 +330,9 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
"https://example.com/account.png",
},
AssetSummary: map[string]any{
"import_meta": map[string]any{"uploader_name": "客服1"},
"price_breakdown": map[string]any{"buyer_total_price": 100},
"import_meta": map[string]any{"uploader_name": "客服1"},
"price_breakdown": map[string]any{"buyer_total_price": 100},
"screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}},
},
}
@@ -345,6 +347,78 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
if _, ok := item.AssetSummary["price_breakdown"]; ok {
t.Fatal("expected price_breakdown hidden from public listing")
}
if _, ok := item.AssetSummary["screenshot_groups"]; ok {
t.Fatal("expected screenshot_groups hidden from public listing")
}
}
func TestPublicListingDTOsDoNotSerializeSensitiveFields(t *testing.T) {
item := ListingDTO{
ID: 1,
ListingNo: "SP000001",
AccountID: 2,
OwnerID: 3,
OwnerPhone: "13800001234",
OwnerNickname: "号主",
SourceChannel: "外部渠道",
IsExternalUpload: true,
Title: "测试账号",
Description: "公开描述",
ScreenshotURLS: []string{"/api/listings/1/screenshots/0"},
Status: "published",
ReviewStatus: "approved",
HandoffMode: "platform",
SettlementMode: "platform_managed",
ManagedAdminID: uint64Pointer(4),
ReviewReason: "内部审核备注",
ListingGroupConversationID: 5,
AssetSummary: map[string]any{
"season_insurance": "3*3",
"contact_phone": "13900005678",
"remark": "首页不应携带",
},
}
listRaw, err := json.Marshal(PublicListResult{Items: publicListingListItems([]ListingDTO{item})})
if err != nil {
t.Fatalf("marshal public list error = %v", err)
}
detailRaw, err := json.Marshal(item.toPublicDetail())
if err != nil {
t.Fatalf("marshal public detail error = %v", err)
}
for _, field := range []string{
"account_id",
"owner_id",
"owner_phone",
"owner_nickname",
"source_channel",
"is_external_upload",
"status",
"review_status",
"handoff_mode",
"settlement_mode",
"managed_admin_id",
"review_reason",
"listing_group_conversation_id",
} {
if strings.Contains(string(listRaw), field) || strings.Contains(string(detailRaw), field) {
t.Fatalf("public response contains sensitive field %q", field)
}
}
if strings.Contains(string(listRaw), "description") || strings.Contains(string(listRaw), "screenshot_urls") {
t.Fatalf("public list contains detail-only fields: %s", listRaw)
}
if strings.Contains(string(listRaw), "contact_phone") || strings.Contains(string(listRaw), "首页不应携带") {
t.Fatalf("public list contains non-display asset fields: %s", listRaw)
}
if strings.Contains(string(detailRaw), "contact_phone") {
t.Fatalf("public detail contains non-public asset field: %s", detailRaw)
}
}
func uint64Pointer(value uint64) *uint64 {
return &value
}
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
+79
View File
@@ -77,6 +77,46 @@ type OrderDTO struct {
UpdatedAt time.Time `json:"updated_at"`
}
// UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
type UserOrderListItemDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
Role string `json:"role"`
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
DisplayAmountCent int64 `json:"display_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminOrderListItemDTO 是后台订单表格的最小展示数据,敏感详情仅由详情接口按权限获取。
type AdminOrderListItemDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
OwnerPhone string `json:"owner_phone,omitempty"`
RenterPhone string `json:"renter_phone,omitempty"`
Title string `json:"title"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RentAmountCent int64 `json:"rent_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
HandoffMode string `json:"handoff_mode"`
SettlementMode string `json:"settlement_mode"`
SettlementStatus string `json:"settlement_status"`
CreatedAt time.Time `json:"created_at"`
}
type AdminActionsDTO struct {
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
@@ -169,6 +209,45 @@ type PaginatedResult struct {
PageSize int `json:"page_size"`
}
// UserPaginatedResult 为用户订单列表提供受限分页,避免一次导出全部历史订单。
type UserPaginatedResult struct {
Items []UserOrderListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// SellerHandoffQuery 是号主待办列表的受限查询条件。
type SellerHandoffQuery struct {
Page int
PageSize int
Status string
}
// SellerHandoffMetrics 为号主待办页提供跨分页的状态统计。
type SellerHandoffMetrics struct {
PendingHandoff int64 `json:"pending_handoff"`
PendingCheckout int64 `json:"pending_checkout"`
Abnormal int64 `json:"abnormal"`
}
// SellerHandoffPaginatedResult 只包含当前用户作为号主时需要处理的订单。
type SellerHandoffPaginatedResult struct {
Items []UserOrderListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Metrics SellerHandoffMetrics `json:"metrics"`
}
// AdminOrderListPaginatedResult 使用列表专用 DTO,避免后台首页携带订单敏感详情。
type AdminOrderListPaginatedResult struct {
Items []AdminOrderListItemDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type RefundStatusDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
@@ -16,6 +16,8 @@ func writeOrderError(c *gin.Context, err error) {
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidRentHours):
response.BadRequest(c, "订单信息不符合规则")
case errors.Is(err, ErrInvalidSellerHandoffStatus):
response.BadRequest(c, "待办状态不正确")
case errors.Is(err, ErrListingUnavailable):
response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租")
case errors.Is(err, ErrCannotRentOwnListing):
+22 -2
View File
@@ -31,12 +31,32 @@ func (h *Handler) List(c *gin.Context) {
response.Unauthorized(c, "缺少用户上下文")
return
}
items, err := h.service.ListForUser(c.Request.Context(), userID)
page, pageSize := parsePagination(c)
items, err := h.service.ListForUser(c.Request.Context(), userID, page, pageSize)
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, gin.H{"items": items})
response.OK(c, items)
}
func (h *Handler) ListSellerHandoffs(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
page, pageSize := parsePagination(c)
items, err := h.service.ListSellerHandoffs(c.Request.Context(), userID, SellerHandoffQuery{
Page: page,
PageSize: pageSize,
Status: c.Query("status"),
})
if err != nil {
writeOrderError(c, err)
return
}
response.OK(c, items)
}
func (h *Handler) Detail(c *gin.Context) {
@@ -2,6 +2,7 @@ package order
import (
"encoding/json"
"time"
"hfb_sys/backend/internal/model"
@@ -150,6 +151,63 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
return dto
}
func (row orderRow) toUserListItem(userID uint64, paymentDeadlineAt *time.Time) UserOrderListItemDTO {
role := "renter"
if userID == row.OwnerID {
role = "owner"
}
displayAmountCent := row.RentAmountCent
if role == "owner" && row.OwnerRentAmountCent > 0 {
displayAmountCent = row.OwnerRentAmountCent
}
return UserOrderListItemDTO{
ID: row.ID,
OrderNo: row.OrderNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
Role: role,
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
DisplayAmountCent: displayAmountCent,
DepositAmountCent: row.DepositAmountCent,
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
PaymentDeadlineAt: paymentDeadlineAt,
CreatedAt: row.CreatedAt,
}
}
func (row orderRow) toAdminListItem() AdminOrderListItemDTO {
return AdminOrderListItemDTO{
ID: row.ID,
OrderNo: row.OrderNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
OwnerPhone: maskPhone(row.OwnerPhone),
RenterPhone: maskPhone(row.RenterPhone),
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
RentAmountCent: row.RentAmountCent,
DepositAmountCent: row.DepositAmountCent,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
HandoffMode: effectiveHandoffMode(row.RentalOrder),
SettlementMode: effectiveSettlementMode(row.RentalOrder),
SettlementStatus: row.SettlementStatus,
CreatedAt: row.CreatedAt,
}
}
func maskPhone(phone string) string {
if len(phone) < 7 {
return ""
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
func effectiveHandoffMode(order model.RentalOrder) string {
if order.HandoffMode != "" {
return order.HandoffMode
@@ -1,6 +1,8 @@
package order
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -39,3 +41,52 @@ func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) {
t.Fatalf("deposit hold released at = %v, want nil", dto.DepositHoldReleasedAt)
}
}
func TestUserOrderListItemDoesNotSerializeDetailFields(t *testing.T) {
row := orderRow{
RentalOrder: model.RentalOrder{
ID: 1,
OrderNo: "ORD-001",
ListingID: 2,
OwnerID: 10,
RenterID: 20,
RentAmountCent: 1000,
DepositAmountCent: 2000,
DepositHoldReason: "风控复核",
DepositFreeManualQuotaCent: 5000,
Status: orderStatusPendingPayment,
},
ListingNo: "SP000002",
Title: "测试账号",
}
raw, err := json.Marshal(row.toUserListItem(20, nil))
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(raw)
for _, field := range []string{"account_snapshot", "deposit_hold_reason", "deposit_free_manual_quota_cent", "owner_id", "renter_id"} {
if strings.Contains(text, field) {
t.Fatalf("list response contains sensitive field %q: %s", field, text)
}
}
}
func TestAdminOrderListItemMasksPhonesAndOmitsSnapshot(t *testing.T) {
row := orderRow{
RentalOrder: model.RentalOrder{ID: 1, OrderNo: "ORD-001", Status: orderStatusPendingPayment},
OwnerPhone: "13800001234",
RenterPhone: "13900005678",
}
raw, err := json.Marshal(row.toAdminListItem())
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(raw)
if strings.Contains(text, "13800001234") || strings.Contains(text, "13900005678") {
t.Fatalf("list response contains full phone number: %s", text)
}
if strings.Contains(text, "account_snapshot") {
t.Fatalf("list response contains account snapshot: %s", text)
}
}
+112 -18
View File
@@ -11,32 +11,116 @@ import (
"gorm.io/gorm"
)
func (r *Repository) ListForUser(ctx context.Context, userID uint64) ([]OrderDTO, error) {
func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*UserPaginatedResult, error) {
page, pageSize = normalizePagination(page, pageSize)
base := r.baseQuery(ctx).Where("o.renter_id = ? OR o.owner_id = ?", userID, userID)
var total int64
if err := r.db.WithContext(ctx).Table("rental_orders AS o").
Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).
Count(&total).Error; err != nil {
return nil, err
}
var rows []orderRow
db := r.db.WithContext(ctx)
err := r.baseQuery(ctx).
Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).
err := base.
Order("o.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&rows).Error
if err != nil {
return nil, err
}
items := make([]OrderDTO, 0, len(rows))
items := make([]UserOrderListItemDTO, 0, len(rows))
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
for _, row := range rows {
dto := row.toDTOForUser(userID)
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
if shouldAttachCheckout(row.Status) {
dto.Checkout = r.latestCheckoutDTOForUser(ctx, row.ID, userID, row.RentalOrder)
var deadline *time.Time
if row.Status == orderStatusPendingPayment && paymentTimeoutMinutes > 0 {
value := row.CreatedAt.Add(time.Duration(paymentTimeoutMinutes) * time.Minute)
deadline = &value
}
items = append(items, dto)
items = append(items, row.toUserListItem(userID, deadline))
}
return items, nil
return &UserPaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*PaginatedResult, error) {
func (r *Repository) ListSellerHandoffs(ctx context.Context, userID uint64, query SellerHandoffQuery) (*SellerHandoffPaginatedResult, error) {
page, pageSize := normalizePagination(query.Page, query.PageSize)
base := r.baseQuery(ctx).
Where("o.owner_id = ?", userID).
Where("o.status IN ?", sellerHandoffStatuses())
if query.Status != "" {
base = base.Where("o.status = ?", query.Status)
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, err
}
var rows []orderRow
if err := base.Order("o.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]UserOrderListItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toUserListItem(userID, nil))
}
metrics, err := r.sellerHandoffMetrics(ctx, userID)
if err != nil {
return nil, err
}
return &SellerHandoffPaginatedResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
Metrics: metrics,
}, nil
}
func (r *Repository) sellerHandoffMetrics(ctx context.Context, userID uint64) (SellerHandoffMetrics, error) {
base := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ?", userID)
var metrics SellerHandoffMetrics
if err := base.Where("o.status = ? AND o.handoff_status = ?", orderStatusPendingHandoff, handoffStatusPendingOwner).Count(&metrics.PendingHandoff).Error; err != nil {
return SellerHandoffMetrics{}, err
}
if err := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.status = ?", userID, orderStatusPendingCheckoutConfirm).Count(&metrics.PendingCheckout).Error; err != nil {
return SellerHandoffMetrics{}, err
}
if err := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.status IN ?", userID, sellerHandoffAbnormalStatuses()).Count(&metrics.Abnormal).Error; err != nil {
return SellerHandoffMetrics{}, err
}
return metrics, nil
}
func sellerHandoffStatuses() []string {
return []string{
orderStatusPendingHandoff,
orderStatusRenting,
orderStatusOverdue,
orderStatusPendingCheckoutConfirm,
orderStatusPendingCheckoutAccept,
orderStatusCheckoutDisputing,
"disputing",
orderStatusAbnormal,
}
}
func sellerHandoffAbnormalStatuses() []string {
return []string{orderStatusOverdue, orderStatusCheckoutDisputing, "disputing", orderStatusAbnormal}
}
func isSellerHandoffStatus(status string) bool {
for _, candidate := range sellerHandoffStatuses() {
if status == candidate {
return true
}
}
return false
}
func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*AdminOrderListPaginatedResult, error) {
var total int64
db := r.db.WithContext(ctx)
countDB := applyAdminOrderFilters(r.adminOrderJoinQuery(ctx), query)
if err := countDB.Count(&total).Error; err != nil {
return nil, err
@@ -61,15 +145,12 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
return nil, err
}
items := make([]OrderDTO, 0, len(rows))
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
items := make([]AdminOrderListItemDTO, 0, len(rows))
for _, row := range rows {
dto := row.toAdminDTO()
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
items = append(items, dto)
items = append(items, row.toAdminListItem())
}
return &PaginatedResult{
return &AdminOrderListPaginatedResult{
Items: items,
Total: total,
Page: page,
@@ -77,6 +158,19 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
}, nil
}
func normalizePagination(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
if query.Status != "" {
db = db.Where("o.status = ?", query.Status)
+14 -3
View File
@@ -32,6 +32,7 @@ var (
ErrDepositNotHeld = errors.New("deposit not held")
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark")
ErrInvalidSellerHandoffStatus = errors.New("invalid seller handoff status")
)
const internalOrderHours = 24
@@ -146,14 +147,24 @@ func (s *Service) AcceptCheckout(ctx context.Context, userID uint64, orderID uin
return s.repo.AcceptCheckout(ctx, userID, orderID)
}
func (s *Service) ListForUser(ctx context.Context, userID uint64) ([]OrderDTO, error) {
func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*UserPaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListForUser(ctx, userID)
return s.repo.ListForUser(ctx, userID, page, pageSize)
}
func (s *Service) ListAdmin(ctx context.Context, query AdminOrderQuery) (*PaginatedResult, error) {
func (s *Service) ListSellerHandoffs(ctx context.Context, userID uint64, query SellerHandoffQuery) (*SellerHandoffPaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if query.Status != "" && !isSellerHandoffStatus(query.Status) {
return nil, ErrInvalidSellerHandoffStatus
}
return s.repo.ListSellerHandoffs(ctx, userID, query)
}
func (s *Service) ListAdmin(ctx context.Context, query AdminOrderQuery) (*AdminOrderListPaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
@@ -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 {