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