feat: 订单售后

This commit is contained in:
2026-07-30 18:31:54 +08:00
parent dcc32b3d8f
commit 898e239463
36 changed files with 3842 additions and 398 deletions
@@ -2,6 +2,8 @@ import type { Context } from '../utils/handler.js'
import { guardMethod } from '../utils/method.js'
import { withErrorBoundary } from '../utils/handler.js'
import { basicService } from '../services/basic.service.js'
import { minioUploadService, type PreviewInput, type UploadInput } from '../services/minio-upload.service.js'
import { readJson } from '../utils/request.js'
import { toHttpResponse } from './controller-response.js'
export const loginController = withErrorBoundary(async (request: Request, _context: Context) => {
@@ -22,7 +24,14 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(basicService.upload())
return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request)))
})
export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(minioUploadService.preview(await readJson<PreviewInput>(request)))
})
export const userInfoController = withErrorBoundary(async (request: Request, _context: Context) => {
@@ -0,0 +1,69 @@
import type { Context } from '../utils/handler.js'
import { guardMethod } from '../utils/method.js'
import { readJson } from '../utils/request.js'
import { withErrorBoundary } from '../utils/handler.js'
import {
merchantService,
type MerchantAfterSaleListInput,
type MerchantOrderListInput,
type MerchantShipInput
} from '../services/merchant.service.js'
import type { AfterSaleAuditInput, AfterSaleLogisticsInput } from '../services/after-sale.service.js'
import { toHttpResponse } from './controller-response.js'
type MerchantAfterSaleActionInput = AfterSaleLogisticsInput & {
logisticsNo?: string
logisticsCompany?: string
}
export const merchantOrderListController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.listOrders(await readJson<MerchantOrderListInput>(request)))
})
export const merchantOrderShipController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.shipOrder(await readJson<MerchantShipInput>(request)))
})
export const merchantAfterSaleListController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.listAfterSales(await readJson<MerchantAfterSaleListInput>(request)))
})
export const merchantAfterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.auditAfterSale(await readJson<AfterSaleAuditInput>(request)))
})
export const merchantAfterSaleReceiveController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
const body = await readJson<MerchantAfterSaleActionInput>(request)
return toHttpResponse(await merchantService.receiveAfterSale(
String(body.serviceOrderId || '').trim(),
String(body.supplierId || '').trim()
))
})
export const merchantAfterSaleShipController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
const body = await readJson<MerchantAfterSaleActionInput>(request)
return toHttpResponse(await merchantService.shipExchange(
String(body.serviceOrderId || '').trim(),
String(body.supplierId || '').trim(),
String(body.logisticsNo || body.merchantLogisticsNo || '').trim(),
String(body.logisticsCompany || body.merchantLogisticsCompany || '').trim()
))
})
@@ -6,6 +6,7 @@ import {
afterSaleService,
type AfterSaleApplyInput,
type AfterSaleAuditInput,
type AfterSaleDetailInput,
type AfterSaleListInput,
type AfterSaleLogisticsInput
} from '../services/after-sale.service.js'
@@ -99,6 +100,13 @@ export const afterSaleListController = withErrorBoundary(async (request: Request
return toHttpResponse(await afterSaleService.list(await readJson<AfterSaleListInput>(request)))
})
export const afterSaleDetailController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await afterSaleService.detail(await readJson<AfterSaleDetailInput>(request)))
})
export const afterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
+34
View File
@@ -137,6 +137,40 @@ export const orderDao = {
})
},
findMerchantSubOrders(supplierId: string, orderState?: number) {
return prisma.supplierOrderSub.findMany({
where: {
supplierId,
deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {})
},
include: {
products: {
where: { deleted: 0 },
orderBy: { createAt: 'asc' }
}
},
orderBy: [
{ createTime: 'desc' },
{ createAt: 'desc' }
]
})
},
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
return prisma.supplierAfterSale.findMany({
where: {
supplierId,
deleted: 0,
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
},
orderBy: [
{ createTime: 'desc' },
{ id: 'desc' }
]
})
},
findUserAddresses(supplierId: string, userId: string) {
return prisma.supplierUserAddress.findMany({
where: { supplierId, userId, deleted: 0 },
@@ -0,0 +1 @@
export { afterSaleDetailController as default } from '../controllers/order.controller.js'
@@ -0,0 +1 @@
export { merchantAfterSaleAuditController as default } from '../controllers/merchant.controller.js'
@@ -0,0 +1 @@
export { merchantAfterSaleListController as default } from '../controllers/merchant.controller.js'
@@ -0,0 +1 @@
export { merchantAfterSaleReceiveController as default } from '../controllers/merchant.controller.js'
@@ -0,0 +1 @@
export { merchantAfterSaleShipController as default } from '../controllers/merchant.controller.js'
@@ -0,0 +1 @@
export { merchantOrderListController as default } from '../controllers/merchant.controller.js'
@@ -0,0 +1 @@
export { merchantOrderShipController as default } from '../controllers/merchant.controller.js'
@@ -0,0 +1 @@
export { uploadPreviewController as default } from '../controllers/basic.controller.js'
+19 -1
View File
@@ -14,11 +14,13 @@ import {
cartAddController,
loginController,
uploadController,
uploadPreviewController,
userInfoController
} from './controllers/basic.controller.js'
import {
afterSaleApplyController,
afterSaleAuditController,
afterSaleDetailController,
afterSaleListController,
afterSaleLogisticsController,
orderCancelMainController,
@@ -33,6 +35,14 @@ import {
orderShipController,
supplierOrderInfoController
} from './controllers/order.controller.js'
import {
merchantAfterSaleAuditController,
merchantAfterSaleListController,
merchantAfterSaleReceiveController,
merchantAfterSaleShipController,
merchantOrderListController,
merchantOrderShipController
} from './controllers/merchant.controller.js'
import { productDetailController, productListController } from './controllers/product.controller.js'
import {
supplierDebugTokensController,
@@ -52,10 +62,17 @@ const handlers: Record<string, FunctionHandler> = {
'address-save': addressSaveController,
'after-sale-apply': afterSaleApplyController,
'after-sale-audit': afterSaleAuditController,
'after-sale-detail': afterSaleDetailController,
'after-sale-list': afterSaleListController,
'after-sale-logistics': afterSaleLogisticsController,
'cart-add': cartAddController,
login: loginController,
'merchant-after-sale-audit': merchantAfterSaleAuditController,
'merchant-after-sale-list': merchantAfterSaleListController,
'merchant-after-sale-receive': merchantAfterSaleReceiveController,
'merchant-after-sale-ship': merchantAfterSaleShipController,
'merchant-order-list': merchantOrderListController,
'merchant-order-ship': merchantOrderShipController,
'order-cancel-main': orderCancelMainController,
'order-cancel-paid-main': orderCancelPaidMainController,
'order-cancel-sub': orderCancelSubController,
@@ -74,6 +91,7 @@ const handlers: Record<string, FunctionHandler> = {
'supplier-points': supplierPointsController,
'supplier-token': supplierTokenController,
upload: uploadController,
'upload-preview': uploadPreviewController,
'user-info': userInfoController
}
@@ -116,7 +134,7 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
await writeResponse(ctx, response)
}
app.use(bodyParser())
app.use(bodyParser({ jsonLimit: '10mb' }))
app.use(async (ctx, next) => {
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
if (ctx.method === 'OPTIONS') {
@@ -5,6 +5,7 @@ import {
ORDER_STATE
} from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js'
import { productDao } from '../dao/product.dao.js'
import { logger } from '../utils/logger.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
@@ -17,6 +18,16 @@ export type AfterSaleApplyInput = {
serviceType?: number
amount?: number
reason?: string
reasonType?: string
problemDescription?: string
pickupMethod?: string
pickupName?: string
pickupMobile?: string
pickupAddress?: string
images?: unknown[]
exchangeProductSku?: string
exchangeProductName?: string
exchangeProductPrice?: number | string
}
export type AfterSaleListInput = {
@@ -24,6 +35,12 @@ export type AfterSaleListInput = {
userId?: string
}
export type AfterSaleDetailInput = {
supplierId?: string
userId?: string
serviceOrderId?: string
}
export type AfterSaleAuditInput = {
supplierId?: string
serviceOrderId?: string
@@ -111,6 +128,61 @@ const createServiceOrderId = (subOrderId: string, serviceType: number) => {
return `AS${serviceType}_${subOrderId}_${Date.now()}`
}
const afterSaleDetailPayload = (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, subOrder?: Awaited<ReturnType<typeof orderDao.findOpenApiSubOrders>>[number] | null) => {
const requestBody = record.requestBody && typeof record.requestBody === 'object' ? record.requestBody as AfterSaleApplyInput : {}
const products = subOrder?.products.map(product => ({
id: product.id,
sku: product.sku,
name: product.name,
number: product.number,
price: toYuanText(product.price),
image: product.productImgUrl || firstImage(product.picInfo)
})) || []
return {
serviceOrderId: record.serviceOrderId,
supplierId: record.supplierId,
userId: record.userId,
mainOrderId: record.mainOrderId,
subOrderId: record.subOrderId,
orderId: record.subOrderId,
orderNumberId: subOrder?.orderNumberId || '',
serviceType: record.serviceType,
serviceTypeText: serviceTypeText(record.serviceType),
serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState),
auditStatus: record.auditStatus,
logisticsState: record.logisticsState,
logisticsStateText: logisticsStateText(record.logisticsState),
amount: toYuanText(record.amount),
reason: record.reason || '',
reasonType: record.reasonType || '',
problemDescription: record.problemDescription || '',
pickupMethod: record.pickupMethod || '',
pickupName: record.pickupName || '',
pickupMobile: record.pickupMobile || '',
pickupAddress: record.pickupAddress || '',
exchangeProduct: requestBody.exchangeProductSku ? {
sku: requestBody.exchangeProductSku,
name: requestBody.exchangeProductName || '',
price: requestBody.exchangeProductPrice || ''
} : null,
images: Array.isArray(record.images) ? record.images : [],
auditRemark: record.auditRemark || '',
userLogisticsNo: record.userLogisticsNo || '',
userLogisticsCompany: record.userLogisticsCompany || '',
merchantLogisticsNo: record.merchantLogisticsNo || '',
merchantLogisticsCompany: record.merchantLogisticsCompany || '',
applyTime: toDateTimeText(record.applyTime || record.createTime),
auditTime: toDateTimeText(record.auditTime),
userShipTime: toDateTimeText(record.userShipTime),
merchantReceiveTime: toDateTimeText(record.merchantReceiveTime),
merchantShipTime: toDateTimeText(record.merchantShipTime),
finishTime: toDateTimeText(record.finishTime),
products
}
}
export const afterSaleService = {
async apply(input: AfterSaleApplyInput) {
const supplierId = String(input.supplierId || '').trim()
@@ -118,6 +190,14 @@ export const afterSaleService = {
const subOrderId = String(input.subOrderId || input.orderId || '').trim()
const serviceType = Number(input.serviceType)
const reason = String(input.reason || '').trim()
const reasonType = String(input.reasonType || '').trim()
const problemDescription = String(input.problemDescription || '').trim()
const pickupMethod = String(input.pickupMethod || '').trim()
const pickupName = String(input.pickupName || '').trim()
const pickupMobile = String(input.pickupMobile || '').trim()
const pickupAddress = String(input.pickupAddress || '').trim()
const images = Array.isArray(input.images) ? input.images.slice(0, 3) : []
const exchangeProductSku = String(input.exchangeProductSku || '').trim()
logger.info('after sale apply', { supplierId, userId, subOrderId, serviceType })
if (!supplierId || !userId || !subOrderId || !serviceType) {
@@ -137,6 +217,22 @@ export const afterSaleService = {
return serviceFail('当前订单状态不能申请退款/售后', 400, 'ORDER_NOT_ALLOW_AFTER_SALE')
}
if (serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER) {
if (!exchangeProductSku) {
return serviceFail('换货商品不能为空', 400, 'EXCHANGE_PRODUCT_REQUIRED')
}
const originalProduct = subOrder.products[0]
const exchangeProduct = await productDao.findEnabledProduct('', exchangeProductSku)
if (!originalProduct || !exchangeProduct) {
return serviceFail('换货商品不存在或已下架', 404, 'EXCHANGE_PRODUCT_NOT_FOUND')
}
if (toYuanText(exchangeProduct.price) !== toYuanText(originalProduct.price)) {
return serviceFail('换货商品价格必须与原商品价格一致', 400, 'EXCHANGE_PRODUCT_PRICE_NOT_MATCH')
}
}
const activeAfterSale = await orderDao.findAfterSale({
supplierId,
userId,
@@ -166,6 +262,13 @@ export const afterSaleService = {
logisticsState: AFTER_SALE_LOGISTICS_STATE.NONE,
amount,
reason: reason || null,
reasonType: reasonType || null,
problemDescription: problemDescription || null,
pickupMethod: pickupMethod || null,
pickupName: pickupName || null,
pickupMobile: pickupMobile || null,
pickupAddress: pickupAddress || null,
images,
applyTime: now,
requestBody: input
}
@@ -196,7 +299,20 @@ export const afterSaleService = {
serviceState: AFTER_SALE_SERVICE_STATE.PENDING_AUDIT,
serviceStateText: serviceStateText(AFTER_SALE_SERVICE_STATE.PENDING_AUDIT),
logisticsState: AFTER_SALE_LOGISTICS_STATE.NONE,
logisticsStateText: logisticsStateText(AFTER_SALE_LOGISTICS_STATE.NONE)
logisticsStateText: logisticsStateText(AFTER_SALE_LOGISTICS_STATE.NONE),
reason,
reasonType,
problemDescription,
pickupMethod,
pickupName,
pickupMobile,
pickupAddress,
exchangeProduct: exchangeProductSku ? {
sku: exchangeProductSku,
name: input.exchangeProductName || '',
price: input.exchangeProductPrice || ''
} : null,
images
})
},
@@ -222,50 +338,38 @@ export const afterSaleService = {
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
const afterSaleItems = records.map(record => {
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
const products = subOrder?.products.map(product => ({
id: product.id,
sku: product.sku,
name: product.name,
number: product.number,
price: toYuanText(product.price),
image: product.productImgUrl || firstImage(product.picInfo)
})) || []
return {
serviceOrderId: record.serviceOrderId,
supplierId: record.supplierId,
userId: record.userId,
mainOrderId: record.mainOrderId,
subOrderId: record.subOrderId,
orderId: record.subOrderId,
orderNumberId: subOrder?.orderNumberId || '',
serviceType: record.serviceType,
serviceTypeText: serviceTypeText(record.serviceType),
serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState),
auditStatus: record.auditStatus,
logisticsState: record.logisticsState,
logisticsStateText: logisticsStateText(record.logisticsState),
amount: toYuanText(record.amount),
reason: record.reason || '',
auditRemark: record.auditRemark || '',
userLogisticsNo: record.userLogisticsNo || '',
userLogisticsCompany: record.userLogisticsCompany || '',
merchantLogisticsNo: record.merchantLogisticsNo || '',
merchantLogisticsCompany: record.merchantLogisticsCompany || '',
applyTime: toDateTimeText(record.applyTime || record.createTime),
auditTime: toDateTimeText(record.auditTime),
userShipTime: toDateTimeText(record.userShipTime),
merchantReceiveTime: toDateTimeText(record.merchantReceiveTime),
merchantShipTime: toDateTimeText(record.merchantShipTime),
finishTime: toDateTimeText(record.finishTime),
products
}
return afterSaleDetailPayload(record, subOrder)
})
return serviceOk({ afterSaleItems })
},
async detail(input: AfterSaleDetailInput) {
const supplierId = String(input.supplierId || '').trim()
const userId = String(input.userId || '').trim()
const serviceOrderId = String(input.serviceOrderId || '').trim()
logger.info('after sale detail query', { supplierId, userId, serviceOrderId })
if (!supplierId || !userId || !serviceOrderId) {
return serviceFail('供应商、用户或售后单不能为空')
}
const record = await orderDao.findAfterSale({
supplierId,
userId,
serviceOrderId,
deleted: 0
})
if (!record) {
return serviceFail('售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
const subOrders = record.subOrderId
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
: []
return serviceOk({ afterSale: afterSaleDetailPayload(record, subOrders[0]) })
},
async audit(input: AfterSaleAuditInput) {
const supplierId = String(input.supplierId || '').trim()
const serviceOrderId = String(input.serviceOrderId || '').trim()
@@ -0,0 +1,204 @@
import {
AFTER_SALE_LOGISTICS_STATE,
AFTER_SALE_SERVICE_STATE,
ORDER_EVENT_TYPE,
ORDER_STATE
} from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js'
import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js'
import { orderActionService } from './order-action.service.js'
import { logger } from '../utils/logger.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type MerchantOrderListInput = {
supplierId?: string
orderState?: number
}
export type MerchantShipInput = {
supplierId?: string
userId?: string
orderId?: string
logisticsOrderId?: string
crrgBsnNm?: string
}
export type MerchantAfterSaleListInput = {
supplierId?: string
scene?: string
}
const serviceStateText = (serviceState: number) => {
if (serviceState === AFTER_SALE_SERVICE_STATE.PENDING_AUDIT) return '商家待审核'
if (serviceState === AFTER_SALE_SERVICE_STATE.APPROVED) return '审核通过'
if (serviceState === AFTER_SALE_SERVICE_STATE.REJECTED) return '审核拒绝'
if (serviceState === AFTER_SALE_SERVICE_STATE.USER_SHIPPED) return '用户已寄回'
if (serviceState === AFTER_SALE_SERVICE_STATE.RECEIVED) return '商家已收货'
if (serviceState === AFTER_SALE_SERVICE_STATE.FINISHED) return '售后完成'
if (serviceState === AFTER_SALE_SERVICE_STATE.CANCELLED) return '已撤销'
return '处理中'
}
const serviceTypeText = (serviceType: number) => {
if (serviceType === ORDER_EVENT_TYPE.RETURN_ORDER) return '退货'
if (serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER) return '换货'
return '售后'
}
const toYuanText = (value: { toFixed: (digits: number) => string } | number | string | null | undefined) => {
if (value && typeof value === 'object' && 'toFixed' in value) {
return value.toFixed(2)
}
return Number(value || 0).toFixed(2)
}
const toDateTimeText = (date?: Date | null) => {
if (!date) return ''
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const firstImage = (picInfo: unknown) => {
if (Array.isArray(picInfo)) {
return String(picInfo[0] || '')
}
return ''
}
const afterSaleStatesByScene = (scene: string) => {
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT]
if (scene === 'process') {
return [
AFTER_SALE_SERVICE_STATE.APPROVED,
AFTER_SALE_SERVICE_STATE.USER_SHIPPED,
AFTER_SALE_SERVICE_STATE.RECEIVED
]
}
return undefined
}
export const merchantService = {
async listOrders(input: MerchantOrderListInput) {
const supplierId = String(input.supplierId || '').trim()
const orderState = Number(input.orderState ?? ORDER_STATE.FINISH_PAY)
logger.info('merchant order list query', { supplierId, orderState })
if (!supplierId) {
return serviceFail('供应商不能为空')
}
const orders = await orderDao.findMerchantSubOrders(supplierId, orderState)
const orderItems = orders.map(order => ({
orderId: order.orderId,
mainOrderId: order.mainOrderId,
userId: order.userId || '',
supplierId: order.supplierId,
orderState: order.orderState,
orderNumberId: order.orderNumberId || '',
createTime: toDateTimeText(order.createTime || order.createAt),
amount: toYuanText(order.totalPrice),
logisticsOrderId: order.logisticsOrderId || '',
crrgBsnNm: order.crrgBsnNm || '',
products: order.products.map(product => ({
id: product.id,
sku: product.sku,
name: product.name,
number: product.number,
price: toYuanText(product.price),
image: product.productImgUrl || firstImage(product.picInfo)
}))
}))
return serviceOk({ orderItems })
},
shipOrder(input: MerchantShipInput) {
return orderActionService.shipOrder({
supplierId: String(input.supplierId || '').trim(),
userId: String(input.userId || '').trim(),
orderId: String(input.orderId || '').trim(),
deliveryStatus: 1,
logisticsOrderId: String(input.logisticsOrderId || '').trim(),
crrgBsnNm: String(input.crrgBsnNm || '').trim()
})
},
async listAfterSales(input: MerchantAfterSaleListInput) {
const supplierId = String(input.supplierId || '').trim()
const scene = String(input.scene || '').trim()
logger.info('merchant after sale list query', { supplierId, scene })
if (!supplierId) {
return serviceFail('供应商不能为空')
}
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene))
const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[]
const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : []
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
const afterSaleItems = records.map(record => {
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
return {
serviceOrderId: record.serviceOrderId,
supplierId: record.supplierId,
userId: record.userId || '',
mainOrderId: record.mainOrderId || '',
subOrderId: record.subOrderId || '',
serviceType: record.serviceType,
serviceTypeText: serviceTypeText(record.serviceType),
serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState),
logisticsState: record.logisticsState,
reason: record.reason || '',
problemDescription: record.problemDescription || '',
pickupMethod: record.pickupMethod || '',
pickupName: record.pickupName || '',
pickupMobile: record.pickupMobile || '',
pickupAddress: record.pickupAddress || '',
userLogisticsNo: record.userLogisticsNo || '',
userLogisticsCompany: record.userLogisticsCompany || '',
applyTime: toDateTimeText(record.applyTime || record.createTime),
products: subOrder?.products.map(product => ({
id: product.id,
sku: product.sku,
name: product.name,
number: product.number,
price: toYuanText(product.price),
image: product.productImgUrl || firstImage(product.picInfo)
})) || []
}
})
return serviceOk({ afterSaleItems })
},
auditAfterSale(input: AfterSaleAuditInput) {
return afterSaleService.audit(input)
},
updateAfterSaleLogistics(input: AfterSaleLogisticsInput) {
return afterSaleService.updateLogistics(input)
},
receiveAfterSale(serviceOrderId: string, supplierId: string) {
return afterSaleService.updateLogistics({
supplierId,
serviceOrderId,
logisticsState: AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED
})
},
shipExchange(serviceOrderId: string, supplierId: string, logisticsNo: string, logisticsCompany: string) {
return afterSaleService.updateLogistics({
supplierId,
serviceOrderId,
logisticsState: AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED,
merchantLogisticsNo: logisticsNo,
merchantLogisticsCompany: logisticsCompany
})
}
}
@@ -0,0 +1,178 @@
import crypto from 'node:crypto'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type UploadInput = {
fileName?: string
contentType?: string
contentBase64?: string
folder?: string
}
export type PreviewInput = {
objectName?: string
expires?: number
}
const minioConfig = {
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || '',
endPoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000',
bucket: process.env.MINIO_BUCKET || 'njupt',
region: process.env.MINIO_REGION || 'us-east-1'
}
const amzDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, '')
const shortDate = (date: Date) => amzDate(date).slice(0, 8)
const sha256 = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex')
const hmac = (key: crypto.BinaryLike | crypto.KeyObject, value: string) => crypto.createHmac('sha256', key).update(value).digest()
const encodePath = (value: string) => value.split('/').map(item => encodeURIComponent(item)).join('/')
const ensureConfig = () => Boolean(minioConfig.accessKey && minioConfig.secretKey && minioConfig.endPoint && minioConfig.bucket)
const signingKey = (date: string) => {
const dateKey = hmac(`AWS4${minioConfig.secretKey}`, date)
const regionKey = hmac(dateKey, minioConfig.region)
const serviceKey = hmac(regionKey, 's3')
return hmac(serviceKey, 'aws4_request')
}
const objectUrl = (objectName: string) => {
const endpoint = new URL(minioConfig.endPoint)
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}`
return endpoint
}
const extName = (fileName: string) => {
const matched = fileName.match(/\.[a-zA-Z0-9]+$/)
return matched ? matched[0].toLowerCase() : ''
}
const safeFolder = (folder?: string) => String(folder || 'after-sale').replace(/[^a-zA-Z0-9/_-]/g, '').replace(/^\/+|\/+$/g, '') || 'after-sale'
const presign = (method: 'GET', objectName: string, expires = 3600) => {
const now = new Date()
const date = shortDate(now)
const fullDate = amzDate(now)
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
const endpoint = objectUrl(objectName)
const params = new URLSearchParams({
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
'X-Amz-Date': fullDate,
'X-Amz-Expires': String(Math.min(Math.max(Number(expires) || 3600, 1), 604800)),
'X-Amz-SignedHeaders': 'host'
})
const canonicalRequest = [
method,
endpoint.pathname,
params.toString(),
`host:${endpoint.host}\n`,
'host',
'UNSIGNED-PAYLOAD'
].join('\n')
const stringToSign = [
'AWS4-HMAC-SHA256',
fullDate,
credentialScope,
sha256(canonicalRequest)
].join('\n')
const signature = crypto.createHmac('sha256', signingKey(date)).update(stringToSign).digest('hex')
params.set('X-Amz-Signature', signature)
endpoint.search = params.toString()
return endpoint.toString()
}
const signedPutHeaders = (url: URL, body: Buffer, contentType: string) => {
const now = new Date()
const date = shortDate(now)
const fullDate = amzDate(now)
const payloadHash = sha256(body)
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
const canonicalHeaders = [
`content-type:${contentType}`,
`host:${url.host}`,
`x-amz-content-sha256:${payloadHash}`,
`x-amz-date:${fullDate}`
].join('\n') + '\n'
const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
const canonicalRequest = [
'PUT',
url.pathname,
'',
canonicalHeaders,
signedHeaders,
payloadHash
].join('\n')
const stringToSign = [
'AWS4-HMAC-SHA256',
fullDate,
credentialScope,
sha256(canonicalRequest)
].join('\n')
const signature = crypto.createHmac('sha256', signingKey(date)).update(stringToSign).digest('hex')
return {
'Content-Type': contentType,
'X-Amz-Content-Sha256': payloadHash,
'X-Amz-Date': fullDate,
Authorization: `AWS4-HMAC-SHA256 Credential=${minioConfig.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
}
}
export const minioUploadService = {
async upload(input: UploadInput) {
if (!ensureConfig()) {
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
}
const fileName = String(input.fileName || '').trim()
const contentType = String(input.contentType || 'application/octet-stream').trim()
const contentBase64 = String(input.contentBase64 || '').replace(/^data:[^;]+;base64,/, '')
if (!fileName || !contentBase64) {
return serviceFail('文件名或图片内容不能为空')
}
if (!contentType.startsWith('image/')) {
return serviceFail('只支持上传图片', 400, 'ONLY_IMAGE_ALLOWED')
}
const body = Buffer.from(contentBase64, 'base64')
const objectName = `${safeFolder(input.folder)}/${Date.now()}_${crypto.randomUUID()}${extName(fileName)}`
const url = objectUrl(objectName)
const response = await fetch(url, {
method: 'PUT',
headers: signedPutHeaders(url, body, contentType),
body
})
if (!response.ok) {
return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED')
}
const previewUrl = presign('GET', objectName)
return serviceOk({
objectName,
fileName,
contentType,
size: body.length,
url: previewUrl,
previewUrl
})
},
preview(input: PreviewInput) {
if (!ensureConfig()) {
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
}
const objectName = String(input.objectName || '').trim()
if (!objectName) {
return serviceFail('图片对象名不能为空')
}
const previewUrl = presign('GET', objectName, Number(input.expires || 3600))
return serviceOk({
objectName,
url: previewUrl,
previewUrl
})
}
}
@@ -197,6 +197,7 @@ export const orderActionService = {
return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE')
}
const payTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId },
@@ -206,12 +207,13 @@ export const orderActionService = {
totalPrice,
wPayOrAPay,
pointsPrice,
updateTime: new Date()
payTime,
updateTime: payTime
}
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId: orderId, deleted: 0 },
data: { orderState: ORDER_STATE.FINISH_PAY, updateTime: new Date() }
data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime }
})
})
@@ -109,6 +109,7 @@ export const orderListService = {
receiverAddress: addressView?.address || '',
remark: subOrder.remark || mainOrder.remark || '',
createTime: toDateTimeText(subOrder.createTime || subOrder.createAt),
payTime: toDateTimeText(subOrder.payTime || mainOrder.payTime),
status: status.text,
statusClass: status.statusClass,
orderState: subOrder.orderState,
@@ -138,6 +139,7 @@ export const orderListService = {
receiverAddress: addressView?.address || '',
remark: mainOrder.remark || '',
createTime: toDateTimeText(mainOrder.createTime || mainOrder.createAt),
payTime: toDateTimeText(mainOrder.payTime),
status: mainStatus.text,
statusClass: mainStatus.statusClass,
orderState: mainOrder.orderState,