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

- 订单列表使用独立最小 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
+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
}