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

605 lines
20 KiB
Go

package order
import (
"encoding/json"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
)
func adminActionsForOrder(order model.RentalOrder) *AdminActionsDTO {
actions := &AdminActionsDTO{}
target, ok := resetTargetForOrder(order)
if ok {
actions.ResetHandoff = &AdminActionDTO{
Enabled: true,
Label: target.Label,
}
}
if canAdminPlatformHandoff(order) {
actions.PlatformHandoff = &AdminActionDTO{
Enabled: true,
Label: "客服代交接",
}
}
if canAdminForceHandoff(order) {
actions.ForceHandoff = &AdminActionDTO{
Enabled: true,
Label: "客服一键交接",
}
}
if canAdminPlatformCheckoutConfirm(order) {
actions.PlatformCheckoutConfirm = &AdminActionDTO{
Enabled: true,
Label: "客服确认结账",
}
actions.PlatformCheckoutCounter = &AdminActionDTO{
Enabled: true,
Label: "客服修改结账方案",
}
actions.PlatformCheckoutDispute = &AdminActionDTO{
Enabled: true,
Label: "发起结账争议",
}
}
if canAdminMarkOfflineSettlement(order) {
actions.PlatformOfflineSettlement = &AdminActionDTO{
Enabled: true,
Label: "确认线下结算",
}
}
if actions.ResetHandoff == nil && actions.PlatformHandoff == nil && actions.ForceHandoff == nil &&
actions.PlatformCheckoutConfirm == nil && actions.PlatformCheckoutCounter == nil &&
actions.PlatformCheckoutDispute == nil && actions.PlatformOfflineSettlement == nil {
return nil
}
return actions
}
func (row orderRow) toAdminDTO() OrderDTO {
rentedAt := row.RentedAt
durationHours := orderDurationHours(row.RentalOrder)
estimatedEndAt := orderEstimatedEndAt(row.RentalOrder)
rentAmountCent := row.RentAmountCent
ownerRentAmountCent := row.OwnerRentAmountCent
platformFeeCent := row.PlatformFeeCent
return OrderDTO{
ID: row.ID,
OrderNo: row.OrderNo,
ListingID: row.ListingID,
ListingNo: row.ListingNo,
AccountID: row.AccountID,
OwnerID: row.OwnerID,
RenterID: row.RenterID,
OwnerPhone: row.OwnerPhone,
RenterPhone: row.RenterPhone,
Title: row.Title,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
RentedAt: rentedAt,
EstimatedDurationHours: durationHours,
EstimatedEndAt: estimatedEndAt,
PriceRole: "admin",
DisplayAmountCent: row.RentAmountCent,
RentAmountCent: &rentAmountCent,
OwnerRentAmountCent: &ownerRentAmountCent,
DepositAmountCent: row.DepositAmountCent,
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent,
DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent,
DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent,
PlatformFeeCent: &platformFeeCent,
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
RentDiscountAmountCent: row.RentDiscountAmountCent,
PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder),
ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder),
ActualCoinConsumedM: row.ActualCoinConsumedM,
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
ActualExtraItemAmountCent: row.ActualExtraItemAmountCent,
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
GrowthPointsBasisCent: row.GrowthPointsBasisCent,
GrowthPointsPerYuan: row.GrowthPointsPerYuan,
GrowthPointsAwarded: row.GrowthPointsAwarded,
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
AccountSnapshot: row.AccountSnapshot,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
HandoffMode: effectiveHandoffMode(row.RentalOrder),
SettlementMode: effectiveSettlementMode(row.RentalOrder),
ManagedAdminID: row.ManagedAdminID,
SettlementStatus: row.SettlementStatus,
OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder),
OfflineSettlementAmountCent: row.OfflineSettlementAmountCent,
OfflineSettlementRemark: row.OfflineSettlementRemark,
OfflineSettledBy: row.OfflineSettledBy,
OfflineSettledAt: row.OfflineSettledAt,
RefundStatus: row.RefundStatus,
RefundAmountCent: row.RefundAmountCent,
DepositHoldStatus: row.DepositHoldStatus,
DepositHoldAmountCent: row.DepositHoldAmountCent,
DepositHoldReason: row.DepositHoldReason,
DepositHeldAt: row.DepositHeldAt,
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
AdminActions: adminActionsForOrder(row.RentalOrder),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
dto := row.toAdminDTO()
dto.AdminActions = nil
// 押金暂扣属于客服内部处置,不向租客/号主暴露。
dto.DepositHoldStatus = ""
dto.DepositHoldAmountCent = 0
dto.DepositHoldReason = ""
dto.DepositHeldAt = nil
dto.DepositHoldReleasedAt = nil
dto.ManagedAdminID = nil
dto.OfflineSettlementStatus = ""
dto.OfflineSettlementAmountCent = 0
dto.OfflineSettlementRemark = ""
dto.OfflineSettledBy = nil
dto.OfflineSettledAt = nil
applyOrderPriceView(&dto, row.RentalOrder, userID)
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
}
return handoffModeOwner
}
func effectiveSettlementMode(order model.RentalOrder) string {
if order.SettlementMode != "" {
return order.SettlementMode
}
return settlementModeOwnerWallet
}
func effectiveOfflineSettlementStatus(order model.RentalOrder) string {
if order.OfflineSettlementStatus != "" {
return order.OfflineSettlementStatus
}
return offlineSettlementStatusNone
}
func effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 {
if order.DepositOriginalAmountCent > 0 {
return order.DepositOriginalAmountCent
}
return order.DepositAmountCent
}
func effectiveRentOriginalAmountCent(order model.RentalOrder) int64 {
if order.RentOriginalAmountCent > 0 {
return order.RentOriginalAmountCent
}
return order.RentAmountCent + order.RentDiscountAmountCent
}
func effectiveRenterGrowthLevel(order model.RentalOrder) string {
if order.RenterGrowthLevel != "" {
return order.RenterGrowthLevel
}
return "normal"
}
func effectiveRenterGrowthLevelName(order model.RentalOrder) string {
if order.RenterGrowthLevelName != "" {
return order.RenterGrowthLevelName
}
return "普通"
}
func effectiveRenterDiscountBps(order model.RentalOrder) int {
if order.RenterDiscountBps > 0 {
return order.RenterDiscountBps
}
return 10000
}
func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
rentAmountCent := checkout.RentAmountCent
ownerRentAmountCent := checkout.OwnerRentAmountCent
platformFeeCent := checkout.PlatformFeeCent
renterRefundAmountCent := checkout.RenterRefundAmountCent
ownerIncomeAmountCent := checkout.OwnerIncomeAmountCent
roundCount := checkout.RoundCount
if roundCount <= 0 {
roundCount = 1
}
turn := checkout.Turn
if turn != checkoutTurnOwner && turn != checkoutTurnRenter {
if checkout.Status == checkoutStatusCountered {
turn = checkoutTurnRenter
} else {
turn = checkoutTurnOwner
}
}
return CheckoutDTO{
ID: checkout.ID,
OrderID: checkout.OrderID,
InitiatedBy: checkout.InitiatedBy,
Status: checkout.Status,
RoundCount: roundCount,
Turn: turn,
ProposedBy: checkout.ProposedBy,
MaxRounds: checkoutMaxRounds,
PriceRole: "admin",
DisplayAmountCent: rentAmountCent,
RentAmountCent: &rentAmountCent,
OwnerRentAmountCent: &ownerRentAmountCent,
PlatformFeeCent: &platformFeeCent,
DepositAmountCent: checkout.DepositAmountCent,
PureCoinAmountCent: checkout.PureCoinAmountCent,
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
PureCoinPayableCent: checkout.PureCoinPayableCent,
ConsumableAmountCent: checkout.ConsumableAmountCent,
CoinConsumedM: checkout.CoinConsumedM,
OtherAmountCent: checkout.OtherAmountCent,
DepositDeductAmountCent: checkout.DepositDeductAmountCent,
RenterRefundAmountCent: &renterRefundAmountCent,
OwnerIncomeAmountCent: &ownerIncomeAmountCent,
ShortfallCent: checkout.ShortfallCent,
OvershootAmountCent: checkout.OvershootAmountCent,
Content: checkout.Content,
EvidenceURLS: decodeStringList(checkout.EvidenceURLS),
OwnerAdjustmentReason: checkout.OwnerAdjustmentReason,
OwnerAdjustedAt: checkout.OwnerAdjustedAt,
RenterConfirmedAt: checkout.RenterConfirmedAt,
RenterRejectedAt: checkout.RenterRejectedAt,
CreatedAt: checkout.CreatedAt,
UpdatedAt: checkout.UpdatedAt,
}
}
func toCheckoutDTOForUser(checkout model.OrderCheckout, userID uint64, order model.RentalOrder) CheckoutDTO {
dto := toCheckoutAdminDTO(checkout)
refreshCheckoutSettlementDTO(&dto, order, checkout)
applyCheckoutPriceView(&dto, order, userID)
applyCheckoutActionFlags(&dto, checkout, order, userID)
return dto
}
// refreshCheckoutSettlementDTO 用当前公式重算展示字段(兼容旧单 shortfall/overshoot 为空)
func refreshCheckoutSettlementDTO(dto *CheckoutDTO, order model.RentalOrder, checkout model.OrderCheckout) {
if dto == nil {
return
}
if checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered {
return
}
settlement := calculateCheckoutSettlement(order, checkout.ConsumableAmountCent, checkout.CoinConsumedM, checkout.DepositDeductAmountCent)
rent := settlement.ActualRentAmountCent
ownerRent := settlement.OwnerRentIncomeCent
platform := settlement.PlatformFeeCent
renterRefund := settlement.RenterRefundCent
ownerIncome := settlement.OwnerIncomeCent
dto.RentAmountCent = &rent
dto.OwnerRentAmountCent = &ownerRent
dto.PlatformFeeCent = &platform
dto.RenterRefundAmountCent = &renterRefund
dto.OwnerIncomeAmountCent = &ownerIncome
dto.DisplayAmountCent = rent
dto.ShortfallCent = settlement.ShortfallCent
dto.OvershootAmountCent = settlement.OvershootAmountCent
}
func applyCheckoutActionFlags(dto *CheckoutDTO, checkout model.OrderCheckout, order model.RentalOrder, userID uint64) {
if dto == nil {
return
}
negotiating := order.Status == orderStatusPendingCheckoutConfirm || order.Status == orderStatusPendingCheckoutAccept
if !negotiating || (checkout.Status != checkoutStatusSubmitted && checkout.Status != checkoutStatusCountered) {
return
}
turn := dto.Turn
round := dto.RoundCount
isOwner := userID == order.OwnerID
isRenter := userID == order.RenterID
if isOwner && turn == checkoutTurnOwner && order.Status == orderStatusPendingCheckoutConfirm {
dto.CanAccept = dto.ShortfallCent <= 0
dto.CanCounter = round < checkoutMaxRounds
}
if isRenter && turn == checkoutTurnRenter && order.Status == orderStatusPendingCheckoutAccept {
dto.CanAccept = dto.ShortfallCent <= 0
dto.CanCounter = round < checkoutMaxRounds
}
}
func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) {
if dto == nil {
return
}
dto.PlatformFeeCent = nil
switch {
case userID == order.OwnerID:
ownerAmountCent := order.OwnerRentAmountCent
if ownerAmountCent <= 0 {
ownerAmountCent = order.RentAmountCent
}
dto.PriceRole = "owner"
dto.DisplayAmountCent = ownerAmountCent
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = &ownerAmountCent
dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0
dto.PureCoinOriginalAmountCent = 0
dto.ExtraItemOriginalAmountCent = 0
dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0
dto.ActualExtraItemAmountCent = 0
dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil
dto.GrowthPointsBasisCent = 0
dto.GrowthPointsPerYuan = 0
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
case userID == order.RenterID:
rentAmountCent := order.RentAmountCent
dto.PriceRole = "renter"
dto.DisplayAmountCent = rentAmountCent
dto.RentAmountCent = &rentAmountCent
dto.OwnerRentAmountCent = nil
dto.RentOriginalAmountCent = effectiveRentOriginalAmountCent(order)
dto.RentDiscountAmountCent = order.RentDiscountAmountCent
dto.RenterGrowthLevel = effectiveRenterGrowthLevel(order)
dto.RenterGrowthLevelName = effectiveRenterGrowthLevelName(order)
dto.RenterDiscountBps = effectiveRenterDiscountBps(order)
sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter")
default:
dto.PriceRole = ""
dto.DisplayAmountCent = 0
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = nil
dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0
dto.PureCoinOriginalAmountCent = 0
dto.ExtraItemOriginalAmountCent = 0
dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0
dto.ActualExtraItemAmountCent = 0
dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil
dto.GrowthPointsBasisCent = 0
dto.GrowthPointsPerYuan = 0
sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
}
}
func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID uint64) {
if dto == nil {
return
}
ownerAmountCent := int64(0)
if dto.OwnerRentAmountCent != nil {
ownerAmountCent = *dto.OwnerRentAmountCent
}
ownerIncomeAmountCent := int64(0)
if dto.OwnerIncomeAmountCent != nil {
ownerIncomeAmountCent = *dto.OwnerIncomeAmountCent
}
rentAmountCent := int64(0)
if dto.RentAmountCent != nil {
rentAmountCent = *dto.RentAmountCent
}
renterRefundAmountCent := int64(0)
if dto.RenterRefundAmountCent != nil {
renterRefundAmountCent = *dto.RenterRefundAmountCent
}
dto.PlatformFeeCent = nil
dto.RenterRefundAmountCent = nil
dto.OwnerIncomeAmountCent = nil
switch {
case userID == order.OwnerID:
dto.PriceRole = "owner"
dto.DisplayAmountCent = ownerAmountCent
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = &ownerAmountCent
dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent
dto.PureCoinAmountCent = 0
dto.PureCoinDiscountCent = 0
dto.PureCoinPayableCent = 0
case userID == order.RenterID:
dto.PriceRole = "renter"
dto.DisplayAmountCent = rentAmountCent
dto.RentAmountCent = &rentAmountCent
dto.OwnerRentAmountCent = nil
dto.RenterRefundAmountCent = &renterRefundAmountCent
default:
dto.PriceRole = ""
dto.DisplayAmountCent = 0
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = nil
dto.PureCoinAmountCent = 0
dto.PureCoinDiscountCent = 0
dto.PureCoinPayableCent = 0
}
}
func sanitizeOrderSnapshot(snapshot *datatypes.JSON, role string) {
if snapshot == nil || len(*snapshot) == 0 {
return
}
var payload map[string]any
if err := json.Unmarshal(*snapshot, &payload); err != nil {
return
}
rawSummary, ok := payload["asset_summary"]
if !ok {
return
}
var summary map[string]any
switch typed := rawSummary.(type) {
case map[string]any:
summary = typed
case string:
if err := json.Unmarshal([]byte(typed), &summary); err != nil {
return
}
default:
raw, err := json.Marshal(typed)
if err != nil || json.Unmarshal(raw, &summary) != nil {
return
}
}
// price_breakdown 含双方比例与加价明细,仅把当前角色可见的价格字段摊平到 summary,再删除原对象。
if breakdown, ok := summary["price_breakdown"].(map[string]any); ok && breakdown != nil {
switch role {
case "owner":
if v := readJSONNumber(breakdown["seller_ratio"]); v > 0 {
summary["publish_ratio"] = v
summary["display_ratio"] = v
}
if v := readJSONNumber(breakdown["seller_reference_ratio"]); v > 0 {
summary["reference_ratio"] = v
}
if v := readJSONNumber(breakdown["seller_total_price"]); v > 0 {
summary["display_total_price"] = v
}
if v := readJSONNumber(breakdown["seller_coin_base_price"]); v > 0 {
summary["display_coin_base_price"] = v
}
if v := readJSONNumber(breakdown["accelerated_sale_ratio"]); v > 0 {
summary["accelerated_sale_ratio"] = v
}
case "renter":
if v := readJSONNumber(breakdown["buyer_ratio"]); v > 0 {
summary["publish_ratio"] = v
summary["display_ratio"] = v
}
if v := readJSONNumber(breakdown["buyer_total_price"]); v > 0 {
summary["display_total_price"] = v
}
if v := readJSONNumber(breakdown["buyer_coin_base_price"]); v > 0 {
summary["display_coin_base_price"] = v
}
}
if v := readJSONNumber(breakdown["consumable_price"]); v > 0 {
summary["consumable_price"] = v
}
if v := readJSONNumber(breakdown["daily_loss_ratio_adjustment"]); v != 0 {
summary["daily_loss_ratio_adjustment"] = v
}
}
delete(summary, "price_breakdown")
payload["asset_summary"] = summary
raw, err := json.Marshal(payload)
if err != nil {
return
}
*snapshot = datatypes.JSON(raw)
}
func marshalStringList(items []string) (datatypes.JSON, error) {
if items == nil {
items = []string{}
}
raw, err := json.Marshal(items)
return datatypes.JSON(raw), err
}
func decodeStringList(raw datatypes.JSON) []string {
if len(raw) == 0 {
return []string{}
}
var items []string
if err := json.Unmarshal(raw, &items); err != nil {
return []string{}
}
return items
}
func makeAccountSnapshot(account model.GameAccount, listing model.RentalListing) (datatypes.JSON, error) {
payload := map[string]any{
"listing_id": listing.ID,
"listing_no": listing.ListingNo,
"account_id": account.ID,
"title": account.Title,
"game_name": account.GameName,
"server_region": account.ServerRegion,
"login_platform": account.LoginPlatform,
"rank_level": account.RankLevel,
"haf_coin_amount": account.HafCoinAmount,
"asset_summary": account.AssetSummary,
"season_tags": account.SeasonTags,
"screenshot_urls": account.ScreenshotURLS,
"snapshot_version": 1,
}
raw, err := json.Marshal(payload)
return datatypes.JSON(raw), err
}