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