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

- 订单列表使用独立最小 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
+78 -6
View File
@@ -75,6 +75,56 @@ export interface Order {
updated_at: string
}
// 订单列表只承载卡片和表格所需字段;完整订单信息由详情接口返回。
export interface UserOrderListItem {
id: number
order_no: string
listing_id: number
listing_no: string
role: 'owner' | 'renter'
title: string
server_region: string
login_platform: string
display_amount_cent: number
deposit_amount_cent: number
deposit_waived_amount_cent: number
status: OrderStatus
handoff_status: HandoffStatus
payment_deadline_at?: string
created_at: string
}
// 后台订单列表使用脱敏后的表格数据,敏感字段仅在详情接口中按权限返回。
export interface AdminOrderListItem {
id: number
order_no: string
listing_id: number
listing_no: string
owner_phone?: string
renter_phone?: string
title: string
server_region: string
login_platform: string
rent_amount_cent: number
deposit_amount_cent: number
status: OrderStatus
handoff_status: HandoffStatus
handoff_mode: string
settlement_mode: string
settlement_status: SettlementStatus
created_at: string
}
export interface SellerHandoffMetrics {
pending_handoff: number
pending_checkout: number
abnormal: number
}
export interface SellerHandoffResult extends PaginatedResult<UserOrderListItem> {
metrics: SellerHandoffMetrics
}
export interface AdminActions {
reset_handoff?: AdminAction
platform_handoff?: AdminAction
@@ -246,9 +296,28 @@ export async function fetchPostRentalNotice() {
return data.data
}
export async function fetchOrders() {
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/orders')
return Array.isArray(data.data?.items) ? data.data.items : []
export async function fetchOrders(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<UserOrderListItem>>>('/orders', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchSellerHandoffs(
params: {
page?: number
pageSize?: number
status?: string
} = {}
) {
const { data } = await apiClient.get<ApiResponse<SellerHandoffResult>>('/orders/handoffs', {
params: {
page: params.page || 1,
page_size: params.pageSize || 20,
status: params.status || undefined,
},
})
return data.data
}
export async function fetchOrder(id: string | number) {
@@ -363,9 +432,12 @@ export async function counterCheckout(id: number, payload: SubmitCheckoutPayload
}
export async function fetchAdminOrders(page = 1, pageSize = 20, query: AdminOrderQuery = {}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Order>>>('/admin/orders', {
params: { page, page_size: pageSize, ...query },
})
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminOrderListItem>>>(
'/admin/orders',
{
params: { page, page_size: pageSize, ...query },
}
)
return data.data
}
@@ -5,7 +5,11 @@ import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchOrders, startOrderPayment, type Order } from '@/features/orders/api/orders'
import {
fetchOrders,
startOrderPayment,
type UserOrderListItem,
} from '@/features/orders/api/orders'
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
@@ -17,7 +21,6 @@ import {
type MohongOrder,
} from '@/features/mohong/api/mohong'
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay'
@@ -35,18 +38,20 @@ interface UnifiedOrderItem {
quantity?: number
unit?: string
amountText: string
rental?: Order
rental?: UserOrderListItem
crash?: MohongOrder
}
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const loading = ref(false)
const rentalOrders = ref<Order[]>([])
const rentalOrders = ref<UserOrderListItem[]>([])
const crashOrders = ref<MohongOrder[]>([])
const activeTab = ref('all')
const payingOrderId = ref<number | null>(null)
const rentalTotal = ref(0)
const rentalPage = ref(1)
const rentalPageSize = 20
const {
paymentPopupVisible,
activePayment,
@@ -139,14 +144,16 @@ async function loadOrders() {
loading.value = true
try {
const [rentalResult, crashResult] = await Promise.allSettled([
fetchOrders(),
fetchOrders(rentalPage.value, rentalPageSize),
fetchMyMohongOrders({ page: 1, page_size: 50 }),
])
if (rentalResult.status === 'fulfilled') {
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
rentalOrders.value = rentalResult.value?.items || []
rentalTotal.value = rentalResult.value?.total || 0
} else {
rentalOrders.value = []
rentalTotal.value = 0
showToast({ message: '租赁订单加载失败', icon: 'warning-o' })
}
@@ -164,6 +171,11 @@ async function loadOrders() {
}
}
function handleRentalPageChange(page: number) {
rentalPage.value = page
void loadOrders()
}
function statusLabel(item: UnifiedOrderItem) {
if (item.bizType === 'crash') return mohongOrderStatusLabel(item.status)
const map: Record<string, string> = {
@@ -196,7 +208,7 @@ function goDetail(item: UnifiedOrderItem) {
router.push(`/m/orders/${item.id}`)
}
async function handlePay(order: Order) {
async function handlePay(order: UserOrderListItem) {
const payWay = await selectPayWay()
if (!payWay) return
payingOrderId.value = order.id
@@ -215,7 +227,7 @@ async function handlePay(order: Order) {
}
}
async function openOrderChatAfterPayment(order: Order) {
async function openOrderChatAfterPayment(order: UserOrderListItem) {
try {
const chat = await fetchOrderChat(order.id)
await router.push(`/m/chats/${chat.id}`)
@@ -229,11 +241,11 @@ function money(value: unknown) {
return formatMoney(Number(value || 0))
}
function isOwner(order: Order) {
return order.owner_id === session.userId
function isOwner(order: UserOrderListItem) {
return order.role === 'owner'
}
function amountLabel(order: Order) {
function amountLabel(order: UserOrderListItem) {
return isOwner(order) ? '预计租金' : '支付租金'
}
@@ -242,23 +254,15 @@ function amountYuan(cent: unknown) {
return 0
}
function orderRentAmount(order: Order) {
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
return amountYuan(order.rent_amount_cent)
function orderRentAmount(order: UserOrderListItem) {
return amountYuan(order.display_amount_cent)
}
function ownerActualIncome(order: Order) {
if (!isOwner(order)) return null
const value = order.checkout?.owner_income_amount_cent
if (typeof value === 'number') return centToYuan(value)
return null
}
function formatListingCode(order: Order) {
function formatListingCode(order: UserOrderListItem) {
return formatListingNo(order.listing_no, order.listing_id)
}
async function copyListingCode(order: Order) {
async function copyListingCode(order: UserOrderListItem) {
try {
await navigator.clipboard.writeText(formatListingCode(order))
showToast({ message: '商品编号已复制', icon: 'passed' })
@@ -340,9 +344,6 @@ async function copyListingCode(order: Order) {
<div class="price-item">
<span class="price-label">{{ amountLabel(item.rental) }}</span>
<span class="price-val">¥{{ money(orderRentAmount(item.rental)) }}</span>
<span v-if="ownerActualIncome(item.rental) !== null" class="price-sub"
>实际到手 ¥{{ money(ownerActualIncome(item.rental)) }}</span
>
</div>
<div class="price-item">
<span class="price-label">押金金额</span>
@@ -363,10 +364,7 @@ async function copyListingCode(order: Order) {
</div>
<div class="footer-action">
<van-button
v-if="
item.rental.status === 'pending_payment' &&
item.rental.renter_id === session.userId
"
v-if="item.rental.status === 'pending_payment' && item.rental.role === 'renter'"
size="small"
type="warning"
round
@@ -421,6 +419,15 @@ async function copyListingCode(order: Order) {
</div>
</template>
</div>
<van-pagination
v-if="rentalTotal > rentalPageSize"
v-model="rentalPage"
class="orders-pagination"
mode="simple"
:total-items="rentalTotal"
:items-per-page="rentalPageSize"
@change="handleRentalPageChange"
/>
</section>
<MobilePayWaySelectPopup
@@ -5,14 +5,13 @@ import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { fetchOrders, type Order } from '@/features/orders'
import { fetchOrders, type UserOrderListItem } from '@/features/orders'
import {
fetchMyMohongOrders,
mohongOrderStatusLabel,
type MohongOrder,
} from '@/features/mohong/api/mohong'
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { orderStatusLabel } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatListingNo } from '@/shared/utils/listingDisplay'
@@ -31,19 +30,21 @@ interface UnifiedOrderItem {
coverUrl?: string
quantity?: number
unit?: string
rental?: Order
rental?: UserOrderListItem
crash?: MohongOrder
}
const loading = ref(false)
const rentalOrders = ref<Order[]>([])
const rentalOrders = ref<UserOrderListItem[]>([])
const crashOrders = ref<MohongOrder[]>([])
const payingOrderId = ref<number | null>(null)
const session = useSessionStore()
const route = useRoute()
const router = useRouter()
const searchKeyword = ref('')
const fallbackPendingPaymentMinutes = 15
const rentalTotal = ref(0)
const rentalPage = ref(1)
const rentalPageSize = 20
const statusTabs = [
{ key: 'all', label: '全部' },
@@ -107,8 +108,7 @@ const displayOrders = computed(() => {
if (order.bizType === 'rental' && order.rental) {
return (
formatListingCode(order.rental).toLowerCase().includes(keyword) ||
String(order.rental.listing_id).includes(keyword) ||
String(order.rental.account_id).includes(keyword)
String(order.rental.listing_id).includes(keyword)
)
}
return false
@@ -152,14 +152,16 @@ async function loadOrders() {
loading.value = true
try {
const [rentalResult, crashResult] = await Promise.allSettled([
fetchOrders(),
fetchOrders(rentalPage.value, rentalPageSize),
fetchMyMohongOrders({ page: 1, page_size: 50 }),
])
if (rentalResult.status === 'fulfilled') {
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
rentalOrders.value = rentalResult.value?.items || []
rentalTotal.value = rentalResult.value?.total || 0
} else {
rentalOrders.value = []
rentalTotal.value = 0
ElMessage.error('租赁订单加载失败')
}
@@ -174,6 +176,11 @@ async function loadOrders() {
}
}
function handleRentalPageChange(page: number) {
rentalPage.value = page
void loadOrders()
}
function goDetail(item: UnifiedOrderItem) {
if (item.bizType === 'crash') {
router.push(`/crash/orders/${item.id}`)
@@ -182,7 +189,7 @@ function goDetail(item: UnifiedOrderItem) {
router.push(`/orders/${item.id}`)
}
async function handlePay(order: Order) {
async function handlePay(order: UserOrderListItem) {
payingOrderId.value = order.id
try {
await router.push(`/orders/${order.id}?pay=1`)
@@ -193,21 +200,15 @@ async function handlePay(order: Order) {
}
}
function orderRole(order: Order) {
if (order.renter_id === session.userId) return '租客'
if (order.owner_id === session.userId) return '号主'
return '-'
function orderRole(order: UserOrderListItem) {
return order.role === 'owner' ? '号主' : '租客'
}
function isRenter(order: Order) {
return order.renter_id === session.userId
function isRenter(order: UserOrderListItem) {
return order.role === 'renter'
}
function isOwner(order: Order) {
return order.owner_id === session.userId
}
function amountLabel(order: Order) {
function amountLabel(order: UserOrderListItem) {
return isRenter(order) ? '支付租金' : '预计租金'
}
@@ -216,19 +217,10 @@ function amountYuan(cent: unknown) {
return 0
}
function orderRentAmount(order: Order) {
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
if (isRenter(order)) return amountYuan(order.rent_amount_cent)
function orderRentAmount(order: UserOrderListItem) {
return amountYuan(order.display_amount_cent)
}
function ownerActualIncome(order: Order) {
if (!isOwner(order)) return null
const value = order.checkout?.owner_income_amount_cent
if (typeof value === 'number') return centToYuan(value)
return null
}
function money(value: unknown) {
return formatMoney(Number(value || 0))
}
@@ -262,11 +254,11 @@ async function copyOrderNo(orderNo: unknown) {
}
}
function formatListingCode(order: Order) {
function formatListingCode(order: UserOrderListItem) {
return formatListingNo(order.listing_no, order.listing_id)
}
async function copyListingCode(order: Order) {
async function copyListingCode(order: UserOrderListItem) {
try {
await navigator.clipboard.writeText(formatListingCode(order))
ElMessage.success('商品编号已复制')
@@ -275,7 +267,7 @@ async function copyListingCode(order: Order) {
}
}
function getPaymentDeadline(order: Order) {
function getPaymentDeadline(order: UserOrderListItem) {
if (order.status !== 'pending_payment' || !order.created_at) return null
if (order.payment_deadline_at) {
const serverDeadline = new Date(order.payment_deadline_at)
@@ -285,7 +277,7 @@ function getPaymentDeadline(order: Order) {
return new Date(created.getTime() + fallbackPendingPaymentMinutes * 60 * 1000)
}
function getCountdownMinutes(order: Order) {
function getCountdownMinutes(order: UserOrderListItem) {
const deadline = getPaymentDeadline(order)
if (!deadline) return 0
const now = new Date()
@@ -366,9 +358,7 @@ function bizTypeLabel(bizType: BizType) {
</div>
<div class="crash-product-info">
<strong>{{ row.title }}</strong>
<span
>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span
>
<span>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span>
</div>
</div>
<div v-else class="rental-product-cell">
@@ -390,9 +380,6 @@ function bizTypeLabel(bizType: BizType) {
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
<span class="amount-value">¥{{ money(orderRentAmount(row.rental)) }}</span>
<span class="amount-label">{{ amountLabel(row.rental) }}</span>
<span v-if="ownerActualIncome(row.rental) !== null" class="amount-sub"
>实际到手 ¥{{ money(ownerActualIncome(row.rental)) }}</span
>
</div>
<div v-else class="amount-cell">
<span class="amount-value">¥{{ row.amountText }}</span>
@@ -403,8 +390,12 @@ function bizTypeLabel(bizType: BizType) {
<el-table-column label="押金 / 数量" width="110">
<template #default="{ row }">
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
<span class="amount-value">¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span>
<span v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0" class="amount-label"
<span class="amount-value"
>¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span
>
<span
v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0"
class="amount-label"
>免 ¥{{ money(amountYuan(row.rental.deposit_waived_amount_cent)) }}</span
>
</div>
@@ -451,13 +442,11 @@ function bizTypeLabel(bizType: BizType) {
<div class="action-buttons">
<el-button
v-if="
row.bizType === 'rental' &&
row.rental &&
row.rental.status === 'pending_payment'
row.bizType === 'rental' && row.rental && row.rental.status === 'pending_payment'
"
size="small"
type="primary"
:disabled="row.rental.renter_id !== session.userId"
:disabled="!isRenter(row.rental)"
:loading="payingOrderId === row.rental.id"
@click="handlePay(row.rental)"
>
@@ -511,10 +500,6 @@ function bizTypeLabel(bizType: BizType) {
<span class="meta-label">{{ amountLabel(item.rental) }}</span>
<span class="meta-value amount">¥{{ money(orderRentAmount(item.rental)) }}</span>
</div>
<div v-if="ownerActualIncome(item.rental) !== null" class="meta-row">
<span class="meta-label">实际到手</span>
<span class="meta-value amount">¥{{ money(ownerActualIncome(item.rental)) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">押金</span>
<span class="meta-value">
@@ -582,6 +567,15 @@ function bizTypeLabel(bizType: BizType) {
</template>
</div>
</div>
<el-pagination
v-if="rentalTotal > rentalPageSize"
v-model:current-page="rentalPage"
class="orders-pagination"
layout="prev, pager, next"
:page-size="rentalPageSize"
:total="rentalTotal"
@current-change="handleRentalPageChange"
/>
</div>
</section>
</template>