Compare commits

..
7 Commits
103 changed files with 8073 additions and 325 deletions
+57 -2
View File
@@ -1,10 +1,65 @@
# points-mall
积分商城-供应商模块-订单操作模块
积分商城供应商项目,对接 zhgh 工会平台,包含商城 H5、供应商管理后台及业务服务。
## 页面与接口
## 项目组成
| 目录 | 用途 | 技术栈 |
| --- | --- | --- |
| `supplied-h5` | 商品浏览、购物车、下单支付、订单与售后、商家处理台 | Vue 3、Vant 3、Pinia、Vue Router 4、Vue CLI |
| `supplied-admin` | 商品管理、订单管理、售后处理、订单对账 | Vue 3、Element Plus、Pinia、Vue Router 4、Vite |
| `supplied-service` | 商城接口、管理端接口、工会平台对接、文件处理 | Koa、TypeScript、Prisma、MySQL、Redis、MinIO |
H5 和管理端的 Vue 版本声明均为 `^3.5.13`,H5 的 Vant 版本声明为 `^3.6.16`。
## 主要功能
- 商城 H5:商品与规格选择、购物车、地址管理、下单支付、订单查询及售后申请。
- 管理后台:账号登录、商品维护、订单筛选与分页、确认发货、重新推送拆单、售后审核与处理、订单对账。
- 平台对接:供应商授权、订单事件推送、对账及发票相关接口。
- 调试日志:查看供应商后端与 zhgh 之间的请求记录。
## 本地访问入口
以下为默认开发地址,实际地址以端口和环境配置为准。
| 入口 | 地址 |
| --- | --- |
| 管理端登录 | `http://localhost:5173/login` |
| 管理端订单列表 | `http://localhost:5173/orders/list` |
| 商城 H5 | `http://localhost:3001/` |
| 商城 API | `http://localhost:8888/api` |
| 管理端 API | `http://localhost:8888/api/admin` |
### 商家处理与日志
- 商家处理台:`http://localhost:3001/#/joyful-selection/merchant`
- 供应商日志页面:`http://localhost:3001/#/joyful-selection/supplier-logs`
- 供应商日志接口:`GET http://localhost:8888/api/supplier-logs`
- 日志内容包含:当前后端接收请求、向zhgh发送请求、接收zhgh请求。
## 管理端登录
当前本地开发数据库已初始化以下账号;密码由初始化脚本生成并单独交付,不在文档中保存。
| 项目 | 内容 |
| --- | --- |
| 账号 | `admin` |
| 登录后进入订单列表 | `http://localhost:5173/login?redirect=/orders/list` |
- 登录账号存储在 `supplied_user`,密码使用随机盐和 scrypt 哈希保存,不保存明文密码。
- 管理端使用 Bearer Token,登录有效期为 8 小时;同一账号重新登录会替换旧 Token。
- `/api/admin/*` 除登录接口外均校验登录状态;退出登录会使当前 Token 失效。
- 同一启用账号连续输错密码 5 次后锁定 15 分钟。
## 数据库与配置
- 管理账号表:`supplied_user`。
- 购物车表:`supplied_cart_item`;保留业务用户 `userId`,不关联后台管理员账号。
- 原 `user` 空表已移除,购物车表名统一使用 `supplied_cart_item`。
- 后端配置位于 `supplied-service/.env`,字段参考 `.env.example`。
- H5 使用 `VUE_APP_SUPPLIED_API_BASE` 指定后端地址,使用 `VUE_APP_SUPPLIER_IDENTIFICATION` 指定供应商标识。
- 管理端使用 `VITE_SUPPLIED_ADMIN_API_BASE` 指定管理接口地址。
后端目录、接口及初始化脚本说明见 [supplied-service/README.md](supplied-service/README.md)。
+2
View File
@@ -0,0 +1,2 @@
PORT=8050
SUPPLIED_SERVICE_BASE_URL=http://127.0.0.1:8888
+8
View File
@@ -0,0 +1,8 @@
{
"name": "esf-relay",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs"
}
}
+74
View File
@@ -0,0 +1,74 @@
import http from 'node:http'
const port = Number(process.env.PORT || 8050)
const suppliedServiceBaseUrl = String(process.env.SUPPLIED_SERVICE_BASE_URL || 'http://127.0.0.1:8888').replace(/\/$/, '')
const addressRoutes = new Map([
['/supplierOpenApi/pointOrder/queryOrderInfo', '/supplierOpenApi/pointOrder/queryOrderInfo'],
['/supplierOpenApi/pointOrder/queryReconciliationInfo', '/supplierOpenApi/pointOrder/queryReconciliationInfo'],
['/supplierOpenApi/pointOrder/applyForInvoices', '/supplierOpenApi/pointOrder/applyForInvoices'],
['/supplierOpenApi/pointOrder/queryInvoicesInfo', '/supplierOpenApi/pointOrder/queryInvoicesInfo'],
['/supplierOpenApi/pointOrder/queryFileBase64Info', '/supplierOpenApi/pointOrder/queryFileBase64Info'],
['/supplierOpenApi/pointOrder/unReconciledMsg', '/supplierOpenApi/pointOrder/unReconciledMsg'],
['/supplierOpenApi/pointOrder/reconciliationResult', '/supplierOpenApi/pointOrder/reconciliationResult']
])
const readBody = request => new Promise((resolve, reject) => {
const chunks = []
request.on('data', chunk => chunks.push(chunk))
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
request.on('error', reject)
})
const getForwardPath = address4 => {
if (typeof address4 !== 'string') {
return null
}
try {
return addressRoutes.get(new URL(address4).pathname) || null
} catch {
return addressRoutes.get(address4) || null
}
}
const writeJson = (response, statusCode, body) => {
response.writeHead(statusCode, { 'Content-Type': 'application/json;charset=UTF-8' })
response.end(JSON.stringify(body))
}
const server = http.createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/esf/S120020967Api') {
writeJson(response, 404, { resultCode: 404, message: '接口不存在' })
return
}
try {
const bodyText = await readBody(request)
const body = JSON.parse(bodyText)
const forwardPath = getForwardPath(body.Address4)
if (!forwardPath) {
writeJson(response, 400, { resultCode: 400, message: '不支持的Address4' })
return
}
const upstreamResponse = await fetch(`${suppliedServiceBaseUrl}${forwardPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
body: bodyText
})
const responseBody = await upstreamResponse.text()
response.writeHead(upstreamResponse.status, {
'Content-Type': upstreamResponse.headers.get('content-type') || 'application/json;charset=UTF-8'
})
response.end(responseBody)
} catch (error) {
writeJson(response, 502, {
resultCode: 502,
message: error instanceof Error ? error.message : 'ESF中转请求失败'
})
}
})
server.listen(port, () => {
console.log(`ESF relay running at http://127.0.0.1:${port}/esf/S120020967Api`)
})
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>供应商管理后台</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
+2697
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "supplied-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"ele-admin-plus": "^1.6.0",
"element-plus": "^2.9.1",
"pinia": "^3.0.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"sass-embedded": "^1.103.1",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+1
View File
@@ -0,0 +1 @@
<template><RouterView /></template>
+29
View File
@@ -0,0 +1,29 @@
import { clearAdminSession, readAdminSession } from '@/utils/auth'
const API_BASE = import.meta.env.VITE_SUPPLIED_ADMIN_API_BASE || 'http://127.0.0.1:8888/api/admin'
const normalizeBase = (base: string) => base.replace(/\/$/, '')
export const resolveImageUrl = (value: string) => /^\/api\/(?:file|upload-preview)\?/.test(value)
? `${normalizeBase(API_BASE).replace(/\/api\/admin$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
: value
export const postAdmin = async <T>(name: string, body: Record<string, unknown>): Promise<T> => {
const token = readAdminSession()?.token
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body)
})
const payload = await response.json().catch(() => ({}))
if (response.status === 401 && name !== 'login') {
clearAdminSession()
if (name !== 'admin-session' && window.location.pathname !== '/login') {
window.location.replace(`/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`)
}
}
if (!response.ok || payload.success === false) {
throw new Error(payload.error?.message || payload.msg || '请求失败')
}
return payload.data as T
}
@@ -0,0 +1,111 @@
<template>
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form" @submit.prevent="loadRecords"><el-form-item label="售后单号"><el-input v-model="serviceOrderId" placeholder="请输入售后单号" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><div v-if="scene !== 'process'" v-loading="loading" class="audit-list">
<article v-for="row in records" :key="row.serviceOrderId" class="audit-card">
<header class="audit-card-header"><strong>{{ row.serviceOrderId }}</strong><el-tag type="warning">{{ row.serviceTypeText || '-' }}</el-tag></header>
<div v-for="product in row.products || []" :key="product.id || product.sku" class="audit-product">
<el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image>
<div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span>
</div>
<div class="audit-summary"><dl><dt>原因</dt><dd>{{ row.reason || '-' }}</dd><dt>描述</dt><dd>{{ row.problemDescription || '-' }}</dd></dl><el-tag :type="row.isSigned === true ? 'success' : 'info'">{{ row.isSigned === true ? '已签收' : row.isSigned === false ? '未签收' : '签收状态未知' }}</el-tag></div>
<div v-if="scene === 'audit'" class="after-sale-status"><el-tag :type="row.serviceState === 0 ? 'warning' : 'primary'">{{ currentStatusText(row) }}</el-tag></div>
<div v-if="scene === 'history'" class="history-result"><span class="result-stamp" :class="{ approved: row.serviceState === 5, rejected: row.serviceState === 2 }">{{ historyResultText(row) }}</span></div>
<footer class="audit-card-footer"><span>{{ row.applyTime || '-' }}</span><div><el-button @click="openDetail(row)">查看详情</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="danger" plain :disabled="auditing" @click="audit(row, 2)">拒绝</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="primary" :disabled="auditing" @click="audit(row, 1)">通过</el-button><el-button v-if="canReceive(row)" type="primary" :disabled="auditing" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" type="primary" :disabled="auditing" @click="openShipDialog(row)">换货发货</el-button></div></footer>
</article>
<el-empty v-if="!loading && !records.length" :description="scene === 'audit' ? '暂无待处理售后' : '暂无售后记录'" />
</div><el-table v-else v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" :width="scene !== 'process' ? '1080px' : '760px'">
<div v-if="scene !== 'process' && detail.afterSale" class="audit-detail">
<section class="audit-detail-section audit-progress"><span v-if="scene === 'history'" class="result-stamp detail-result-stamp" :class="{ approved: detail.afterSale.serviceState === 5, rejected: detail.afterSale.serviceState === 2 }" aria-label="处理结果">{{ historyResultText(detail.afterSale) }}</span><h3>售后进度</h3><el-steps align-center>
<el-step title="提交申请" status="success"><template #description><p>用户提交售后申请</p><p>{{ detail.afterSale.applyTime || '-' }}</p></template></el-step>
<el-step title="商家审核" :status="detail.afterSale.auditTime ? (detail.afterSale.auditStatus === 2 ? 'error' : 'success') : detail.afterSale.serviceState === 6 ? 'wait' : 'process'"><template #description><p>{{ detail.afterSale.auditRemark || (detail.afterSale.serviceState === 6 ? '申请已撤销' : '待商家审核') }}</p><p>{{ detail.afterSale.auditTime || '-' }}</p></template></el-step>
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商品寄回" :status="detail.afterSale.userShipTime ? 'success' : 'process'"><template #description><p>{{ detail.afterSale.userShipTime ? '用户已寄回商品' : '等待用户填写寄回物流' }}</p><p>{{ detail.afterSale.userShipTime || '-' }}</p></template></el-step>
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商家收货" :status="detail.afterSale.merchantReceiveTime ? 'success' : detail.afterSale.userShipTime ? 'process' : 'wait'"><template #description><p>{{ detail.afterSale.merchantReceiveTime ? '商家已确认收货' : '等待商家收到商品' }}</p><p>{{ detail.afterSale.merchantReceiveTime || '-' }}</p></template></el-step>
<el-step :title="detail.afterSale.serviceState === 6 ? '已撤销' : detail.afterSale.serviceState === 2 ? '审核未通过' : '售后完成'" :status="detail.afterSale.serviceState === 5 ? 'success' : 'wait'"><template #description><p>{{ detail.afterSale.serviceState === 5 ? '售后已完成' : detail.afterSale.serviceState === 2 ? '审核未通过,售后已结束' : detail.afterSale.serviceState === 6 ? '用户已撤销售后申请' : '等待售后处理完成' }}</p><p>{{ detail.afterSale.finishTime || '-' }}</p></template></el-step>
</el-steps></section>
<section class="audit-detail-section"><h3>售后商品</h3><div v-for="product in detail.afterSale.products || []" :key="product.id || product.sku" class="audit-product"><el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image><div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span></div></section>
<section class="audit-detail-section"><h3>售后信息</h3><el-descriptions :column="1" border>
<el-descriptions-item label="售后单号">{{ detail.afterSale.serviceOrderId }}</el-descriptions-item><el-descriptions-item label="子订单号">{{ detail.afterSale.orderNumberId || detail.afterSale.subOrderId || '-' }}</el-descriptions-item><el-descriptions-item label="售后类型"><el-tag type="warning">{{ detail.afterSale.serviceTypeText || '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="签收状态"><el-tag :type="detail.afterSale.isSigned === true ? 'success' : 'info'">{{ detail.afterSale.isSigned === true ? '已签收' : detail.afterSale.isSigned === false ? '未签收' : '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag>{{ currentStatusText(detail.afterSale) }}</el-tag></el-descriptions-item><el-descriptions-item v-if="detail.afterSale.userLogisticsNo" label="寄回物流">{{ detail.afterSale.userLogisticsCompany || '-' }} / {{ detail.afterSale.userLogisticsNo }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantReceiveTime" label="商家收货时间">{{ detail.afterSale.merchantReceiveTime }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantLogisticsNo" label="换货物流">{{ detail.afterSale.merchantLogisticsCompany || '-' }} / {{ detail.afterSale.merchantLogisticsNo }}</el-descriptions-item><el-descriptions-item label="售后原因">{{ detail.afterSale.reason || '-' }}</el-descriptions-item><el-descriptions-item label="问题描述">{{ detail.afterSale.problemDescription || '-' }}</el-descriptions-item><el-descriptions-item label="申请时间">{{ detail.afterSale.applyTime || '-' }}</el-descriptions-item>
</el-descriptions><div v-if="detailImages.length" class="audit-evidence"><el-image v-for="url in detailImages" :key="url" :src="url" :preview-src-list="detailImages" preview-teleported fit="cover" /></div></section>
</div><pre v-else class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre>
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="scene === 'audit' && detail.afterSale?.serviceState === 0"><el-button type="danger" plain :disabled="auditing" @click="audit(detail.afterSale, 2)">拒绝</el-button><el-button type="primary" :disabled="auditing" @click="audit(detail.afterSale, 1)">通过</el-button></template><el-button v-if="detail.afterSale && canReceive(detail.afterSale)" type="primary" :disabled="auditing" @click="receive(detail.afterSale)">确认收货</el-button><el-button v-if="detail.afterSale && canShip(detail.afterSale)" type="primary" :disabled="auditing" @click="openShipDialog(detail.afterSale)">换货发货</el-button></template>
</el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px" append-to-body><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" :loading="auditing" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin, resolveImageUrl } from '@/api/admin'
const props = defineProps<{ scene: 'audit' | 'process' | 'history'; title: string; description: string }>()
type AfterSaleProduct = { id?: number; sku: string; name: string; number: number; price: string; image?: string }
type AfterSale = { serviceOrderId: string; supplierId: string; serviceState?: number; logisticsState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string; problemDescription?: string; products?: AfterSaleProduct[]; subOrderId?: string; orderNumberId?: string; auditStatus?: number; auditTime?: string; auditRemark?: string; finishTime?: string; userLogisticsNo?: string; userLogisticsCompany?: string; merchantLogisticsNo?: string; merchantLogisticsCompany?: string; userShipTime?: string; merchantReceiveTime?: string; merchantShipTime?: string; images?: Array<string | { url?: string; previewUrl?: string; fileUrl?: string }> }
const serviceOrderId = ref('')
const auditing = ref(false)
const currentStatusText = (row: AfterSale) => {
if (row.serviceState === 1 && row.logisticsState === 1) return '审核通过,待用户寄回'
if (row.serviceState === 3) return '用户已寄回,待商家收货'
if (row.serviceState === 4 && row.serviceType === 5) return '商家已收货,待换货发货'
if (row.serviceState === 1 && row.serviceType === 5 && row.isSigned === false) return '审核通过,待换货发货'
return row.serviceStateText || '-'
}
const historyResultText = (row: AfterSale) => row.serviceState === 5 ? '审核通过' : row.serviceStateText || '-'
const money = (value: string) => Number(value || 0).toFixed(2)
const specText = (sku: string) => {
const labels: Record<string, string> = { cream: '奶油白', blue: '雾霾蓝', orange: '暖橙色', standard: '标准款', plus: '升级款' }
const parts = String(sku || '').split('-').slice(-2)
return parts.some(part => labels[part]) ? parts.map(part => labels[part] || part).join(' | ') : '-'
}
const detailImages = computed(() => (detail.value.afterSale?.images || []).map(item => resolveImageUrl(typeof item === 'string' ? item : item.fileUrl || item.url || item.previewUrl || '')).filter(Boolean))
const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<{ afterSale?: AfterSale }>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' })
const loadRecords = async () => { loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { scene: props.scene, serviceOrderId: serviceOrderId.value.trim() }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } }
const reset = () => { serviceOrderId.value = ''; return loadRecords() }
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin<{ afterSale: AfterSale }>('merchant-after-sale-detail', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } }
const audit = async (row: AfterSale, auditStatus: number) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); detailVisible.value = false; await loadRecords() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '处理失败') } finally { auditing.value = false } }
const canReceive = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceState) === 3
const canShip = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
const receive = async (row: AfterSale) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords(); if (detailVisible.value) await openDetail(row) } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '操作失败') } finally { auditing.value = false } }
const openShipDialog = (row: AfterSale) => { current.value = row; shipForm.value = { logisticsCompany: '', logisticsNo: '' }; shipVisible.value = true }
const shipExchange = async () => { if (auditing.value) return; if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); auditing.value = true; try { await postAdmin('merchant-after-sale-ship', { supplierId: current.value.supplierId, serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords(); if (detailVisible.value) await openDetail(current.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { auditing.value = false } }
onMounted(loadRecords)
</script>
<style scoped>
.after-sale-status { margin-top: 16px; }
.audit-card-footer > div { display: flex; flex-wrap: wrap; gap: 8px; }
.audit-card-footer > div .el-button { margin-left: 0; }
.history-result { display: flex; justify-content: flex-end; margin-top: 16px; padding: 8px 6px; }
.result-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 112px; height: 56px; margin: 6px 0; border: 2px solid currentColor; border-radius: 50%; color: #909399; font-size: 16px; font-weight: 700; letter-spacing: 2px; line-height: 1; transform: rotate(-14deg); }
.result-stamp::after { position: absolute; inset: 4px; border: 1px dashed currentColor; border-radius: 50%; content: ""; }
.result-stamp.approved { color: #389e63; }
.result-stamp.rejected { color: #d9534f; }
.audit-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 390px), 1fr)); gap: 20px; }
.audit-list > .el-empty { grid-column: 1 / -1; }
.audit-card { padding: 20px; border: 1px solid #e9eef5; border-radius: 12px; background: #fff; }
.audit-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.audit-card-header strong { min-width: 0; overflow-wrap: anywhere; line-height: 22px; }
.audit-card-header .el-tag { flex-shrink: 0; }
.audit-product { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.audit-product > .el-image { width: 72px; height: 72px; flex-shrink: 0; border-radius: 8px; }
.audit-product-info { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 6px; }
.audit-product-info strong { overflow-wrap: anywhere; }
.audit-product-info span, .audit-card-footer > span { color: #8793a5; font-size: 12px; }
.audit-product-info b { color: #ff6b35; }
.audit-quantity { flex-shrink: 0; font-weight: 600; }
.image-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #8793a5; font-size: 12px; }
.audit-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.audit-summary dl { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px 12px; flex: 1; min-width: 0; margin: 0; font-size: 13px; }
.audit-summary dt { color: #8793a5; }
.audit-summary dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.audit-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #edf0f5; }
.audit-detail { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); align-items: start; gap: 20px; }
.audit-progress { position: relative; grid-column: 1 / -1; }
.detail-result-stamp { position: absolute; z-index: 10; top: -18px; right: 8px; pointer-events: none; width: 128px; height: 128px; margin: 0; border-width: 3px; font-size: 20px; }
.detail-result-stamp::after { inset: 7px; border-width: 2px; }
.audit-detail-section { min-width: 0; padding: 20px; border: 1px solid #e9eef5; border-radius: 10px; }
@media (max-width: 767px) { .audit-detail { grid-template-columns: minmax(0, 1fr); } }
.audit-detail-section h3 { margin: 0 0 18px; font-size: 15px; }
.audit-progress :deep(.el-step__description) { overflow-wrap: anywhere; }
.audit-detail-section p { margin: 6px 0 0; color: #8793a5; font-size: 12px; }
.audit-detail :deep(.el-descriptions__label) { width: 100px; white-space: nowrap; }
.audit-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; white-space: pre-wrap; }
.audit-evidence { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.audit-evidence .el-image { width: 80px; height: 80px; border-radius: 6px; }
.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,71 @@
<template>
<el-container class="admin-layout">
<el-aside :width="appStore.sidebarCollapsed ? '64px' : '224px'" class="sidebar">
<div class="brand"><el-icon><ShoppingBag /></el-icon><span v-show="!appStore.sidebarCollapsed">供应商管理</span></div>
<el-menu :collapse="appStore.sidebarCollapsed" :default-active="$route.path" background-color="#152238" text-color="#afbed3" active-text-color="#ffffff" router>
<el-sub-menu index="/billing"><template #title><el-icon><Tickets /></el-icon><span>对账开票</span></template><el-menu-item index="/reconciliation">订单对账</el-menu-item><el-menu-item index="/invoices">发票管理</el-menu-item></el-sub-menu>
<el-sub-menu index="/orders"><template #title><el-icon><List /></el-icon><span>订单管理</span></template><el-menu-item index="/orders/list">订单列表</el-menu-item><el-menu-item index="/orders/after-sales/audit">订单售后</el-menu-item><el-menu-item index="/orders/after-sales/history">售后记录</el-menu-item></el-sub-menu>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><template #title>商品管理</template></el-menu-item>
</el-menu>
</el-aside>
<el-container><el-header class="header"><el-button text aria-label="切换侧栏" @click="appStore.toggleSidebar"><el-icon :size="18"><Expand v-if="appStore.sidebarCollapsed" /><Fold v-else /></el-icon></el-button><el-breadcrumb separator="/"><el-breadcrumb-item>供应商管理</el-breadcrumb-item><el-breadcrumb-item>{{ String($route.meta.title || '') }}</el-breadcrumb-item></el-breadcrumb><div class="header-user"><span class="user-avatar" aria-hidden="true">{{ (currentUser?.nickname || currentUser?.username || '管').slice(0, 1) }}</span><span>{{ currentUser?.nickname || currentUser?.username }}</span><el-button text type="primary" :loading="loggingOut" @click="logout">退出登录</el-button></div></el-header><el-main class="main"><RouterView /></el-main></el-container>
</el-container>
</template>
<script setup lang="ts">
import { Expand, Fold, Goods, List, ShoppingBag, Tickets } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { postAdmin } from '@/api/admin'
import { clearAdminSession, readAdminSession } from '@/utils/auth'
const appStore = useAppStore()
const router = useRouter()
const currentUser = readAdminSession()?.user
const loggingOut = ref(false)
const logout = async () => {
if (loggingOut.value) return
loggingOut.value = true
try {
await postAdmin('admin-logout', {})
clearAdminSession()
await router.replace('/login')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '退出失败,请稍后重试')
} finally {
loggingOut.value = false
}
}
</script>
<style scoped lang="scss">
.admin-layout { min-height: 100vh; }
.sidebar { position: sticky; top: 0; height: 100vh; flex-shrink: 0; overflow-x: hidden; overflow-y: auto; background: #152238; transition: width .2s ease; }
.brand { display: flex; align-items: center; gap: 12px; height: 80px; padding: 0 20px; color: #f5f8ff; font-size: 17px; font-weight: 600; letter-spacing: .5px; white-space: nowrap; }
.brand > .el-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 9px; background: #2a66d8; color: #fff; font-size: 21px; }
.sidebar :deep(.el-menu) { border-right: 0; background: transparent; }
.sidebar > :deep(.el-menu) { padding: 8px; }
.sidebar :deep(.el-menu-item), .sidebar :deep(.el-sub-menu__title) { height: 46px; margin-bottom: 5px; border-radius: 7px; line-height: 46px; font-size: 14px; }
.sidebar :deep(.el-menu-item:hover), .sidebar :deep(.el-sub-menu__title:hover) { color: #fff; background: #21334e; }
.sidebar :deep(.el-menu-item.is-active) { background: #2862cb; color: #fff; box-shadow: 0 4px 12px rgba(0, 0, 0, .12); }
.sidebar :deep(.el-sub-menu .el-menu-item) { min-width: 0; font-size: 13px; }
.sidebar :deep(.el-menu--collapse) { width: 64px; }
.sidebar :deep(.el-menu--collapse .el-menu-item), .sidebar :deep(.el-menu--collapse .el-sub-menu__title) { padding: 0 12px !important; }
.admin-layout > .el-container { min-width: 0; }
.header { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: 18px; height: 64px; padding: 0 28px; border-bottom: 1px solid #e7ecf3; background: #fff; }
.header > .el-button { width: 34px; height: 34px; padding: 0; color: #69778e; background: #f4f6fa; }
.header :deep(.el-breadcrumb) { font-size: 13px; }
.header-user { display: flex; align-items: center; gap: 12px; margin-left: auto; color: #526078; font-size: 13px; white-space: nowrap; }
.user-avatar { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid #dce7fb; border-radius: 50%; background: #edf3fd; color: #2463d4; font-weight: 600; }
.header-user > .el-button { margin-left: 4px; font-size: 12px; }
.main { min-width: 0; min-height: calc(100vh - 64px); padding: 24px; background: #f3f5f9; }
.main :deep(.ele-page) { padding: 0; }
@media (max-width: 1100px) { .header { padding: 0 20px; } .main { padding: 18px; } }
@media (max-width: 767px) {
.header { gap: 10px; padding: 0 12px; }
.header :deep(.el-breadcrumb) { display: none; }
.header-user { gap: 8px; }
.main { padding: 12px; }
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import ElePage from 'ele-admin-plus/es/ele-page/index.js'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import { pinia } from './stores'
import './styles/index.scss'
createApp(App).component('ElePage', ElePage).use(pinia).use(router).use(ElementPlus).mount('#app')
+34
View File
@@ -0,0 +1,34 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import AdminLayout from '@/layouts/AdminLayout.vue'
import { ElMessage } from 'element-plus'
import { postAdmin } from '@/api/admin'
import { clearAdminSession, readAdminSession } from '@/utils/auth'
const routes: RouteRecordRaw[] = [{ path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: AdminLayout, redirect: '/reconciliation', children: [
{ path: 'reconciliation', name: 'Reconciliation', component: () => import('@/views/reconciliation/index.vue'), meta: { title: '订单对账' } },
{ path: 'invoices', name: 'Invoices', component: () => import('@/views/invoices/index.vue'), meta: { title: '发票管理' } },
{ path: 'orders', redirect: '/orders/list' },
{ path: 'orders/list', name: 'Orders', component: () => import('@/views/orders/index.vue'), meta: { title: '订单列表' } },
{ path: 'orders/after-sales/audit', name: 'AfterSaleAudit', component: () => import('@/views/after-sales/audit.vue'), meta: { title: '订单售后' } },
{ path: 'orders/after-sales/process', name: 'AfterSaleProcess', redirect: '/orders/after-sales/audit' },
{ path: 'orders/after-sales/history', name: 'AfterSaleHistory', component: () => import('@/views/after-sales/history.vue'), meta: { title: '售后记录' } },
{ path: 'products', name: 'Products', component: () => import('@/views/products/index.vue'), meta: { title: '商品管理' } }
] }]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach(async to => {
if (to.name === 'Login') return true
if (readAdminSession()) {
try {
await postAdmin('admin-session', {})
return true
} catch (error) {
clearAdminSession()
ElMessage.error(error instanceof Error ? error.message : '登录状态校验失败,请重新登录')
}
}
return { name: 'Login', query: { redirect: to.fullPath } }
})
export default router
+2
View File
@@ -0,0 +1,2 @@
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', { state: () => ({ sidebarCollapsed: false }), actions: { toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed } } })
+2
View File
@@ -0,0 +1,2 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+97
View File
@@ -0,0 +1,97 @@
:root {
--el-color-primary: #2463d4;
--el-color-primary-light-3: #668fdf;
--el-color-primary-light-5: #91b1ea;
--el-color-primary-light-7: #bed0f2;
--el-color-primary-light-8: #d3e0f7;
--el-color-primary-light-9: #edf3fd;
--el-color-primary-dark-2: #1d4faa;
--el-border-radius-base: 7px;
--el-border-color: #dce3ec;
--el-border-color-light: #e9eef5;
--el-text-color-primary: #202e45;
--el-text-color-regular: #526078;
--el-text-color-secondary: #78859a;
--el-font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
}
html, body, #app { min-width: 360px; min-height: 100%; margin: 0; }
body { color: var(--el-text-color-primary); font-family: var(--el-font-family); background: #f3f5f9; -webkit-font-smoothing: antialiased; }
*, *::before, *::after { box-sizing: border-box; }
button, input, textarea { font: inherit; }
:focus-visible { outline: 2px solid #2463d4; outline-offset: 3px; }
.page-card {
min-width: 0;
min-height: calc(100vh - 112px);
padding: 28px;
border: 1px solid #e5eaf2;
border-radius: 14px;
background: #fff;
box-shadow: 0 3px 16px rgba(30, 50, 85, .035);
}
.page-title { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 24px; }
.page-title h2 { margin: 0 0 8px; color: #202e45; font-size: 22px; font-weight: 600; letter-spacing: .5px; }
.page-title p { margin: 0; color: #78859a; font-size: 13px; line-height: 1.7; }
.query-form.el-form--inline { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 20px; padding: 20px; margin-bottom: 24px; border: 1px solid #e9eef5; border-radius: 10px; background: #f8fafc; }
.query-form.el-form--inline .el-form-item { display: flex; flex-direction: row; align-items: center; margin: 0; }
.query-form .el-form-item__label { justify-content: flex-start; flex-shrink: 0; height: 36px; padding: 0 12px 0 0; color: #526078; font-size: 13px; line-height: 36px; white-space: nowrap; }
.query-form .el-form-item__content { min-width: 0; min-height: 36px; }
.query-form .el-input, .query-form .el-select { width: 208px; max-width: 100%; }
.query-form .el-button { min-width: 76px; height: 36px; }
.el-input__wrapper, .el-select__wrapper { min-height: 36px; }
.el-textarea__inner { padding: 10px 12px; line-height: 1.6; }
.page-title > .el-button { height: 36px; padding: 0 18px; }
.el-button { font-weight: 500; }
.el-button:not(.is-link):not(.is-text) { border-radius: 7px; }
.page-card .el-table {
--el-table-border-color: #e9eef5;
--el-table-header-bg-color: #f7f9fc;
--el-table-header-text-color: #526078;
--el-table-row-hover-bg-color: #f3f7ff;
overflow: hidden;
border-radius: 9px;
color: #3f4d63;
font-size: 13px;
}
.page-card .el-table th.el-table__cell { padding: 13px 0; font-size: 12px; font-weight: 600; }
.page-card .el-table td.el-table__cell { padding: 15px 0; }
.page-card .el-table .cell { padding-right: 16px; padding-left: 16px; line-height: 22px; }
.page-card .el-table .el-button.is-link { font-size: 13px; }
.page-card .el-table .el-table__empty-block { min-height: 240px; }
.page-card .el-table .el-table__empty-text { width: 100%; line-height: normal; }
.page-card .el-empty { padding: 36px 0; }
.el-empty__description p { color: #8793a5; font-size: 13px; }
.page-card .el-tag { height: 25px; padding: 0 9px; border-color: transparent; border-radius: 5px; font-size: 12px; font-weight: 500; }
.page-card .el-pagination { justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding-top: 20px; margin-top: 20px; border-top: 1px solid #edf0f5; font-variant-numeric: tabular-nums; }
.page-card .el-pagination .el-pager li { border-radius: 6px; }
.page-card .el-pagination .el-pager li.is-active { color: #fff; background: var(--el-color-primary); }
.el-dialog { max-width: calc(100vw - 32px); padding: 0; overflow: hidden; border-radius: 14px; box-shadow: 0 24px 80px rgba(15, 30, 55, .18); }
.el-dialog__header { padding: 22px 52px 20px 24px; margin: 0; border-bottom: 1px solid #edf0f5; }
.el-dialog__title { color: #202e45; font-size: 17px; font-weight: 600; }
.el-dialog__headerbtn { top: 9px; right: 8px; }
.el-dialog__body { max-height: 65vh; padding: 24px; overflow-y: auto; }
.el-dialog__footer { padding: 16px 24px; border-top: 1px solid #edf0f5; background: #fafbfd; }
.el-dialog .el-form-item { margin-bottom: 22px; }
.el-dialog .el-form-item:last-child { margin-bottom: 0; }
.el-dialog .el-select, .el-dialog .el-input-number { width: 100%; }
.el-dialog__footer .el-button { min-width: 80px; height: 36px; }
.el-message-box { width: 360px; max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px; }
@media (max-width: 1200px) {
.page-card { padding: 22px; }
.query-form .el-input, .query-form .el-select { width: 180px; }
}
@media (max-width: 767px) {
.page-card { min-height: calc(100vh - 88px); padding: 16px; border-radius: 10px; }
.page-title h2 { font-size: 19px; }
.query-form.el-form--inline { gap: 14px; padding: 14px; }
.query-form.el-form--inline .el-form-item { width: 100%; }
.query-form .el-input, .query-form .el-select { width: 100% !important; }
.el-dialog__body { padding: 18px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0s !important; animation-duration: 0s !important; }
}
+14
View File
@@ -0,0 +1,14 @@
export type AdminUser = { id: number; username: string; nickname: string }
export type AdminSession = { token: string; expiresAt: string; user: AdminUser }
const STORAGE_KEY = 'supplied-admin-session'
export const clearAdminSession = () => sessionStorage.removeItem(STORAGE_KEY)
export const saveAdminSession = (session: AdminSession) => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
export const readAdminSession = (): AdminSession | null => {
try {
const session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
if (session?.token && session?.user && Date.parse(session.expiresAt) > Date.now()) return session
} catch { /* 忽略已损坏的本地登录信息。 */ }
clearAdminSession()
return null
}
@@ -0,0 +1,2 @@
<template><MerchantAfterSaleTable scene="audit" title="订单售后" description="审核退换货申请,处理寄回收货和换货发货。" /></template>
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
@@ -0,0 +1,2 @@
<template><MerchantAfterSaleTable scene="history" title="售后记录" description="查看已完成或已关闭的售后记录。" /></template>
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
@@ -0,0 +1,2 @@
<template><MerchantAfterSaleTable scene="process" title="售后处理" description="处理已审核售后,包括确认收货和换货发货。" /></template>
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<section class="page-card">
<div class="page-title"><div><h2>发票管理</h2><p>查看开票结果、关联订单及电子发票。</p></div></div>
<el-form inline class="query-form" @submit.prevent="search">
<el-form-item label="结算单号"><el-input v-model="query.settlementId" placeholder="请输入结算单号" clearable /></el-form-item>
<el-form-item label="开票状态"><el-select v-model="query.invoiceStatus" placeholder="全部" clearable><el-option v-for="(name, value) in statuses" :key="value" :label="name" :value="Number(value)" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table :data="items" v-loading="loading" border>
<el-table-column prop="settlementId" label="结算单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="invoiceIds" label="发票号码" min-width="190" show-overflow-tooltip />
<el-table-column label="开票状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.invoiceStatus)">{{ statuses[row.invoiceStatus] || '-' }}</el-tag></template></el-table-column>
<el-table-column label="价税合计" width="120"><template #default="{ row }">¥{{ row.invoiceAmount }}</template></el-table-column>
<el-table-column label="开票日期" width="130"><template #default="{ row }">{{ dateText(row.invoiceDate) }}</template></el-table-column>
<el-table-column prop="failureMessage" label="说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.failureMessage || '-' }}</template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :loading="pushingId === row.id" :disabled="pushingId !== null || ![2, 3].includes(row.invoiceStatus)" @click="pushInvoice(row)">推送发票</el-button></template></el-table-column>
<template #empty><el-empty :image-size="80" description="暂无发票记录" /></template>
</el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
<el-dialog v-model="detailVisible" title="发票详情" width="880px">
<template v-if="current"><el-descriptions :column="2" border class="invoice-detail">
<el-descriptions-item label="结算单号">{{ current.settlementId }}</el-descriptions-item><el-descriptions-item label="开票状态"><el-tag :type="statusType(current.invoiceStatus)">{{ statuses[current.invoiceStatus] || '-' }}</el-tag></el-descriptions-item>
<el-descriptions-item label="发票号码">{{ current.invoiceIds || '-' }}</el-descriptions-item><el-descriptions-item label="发票代码">{{ current.invoiceCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="开票日期">{{ dateText(current.invoiceDate) }}</el-descriptions-item><el-descriptions-item label="发票类型">{{ current.invoiceType === 9 ? '全电专票' : current.invoiceType === 10 ? '全电普票' : '-' }}</el-descriptions-item>
<el-descriptions-item label="不含税金额">¥{{ current.invoiceNakeAmount }}</el-descriptions-item><el-descriptions-item label="税率">{{ current.invoiceTaxRate ? `${Number(current.invoiceTaxRate) * 100}%` : '-' }}</el-descriptions-item>
<el-descriptions-item label="税额">¥{{ current.invoiceTaxAmount }}</el-descriptions-item><el-descriptions-item label="价税合计">¥{{ current.invoiceAmount }}</el-descriptions-item>
<el-descriptions-item label="纳税人识别号">{{ current.taxIdNumber || '-' }}</el-descriptions-item><el-descriptions-item label="校验码">{{ current.checkCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="说明" :span="2">{{ current.failureMessage || '-' }}</el-descriptions-item>
</el-descriptions><h3 class="orders-title">关联订单</h3><div class="invoice-orders"><el-tag v-for="orderId in current.orderIds.split(',').filter(Boolean)" :key="orderId" type="info">{{ orderId }}</el-tag><span v-if="!current.orderIds">暂无关联订单</span></div></template>
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="current?.hasFile"><el-button :disabled="fileLoading" @click="openFile(current, true)">下载PDF</el-button><el-button type="primary" :disabled="fileLoading" @click="openFile(current)">预览PDF</el-button></template></template>
</el-dialog>
<el-dialog v-model="previewVisible" title="发票预览" width="1000px" append-to-body @closed="previewUrl = ''"><iframe v-if="previewUrl" :src="previewUrl" title="电子发票" class="invoice-preview" /></el-dialog>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin } from '@/api/admin'
type Invoice = { id: number; settlementId: string; invoiceIds: string; invoiceCode: string; invoiceStatus: number; invoiceDate: string | null; invoiceType: number | null; invoiceAmount: string; invoiceNakeAmount: string; invoiceTaxAmount: string; invoiceTaxRate: string; taxIdNumber: string; checkCode: string; orderIds: string; failureMessage: string; hasFile: boolean }
const statuses: Record<number, string> = { 0: '未开票', 1: '开票中', 2: '成功', 3: '失败' }
const statusType = (status: number) => status === 2 ? 'success' : status === 3 ? 'danger' : status === 1 ? 'warning' : 'info'
const dateText = (value: string | null) => value ? new Date(value).toLocaleDateString('sv-SE') : '-'
const query = reactive<{ settlementId: string; invoiceStatus?: number; pageNumber: number; pageSize: number }>({ settlementId: '', pageNumber: 1, pageSize: 10 })
const items = ref<Invoice[]>([])
const totalCount = ref(0)
const loading = ref(false)
const current = ref<Invoice>()
const detailVisible = ref(false)
const fileLoading = ref(false)
const pushingId = ref<number | null>(null)
const previewVisible = ref(false)
const previewUrl = ref('')
const load = async () => {
loading.value = true
try {
const data = await postAdmin<{ invoiceItems: Invoice[]; totalCount: number }>('merchant-invoice-list', query)
items.value = data.invoiceItems || []
totalCount.value = data.totalCount || 0
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '发票查询失败') } finally { loading.value = false }
}
const search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.settlementId = ''; query.invoiceStatus = undefined; return search() }
const openDetail = (row: Invoice) => { current.value = row; detailVisible.value = true }
const pushInvoice = async (row: Invoice) => {
if (pushingId.value !== null) return
pushingId.value = row.id
try {
await ElMessageBox.confirm(`确认向工会推送结算单 ${row.settlementId} 的开票结果?`, '推送发票', { confirmButtonText: '确认推送', cancelButtonText: '取消' })
await postAdmin('merchant-invoice-push', { id: row.id })
ElMessage.success('发票推送成功')
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '发票推送失败')
} finally { pushingId.value = null }
}
const openFile = async (row: Invoice, download = false) => {
if (fileLoading.value) return
fileLoading.value = true
try {
const data = await postAdmin<{ url: string }>('merchant-invoice-file', { id: row.id, download })
if (download) {
const link = document.createElement('a')
link.href = data.url
link.download = `${row.invoiceIds || 'invoice'}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
} else { previewUrl.value = data.url; previewVisible.value = true }
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '获取发票文件失败') } finally { fileLoading.value = false }
}
onMounted(load)
</script>
<style scoped>
.invoice-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; }
.invoice-detail :deep(.el-descriptions__label) { width: 110px; }
.orders-title { margin: 24px 0 12px; font-size: 15px; }
.invoice-orders { display: flex; flex-wrap: wrap; gap: 10px; }
.invoice-preview { display: block; width: 100%; height: 60vh; border: 0; }
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<main class="login-page">
<section class="login-intro">
<div class="login-brand"><el-icon><ShoppingBag /></el-icon>供应商管理</div>
<h1>高效管理,让服务更进一步</h1>
<p>商品、订单、售后与对账,集中处理每一项业务。</p>
<div class="business-tags"><span>商品管理</span><span>订单处理</span><span>售后服务</span><span>订单对账</span></div>
</section>
<section class="login-card">
<h2>账号登录</h2>
<p class="login-hint">欢迎回来,请登录供应商管理平台</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="账号" prop="username">
<el-input v-model="form.username" :prefix-icon="User" placeholder="请输入账号" autocomplete="username" :maxlength="64" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" :prefix-icon="Lock" type="password" show-password placeholder="请输入密码" autocomplete="current-password" :maxlength="128" />
</el-form-item>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" class="login-error" />
<el-button class="login-submit" type="primary" native-type="submit" :loading="submitting">{{ submitting ? '正在登录' : '登录' }}</el-button>
</el-form>
</section>
</main>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Lock, ShoppingBag, User } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { postAdmin } from '@/api/admin'
import { saveAdminSession, type AdminSession } from '@/utils/auth'
const router = useRouter()
const route = useRoute()
const formRef = ref<FormInstance>()
const form = reactive({ username: '', password: '' })
const submitting = ref(false)
const errorMessage = ref('')
const rules: FormRules = {
username: [{ required: true, whitespace: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const submit = async () => {
if (submitting.value || !await formRef.value?.validate().catch(() => false)) return
submitting.value = true
errorMessage.value = ''
try {
const session = await postAdmin<AdminSession>('login', { username: form.username.trim(), password: form.password })
saveAdminSession(session)
form.password = ''
const target = typeof route.query.redirect === 'string' ? route.query.redirect : ''
const resolved = target.startsWith('/') && !target.startsWith('//') && !target.includes(String.fromCharCode(92)) ? router.resolve(target) : null
await router.replace(resolved?.matched.length && resolved.name !== 'Login' ? target : '/reconciliation')
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.login-page { display: flex; align-items: center; justify-content: center; gap: clamp(48px, 7vw, 120px); min-height: 100vh; padding: 60px 40px; background: linear-gradient(120deg, #eaf0fa 0%, #f6f8fc 55%, #eef2f8 100%); }
.login-intro { max-width: 480px; color: #1c304e; }
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 64px; font-size: 22px; font-weight: 600; letter-spacing: 1px; }
.login-brand .el-icon { width: 48px; height: 48px; border-radius: 13px; background: #2463d4; color: #fff; font-size: 27px; box-shadow: 0 8px 20px rgba(36, 99, 212, .18); }
.login-intro h1 { max-width: 410px; margin: 0 0 20px; font-size: 38px; font-weight: 600; line-height: 1.55; letter-spacing: 1px; }
.login-intro p { color: #738199; font-size: 14px; line-height: 1.9; }
.business-tags { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px; }
.business-tags span { padding: 8px 13px; border: 1px solid #dce5f2; border-radius: 6px; color: #536985; background: rgba(255, 255, 255, .65); font-size: 12px; }
.login-card { width: 420px; flex-shrink: 0; padding: 42px 38px; border: 1px solid #e4eaf3; border-radius: 18px; background: #fff; box-shadow: 0 18px 60px rgba(38, 60, 98, .08); }
.login-card h2 { margin: 0 0 12px; color: #202e45; font-size: 25px; font-weight: 600; }
.login-hint { margin: 0 0 32px; color: #8793a5; font-size: 13px; line-height: 1.7; }
.login-card :deep(.el-form-item) { margin-bottom: 24px; }
.login-card :deep(.el-form-item__label) { color: #526078; font-size: 13px; }
.login-card :deep(.el-input__wrapper) { min-height: 44px; padding: 0 14px; }
.login-submit { width: 100%; height: 44px; margin-top: 10px; letter-spacing: 2px; }
.login-error { margin-bottom: 16px; }
@media (max-width: 960px) { .login-page { gap: 36px; padding: 40px 24px; } .login-intro h1 { font-size: 30px; } .login-card { width: 380px; padding: 34px 28px; } }
@media (max-width: 700px) { .login-page { flex-direction: column; gap: 26px; padding: 32px 20px; } .login-intro { width: 100%; max-width: 420px; } .login-brand { margin-bottom: 0; font-size: 20px; } .login-intro h1, .login-intro p, .business-tags { display: none; } .login-card { width: 100%; max-width: 420px; } }
</style>
+98
View File
@@ -0,0 +1,98 @@
<template>
<section class="page-card">
<div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div><el-button type="primary" :loading="batchShipping" :disabled="loading || submitting || shipDialogVisible || Boolean(repushingOrderId) || !pendingShipOrders.length" @click="quickShipAll">一键发货</el-button></div>
<el-form :inline="true" class="query-form"><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号" clearable /></el-form-item><el-form-item label="用户昵称"><el-input v-model="query.userNickname" placeholder="请输入用户昵称" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="200" show-overflow-tooltip /><el-table-column label="用户昵称" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.userNickname || '-' }}</template></el-table-column><el-table-column label="商品信息" min-width="300"><template #default="{ row }">
<div v-for="(product, index) in row.products || []" :key="product.id || index" class="order-product">
<el-image class="order-product__image" :src="resolveImageUrl(product.image)" fit="cover" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported>
<template #error><span class="order-product__placeholder">暂无图片</span></template>
</el-image>
<div class="order-product__info">
<div class="order-product__name">{{ product.name || '-' }}</div>
<div class="order-product__bottom"><span class="order-product__price">{{ formatPrice(product.price) }}</span><span class="order-product__quantity">×{{ product.number ?? '-' }}</span></div>
</div>
</div>
<span v-if="!row.products?.length">-</span>
</template></el-table-column><el-table-column label="订单金额" width="120" align="right"><template #default="{ row }"><span class="order-amount">{{ formatPrice(row.amount) }}</span></template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="订单状态" width="110"><template #default="{ row }"><el-tag :type="[4, 5].includes(row.orderState) ? 'success' : [0, 1, 6].includes(row.orderState) ? 'warning' : row.orderState === 2 ? 'primary' : 'info'">{{ stateText(row.orderState) }}</el-tag></template></el-table-column><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" :disabled="batchShipping" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="batchShipping || Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无订单" /></template></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="loadOrders" />
<el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin, resolveImageUrl } from '@/api/admin'
type OrderProduct = { id?: number; name?: string; image?: string; price?: string | number; number?: number }
type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; userNickname?: string; orderState: number; createTime?: string; crrgBsnNm?: string; logisticsOrderId?: string; amount?: string | number; products?: OrderProduct[] }
const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }]
const query = reactive<{ orderId: string; userNickname: string; orderState?: number; pageNumber: number; pageSize: number }>({ orderId: '', userNickname: '', pageNumber: 1, pageSize: 10 })
const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' })
const totalCount = ref(0)
const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知'
const loadOrders = async () => { loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[]; totalCount?: number }>('merchant-order-list', query); orders.value = data.orderItems || []; totalCount.value = data.totalCount || 0; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } }
const searchOrders = () => { query.pageNumber = 1; return loadOrders() }
const reset = () => { query.orderId = ''; query.userNickname = ''; query.orderState = undefined; searchOrders() }
const formatPrice = (value?: string | number) => value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value)) ? `¥${Number(value).toFixed(2)}` : '-'
const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '韵达快递', logisticsOrderId: `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}` }; shipDialogVisible.value = true }
const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: currentOrder.value.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } }
const batchShipping = ref(false)
const pendingShipOrders = computed(() => orders.value.filter(order => order.orderState === 1))
const quickShipAll = async () => {
if (batchShipping.value || loading.value || submitting.value || shipDialogVisible.value || repushingOrderId.value) return
const targets = pendingShipOrders.value.map(order => ({ ...order }))
if (!targets.length) return ElMessage.warning('当前页没有待发货订单')
batchShipping.value = true
try {
await ElMessageBox.confirm(`将为当前页的 ${targets.length} 个待发货订单提交发货,默认使用韵达快递,并为每笔订单生成独立快递单号。是否继续?`, '确认一键发货', { type: 'warning', confirmButtonText: '确认发货', cancelButtonText: '取消' })
const results = await Promise.allSettled(targets.map((order, index) => postAdmin('merchant-order-ship', {
supplierId: order.supplierId,
userId: order.userId,
orderId: order.orderId,
crrgBsnNm: order.crrgBsnNm || '韵达快递',
logisticsOrderId: order.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}${index}`
})))
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failures.length) {
const reason = failures[0].reason instanceof Error ? failures[0].reason.message : '请求失败'
ElMessage.warning({ message: `发货成功 ${results.length - failures.length} 笔,失败 ${failures.length} 笔:${reason}`, duration: 6000 })
} else {
ElMessage.success(`已成功发货 ${results.length} 笔订单`)
}
await loadOrders()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '批量发货失败')
} finally {
batchShipping.value = false
}
}
const repushingOrderId = ref('')
const repushSplit = async (order: OrderItem) => {
if (repushingOrderId.value) return
repushingOrderId.value = order.orderId
try {
await postAdmin('merchant-order-repush-split', { supplierId: order.supplierId, userId: order.userId, orderId: order.orderId })
ElMessage.success('重新推送拆单成功')
await loadOrders()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重新推送拆单失败')
} finally {
repushingOrderId.value = ''
}
}
onMounted(loadOrders)
</script>
<style scoped lang="scss">
.order-product { display: flex; align-items: center; gap: 12px; min-width: 0; }
.order-product + .order-product { margin-top: 12px; padding-top: 12px; border-top: 1px solid #edf0f5; }
.order-product__image { width: 58px; height: 58px; flex-shrink: 0; overflow: hidden; border-radius: 7px; background: #f3f5f9; }
.order-product__placeholder { display: grid; height: 100%; place-items: center; color: #8793a5; font-size: 11px; }
.order-product__info { min-width: 0; flex: 1; }
.order-product__name { color: #202e45; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
.order-product__bottom { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 6px; }
.order-product__price, .order-amount { color: #ed6a2c; font-weight: 600; font-variant-numeric: tabular-nums; }
.order-product__quantity { color: #526078; font-size: 12px; white-space: nowrap; }
</style>
@@ -0,0 +1,69 @@
<template>
<section class="page-card">
<div class="page-title"><div><h2>商品管理</h2><p>管理供应商商品的展示、库存和上下架状态。</p></div><el-button type="primary" @click="openCreate">新增商品</el-button></div>
<el-form inline class="query-form" @submit.prevent><el-form-item label="商品"><el-input v-model="query.keyword" placeholder="商品名称或 SKU" clearable /></el-form-item><el-form-item label="分类"><el-select v-model="query.categoryId" placeholder="全部" clearable><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-table :data="products" v-loading="loading" border><el-table-column prop="sku" label="SKU" min-width="140" /><el-table-column prop="name" label="商品名称" min-width="180" /><el-table-column prop="category" label="分类" width="120" /><el-table-column prop="price" label="售价" width="100" /><el-table-column prop="stock" label="库存" width="90" /><el-table-column prop="sortOrder" label="排序" width="80" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '上架' : '下架' }}</el-tag></template></el-table-column><el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无商品" /></template></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
<el-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="760px" destroy-on-close><el-form class="product-form" :model="form" label-width="90px"><el-row :gutter="24"><el-col :xs="24" :sm="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="分类"><el-select v-model="form.categoryId" @change="setCategory"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品标签"><el-input v-model="form.tag" placeholder="如:赠品、积分兑换" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="已售数量"><el-input v-model="form.sales" disabled placeholder="0" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品数量"><el-input-number v-model="form.number" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="税率"><el-input-number v-model="form.taxRate" :min="0" :max="1" :step="0.01" :precision="4" /><span class="product-field-hint">按小数填写,例如 0.13 表示 13%</span></el-form-item></el-col></el-row><el-form-item label="商品图片"><el-upload class="product-image-upload" list-type="picture-card" accept="image/*" :auto-upload="false" :show-file-list="false" :disabled="uploading" :on-change="uploadProductImage" v-loading="uploading" :aria-label="form.imageUrl ? '更换商品图片' : '上传商品图片'"><template v-if="form.imageUrl"><img :src="resolveImageUrl(form.imageUrl)" alt="商品图片" class="product-image-preview" /><span class="product-image-replace">点击更换</span></template><div v-else class="product-image-placeholder"><span class="product-image-plus" aria-hidden="true">+</span><span>上传图片</span></div></el-upload></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item><el-form-item label="商品详情"><el-input v-model="form.detail" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="uploading" @click="save">保存</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin, resolveImageUrl } from '@/api/admin'
import type { UploadFile } from 'element-plus'
type Category = { id: string; name: string }
type Product = { id: number; sku: string; name: string; categoryId: string; category: string; price: string; originPrice: string; stock: number; sortOrder: number; enabled: boolean; imageUrl: string; description: string; tag: string; sales: string; detail: string; number: number; taxRate: number }
type ProductForm = { id?: number; sku: string; name: string; price: number; originPrice?: number; stock: number; sortOrder: number; categoryId: string; category: string; imageUrl: string; description: string; enabled: boolean; tag: string; sales: string; detail: string; number: number; taxRate: number }
const query = reactive({ keyword: '', categoryId: '', pageNumber: 1, pageSize: 10 })
const totalCount = ref(0)
const uploading = ref(false)
const uploadProductImage = async (file: UploadFile) => {
if (!file.raw || uploading.value) return
if (!file.raw.type.startsWith('image/')) return ElMessage.warning('请选择图片文件')
uploading.value = true
try {
const contentBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('图片读取失败'))
reader.readAsDataURL(file.raw!)
})
const data = await postAdmin<{ fileUrl: string }>('upload', { fileName: file.name, contentType: file.raw.type, contentBase64, folder: 'supplied/product' })
form.imageUrl = data.fileUrl
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '图片上传失败') } finally { uploading.value = false }
}
const categories = ref<Category[]>([]); const products = ref<Product[]>([]); const loading = ref(false); const loaded = ref(false); const visible = ref(false); const saving = ref(false)
const emptyForm = (): ProductForm => ({ sku: '', name: '', price: 0, stock: 0, sortOrder: 0, categoryId: '', category: '', imageUrl: '', description: '', enabled: true, tag: '', sales: '0', detail: '', number: 0, taxRate: 0.13 })
const form = reactive<ProductForm>(emptyForm())
const setForm = (value: ProductForm) => Object.assign(form, value)
const load = async () => { loading.value = true; try { const data = await postAdmin<{ categories: Category[]; products: Product[]; totalCount: number }>('merchant-product-list', query); categories.value = data.categories || []; products.value = data.products || []; totalCount.value = data.totalCount || 0; loaded.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '商品查询失败') } finally { loading.value = false } }
const search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.keyword = ''; query.categoryId = ''; return search() }
const openCreate = () => { setForm(emptyForm()); visible.value = true }
const openEdit = (product: Product) => { setForm({ id: product.id, sku: product.sku, name: product.name, price: Number(product.price), originPrice: product.originPrice ? Number(product.originPrice) : undefined, stock: product.stock, sortOrder: product.sortOrder, categoryId: product.categoryId, category: product.category, imageUrl: product.imageUrl, description: product.description || '', enabled: product.enabled, tag: product.tag || '', sales: product.sales?.trim() || '0', detail: product.detail || '', number: product.number ?? 0, taxRate: product.taxRate ?? 0.13 }); visible.value = true }
const setCategory = (categoryId: string) => { form.category = categories.value.find(item => item.id === categoryId)?.name || '' }
const save = async () => { saving.value = true; try { await postAdmin('merchant-product-save', form); ElMessage.success('保存成功'); visible.value = false; await load() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false } }
const remove = async (product: Product) => { try { await ElMessageBox.confirm(`确认删除商品“${product.name}”?`, '删除商品', { type: 'warning' }); await postAdmin('merchant-product-delete', { id: product.id }); ElMessage.success('删除成功'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') } }
onMounted(load)
</script>
<style scoped>
.product-field-hint { margin-top: 6px; color: #8793a5; font-size: 12px; line-height: 18px; }
.product-image-upload :deep(.el-upload--picture-card) { position: relative; width: 128px; height: 128px; overflow: hidden; border-radius: 8px; }
.product-image-preview { display: block; width: 100%; height: 100%; object-fit: cover; }
.product-image-placeholder { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8793a5; font-size: 13px; }
.product-image-plus { font-size: 32px; line-height: 32px; }
.product-image-replace { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 12px; background: rgba(0, 0, 0, .4); color: #fff; font-size: 13px; line-height: 20px; opacity: 0; }
.product-image-upload :deep(.el-upload--picture-card:hover) .product-image-replace,
.product-image-upload :deep(.el-upload--picture-card:focus-within) .product-image-replace { opacity: 1; }
@media (hover: none) { .product-image-replace { top: auto; padding: 6px 12px; opacity: 1; } }
.product-form :deep(.el-row .el-form-item) { margin-bottom: 24px; }
.product-form :deep(.el-form-item__content) { min-width: 0; }
.product-form :deep(.el-input),
.product-form :deep(.el-select),
.product-form :deep(.el-input-number) { width: 100%; }
.product-form :deep(.el-form-item__label) { line-height: 36px; }
</style>
@@ -0,0 +1,67 @@
<template>
<ele-page>
<section class="page-card">
<div class="page-title"><div><h2>订单对账</h2><p>查看 ZHGH 回传的子订单对账结果,并处理异常订单。</p></div></div>
<el-form inline class="query-form" @submit.prevent>
<el-form-item label="子订单"><el-input v-model="query.orderId" placeholder="子订单 ID" clearable /></el-form-item>
<el-form-item label="对账状态"><el-select v-model="query.reconciliationStatus" clearable placeholder="全部"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table :data="items" v-loading="loading" border>
<el-table-column prop="orderId" label="子订单ID" min-width="180" />
<el-table-column prop="reconciliationStatus" label="对账状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.reconciliationStatus)">{{ statusText(row.reconciliationStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="diffReason" label="差异说明" min-width="220" show-overflow-tooltip />
<el-table-column label="平台金额" min-width="120"><template #default="{ row }">{{ row.platformTotalPrice || '-' }}</template></el-table-column>
<el-table-column label="供应商金额" min-width="120"><template #default="{ row }">{{ row.supplierTotalPrice || '-' }}</template></el-table-column>
<el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip />
<el-table-column prop="reconciliationTime" label="对账时间" min-width="170" />
<template #empty><el-empty :image-size="80" description="暂无对账结果" /></template></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
</section>
<el-dialog v-model="dialogVisible" title="处理对账结果" width="460px">
<el-form label-width="90px"><el-form-item label="子订单"><span>{{ editing?.orderId }}</span></el-form-item><el-form-item label="对账状态"><el-select v-model="form.reconciliationStatus"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item><el-form-item label="差异类型"><el-input-number v-model="form.diffType" :min="0" :precision="0" /></el-form-item><el-form-item label="差异说明"><el-input v-model="form.diffReason" type="textarea" :rows="3" /></el-form-item></el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</ele-page>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { postAdmin } from '@/api/admin'
type ReconciliationItem = { supplierId: string; orderId: string; mainOrderId: string; reconciliationStatus: number; diffType?: number; diffReason: string; platformTotalPrice: string; supplierTotalPrice: string; reconciliationBatchNo: string; reconciliationTime: string }
const query = reactive<{ orderId: string; reconciliationStatus?: number; pageNumber: number; pageSize: number }>({ orderId: '', pageNumber: 1, pageSize: 10 })
const totalCount = ref(0)
const items = ref<ReconciliationItem[]>([])
const loading = ref(false)
const loaded = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editing = ref<ReconciliationItem>()
const form = reactive<{ reconciliationStatus: number; diffType?: number; diffReason: string }>({ reconciliationStatus: 0, diffReason: '' })
const statusText = (status: number) => ['未对账', '对账成功', '对账异常'][status] || '未知'
const statusType = (status: number) => status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
const load = async () => {
loading.value = true
try {
const data = await postAdmin<{ reconciliationItems: ReconciliationItem[]; totalCount: number }>('merchant-reconciliation-list', query)
items.value = data.reconciliationItems || []
totalCount.value = data.totalCount || 0
loaded.value = true
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '查询失败') } finally { loading.value = false }
}
const search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.orderId = ''; query.reconciliationStatus = undefined; return search() }
const openEdit = (row: ReconciliationItem) => { editing.value = row; form.reconciliationStatus = row.reconciliationStatus; form.diffType = row.diffType; form.diffReason = row.diffReason || ''; dialogVisible.value = true }
const save = async () => {
if (!editing.value) return
saving.value = true
try {
await postAdmin('merchant-reconciliation-update', { supplierId: editing.value.supplierId, orderId: editing.value.orderId, reconciliationStatus: form.reconciliationStatus, diffType: form.diffType, diffReason: form.diffReason })
ElMessage.success('保存成功'); dialogVisible.value = false; await load()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false }
}
onMounted(load)
</script>
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ES2020", "DOM"], "baseUrl": ".", "paths": { "@/*": ["src/*"] } },
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } })
+146 -83
View File
@@ -11,16 +11,16 @@
"@vant/area-data": "^1.5.2",
"core-js": "^3.37.1",
"pinia": "^2.3.1",
"vant": "^2.13.9",
"vue": "^2.7.16",
"vue-router": "^3.6.5"
"vant": "^3.6.16",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/compiler-sfc": "^3.5.13",
"less": "^4.2.0",
"less-loader": "^10.2.0",
"vue-template-compiler": "^2.7.16"
"less-loader": "^10.2.0"
}
},
"node_modules/@achrinza/node-ipc": {
@@ -337,10 +337,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1446,6 +1448,7 @@
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1482,7 +1485,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -1550,7 +1555,6 @@
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1859,15 +1863,27 @@
"license": "MIT"
},
"node_modules/@vant/icons": {
"version": "3.0.2",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@vant/icons/-/icons-1.8.0.tgz",
"integrity": "sha512-sKfEUo2/CkQFuERxvkuF6mGQZDKu3IQdj5rV9Fm0weJXtchDSSQ+zt8qPCNUEhh9Y8shy5PzxbvAfOOkCwlCXg==",
"license": "MIT"
},
"node_modules/@vant/popperjs": {
"version": "1.3.0",
"license": "MIT"
},
"node_modules/@vant/use": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vant/use/-/use-1.6.0.tgz",
"integrity": "sha512-PHHxeAASgiOpSmMjceweIrv2AxDZIkWXyaczksMoWvKV2YAYEhoizRuk/xFnKF+emUIi46TsQ+rvlm/t2BBCfA==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@vue/babel-helper-vue-jsx-merge-props": {
"version": "1.4.0",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/babel-helper-vue-transform-on": {
@@ -2277,49 +2293,53 @@
"license": "ISC"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.39",
"dev": true,
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
"integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.39",
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.42",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.39",
"dev": true,
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
"integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.39",
"@vue/shared": "3.5.39"
"@vue/compiler-core": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.39",
"dev": true,
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
"integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.39",
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39",
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.42",
"@vue/compiler-dom": "3.5.42",
"@vue/compiler-ssr": "3.5.42",
"@vue/shared": "3.5.42",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.15",
"postcss": "^8.5.19",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.39",
"dev": true,
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
"integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.39",
"@vue/shared": "3.5.39"
"@vue/compiler-dom": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/component-compiler-utils": {
@@ -2386,9 +2406,52 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
"integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
"integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
"integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.42",
"@vue/runtime-core": "3.5.42",
"@vue/shared": "3.5.42",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
"integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.42",
"@vue/runtime-dom": "3.5.42",
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/shared": {
"version": "3.5.39",
"dev": true,
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
"license": "MIT"
},
"node_modules/@vue/vue-loader-v15": {
@@ -3981,11 +4044,8 @@
},
"node_modules/csstype": {
"version": "3.2.3",
"license": "MIT"
},
"node_modules/de-indent": {
"version": "1.0.2",
"dev": true,
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -4400,7 +4460,8 @@
},
"node_modules/entities": {
"version": "7.0.1",
"dev": true,
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -4531,7 +4592,8 @@
},
"node_modules/estree-walker": {
"version": "2.0.2",
"dev": true,
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/esutils": {
@@ -6048,7 +6110,6 @@
},
"node_modules/magic-string": {
"version": "0.30.21",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -6452,7 +6513,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -7020,7 +7083,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.18",
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"funding": [
{
"type": "opencollective",
@@ -7037,7 +7102,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -7553,6 +7618,7 @@
},
"node_modules/prettier": {
"version": "2.8.8",
"dev": true,
"license": "MIT",
"optional": true,
"bin": {
@@ -8397,6 +8463,7 @@
},
"node_modules/source-map": {
"version": "0.6.1",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -9025,19 +9092,17 @@
}
},
"node_modules/vant": {
"version": "2.13.9",
"resolved": "https://registry.npmmirror.com/vant/-/vant-2.13.9.tgz",
"integrity": "sha512-AIDYTUKOlqEolrTs18vZanLX9Mq6IftzwOnbozaNFmx9+xMrCeJIqRKefbGUPZ7irQvO6t397+VwRXqCSHnedQ==",
"version": "3.6.16",
"resolved": "https://registry.npmjs.org/vant/-/vant-3.6.16.tgz",
"integrity": "sha512-9pZao0NEeZQ0ZEb6N7SZxtqcdTp24o8IizhZS1G+FtStlXeKOFzCl+Nf1pIWRneQ9Kn+K+mNrfi2eiIZjVVppw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.x",
"@vant/icons": "^3.0.2",
"@vant/popperjs": "^1.1.0",
"@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
"vue-lazyload": "1.2.3"
"@vant/icons": "^1.8.0",
"@vant/popperjs": "^1.2.1",
"@vant/use": "^1.4.2"
},
"peerDependencies": {
"vue": ">= 2.6.0"
"vue": "^3.0.0"
}
},
"node_modules/vary": {
@@ -9049,11 +9114,24 @@
}
},
"node_modules/vue": {
"version": "2.7.16",
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-sfc": "2.7.16",
"csstype": "^3.1.0"
"@vue/compiler-dom": "3.5.42",
"@vue/compiler-sfc": "3.5.42",
"@vue/runtime-dom": "3.5.42",
"@vue/server-renderer": "3.5.42",
"@vue/shared": "3.5.42"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-hot-reload-api": {
@@ -9061,10 +9139,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/vue-lazyload": {
"version": "1.2.3",
"license": "MIT"
},
"node_modules/vue-loader": {
"version": "17.4.2",
"dev": true,
@@ -9102,10 +9176,19 @@
}
},
"node_modules/vue-router": {
"version": "3.6.5",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-3.6.5.tgz",
"integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ==",
"license": "MIT"
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-style-loader": {
"version": "4.1.3",
@@ -9121,31 +9204,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/vue-template-compiler": {
"version": "2.7.16",
"dev": true,
"license": "MIT",
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
}
},
"node_modules/vue-template-es2015-compiler": {
"version": "1.9.1",
"dev": true,
"license": "MIT"
},
"node_modules/vue/node_modules/@vue/compiler-sfc": {
"version": "2.7.16",
"dependencies": {
"@babel/parser": "^7.23.5",
"postcss": "^8.4.14",
"source-map": "^0.6.1"
},
"optionalDependencies": {
"prettier": "^1.18.2 || ^2.0.0"
}
},
"node_modules/watchpack": {
"version": "2.5.2",
"dev": true,
+4 -4
View File
@@ -10,15 +10,15 @@
"@vant/area-data": "^1.5.2",
"core-js": "^3.37.1",
"pinia": "^2.3.1",
"vant": "^2.13.9",
"vue": "^2.7.16",
"vue-router": "^3.6.5"
"vant": "^3.6.16",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"less": "^4.2.0",
"less-loader": "^10.2.0",
"vue-template-compiler": "^2.7.16"
"@vue/compiler-sfc": "^3.5.13"
}
}
+15
View File
@@ -0,0 +1,15 @@
const STORAGE_KEY = 'supplied-h5-merchant-session'
export const clearMerchantSession = () => sessionStorage.removeItem(STORAGE_KEY)
export const saveMerchantSession = session => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
export const readMerchantSession = () => {
try {
const session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
if (session?.token && session?.user && Date.parse(session.expiresAt) > Date.now()) return session
} catch { /* 损坏的会话按未登录处理。 */ }
clearMerchantSession()
return null
}
export const merchantLoginLocation = () => {
const redirect = window.location.hash.slice(1) || '/joyful-selection/merchant'
return `#/joyful-selection/merchant/login?redirect=${encodeURIComponent(redirect)}`
}
+26 -4
View File
@@ -1,7 +1,20 @@
import { readMerchantSession, clearMerchantSession, merchantLoginLocation } from './merchant-session'
const API_BASE = process.env.VUE_APP_SUPPLIED_API_BASE || 'http://localhost:8888/api'
const normalizeBase = base => base.replace(/\/$/, '')
const resolveFileUrls = value => {
if (typeof value === 'string' && /^\/api\/(?:file|upload-preview)\?/.test(value)) {
return `${normalizeBase(API_BASE).replace(/\/api$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
}
if (Array.isArray(value)) return value.map(resolveFileUrls)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveFileUrls(item)]))
}
return value
}
export const getFunction = async (name, params = {}) => {
const url = new URL(`${normalizeBase(API_BASE)}/${name}`)
Object.entries(params).forEach(([key, value]) => {
@@ -18,25 +31,34 @@ export const getFunction = async (name, params = {}) => {
throw new Error(message)
}
return payload.data
return resolveFileUrls(payload.data)
}
export const postFunction = async (name, body = {}) => {
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
const merchantRequest = name.startsWith('merchant-') || ['login', 'admin-session', 'admin-logout'].includes(name)
const token = merchantRequest ? readMerchantSession()?.token : ''
const response = await fetch(`${normalizeBase(API_BASE)}${merchantRequest ? '/admin' : ''}/${name}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(body)
})
const payload = await response.json().catch(() => ({}))
if (merchantRequest && name !== 'login' && (response.status === 401 || payload.error?.code === 'UNAUTHORIZED')) {
clearMerchantSession()
if (name !== 'admin-session' && !window.location.hash.startsWith('#/joyful-selection/merchant/login')) {
window.location.replace(merchantLoginLocation())
}
}
if (!response.ok || payload.success === false) {
const message = payload.error?.message || payload.msg || '请求失败'
throw new Error(message)
}
return payload.data
return resolveFileUrls(payload.data)
}
export const uploadImageFunction = async (file, folder = 'after-sale') => {
+3 -3
View File
@@ -14,7 +14,7 @@
<van-notice-bar class="notice" left-icon="volume-o" :text="shop.notice" />
<van-search v-model="keyword" placeholder="搜索商品" shape="round" />
<van-tabs v-model="activeCategory" sticky :color="shop.theme" title-active-color="var(--theme)">
<van-tabs v-model:active="activeCategory" sticky :color="shop.theme" title-active-color="var(--theme)">
<van-tab v-for="category in categories" :key="category" :title="category" />
</van-tabs>
@@ -44,7 +44,7 @@
<span class="selected">已选 {{ totalCount }} 件</span>
</van-submit-bar>
<van-popup v-model="checkoutVisible" round position="bottom" :style="{ minHeight: '68%' }" closeable>
<van-popup v-model:show="checkoutVisible" round position="bottom" :style="{ minHeight: '68%' }" closeable>
<section class="checkout">
<h2>确认订单</h2>
<van-contact-card type="add" name="商城收货人" tel="138****8888" @click="Toast('当前使用默认收货地址')" />
@@ -66,7 +66,7 @@
</section>
</van-popup>
<van-popup v-model="successVisible" round closeable class="result-popup">
<van-popup v-model:show="successVisible" round closeable class="result-popup">
<van-icon name="checked" color="#23b65d" size="62" />
<h2>订单提交成功</h2>
<p>订单号:{{ submittedOrderId }}</p>
+3 -12
View File
@@ -1,20 +1,11 @@
import Vue from 'vue'
import { createApp } from 'vue'
import Vant from 'vant'
import { Skeleton } from 'vant'
import { createPinia, PiniaVuePlugin } from 'pinia'
import { createPinia } from 'pinia'
import 'vant/lib/index.css'
import App from './App.vue'
import router from './router'
Vue.use(Vant)
Vue.use(Skeleton)
Vue.use(PiniaVuePlugin)
Vue.config.productionTip = false
const pinia = createPinia()
new Vue({
router,
pinia,
render: h => h(App)
}).$mount('#app')
createApp(App).use(pinia).use(router).use(Vant).mount('#app')
+26 -8
View File
@@ -1,5 +1,7 @@
import Vue from 'vue'
import Router from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import { Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { readMerchantSession, clearMerchantSession } from '@/api/merchant-session'
import SupplierHome from '@/view/index.vue'
import JoyfulSelectionLayout from '@/view/joyful-selection/layout.vue'
import JoyfulSelectionHome from '@/view/joyful-selection/index.vue'
@@ -29,16 +31,16 @@ import HappyShopping from '@/view/happy-shopping/index.vue'
import FreshFruit from '@/view/fresh-fruit/index.vue'
import CuteSelection from '@/view/cute-selection/index.vue'
Vue.use(Router)
export default new Router({
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: SupplierHome },
{ path: '/joyful-selection/merchant/login', component: () => import('@/view/joyful-selection/merchant/login.vue') },
{
path: '/joyful-selection',
component: JoyfulSelectionLayout,
children: [
{ path: '', redirect: 'home' },
{ path: '', redirect: '/joyful-selection/home' },
{ path: 'home', component: JoyfulSelectionHome },
{ path: 'products', component: JoyfulSelectionProductList },
{ path: 'product-detail', component: JoyfulSelectionProductDetail },
@@ -56,8 +58,8 @@ export default new Router({
{ path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect },
{ path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn },
{ path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange },
{ path: 'merchant', component: JoyfulSelectionMerchant },
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail },
{ path: 'merchant', component: JoyfulSelectionMerchant, meta: { merchantAuth: true } },
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail, meta: { merchantAuth: true } },
{ path: 'supplier-logs', component: JoyfulSelectionSupplierLogs },
{ path: 'profile', component: JoyfulSelectionProfile },
{ path: 'points', component: JoyfulSelectionPoints }
@@ -69,3 +71,19 @@ export default new Router({
{ path: '/cute-selection', component: CuteSelection }
]
})
router.beforeEach(async to => {
if (!to.meta.merchantAuth) return true
const login = { path: '/joyful-selection/merchant/login', query: { redirect: to.fullPath } }
if (!readMerchantSession()) return login
try {
await postFunction('admin-session')
return true
} catch (error) {
clearMerchantSession()
Toast(error.message || '登录已失效,请重新登录')
return login
}
})
export default router
+21 -2
View File
@@ -20,6 +20,7 @@ const saveUser = data => {
export const useUserStore = defineStore('joyfulSelectionUser', {
state: () => ({
userId: '',
nickname: '',
supplierIdentification: '',
supplierId: '',
supplierName: '',
@@ -34,6 +35,7 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
loadUser() {
const data = readUser()
this.userId = data.userId || ''
this.nickname = data.nickname || ''
const supplierIdentification = DEFAULT_SUPPLIER_IDENTIFICATION || data.supplierIdentification || ''
const supplierChanged = Boolean(data.supplierIdentification && supplierIdentification !== data.supplierIdentification)
@@ -46,6 +48,7 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
persistUser() {
saveUser({
userId: this.userId,
nickname: this.nickname,
supplierIdentification: this.supplierIdentification,
supplierId: this.supplierId,
supplierName: this.supplierName,
@@ -53,17 +56,33 @@ export const useUserStore = defineStore('joyfulSelectionUser', {
})
this.loaded = true
},
hydrateFromRoute(query = {}) {
async hydrateFromRoute(query = {}) {
if (!this.loaded) {
this.loadUser()
}
const nextUserId = query.userId || ''
const keyword = query.keyword || new URLSearchParams(window.location.search).get('keyword') || ''
let nextUserId = query.userId || ''
let nextNickname
if (keyword) {
const sessionKeyword = String(keyword).replace(/ /g, '+')
console.log('[supplier-session] keyword', {
routeKeyword: query.keyword,
searchKeyword: new URLSearchParams(window.location.search).get('keyword') || '',
sessionKeyword
})
const data = await postFunction('supplier-session', { keyword: sessionKeyword })
nextUserId = data?.userId || ''
nextNickname = data?.userInfo?.nickname || ''
}
if (nextUserId && nextUserId !== this.userId) {
this.userId = nextUserId
this.nickname = ''
this.pointsBalance = 0
}
if (nextNickname !== undefined) this.nickname = nextNickname
if (!this.supplierIdentification) {
this.supplierIdentification = DEFAULT_SUPPLIER_IDENTIFICATION
}
@@ -87,7 +87,7 @@
<button v-else type="button" class="primary" @click="contactService">联系客服</button>
</footer>
<van-popup v-model="showLogisticsPopup" round position="bottom">
<van-popup v-model:show="showLogisticsPopup" round position="bottom">
<section class="logistics-popup">
<h3>填写物流单号</h3>
<van-field v-model="logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
@@ -96,7 +96,7 @@
</section>
<van-button v-show="!loading" class="submit-button" block :loading="submitting" @click="submitReturn">提交申请</van-button>
<van-popup v-model="showReasonPopup" round position="bottom">
<van-popup v-model:show="showReasonPopup" round position="bottom">
<section class="reason-popup">
<h3>请选择换货原因</h3>
<van-radio-group v-model="selectedReason">
@@ -116,7 +116,7 @@
</van-radio-group>
</section>
</van-popup>
<van-popup v-model="showExchangeGoodsPopup" round position="bottom" :style="{ height: '80vh' }">
<van-popup v-model:show="showExchangeGoodsPopup" round position="bottom" :style="{ height: '80vh' }">
<section class="exchange-popup">
<h3>选择换货商品</h3>
<p>仅可选择价格为 ¥{{ originalProductPrice }} 的商品</p>
@@ -3,7 +3,7 @@
<van-nav-bar title="退款/售后" left-arrow @click-left="$router.replace('/joyful-selection/profile')" />
<van-pull-refresh v-model="refreshing" class="after-sale-list" @refresh="onRefresh">
<van-tabs v-model="activeTab" color="#ff5b20">
<van-tabs v-model:active="activeTab" color="#ff5b20">
<van-tab title="处理中" name="processing" />
<van-tab title="历史" name="history" />
</van-tabs>
@@ -76,7 +76,7 @@
</section>
<van-button v-show="!loading" class="submit-button" block :loading="submitting" @click="submitReturn">提交申请</van-button>
<van-popup v-model="showReasonPopup" round position="bottom">
<van-popup v-model:show="showReasonPopup" round position="bottom">
<section class="reason-popup">
<h3>请选择退货原因</h3>
<van-radio-group v-model="selectedReason">
@@ -18,10 +18,10 @@
class="cart-item"
>
<van-checkbox
:value="isItemChecked(item)"
:model-value="isItemChecked(item)"
class="item-check"
checked-color="#ff5b20"
@input="toggleItem(item, $event)"
@update:model-value="toggleItem(item, $event)"
/>
<van-image class="item-thumb" :src="item.image" fit="cover" />
<div class="item-info">
@@ -52,10 +52,10 @@
@submit="handleCheckout"
>
<van-checkbox
:value="isAllChecked"
:model-value="isAllChecked"
:indeterminate="isPartChecked"
checked-color="#ff5b20"
@input="toggleAll"
@update:model-value="toggleAll"
>全选</van-checkbox>
</van-submit-bar>
</section>
@@ -7,10 +7,10 @@
<van-empty v-if="!items.length" description="购物车还是空的" />
<article v-for="item in items" :key="item.cartId" class="edit-item">
<van-checkbox
:value="isItemChecked(item)"
:model-value="isItemChecked(item)"
class="item-check"
checked-color="#ff5b20"
@input="toggleItem(item, $event)"
@update:model-value="toggleItem(item, $event)"
/>
<van-image class="item-thumb" :src="item.image" fit="cover" />
<div class="item-info">
@@ -26,10 +26,10 @@
</section>
<van-submit-bar class="edit-submit" button-text="删除" @submit="remove">
<van-checkbox
:value="isAllChecked"
:model-value="isAllChecked"
:indeterminate="isPartChecked"
checked-color="#ff5b20"
@input="toggleAll"
@update:model-value="toggleAll"
>全选</van-checkbox>
</van-submit-bar>
</main>
@@ -49,15 +49,21 @@ export default {
methods: {
...mapActions(useCartStore, ['loadCart']),
...mapActions(useUserStore, ['hydrateFromRoute', 'fetchBridgeToken']),
prepareUserSession() {
async prepareUserSession() {
if (this.$route.meta.merchantAuth) {
this.sessionLoading = false
return
}
this.sessionLoading = true
this.hydrateFromRoute(this.$route.query)
this.fetchBridgeToken().catch(error => {
try {
await this.hydrateFromRoute(this.$route.query)
await this.fetchBridgeToken()
} catch (error) {
console.warn('获取平台授权令牌失败', error)
this.$toast(error.message || '供应商验证失败,请稍后再试')
}).finally(() => {
} finally {
this.sessionLoading = false
})
}
}
}
}
@@ -91,7 +91,7 @@
</button>
</footer>
<van-popup v-model="showExchangeShipPopup" round position="bottom">
<van-popup v-model:show="showExchangeShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写换货发货信息</h3>
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
@@ -336,7 +336,7 @@ export default {
this.loadDetail()
},
methods: {
...mapActions(useUserStore, ['fetchBridgeToken']),
...mapActions(useUserStore, ['fetchSupplierInfo']),
normalizeSteps(steps) {
const serviceState = Number(this.afterSale.serviceState)
return steps.map((step, index) => ({
@@ -359,9 +359,9 @@ export default {
async loadDetail() {
try {
this.loading = true
await this.fetchBridgeToken()
if (!this.supplierId && !this.$route.query.supplierId) await this.fetchSupplierInfo()
const data = await postFunction('merchant-after-sale-detail', {
supplierId: this.supplierId,
supplierId: String(this.$route.query.supplierId || this.supplierId),
serviceOrderId: String(this.$route.query.serviceOrderId || '')
})
@@ -407,7 +407,7 @@ export default {
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
})
await postFunction('merchant-after-sale-audit', {
supplierId: this.supplierId,
supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId,
auditStatus,
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
@@ -421,7 +421,7 @@ export default {
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
})
await postFunction('merchant-after-sale-receive', {
supplierId: this.supplierId,
supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId
})
Toast('已确认收货')
@@ -441,7 +441,7 @@ export default {
}
await postFunction('merchant-after-sale-ship', {
supplierId: this.supplierId,
supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId,
logisticsCompany: this.exchangeShipForm.logisticsCompany,
logisticsNo: this.exchangeShipForm.logisticsNo
@@ -3,9 +3,10 @@
<van-nav-bar title="商家处理台" left-arrow @click-left="$router.back()" />
<van-pull-refresh v-model="refreshing" class="merchant-refresh" @refresh="onRefresh">
<van-tabs v-model="activeTab" sticky offset-top="46" color="#ff5b20" @change="loadCurrentTab">
<van-tabs v-model:active="activeTab" sticky offset-top="46" color="#ff5b20" @change="loadCurrentTab">
<van-tab title="待发货" name="ship" :badge="tabBadge('ship')">
<section class="list-panel">
<button v-if="orderItems.length" type="button" class="batch-ship-button" @click="quickShipAll">一键发货</button>
<section v-if="loading" class="skeleton-card">
<van-skeleton title :row="3" />
<van-skeleton title :row="3" />
@@ -160,7 +161,7 @@
</van-tabs>
</van-pull-refresh>
<van-popup v-model="showShipPopup" round position="bottom">
<van-popup v-model:show="showShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写发货信息</h3>
<van-field v-model="shipForm.crrgBsnNm" label="快递公司" placeholder="请输入快递公司" />
@@ -169,7 +170,7 @@
</section>
</van-popup>
<van-popup v-model="showExchangeShipPopup" round position="bottom">
<van-popup v-model:show="showExchangeShipPopup" round position="bottom">
<section class="action-popup">
<h3>填写换货发货信息</h3>
<van-field v-model="exchangeShipForm.logisticsCompany" label="快递公司" placeholder="请输入快递公司" />
@@ -251,7 +252,7 @@ export default {
this.prepareSupplier().then(() => this.loadAllTabs())
},
methods: {
...mapActions(useUserStore, ['fetchSupplierInfo', 'fetchBridgeToken']),
...mapActions(useUserStore, ['fetchSupplierInfo']),
...mapActions(useUiStore, ['loadUi', 'setMerchantTab']),
skuText(item = {}) {
const sku = String(item.sku || item.skuText || '').trim()
@@ -272,7 +273,6 @@ export default {
return count > 0 ? count : ''
},
async prepareSupplier() {
await this.fetchBridgeToken()
if (!this.supplierId) {
await this.fetchSupplierInfo()
}
@@ -352,18 +352,34 @@ export default {
this.$router.push({
path: '/joyful-selection/merchant/after-sale-detail',
query: {
serviceOrderId: item.serviceOrderId
serviceOrderId: item.serviceOrderId,
supplierId: item.supplierId || this.supplierId
}
})
},
openShipPopup(item) {
this.currentOrder = item
this.shipForm = {
crrgBsnNm: item.crrgBsnNm || '',
logisticsOrderId: item.logisticsOrderId || ''
crrgBsnNm: item.crrgBsnNm || '韵达快递',
logisticsOrderId: item.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
}
this.showShipPopup = true
},
async quickShipAll() {
await Dialog.confirm({
title: '确认一键发货?',
message: `将为当前 ${this.orderItems.length} 个待发货订单提交发货。`
})
await Promise.all(this.orderItems.map(item => postFunction('merchant-order-ship', {
supplierId: this.supplierId,
userId: item.userId,
orderId: item.orderId,
crrgBsnNm: item.crrgBsnNm || '韵达快递',
logisticsOrderId: item.logisticsOrderId || `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
})))
Toast('批量发货成功')
await this.loadOrders()
},
async submitShip() {
if (!this.shipForm.crrgBsnNm || !this.shipForm.logisticsOrderId) {
Toast('请填写完整发货信息')
@@ -467,6 +483,18 @@ export default {
padding: 12px 10px 18px;
}
.batch-ship-button {
width: 100%;
height: 38px;
margin-bottom: 12px;
border: 0;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 700;
background: linear-gradient(90deg, #ff6a2a 0%, #ff4b18 100%);
}
.work-card {
position: relative;
margin-bottom: 12px;
@@ -0,0 +1,56 @@
<template>
<main class="merchant-login-page">
<van-nav-bar title="商家登录" left-arrow @click-left="$router.replace('/joyful-selection/home')" />
<section class="merchant-login-card">
<div class="login-icon"><van-icon name="shop-o" size="36" /></div>
<h1>商家处理台</h1><p>使用供应商管理账号登录</p>
<van-form @submit="submit">
<van-cell-group inset>
<van-field v-model="username" name="username" label="账号" placeholder="请输入账号" autocomplete="username" :rules="[{ required: true, message: '请输入账号' }]" />
<van-field v-model="password" name="password" type="password" label="密码" placeholder="请输入密码" autocomplete="current-password" :rules="[{ required: true, message: '请输入密码' }]" />
</van-cell-group>
<van-button block round type="primary" native-type="submit" :loading="submitting" :disabled="submitting" color="#ff5b20" class="login-submit">登录</van-button>
</van-form>
</section>
</main>
</template>
<script>
import { Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { saveMerchantSession, clearMerchantSession } from '@/api/merchant-session'
export default {
name: 'MerchantLogin',
data: () => ({ username: '', password: '', submitting: false }),
methods: {
async submit() {
if (this.submitting) return
this.submitting = true
try {
const session = await postFunction('login', { username: this.username.trim(), password: this.password })
saveMerchantSession(session)
await postFunction('admin-session')
this.password = ''
const target = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : ''
const resolved = this.$router.resolve(target || '/joyful-selection/merchant')
await this.$router.replace(resolved.meta.merchantAuth ? resolved.fullPath : '/joyful-selection/merchant')
} catch (error) {
clearMerchantSession()
Toast(error.message || '登录失败')
} finally { this.submitting = false }
}
}
}
</script>
<style scoped>
.merchant-login-page { min-height: 100vh; background: #f7f8fa; }
.merchant-login-card { max-width: 420px; margin: 48px auto; padding: 28px 20px; background: #fff; border-radius: 16px; }
.login-icon { display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0 auto 20px; border-radius: 20px; color: #ff5b20; background: #fff1e9; }
.merchant-login-card h1 { margin: 0; text-align: center; font-size: 23px; }
.merchant-login-card > p { margin: 12px 0 28px; text-align: center; color: #8793a5; font-size: 14px; }
.merchant-login-card :deep(.van-cell-group--inset) { margin: 0; }
.login-submit { margin-top: 28px; }
@media (max-width: 480px) { .merchant-login-card { margin: 32px 16px; } }
</style>
@@ -43,7 +43,7 @@
立即支付 ¥{{ amountText }}
</van-button>
<van-popup v-model="showConfirm" class="pay-popup" round position="bottom">
<van-popup v-model:show="showConfirm" class="pay-popup" round position="bottom">
<article class="pay-panel">
<header>
<van-icon name="cross" @click="showConfirm = false" />
@@ -59,16 +59,16 @@
</div>
</section>
<van-goods-action>
<van-goods-action-icon icon="chat-o" text="客服" dot />
<van-goods-action-icon icon="cart-o" text="购物车" :badge="cartBadge" @click="openCartPopup" />
<van-goods-action-icon icon="star" text="已收藏" color="#ff5000" />
<van-goods-action-button type="warning" text="加入购物车" @click="openSku('cart')" />
<van-goods-action-button type="danger" text="立即购买" @click="openSku('buy')" />
</van-goods-action>
<van-action-bar>
<van-action-bar-icon icon="chat-o" text="客服" dot />
<van-action-bar-icon icon="cart-o" text="购物车" :badge="cartBadge" @click="openCartPopup" />
<van-action-bar-icon icon="star" text="已收藏" color="#ff5000" />
<van-action-bar-button type="warning" text="加入购物车" @click="openSku('cart')" />
<van-action-bar-button type="danger" text="立即购买" @click="openSku('buy')" />
</van-action-bar>
<joyful-sku-popup ref="skuPopup" @add-cart="loadCart" />
<van-popup v-model="showCartPopup" class="cart-popup" position="bottom" round>
<van-popup v-model:show="showCartPopup" class="cart-popup" position="bottom" round>
<header class="cart-popup-head">
<b>购物车</b>
<van-icon name="cross" @click="showCartPopup = false" />
@@ -251,7 +251,7 @@ export default {
}
.product-info {
margin-top: -4px;
margin-top: 12px;
padding: 18px 16px 16px;
}
@@ -1,26 +1,70 @@
<template>
<van-sku
:key="skuKey"
v-model="show"
<van-popup
v-model:show="show"
:class="`sku-popup--${mode}`"
:sku="sku"
:goods="skuGoods"
:goods-id="currentGoods.id"
:initial-sku="initialSku"
:hide-stock="sku.hide_stock"
:show-stepper="false"
:show-add-cart-btn="mode !== 'buy'"
:show-buy-btn="true"
:preview-on-click-image="true"
add-cart-text="加入购物车"
buy-text="立即购买"
@add-cart="onAddCartClicked"
@buy-clicked="onBuyClicked"
class="van-sku-container"
position="bottom"
round
closeable
safe-area-inset-bottom
>
<div class="sku-content">
<div class="van-sku-header">
<div class="van-sku-header__goods-info">
<div class="van-sku__goods-price">
<span class="van-sku__price-symbol">¥</span>
<span class="van-sku__price-num">{{ sku.price }}</span>
</div>
<div class="van-sku__goods-name">{{ skuGoods.title }}</div>
<div class="van-sku__stock">库存 {{ sku.stock_num }} 件</div>
</div>
</div>
<van-cell-group class="van-sku-body" :border="false">
<div v-for="group in sku.tree" :key="group.k_s" class="van-sku-row">
<div class="van-sku-row__title">{{ group.k }}</div>
<div class="sku-options" :class="{ 'sku-options--colors': group.k_s === 's1' }">
<van-button
v-for="option in group.v"
:key="option.id"
size="small"
class="van-sku-row__item"
:class="{ 'van-sku-row__item--active': selectedSku[group.k_s] === option.id, 'sku-color-card': group.k_s === 's1' }"
@click="selectedSku[group.k_s] = option.id"
>
<span v-if="group.k_s === 's1'" class="sku-color-card__picture">
<van-image class="sku-color-card__image" :src="option.imgUrl" width="100%" height="100%" fit="cover" />
<van-icon
v-if="option.previewImgUrl"
name="expand-o"
class="sku-color-card__preview"
role="button"
tabindex="0"
aria-label="预览商品图片"
@click.stop="previewImage(option.previewImgUrl)"
@keydown.enter.stop.prevent="previewImage(option.previewImgUrl)"
@keydown.space.stop.prevent="previewImage(option.previewImgUrl)"
/>
</span>
<span :class="{ 'sku-color-card__name': group.k_s === 's1' }">{{ option.name }}</span>
</van-button>
</div>
</div>
<van-cell title="购买数量" class="sku-quantity" center>
<template #right-icon>
<van-stepper v-model="selectedNum" integer :min="1" :max="sku.stock_num" />
</template>
</van-cell>
</van-cell-group>
<div class="sku-actions">
<van-button v-if="mode !== 'buy'" round type="warning" @click="onAddCartClicked(selectionData())">加入购物车</van-button>
<van-button round type="danger" @click="onBuyClicked(selectionData())">立即购买</van-button>
</div>
</div>
</van-popup>
</template>
<script>
import { Toast } from 'vant'
import { ImagePreview, Toast } from 'vant'
import { useCartStore } from '@/store/cart'
const colorOptions = [
@@ -47,7 +91,8 @@ export default {
data: () => ({
show: false,
mode: 'cart',
skuKey: 0,
selectedSku: {},
selectedNum: 1,
currentGoods: emptyGoods
}),
computed: {
@@ -105,9 +150,19 @@ export default {
open(goods, mode = 'cart') {
this.currentGoods = goods
this.mode = mode
this.skuKey += 1
this.selectedSku = { ...this.initialSku }
this.selectedNum = 1
this.show = true
},
previewImage(image) {
if (image) ImagePreview([image])
},
selectionData() {
return {
selectedSkuComb: this.sku.list.find(item => item.s1 === this.selectedSku.s1 && item.s2 === this.selectedSku.s2),
selectedNum: Math.min(this.sku.stock_num, Math.max(1, Math.floor(Number(this.selectedNum) || 1)))
}
},
getCartPayload(data) {
const selectedSku = data.selectedSkuComb || {}
const selectedColor = colorOptions.find(item => item.id === selectedSku.s1) || colorOptions[0]
@@ -153,14 +208,110 @@ export default {
</script>
<style scoped lang="less">
::v-deep .van-sku-container {
.sku-content {
padding: 15px 15px 0;
}
.sku-options .van-sku-row__item.sku-color-card {
width: 100%;
min-width: 0;
overflow: hidden;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: #f6f7f9;
}
.sku-color-card :deep(.van-button__content),
.sku-color-card :deep(.van-button__text) {
display: block;
width: 100%;
}
.sku-color-card__picture {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
line-height: 0;
background: #f1f1f3;
}
.sku-color-card__image {
position: absolute;
inset: 0;
display: block;
}
.sku-color-card__preview {
position: absolute;
top: 3px;
right: 3px;
padding: 2px;
border-radius: 2px;
background: rgba(0, 0, 0, .4);
color: #fff;
font-size: 14px;
}
.sku-color-card__name {
display: block;
padding: 10px 4px;
color: #333;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.sku-color-card.van-sku-row__item--active .sku-color-card__name {
color: #ff5b20;
}
.sku-options,
.sku-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.sku-options.sku-options--colors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.sku-options .van-sku-row__item {
height: auto;
min-height: 36px;
padding: 6px 12px;
}
.sku-quantity {
padding: 12px 0;
}
.sku-actions {
padding: 15px 0;
}
.sku-actions .van-button {
flex: 1;
}
.van-sku-container {
max-height: 85vh;
overflow-y: auto;
border-radius: 14px 14px 0 0;
}
.van-sku-container.van-popup--safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
::v-deep .van-sku-header {
min-height: 96px;
padding: 18px 42px 12px 16px;
padding: 0 30px 15px 0;
}
::v-deep .van-sku-header__img-wrap {
@@ -210,7 +361,7 @@ export default {
}
::v-deep .van-sku-body {
padding: 0 16px;
padding: 0;
}
::v-deep .van-sku-group-container {
@@ -236,7 +387,7 @@ export default {
::v-deep .van-sku-row:not(:first-child) .van-sku-row__item {
height: 31px;
margin: 0 10px 8px 0;
margin: 0;
padding: 0 14px;
border: 0;
border-radius: 4px;
@@ -43,7 +43,7 @@
<div class="goods-bottom">
<div class="price-stack">
<div><b>¥{{ item.price }}</b></div>
<div><span>已售{{ item.sales }}</span></div>
<div><span>已售{{ formatSales(item.sales) }}件</span></div>
</div>
<button type="button" class="cart-button" @click.stop="openSku(item)">
<van-icon name="cart-o" />
@@ -96,6 +96,11 @@ export default {
this.loadProducts()
},
methods: {
formatSales(value) {
const number = Number(value)
const count = Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0
return count >= 10000 ? `${Math.floor(count / 10000)}万` : String(count)
},
async loadProducts() {
this.loading = true
try {
@@ -15,7 +15,7 @@
src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
/>
<div>
<b>张三</b>
<b>{{ nickname || '-' }}</b>
<p>欢迎来到悦享优选</p>
</div>
</div>
@@ -103,7 +103,7 @@ export default {
}
}),
computed: {
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId']),
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId', 'nickname']),
formattedPoints() {
return Number(this.pointsBalance || 0).toLocaleString('zh-CN', {
maximumFractionDigits: 2
@@ -4,7 +4,7 @@
<main v-else class="orders-page">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.replace('/joyful-selection/profile')" />
<van-tabs
v-model="active"
v-model:active="active"
class="order-tabs"
line-width="18"
line-height="2"
@@ -15,6 +15,15 @@
</van-tabs>
<van-pull-refresh v-model="refreshing" class="order-list" @refresh="onRefresh">
<van-button
v-if="receivableOrders.length"
block
round
class="batch-receive-button"
@click="confirmReceiveAll"
>
一键收货({{ receivableOrders.length }})
</van-button>
<van-empty v-if="visibleOrders.length === 0" description="暂无订单" />
<template v-else>
@@ -164,6 +173,11 @@ export default {
activeTab() {
return this.tabs[this.active] || this.tabs[0]
},
receivableOrders() {
return this.visibleOrders.filter(order => {
return order.displayType === 'sub' && Number(order.orderState) === ORDER_STATE.IN_PROGRESS
})
},
visibleOrders() {
const tab = this.activeTab
@@ -321,6 +335,44 @@ export default {
this.refreshing = false
}
},
async confirmReceiveAll() {
try {
await Dialog.confirm({
title: '一键收货',
message: `确定将当前 ${this.receivableOrders.length} 个订单全部确认收货吗?`
})
} catch (error) {
return
}
this.operationKey = 'batch-confirm-receive'
try {
await this.fetchBridgeToken()
if (!this.supplierId || !this.userId) {
Toast('用户信息不完整,请重新进入商城')
return
}
await Promise.all(this.receivableOrders.map(order => postFunction('order-push-event', {
supplierId: this.supplierId,
userId: this.userId,
orderType: ORDER_EVENT_TYPE.CONFIRM_RECEIVE,
orderId: order.orderId,
mainOrderId: order.mainOrderId,
subOrderId: order.orderId,
amount: Number(order.amount || 0),
reason: '确认收货',
time: Date.now()
})))
Toast('批量收货成功')
await this.loadOrders()
} catch (error) {
Toast(error.message || '批量收货失败,请稍后再试')
} finally {
this.operationKey = ''
}
},
orderActions(order) {
if (order.displayType === 'main') {
return Number(order.orderState) === ORDER_STATE.PENDING_PAY
@@ -583,6 +635,12 @@ export default {
padding: 12px 10px 18px;
}
.batch-receive-button {
margin-bottom: 12px;
color: #ff5b20;
background: #fff1e8;
}
.order-card {
overflow: hidden;
margin-bottom: 14px;
@@ -50,7 +50,7 @@ export default {
this.loadLogs()
this.startListening()
},
beforeDestroy() {
beforeUnmount() {
this.stopListening()
},
methods: {
+107 -42
View File
@@ -1,61 +1,126 @@
# supplied-service
Netlify Functions backend for the supplied mall H5 project.
供应商业务后端,为 supplied-h5、supplied-admin 和 zhgh 平台对接提供接口。当前 HTTP 入口为 `src/server.ts`,默认端口为 `8888`。
## Stack
## 技术栈
- Netlify Functions
- TypeScript
- Prisma ORM
- Neon Postgres
- `@prisma/adapter-neon` + `@neondatabase/serverless`
- Koa、TypeScript:HTTP 服务与业务逻辑。
- Prisma、MySQL:数据库访问与模型定义。
- Redis:平台桥接 Token 缓存。
- MinIO:文件对象存储。
- Node.js crypto:管理端密码哈希与随机登录 Token。
## Directory
## 目录结构
```text
netlify/
functions/ # API entrypoints
utils/ # shared function utilities
types/ # shared API types
src/
server.ts # Koa 入口、路由分发、管理端登录校验
controllers/ # 请求解析与响应封装
services/ # 业务逻辑
dao/ # 数据访问
functions/ # 导出各接口处理器
utils/ # 数据库、日志、平台桥接等工具
types/ # 公共类型
prisma/
schema.prisma # Prisma models
schema.prisma # 数据模型与表映射
sql/ # 专项表结构脚本
script/supplier_db.sql # 数据库结构及开发数据快照
init-supplied-user.mjs # 管理账号初始化与旧表处理
tools/ # 发票等辅助工具
```
## Environment
`src/functions` 保留接口处理器导出,当前本地服务由 Koa 的 `/api` 路由分发。
Copy `.env.example` to `.env` and set:
## 环境配置
- `DATABASE_URL`: Neon Postgres connection string for Netlify Functions runtime and Prisma CLI
- `JWT_SECRET`: auth signing secret
- `UPLOAD_TOKEN`: upload guard token
- `POINTS_MALL_OPEN_API_BASE`: main platform bridge base URL, for example `http://localhost/open`
配置文件为本目录 `.env`,可参考 `.env.example`。数据库及存储服务的账号信息不在本文重复列出。
Supplier H5 uses `VUE_APP_SUPPLIER_IDENTIFICATION` to request supplier config from this service. The supplier `clientSecret` stays in the database and is only used by the Netlify Function when requesting the platform bridge token.
| 配置项 | 用途 |
| --- | --- |
| `DATABASE_URL` | MySQL 连接地址 |
| `PORT` | Koa 服务端口,默认 `8888` |
| `PRISMA_CLIENT_ENGINE_TYPE` | Prisma 引擎设置,示例为 `binary` |
| `POINTS_MALL_OPEN_API_BASE` | zhgh 平台接口根地址,例如 `http://localhost/open` |
| `SUPPLIER_OPEN_SM4_KEY_HEX`、`SUPPLIER_OPEN_SM4_IV_HEX` | 供应商开放接口 SM4 配置 |
| `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DB` | 平台 Token 缓存连接配置 |
| `MINIO_ENDPOINT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY` | MinIO 地址与访问凭据 |
| `MINIO_BUCKET`、`MINIO_REGION` | 存储桶与区域;本项目配置示例使用 `njupt` |
## Scripts
`.env.example` 中的 `JWT_SECRET`、`UPLOAD_TOKEN` 不参与当前管理端登录校验;管理端采用数据库保存 Token 哈希的会话机制。
```bash
npm run dev
npm run build
npm run typecheck
npm run prisma:generate
npm run prisma:migrate
npm run prisma:studio
```
H5 通过供应商标识获取配置。供应商 `clientSecret` 保存在后端数据库中,用于请求平台桥接 Token,与管理端登录密码、登录 Token 分开处理。
## Function Routes
## 已有脚本说明
Netlify exposes functions under `/.netlify/functions/<name>`.
以下列出 `package.json` 中已有脚本的用途。
Current placeholders:
| 脚本名 | 用途 |
| --- | --- |
| `dev`、`start` | 通过 tsx 运行 Koa 服务 |
| `build`、`prisma:generate` | 生成 Prisma Client;`build` 不生成后端应用打包产物 |
| `typecheck` | TypeScript 静态类型检查 |
| `prisma:migrate` | Prisma 开发迁移 |
| `prisma:studio` | Prisma 数据查看界面 |
| `seed:products` | 商品初始化数据脚本 |
- `login`
- `product-list`
- `product-detail`
- `cart-add`
- `order-create`
- `order-pay`
- `user-info`
- `upload`
- `supplier-info`
- `supplier-token`
## 管理账号与表结构
| 表名 | 用途 |
| --- | --- |
| `supplied_user` | 管理端账号、密码哈希、会话及失败登录状态 |
| `supplied_cart_item` | 购物车条目,Prisma 模型为 `CartItem` |
管理端密码使用随机盐和 scrypt 哈希,格式为 `scrypt$盐$哈希`。登录时校验密码后签发随机 Token,数据库仅保存 Token 的 SHA-256 哈希及有效期。
- 会话有效期:8 小时,同一账号仅保留最新一次登录。
- 连续输错密码:启用账号累计 5 次后锁定 15 分钟。
- 退出登录:清除当前 Token 哈希与有效期。
- 登录请求密码不会写入供应商调试日志。
- 购物车 `userId` 仍为业务用户 ID,不关联 `supplied_user` 中的管理员。
### 账号初始化脚本
`prisma/init-supplied-user.mjs` 读取本目录 `.env`,创建账号表并插入管理账号:
- `SUPPLIED_ADMIN_USERNAME`:初始化账号,默认 `admin`。
- `SUPPLIED_ADMIN_PASSWORD`:初始化密码;未指定时随机生成,长度要求为 12–128 位。
- 账号创建成功后输出账号和密码,已存在账号不会覆盖密码。
- 可将旧 `cartitem` 重命名为 `supplied_cart_item`;新旧表同时存在时停止,避免覆盖数据。
- 旧 `user` 表有数据时停止;为空时移除。表结构处理发生在账号是否已存在的判断之前。
- 此脚本不会创建缺失的购物车表,也不处理曾使用过的 `supplied_cartitem` 名称。
当前开发账号见 [项目 README](../README.md#管理端登录)。
## 接口入口
| 路径 | 用途 |
| --- | --- |
| `/api/:name` | H5 及通用业务接口 |
| `/api/admin/:name` | 管理端入口,除 `login` 外校验 Bearer Token |
| `/supplierOpenApi/pointOrder/*` | zhgh 调用的供应商开放接口 |
| `/api/supplier-logs` | 查询或清除当前进程保存的调试日志 |
### 管理端登录接口
| 方法 | 路径 | 请求内容 |
| --- | --- | --- |
| POST | `/api/admin/login` | JSON:`username`、`password` |
| POST | `/api/admin/admin-session` | Bearer Token,返回用户信息及过期时间 |
| POST | `/api/admin/admin-logout` | Bearer Token,使当前登录失效 |
登录成功返回 `data.token`、`data.expiresAt` 和 `data.user`。管理端后续请求通过 `Authorization: Bearer <token>` 携带凭据。
### 业务接口分组
- 商品:`product-list`、`product-detail`、`merchant-product-*`。
- 订单:`order-create`、`order-pay`、`order-list`、`order-detail`、取消及事件推送接口。
- 商家处理:`merchant-order-*`、`merchant-after-sale-*`。
- 对账:`merchant-reconciliation-*`、`supplier-reconciliation-*`。
- 发票:`supplier-invoice-apply`、`supplier-invoice-info`、`supplier-file-base64-info`。
- 平台授权:`supplier-info`、`supplier-session`、`supplier-token`。
完整路由以 `src/server.ts` 为准。`cart-add`、`user-info` 仍为占位实现。
## 调试日志
`GET /api/supplier-logs` 返回当前后端接收请求、向 zhgh 发送请求、接收 zhgh 请求的记录;`DELETE /api/supplier-logs` 清除记录。日志保存在进程内存中,最多保留 200 条,服务重启后清空。
+334
View File
@@ -17,6 +17,7 @@
"ioredis": "^5.4.2",
"koa": "^2.15.3",
"koa-bodyparser": "^4.4.1",
"minio": "^8.0.5",
"ws": "^8.18.3"
},
"devDependencies": {
@@ -510,6 +511,18 @@
"node": ">=19.0.0"
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@prisma/adapter-neon": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/adapter-neon/-/adapter-neon-6.19.3.tgz",
@@ -840,6 +853,48 @@
"node": ">= 0.6"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/block-stream2": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
"integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/browser-or-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1074,6 +1129,15 @@
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/deep-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz",
@@ -1278,6 +1342,12 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
@@ -1308,6 +1378,54 @@
"node": ">=8.0.0"
}
},
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -1572,6 +1690,15 @@
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
"integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -1609,6 +1736,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1732,6 +1871,12 @@
"node": ">= 0.6"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1771,6 +1916,30 @@
"node": ">= 0.6"
}
},
"node_modules/minio": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
"integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.4",
"block-stream2": "^2.1.0",
"browser-or-node": "^2.1.1",
"buffer-crc32": "^1.0.0",
"eventemitter3": "^5.0.1",
"fast-xml-parser": "^5.3.4",
"ipaddr.js": "^2.0.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"query-string": "^7.1.3",
"stream-json": "^1.8.0",
"through2": "^4.0.2",
"xml2js": "^0.5.0 || ^0.6.2"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1863,6 +2032,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-to-regexp": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
@@ -1963,6 +2147,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
@@ -1989,6 +2191,20 @@
"destr": "^2.0.3"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2067,6 +2283,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2145,6 +2370,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -2160,6 +2394,63 @@
"node": ">= 0.8"
}
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strnum": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
"license": "MIT",
"dependencies": {
"readable-stream": "3"
}
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
@@ -2250,6 +2541,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -2280,6 +2577,43 @@
}
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/ylru": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@neondatabase/serverless": "^1.0.1",
"@prisma/adapter-neon": "6.19.3",
"@prisma/client": "6.19.3",
"minio": "^8.0.5",
"crypto-js": "^4.2.0",
"dotenv": "^17.2.3",
"ioredis": "^5.4.2",
@@ -0,0 +1,41 @@
import { config } from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { randomBytes, scryptSync } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
config({ path: fileURLToPath(new URL('../.env', import.meta.url)), quiet: true })
const prisma = new PrismaClient()
try {
const username = (process.env.SUPPLIED_ADMIN_USERNAME || 'admin').trim()
const password = process.env.SUPPLIED_ADMIN_PASSWORD || `Sp!${randomBytes(12).toString('base64url')}9a`
if (!username || username.length > 64 || password.length < 12 || password.length > 128) {
throw new Error('账号长度需为 1-64,密码长度需为 12-128')
}
const tables = await prisma.$queryRaw`SELECT TABLE_NAME AS name FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND LOWER(TABLE_NAME) IN ('user', 'cartitem', 'supplied_cart_item')`
const legacyUser = tables.find(row => row.name.toLowerCase() === 'user')
const legacyCart = tables.find(row => row.name.toLowerCase() === 'cartitem')
if (legacyUser) {
const rows = await prisma.$queryRawUnsafe('SELECT COUNT(*) AS count FROM `' + legacyUser.name.replace(/`/g, '``') + '`')
if (Number(rows[0].count) > 0) throw new Error('旧 user 表存在数据,未删除,请先处理原有数据')
}
if (legacyCart) {
if (tables.some(row => row.name.toLowerCase() === 'supplied_cart_item')) throw new Error('新旧购物车表同时存在,未覆盖数据')
await prisma.$executeRawUnsafe('RENAME TABLE `' + legacyCart.name.replace(/`/g, '``') + '` TO `supplied_cart_item`')
}
if (legacyUser) await prisma.$executeRawUnsafe('DROP TABLE `' + legacyUser.name.replace(/`/g, '``') + '`')
await prisma.$executeRawUnsafe(readFileSync(new URL('./sql/supplied-user.sql', import.meta.url), 'utf8').trim().replace(/;$/, ''))
const existing = await prisma.$queryRaw`SELECT id FROM supplied_user WHERE username = ${username} LIMIT 1`
if (existing.length) throw new Error('账号已存在,未覆盖原有密码')
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex')
const passwordHash = `scrypt$${salt}$${hash}`
await prisma.$executeRaw`INSERT INTO supplied_user (username, passwordHash, nickname) VALUES (${username}, ${passwordHash}, ${'管理员'})`
console.log(JSON.stringify({ username, password, table: 'supplied_user' }))
} catch (error) {
console.error(error instanceof Error ? error.message : '初始化账号失败')
process.exitCode = 1
} finally {
await prisma.$disconnect()
}
+71 -8
View File
@@ -7,16 +7,21 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
model SuppliedUser {
id Int @id @default(autoincrement())
mobile String @unique
nickname String?
avatarUrl String?
points Int @default(0)
username String? @unique @db.VarChar(64)
virtualId String? @unique @db.VarChar(191)
passwordHash String? @db.VarChar(255)
nickname String @default("管理员") @db.VarChar(64)
enabled Boolean @default(true)
tokenHash String? @unique @db.Char(64)
tokenExpiresAt DateTime?
failedLoginCount Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carts CartItem[]
@@map("supplied_user")
}
model SupplierProduct {
@@ -75,10 +80,10 @@ model CartItem {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product SupplierProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId, skuText])
@@unique([userId, productId, skuText], map: "CartItem_userId_productId_skuText_key")
@@map("supplied_cart_item")
}
model SupplierOrderMain {
@@ -159,6 +164,37 @@ model SupplierOrderSub {
@@map("supplier_order_sub")
}
model SupplierReconciliationOrder {
id Int @id @default(autoincrement())
supplierId String
orderId String
mainOrderId String?
userId String?
reconciliationStatus Int @default(0)
diffType String? @db.VarChar(64)
diffReason String? @db.VarChar(1000)
platformTotalPrice Decimal? @db.Decimal(12, 2)
platformCashAmount Decimal? @db.Decimal(12, 2)
platformPointsPrice Decimal? @db.Decimal(12, 2)
platformRefundPrice Decimal? @db.Decimal(12, 2)
supplierTotalPrice Decimal? @db.Decimal(12, 2)
supplierCashAmount Decimal? @db.Decimal(12, 2)
supplierPointsPrice Decimal? @db.Decimal(12, 2)
supplierRefundPrice Decimal? @db.Decimal(12, 2)
reconciliationBatchNo String?
reconciliationTime DateTime?
resultPayload Json?
createAt DateTime @default(now())
updateAt DateTime @updatedAt
deleted Int @default(0)
@@unique([supplierId, orderId])
@@index([supplierId, reconciliationStatus])
@@index([reconciliationBatchNo])
@@index([mainOrderId])
@@map("supplier_reconciliation_order")
}
model SupplierOrderProduct {
id Int @id @default(autoincrement())
subOrderId String
@@ -342,6 +378,23 @@ model SupplierAfterSale {
@@map("supplier_after_sale")
}
model SupplierFile {
id Int @id @default(autoincrement())
fileId String @unique
supplierId String?
fileName String
objectName String
bucketName String
contentType String?
fileSize Int?
createTime DateTime @default(now())
updateTime DateTime @updatedAt
deleted Int @default(0)
@@index([supplierId, deleted])
@@map("supplier_file")
}
model SupplierInvoiceInfo {
id Int @id @default(autoincrement())
invoiceBizId String @unique
@@ -351,11 +404,21 @@ model SupplierInvoiceInfo {
subOrderId String?
settlementId String?
invoiceCode String?
taxIdNumber String?
invoiceIds String? @db.VarChar(1000)
orderIds String? @db.VarChar(1000)
invoiceStatus Int @default(0)
invoiceDate DateTime?
invoiceType Int?
invoiceNakeAmount Decimal? @db.Decimal(12, 2)
invoiceTaxRate Decimal? @db.Decimal(8, 4)
invoiceTaxAmount Decimal? @db.Decimal(12, 2)
invoiceAmount Decimal? @db.Decimal(12, 2)
checkCode String?
fileType String? @db.VarChar(16)
fileId String?
filePath String?
failureMessage String? @db.Text
requestBody Json?
createTime DateTime @default(now())
updateTime DateTime @updatedAt
@@ -0,0 +1,550 @@
/*
Navicat Premium Dump SQL
Source Server : localhost
Source Server Type : MySQL
Source Server Version : 80046 (8.0.46)
Source Host : 127.0.0.1:10086
Source Schema : supplier_db
Target Server Type : MySQL
Target Server Version : 80046 (8.0.46)
File Encoding : 65001
Date: 02/09/2026 11:08:42
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for supplied_cart_item
-- ----------------------------
DROP TABLE IF EXISTS `supplied_cart_item`;
CREATE TABLE `supplied_cart_item` (
`id` int NOT NULL AUTO_INCREMENT,
`userId` int NOT NULL,
`productId` int NOT NULL,
`skuText` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`quantity` int NOT NULL DEFAULT 1,
`createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` datetime(3) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `CartItem_userId_productId_skuText_key`(`userId` ASC, `productId` ASC, `skuText` ASC) USING BTREE,
INDEX `CartItem_productId_fkey`(`productId` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplied_cart_item
-- ----------------------------
-- ----------------------------
-- Table structure for supplier_after_sale
-- ----------------------------
DROP TABLE IF EXISTS `supplier_after_sale`;
CREATE TABLE `supplier_after_sale` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '供应商售后单主键ID',
`serviceOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '售后业务单号',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联主单ID',
`subOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联子单ID',
`serviceType` int NOT NULL COMMENT '售后类型:4退货,5换货',
`serviceState` int NOT NULL DEFAULT 0 COMMENT '售后状态:0商家待审核,1审核通过,2审核拒绝,3用户已寄回,4商家已收货,5已完成,6已取消',
`auditStatus` int NOT NULL DEFAULT 0 COMMENT '审核状态:0待审核,1审核通过,2审核拒绝',
`logisticsState` int NOT NULL DEFAULT 0 COMMENT '物流状态:0无物流,1待用户寄回,2用户已寄回,3商家已收货,4商家已发货,5用户已收货',
`amount` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '售后金额',
`reason` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '售后原因',
`auditRemark` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '审核备注',
`userLogisticsNo` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户寄回物流单号',
`userLogisticsCompany` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户寄回物流公司',
`merchantLogisticsNo` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商家换货物流单号',
`merchantLogisticsCompany` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商家换货物流公司',
`applyTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '申请时间',
`auditTime` datetime(3) NULL DEFAULT NULL COMMENT '审核时间',
`userShipTime` datetime(3) NULL DEFAULT NULL COMMENT '用户寄回时间',
`merchantReceiveTime` datetime(3) NULL DEFAULT NULL COMMENT '商家收货时间',
`merchantShipTime` datetime(3) NULL DEFAULT NULL COMMENT '商家发货时间',
`finishTime` datetime(3) NULL DEFAULT NULL COMMENT '完成时间',
`requestBody` json NULL COMMENT '原始业务请求体',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
`reasonType` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`problemDescription` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`pickupMethod` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`pickupName` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`pickupMobile` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`pickupAddress` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`images` json NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_after_sale_serviceOrderId_key`(`serviceOrderId` ASC) USING BTREE,
INDEX `supplier_after_sale_supplierId_mainOrderId_idx`(`supplierId` ASC, `mainOrderId` ASC) USING BTREE,
INDEX `supplier_after_sale_supplierId_userId_idx`(`supplierId` ASC, `userId` ASC) USING BTREE,
INDEX `supplier_after_sale_subOrderId_idx`(`subOrderId` ASC) USING BTREE,
INDEX `supplier_after_sale_serviceType_serviceState_idx`(`serviceType` ASC, `serviceState` ASC) USING BTREE,
INDEX `supplier_after_sale_auditStatus_logisticsState_idx`(`auditStatus` ASC, `logisticsState` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商退款售后流程表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_after_sale
-- ----------------------------
-- ----------------------------
-- Table structure for supplier_info
-- ----------------------------
DROP TABLE IF EXISTS `supplier_info`;
CREATE TABLE `supplier_info` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`supplierIdentification` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商唯一标识',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`supplierName` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商名称',
`clientId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台分配的客户端ID',
`clientSecret` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台分配的客户端密钥',
`supplierApiUrl` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '供应商开放接口地址',
`sroNo` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'BAB7服务商编号',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL COMMENT '更新时间',
`creator` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人',
`updator` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '更新人',
`version` int NOT NULL DEFAULT 0 COMMENT '版本号',
`deleted` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_info_supplierIdentification_key`(`supplierIdentification` ASC) USING BTREE,
UNIQUE INDEX `supplier_info_supplierId_key`(`supplierId` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_info
-- ----------------------------
INSERT INTO `supplier_info` VALUES (1, 'joyful-selection', '99999999', '悦享优选', 'pmall_5f8c2d91a7b34e3db1c9f4e6a8d2b7c1', 'pmsec_8d7f4c1a9b3e6f2d5c8a1e7b9f4d6c2a3b8e1f5d9c7a6b4e2f1a8c3d6b9e7f', NULL, NULL, '2026-07-28 15:16:35.000', '2026-07-28 15:16:35.000', 'system', 'system', 0, 0);
-- ----------------------------
-- Table structure for supplier_invoice_info
-- ----------------------------
DROP TABLE IF EXISTS `supplier_invoice_info`;
CREATE TABLE `supplier_invoice_info` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '发票信息主键ID',
`invoiceBizId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发票业务唯一ID,优先使用发票号或事件订单号',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联主单ID',
`subOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联子单ID',
`settlementId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '结算单号',
`invoiceCode` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票代码或纳税识别号',
`invoiceIds` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票号码列表,多个用英文逗号分隔',
`orderIds` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联订单号列表,多个用英文逗号分隔',
`invoiceStatus` int NOT NULL DEFAULT 0 COMMENT '发票状态:0已记录,1已申请,2已开票,3失败',
`fileId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票文件ID',
`filePath` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票文件路径',
`requestBody` json NULL COMMENT '原始业务请求体',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_invoice_info_invoiceBizId_key`(`invoiceBizId` ASC) USING BTREE,
INDEX `supplier_invoice_info_supplierId_mainOrderId_idx`(`supplierId` ASC, `mainOrderId` ASC) USING BTREE,
INDEX `supplier_invoice_info_settlementId_idx`(`settlementId` ASC) USING BTREE,
INDEX `supplier_invoice_info_invoiceCode_idx`(`invoiceCode` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商发票信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_invoice_info
-- ----------------------------
INSERT INTO `supplier_invoice_info` VALUES (1, 'INV_ORD20260729134300_001', '99999999', '17a7f8ad3ee947b4a26175049a9c253d', 'ORD20260729134300', 'ORD20260729134300_001', NULL, NULL, 'INV_ORD20260729134300_001', 'ORD20260729134300_001', 2, NULL, NULL, '{\"time\": 1785303898394, \"amount\": 20.02, \"reason\": \"发票信息\", \"userId\": \"17a7f8ad3ee947b4a26175049a9c253d\", \"orderId\": \"ORD20260729134300_001\", \"orderIds\": \"ORD20260729134300_001\", \"orderType\": 11, \"invoiceIds\": \"INV_ORD20260729134300_001\", \"subOrderId\": \"ORD20260729134300_001\", \"supplierId\": \"99999999\", \"mainOrderId\": \"ORD20260729134300\", \"invoiceBizId\": \"INV_ORD20260729134300_001\"}', '2026-07-29 05:45:01.657', '2026-07-29 05:45:01.657', 0);
-- ----------------------------
-- Table structure for supplier_order_main
-- ----------------------------
DROP TABLE IF EXISTS `supplier_order_main`;
CREATE TABLE `supplier_order_main` (
`id` int NOT NULL AUTO_INCREMENT,
`orderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主订单ID',
`orderNumberId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '主订单编号',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '平台用户ID',
`aplName` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '应用名称',
`addressId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收货地址ID',
`remark` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '订单备注',
`createTime` datetime(3) NULL DEFAULT NULL COMMENT '订单创建时间',
`updateTime` datetime(3) NULL DEFAULT NULL COMMENT '订单更新时间',
`deliveryTime` datetime(3) NULL DEFAULT NULL COMMENT '发货时间',
`orderFinishTime` datetime(3) NULL DEFAULT NULL COMMENT '订单完成时间',
`completeTime` datetime(3) NULL DEFAULT NULL COMMENT '交易完成时间',
`deliveryStatus` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '配送状态',
`freightCost` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '运费积分金额',
`wPayOrAPayFreightCost` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '微信或支付宝支付运费金额',
`refund` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '积分退款金额',
`wPayOrAPayRefund` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '微信或支付宝退款金额',
`logisticsOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '物流单号',
`crrgBsnNm` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '承运商名称',
`orderState` int NOT NULL DEFAULT 0 COMMENT '订单状态:0待付款,1待发货,2待收货,3已取消,4已完成兼容旧值,5已完成,6换货,7退货,8废除',
`isSplit` tinyint NOT NULL DEFAULT 0 COMMENT '主单是否拆单:0未拆单,1已拆单',
`splitTime` datetime(3) NULL DEFAULT NULL COMMENT '主单拆单时间',
`totalPrice` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '订单总金额',
`wPayOrAPay` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '微信或支付宝支付金额',
`pointsPrice` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '积分支付金额',
`invoiceCode` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票编码',
`createAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
`updateAt` datetime(3) NOT NULL COMMENT '记录更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
`payTime` datetime NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_order_main_orderId_key`(`orderId` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商主订单表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_order_main
-- ----------------------------
INSERT INTO `supplier_order_main` VALUES (1, 'ORD20260901165221', 'TR20260901165221752', '99999999', 'c281e124321c8db3a14f0a7c86046c3c', '积分商城', 'addr-1788252731181', '', '2026-09-01 08:52:21.752', '2026-09-01 08:55:56.487', '2026-09-01 08:55:23.144', NULL, '2026-09-01 08:55:56.487', '1', 0.00, 0.00, 0.00, 0.00, '20260901', '韵达快递', 5, 1, '2026-09-01 08:52:25.891', 10.01, 0.00, 10.01, NULL, '2026-09-01 08:52:21.766', '2026-09-01 08:55:56.526', 0, '2026-09-01 08:52:26');
-- ----------------------------
-- Table structure for supplier_order_operate_log
-- ----------------------------
DROP TABLE IF EXISTS `supplier_order_operate_log`;
CREATE TABLE `supplier_order_operate_log` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '订单操作日志主键ID',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '主单ID',
`subOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '子单ID',
`operateType` int NOT NULL COMMENT '操作类型:1创建待支付,2支付成功,3拆单,4取消主单,5取消子单,6取消已支付未拆单整单,7确认收货,8已完成,9退款,10换货,11废除子单,12发票信息',
`beforeState` int NULL DEFAULT NULL COMMENT '操作前订单状态',
`afterState` int NULL DEFAULT NULL COMMENT '操作后订单状态',
`beforeSplit` int NULL DEFAULT NULL COMMENT '操作前拆单状态:0未拆单,1已拆单',
`afterSplit` int NULL DEFAULT NULL COMMENT '操作后拆单状态:0未拆单,1已拆单',
`remark` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '操作备注',
`operator` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '操作人或系统标识',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `supplier_order_operate_log_supplierId_mainOrderId_idx`(`supplierId` ASC, `mainOrderId` ASC) USING BTREE,
INDEX `supplier_order_operate_log_subOrderId_idx`(`subOrderId` ASC) USING BTREE,
INDEX `supplier_order_operate_log_operateType_idx`(`operateType` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商订单操作日志表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_order_operate_log
-- ----------------------------
INSERT INTO `supplier_order_operate_log` VALUES (1, '99999999', 'c281e124321c8db3a14f0a7c86046c3c', 'ORD20260901165221', 'ORD20260901165221_001', 7, 1, 2, NULL, NULL, NULL, 'api', '2026-09-01 08:55:23.165', 0);
INSERT INTO `supplier_order_operate_log` VALUES (2, '99999999', 'c281e124321c8db3a14f0a7c86046c3c', 'ORD20260901165221', 'ORD20260901165221_001', 8, 2, 5, NULL, NULL, '确认收货', 'system', '2026-09-01 08:55:56.515', 0);
-- ----------------------------
-- Table structure for supplier_order_product
-- ----------------------------
DROP TABLE IF EXISTS `supplier_order_product`;
CREATE TABLE `supplier_order_product` (
`id` int NOT NULL AUTO_INCREMENT,
`subOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属子订单ID',
`sku` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商品SKU编码',
`number` int NOT NULL COMMENT '商品购买数量',
`price` decimal(10, 2) NOT NULL COMMENT '商品成交单价',
`name` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商品名称快照',
`taxRate` decimal(5, 4) NOT NULL DEFAULT 0.1300 COMMENT '商品税率快照',
`productImgUrl` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品图片地址',
`fileId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '平台文件ID或图片ID',
`filePath` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件存储路径',
`picInfo` json NULL COMMENT '商品图片列表快照,JSON数组',
`createAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
`updateAt` datetime(3) NOT NULL COMMENT '记录更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `supplier_order_product_subOrderId_idx`(`subOrderId` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商子订单商品表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_order_product
-- ----------------------------
INSERT INTO `supplier_order_product` VALUES (1, 'ORD20260901165221_001', '2-cream-standard', 1, 10.01, '手机壳', 0.1300, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\"]', '2026-09-01 08:52:21.780', '2026-09-01 08:52:21.780', 0);
-- ----------------------------
-- Table structure for supplier_order_push_log
-- ----------------------------
DROP TABLE IF EXISTS `supplier_order_push_log`;
CREATE TABLE `supplier_order_push_log` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '订单推送日志主键ID',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '主单ID',
`orderId` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '推送订单ID,多个子单用英文逗号分隔',
`orderType` int NULL DEFAULT NULL COMMENT '推送事件类型:1取消主单,2拆单子单,3取消子单,7取消已支付未拆单整单',
`apiPath` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '推送接口路径',
`requestBody` json NULL COMMENT '推送请求体',
`responseBody` json NULL COMMENT '推送响应体',
`httpStatus` int NULL DEFAULT NULL COMMENT 'HTTP状态码',
`resultCode` int NULL DEFAULT NULL COMMENT '远程业务返回码',
`pushStatus` int NOT NULL DEFAULT 0 COMMENT '推送状态:0待推送,1推送成功,2推送失败',
`retryCount` int NOT NULL DEFAULT 0 COMMENT '重试次数',
`errorMessage` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '失败原因',
`lastPushTime` datetime(3) NULL DEFAULT NULL COMMENT '最后推送时间',
`nextRetryTime` datetime(3) NULL DEFAULT NULL COMMENT '下次重试时间',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `supplier_order_push_log_supplierId_mainOrderId_idx`(`supplierId` ASC, `mainOrderId` ASC) USING BTREE,
INDEX `supplier_order_push_log_orderType_pushStatus_idx`(`orderType` ASC, `pushStatus` ASC) USING BTREE,
INDEX `supplier_order_push_log_nextRetryTime_idx`(`nextRetryTime` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商订单远程推送日志表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_order_push_log
-- ----------------------------
INSERT INTO `supplier_order_push_log` VALUES (1, '99999999', 'c281e124321c8db3a14f0a7c86046c3c', 'ORD20260901165221', 'ORD20260901165221_001', 6, '/pointOrder/pushOrderInfoMsg', '{\"time\": 1788252956480, \"userId\": \"c281e124321c8db3a14f0a7c86046c3c\", \"orderId\": \"ORD20260901165221_001\", \"orderType\": 6, \"supplierId\": \"99999999\", \"mainOrderId\": \"ORD20260901165221\"}', '{\"message\": \"成功\", \"resultCode\": 0}', 200, 0, 1, 0, NULL, '2026-09-01 08:55:56.601', NULL, '2026-09-01 08:55:56.534', '2026-09-01 08:55:56.604', 0);
-- ----------------------------
-- Table structure for supplier_order_sub
-- ----------------------------
DROP TABLE IF EXISTS `supplier_order_sub`;
CREATE TABLE `supplier_order_sub` (
`id` int NOT NULL AUTO_INCREMENT,
`orderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '子订单ID',
`orderNumberId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '子订单编号',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属主订单ID',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`token` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '平台桥接访问令牌快照',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '平台用户ID',
`aplName` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '应用名称',
`addressId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '收货地址ID',
`remark` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '订单备注',
`createTime` datetime(3) NULL DEFAULT NULL COMMENT '订单创建时间',
`updateTime` datetime(3) NULL DEFAULT NULL COMMENT '订单更新时间',
`deliveryTime` datetime(3) NULL DEFAULT NULL COMMENT '发货时间',
`orderFinishTime` datetime(3) NULL DEFAULT NULL COMMENT '订单完成时间',
`completeTime` datetime(3) NULL DEFAULT NULL COMMENT '交易完成时间',
`deliveryStatus` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '配送状态',
`freightCost` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '运费积分金额',
`wPayOrAPayFreightCost` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '微信或支付宝支付运费金额',
`refund` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '积分退款金额',
`wPayOrAPayRefund` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '微信或支付宝退款金额',
`logisticsOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '物流单号',
`crrgBsnNm` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '承运商名称',
`orderState` int NOT NULL DEFAULT 0 COMMENT '订单状态:0待付款,1待发货,2待收货,3已取消,4已完成兼容旧值,5已完成,6换货,7退货,8废除',
`totalPrice` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '子订单总金额',
`wPayOrAPay` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '微信或支付宝支付金额',
`pointsPrice` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '积分支付金额',
`invoiceCode` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '发票编码',
`createAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
`updateAt` datetime(3) NOT NULL COMMENT '记录更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
`payTime` datetime NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_order_sub_orderId_key`(`orderId` ASC) USING BTREE,
INDEX `supplier_order_sub_mainOrderId_idx`(`mainOrderId` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商子订单表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_order_sub
-- ----------------------------
INSERT INTO `supplier_order_sub` VALUES (1, 'ORD20260901165221_001', 'TR20260901165221752_001', 'ORD20260901165221', '99999999', NULL, 'c281e124321c8db3a14f0a7c86046c3c', '积分商城', 'addr-1788252731181', '', '2026-09-01 08:52:21.752', '2026-09-01 08:55:56.487', '2026-09-01 08:55:23.144', '2026-09-01 08:55:56.487', '2026-09-01 08:55:56.487', '1', 0.00, 0.00, 0.00, 0.00, '20260901', '韵达快递', 5, 10.01, 0.00, 10.01, NULL, '2026-09-01 08:52:21.776', '2026-09-01 08:55:56.509', 0, '2026-09-01 08:52:26');
-- ----------------------------
-- Table structure for supplier_points_ledger
-- ----------------------------
DROP TABLE IF EXISTS `supplier_points_ledger`;
CREATE TABLE `supplier_points_ledger` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '积分流水主键ID',
`ledgerNo` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '流水号,业务唯一',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联主单ID',
`subOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联子单ID',
`bizType` int NOT NULL COMMENT '业务类型:1支付消费,2取消返还,3退货退款,4人工调整',
`direction` int NOT NULL COMMENT '流水方向:1增加,2扣减',
`points` decimal(10, 2) NOT NULL COMMENT '积分变动金额',
`beforePoints` decimal(10, 2) NULL DEFAULT NULL COMMENT '变动前积分余额',
`afterPoints` decimal(10, 2) NULL DEFAULT NULL COMMENT '变动后积分余额',
`remark` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '流水备注',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_points_ledger_ledgerNo_key`(`ledgerNo` ASC) USING BTREE,
INDEX `supplier_points_ledger_supplierId_userId_idx`(`supplierId` ASC, `userId` ASC) USING BTREE,
INDEX `supplier_points_ledger_mainOrderId_idx`(`mainOrderId` ASC) USING BTREE,
INDEX `supplier_points_ledger_subOrderId_idx`(`subOrderId` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商用户积分消费流水表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_points_ledger
-- ----------------------------
-- ----------------------------
-- Table structure for supplier_product
-- ----------------------------
DROP TABLE IF EXISTS `supplier_product`;
CREATE TABLE `supplier_product` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '商品主键ID',
`sku` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商品SKU编码',
`number` int NOT NULL DEFAULT 0 COMMENT '默认购买数量或导入数量',
`price` decimal(10, 2) NOT NULL COMMENT '商品销售价格',
`name` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商品名称',
`taxRate` decimal(5, 4) NOT NULL DEFAULT 0.1300 COMMENT '商品税率',
`picInfo` json NULL COMMENT '商品图片列表,JSON数组',
`description` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品简短描述',
`detail` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品详情描述',
`originPrice` decimal(10, 2) NULL DEFAULT NULL COMMENT '商品原价',
`imageUrl` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品主图地址',
`categoryId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品分类ID',
`category` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品分类名称',
`tag` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品标签',
`sales` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '商品销量展示文案',
`stock` int NOT NULL DEFAULT 0 COMMENT '商品库存',
`sortOrder` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前',
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
`createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updatedAt` datetime(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_product_sku_key`(`sku` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 25 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商商品信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_product
-- ----------------------------
INSERT INTO `supplier_product` VALUES (1, 'SKU001', 2, 149.99, '蓝牙耳机', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\"]', '轻盈佩戴,稳定连接,通勤运动都好用', '轻盈佩戴,稳定连接,通勤运动都好用。精选品质商品,适合积分商城兑换和日常福利采购场景。', 199.99, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1', 'digitalAppliance', '数码家电', '积分兑换', '1.2万件', 180, 1, 1, 0, '2026-07-28 09:43:03.406', '2026-07-28 09:43:03.406');
INSERT INTO `supplier_product` VALUES (2, 'SKU002', 1, 10.01, '手机壳', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\"]', '防滑耐磨,贴合机身,日常保护更安心', '防滑耐磨,贴合机身,日常保护更安心。精选品质商品,适合积分商城兑换和日常福利采购场景。', 19.90, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'digitalAppliance', '数码家电', '赠品', '5632件', 187, 2, 1, 0, '2026-07-28 09:43:03.427', '2026-07-28 09:43:03.427');
INSERT INTO `supplier_product` VALUES (3, 'SKU003', 1, 99.00, '智能音箱 Pro', 0.1300, '[\"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\", \"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '语音唤醒,沉浸音效,适合卧室和书房', '语音唤醒,沉浸音效,适合卧室和书房。精选品质商品,适合积分商城兑换和日常福利采购场景。', 139.00, 'https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1', 'digitalAppliance', '数码家电', '热门推荐', '2.1万件', 194, 3, 1, 0, '2026-07-28 09:43:03.431', '2026-07-28 09:43:03.431');
INSERT INTO `supplier_product` VALUES (4, 'SKU004', 1, 129.00, '奶油白迷你烤箱', 0.1300, '[\"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '小巧不占地,早餐烘焙好帮手', '小巧不占地,早餐烘焙好帮手。精选品质商品,适合积分商城兑换和日常福利采购场景。', 169.00, 'https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'homeLiving', '居家生活', '新品上架', '1.2万件', 201, 4, 1, 0, '2026-07-28 09:43:03.435', '2026-07-28 09:43:03.435');
INSERT INTO `supplier_product` VALUES (5, 'SKU005', 1, 69.90, '北欧陶瓷餐具套装', 0.1300, '[\"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\"]', '简约釉色,日常用餐更有仪式感', '简约釉色,日常用餐更有仪式感。精选品质商品,适合积分商城兑换和日常福利采购场景。', 99.90, 'https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'homeLiving', '居家生活', '品质精选', '6453件', 208, 5, 1, 0, '2026-07-28 09:43:03.438', '2026-07-28 09:43:03.438');
INSERT INTO `supplier_product` VALUES (6, 'SKU006', 1, 179.00, '纯棉舒适四件套', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\"]', '柔软亲肤,安心睡眠,四季可用', '柔软亲肤,安心睡眠,四季可用。精选品质商品,适合积分商城兑换和日常福利采购场景。', 229.00, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1', 'homeLiving', '居家生活', '积分兑换', '8321件', 215, 6, 1, 0, '2026-07-28 09:43:03.441', '2026-07-28 09:43:03.441');
INSERT INTO `supplier_product` VALUES (7, 'SKU007', 1, 99.90, '植萃保湿护肤礼盒', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\"]', '补水保湿,清爽不黏腻', '补水保湿,清爽不黏腻。精选品质商品,适合积分商城兑换和日常福利采购场景。', 139.90, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'beautyCare', '美妆个护', '礼盒精选', '4820件', 222, 7, 1, 0, '2026-07-28 09:43:03.443', '2026-07-28 09:43:03.443');
INSERT INTO `supplier_product` VALUES (8, 'SKU008', 1, 59.90, '香氛洗护旅行套装', 0.1300, '[\"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\", \"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '温和清洁,清新留香,出行便携', '温和清洁,清新留香,出行便携。精选品质商品,适合积分商城兑换和日常福利采购场景。', 89.90, 'https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1', 'beautyCare', '美妆个护', '新品上架', '7932件', 229, 8, 1, 0, '2026-07-28 09:43:03.446', '2026-07-28 09:43:03.446');
INSERT INTO `supplier_product` VALUES (9, 'SKU009', 1, 49.90, '便携补光化妆镜', 0.1300, '[\"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '高清镜面,柔光补妆,随身携带', '高清镜面,柔光补妆,随身携带。精选品质商品,适合积分商城兑换和日常福利采购场景。', 79.90, 'https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'beautyCare', '美妆个护', '热门推荐', '3651件', 236, 9, 1, 0, '2026-07-28 09:43:03.450', '2026-07-28 09:43:03.450');
INSERT INTO `supplier_product` VALUES (10, 'SKU010', 1, 59.90, '橙意零食礼盒', 0.1300, '[\"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\"]', '精选零食组合,办公室分享刚刚好', '精选零食组合,办公室分享刚刚好。精选品质商品,适合积分商城兑换和日常福利采购场景。', 89.90, 'https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'foodDrink', '食品饮料', '礼盒精选', '6324件', 243, 10, 1, 0, '2026-07-28 09:43:03.453', '2026-07-28 09:43:03.453');
INSERT INTO `supplier_product` VALUES (11, 'SKU011', 1, 49.90, '咖啡随行杯组合', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\"]', '锁温防漏,通勤随行更方便', '锁温防漏,通勤随行更方便。精选品质商品,适合积分商城兑换和日常福利采购场景。', 69.90, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1', 'foodDrink', '食品饮料', '积分兑换', '3520件', 250, 11, 1, 0, '2026-07-28 09:43:03.456', '2026-07-28 09:43:03.456');
INSERT INTO `supplier_product` VALUES (12, 'SKU012', 1, 69.90, '下午茶点心礼盒', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\"]', '多种风味,一盒满足甜咸口味', '多种风味,一盒满足甜咸口味。精选品质商品,适合积分商城兑换和日常福利采购场景。', 99.90, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'foodDrink', '食品饮料', '品质精选', '4891件', 257, 12, 1, 0, '2026-07-28 09:43:03.459', '2026-07-28 09:43:03.459');
INSERT INTO `supplier_product` VALUES (13, 'SKU013', 1, 39.90, '安心儿童水杯', 0.1300, '[\"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\", \"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '轻巧易握,防摔耐用,出门好携带', '轻巧易握,防摔耐用,出门好携带。精选品质商品,适合积分商城兑换和日常福利采购场景。', 59.90, 'https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1', 'motherBaby', '母婴玩具', '新品上架', '4250件', 264, 13, 1, 0, '2026-07-28 09:43:03.462', '2026-07-28 09:43:03.462');
INSERT INTO `supplier_product` VALUES (14, 'SKU014', 1, 89.00, '益智积木套装', 0.1300, '[\"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '色彩柔和,锻炼动手能力', '色彩柔和,锻炼动手能力。精选品质商品,适合积分商城兑换和日常福利采购场景。', 129.00, 'https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'motherBaby', '母婴玩具', '热门推荐', '2186件', 271, 14, 1, 0, '2026-07-28 09:43:03.465', '2026-07-28 09:43:03.465');
INSERT INTO `supplier_product` VALUES (15, 'SKU015', 1, 69.90, '柔光儿童夜灯', 0.1300, '[\"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\"]', '温和光线,陪伴睡前时光', '温和光线,陪伴睡前时光。精选品质商品,适合积分商城兑换和日常福利采购场景。', 99.90, 'https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'motherBaby', '母婴玩具', '品质精选', '3548件', 278, 15, 1, 0, '2026-07-28 09:43:03.469', '2026-07-28 09:43:03.469');
INSERT INTO `supplier_product` VALUES (16, 'SKU016', 1, 149.00, '大容量电脑背包', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\"]', '分区收纳,通勤出行更轻松', '分区收纳,通勤出行更轻松。精选品质商品,适合积分商城兑换和日常福利采购场景。', 199.00, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1', 'fashionBag', '服饰鞋包', '积分兑换', '1.6万件', 285, 16, 1, 0, '2026-07-28 09:43:03.472', '2026-07-28 09:43:03.472');
INSERT INTO `supplier_product` VALUES (17, 'SKU017', 1, 79.90, '轻巧通勤斜挎包', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\"]', '简洁百搭,日常出门更实用', '简洁百搭,日常出门更实用。精选品质商品,适合积分商城兑换和日常福利采购场景。', 109.90, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'fashionBag', '服饰鞋包', '新品上架', '5321件', 292, 17, 1, 0, '2026-07-28 09:43:03.475', '2026-07-28 09:43:03.475');
INSERT INTO `supplier_product` VALUES (18, 'SKU018', 1, 69.90, '柔软撞色丝巾礼盒', 0.1300, '[\"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\", \"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '细腻亲肤,点亮日常穿搭', '细腻亲肤,点亮日常穿搭。精选品质商品,适合积分商城兑换和日常福利采购场景。', 99.90, 'https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1', 'fashionBag', '服饰鞋包', '礼盒精选', '2680件', 299, 18, 1, 0, '2026-07-28 09:43:03.479', '2026-07-28 09:43:03.479');
INSERT INTO `supplier_product` VALUES (19, 'SKU019', 1, 49.90, '运动保温水杯', 0.1300, '[\"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '长效锁温,户外运动随行', '长效锁温,户外运动随行。精选品质商品,适合积分商城兑换和日常福利采购场景。', 79.90, 'https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'sportsOutdoor', '运动户外', '热门推荐', '7342件', 306, 19, 1, 0, '2026-07-28 09:43:03.483', '2026-07-28 09:43:03.483');
INSERT INTO `supplier_product` VALUES (20, 'SKU020', 1, 99.00, '筋膜放松按摩仪 Mini', 0.1300, '[\"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\"]', '轻巧便携,缓解久坐酸胀', '轻巧便携,缓解久坐酸胀。精选品质商品,适合积分商城兑换和日常福利采购场景。', 149.00, 'https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'sportsOutdoor', '运动户外', '积分兑换', '3921件', 313, 20, 1, 0, '2026-07-28 09:43:03.486', '2026-07-28 09:43:03.486');
INSERT INTO `supplier_product` VALUES (21, 'SKU021', 1, 79.00, '户外露营灯', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\"]', '多档照明,露营夜晚更安心', '多档照明,露营夜晚更安心。精选品质商品,适合积分商城兑换和日常福利采购场景。', 119.00, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.im_WvX9bWGyvagEZp4Pz0AHaGX?w=142&h=108&c=7&qlt=90&bgcl=a01e15&r=0&o=6&dpr=1.3&pid=13.1', 'sportsOutdoor', '运动户外', '新品上架', '2568件', 320, 21, 1, 0, '2026-07-28 09:43:03.489', '2026-07-28 09:43:03.489');
INSERT INTO `supplier_product` VALUES (22, 'SKU022', 1, 59.90, '悦享精选礼包', 0.1300, '[\"https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1\", \"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\"]', '多场景精选好物,福利兑换优选', '多场景精选好物,福利兑换优选。精选品质商品,适合积分商城兑换和日常福利采购场景。', 89.90, 'https://ts3.tc.mm.bing.net/th/id/OIP-C.MkmCbLoY2S4zqzMeHvCDMQHaHD?w=128&h=108&c=7&qlt=90&bgcl=f61537&r=0&o=6&dpr=1.3&pid=13.1', 'recommend', '精选推荐', '精选推荐', '8820件', 327, 22, 1, 0, '2026-07-28 09:43:03.492', '2026-07-28 09:43:03.492');
INSERT INTO `supplier_product` VALUES (23, 'SKU023', 1, 119.00, '品质生活组合装', 0.1300, '[\"https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1\", \"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '居家、通勤、日用好物组合', '居家、通勤、日用好物组合。精选品质商品,适合积分商城兑换和日常福利采购场景。', 159.00, 'https://ts2.tc.mm.bing.net/th/id/OIP-C.CKSBSB33LwT9qJD_Yhb1mwHaKk?w=132&h=108&c=7&qlt=90&bgcl=2e287d&r=0&o=6&dpr=1.3&pid=13.1', 'recommend', '精选推荐', '热门推荐', '6902件', 334, 23, 1, 0, '2026-07-28 09:43:03.495', '2026-07-28 09:43:03.495');
INSERT INTO `supplier_product` VALUES (24, 'SKU024', 1, 39.90, '积分兑换惊喜盒', 0.1300, '[\"https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\", \"https://tse3-mm.cn.bing.net/th/id/OIP-C.ynSetSr3z884UC6sEp4yiwHaEJ?w=331&h=186&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3\"]', '随机精选商品,兑换更有新鲜感', '随机精选商品,兑换更有新鲜感。精选品质商品,适合积分商城兑换和日常福利采购场景。', 69.90, 'https://tse1-mm.cn.bing.net/th/id/OIP-C.StrDRqennoZNbzSPZapKZwHaEJ?w=286&h=180&c=7&r=0&o=7&cb=thfc1falcon&dpr=1.3&pid=1.7&rm=3', 'recommend', '精选推荐', '限时秒杀', '5310件', 341, 24, 1, 0, '2026-07-28 09:43:03.498', '2026-07-28 09:43:03.498');
-- ----------------------------
-- Table structure for supplier_reconciliation_order
-- ----------------------------
DROP TABLE IF EXISTS `supplier_reconciliation_order`;
CREATE TABLE `supplier_reconciliation_order` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`orderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '子订单ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '主订单ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户ID',
`reconciliationStatus` tinyint NOT NULL DEFAULT 0 COMMENT '对账状态:0未对账,1对账成功,2对账异常',
`diffType` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '差异类型',
`diffReason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '差异说明',
`platformTotalPrice` decimal(12, 2) NULL DEFAULT NULL COMMENT '平台订单总金额',
`platformCashAmount` decimal(12, 2) NULL DEFAULT NULL COMMENT '平台现金支付金额',
`platformPointsPrice` decimal(12, 2) NULL DEFAULT NULL COMMENT '平台积分支付金额',
`platformRefundPrice` decimal(12, 2) NULL DEFAULT NULL COMMENT '平台退款金额',
`supplierTotalPrice` decimal(12, 2) NULL DEFAULT NULL COMMENT '供应商订单总金额',
`supplierCashAmount` decimal(12, 2) NULL DEFAULT NULL COMMENT '供应商现金支付金额',
`supplierPointsPrice` decimal(12, 2) NULL DEFAULT NULL COMMENT '供应商积分支付金额',
`supplierRefundPrice` decimal(12, 2) NULL DEFAULT NULL COMMENT '供应商退款金额',
`reconciliationBatchNo` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '对账批次号',
`reconciliationTime` datetime(3) NULL DEFAULT NULL COMMENT '对账完成时间',
`resultPayload` json NULL COMMENT '对账回调原始结果',
`createAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updateAt` datetime(3) NOT NULL,
`deleted` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_supplier_order`(`supplierId` ASC, `orderId` ASC) USING BTREE,
INDEX `idx_supplier_status`(`supplierId` ASC, `reconciliationStatus` ASC) USING BTREE,
INDEX `idx_batch_no`(`reconciliationBatchNo` ASC) USING BTREE,
INDEX `idx_main_order`(`mainOrderId` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商子订单对账结果表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_reconciliation_order
-- ----------------------------
-- ----------------------------
-- Table structure for supplier_refund_exchange
-- ----------------------------
DROP TABLE IF EXISTS `supplier_refund_exchange`;
CREATE TABLE `supplier_refund_exchange` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '售后记录主键ID',
`serviceOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货/换货业务单号',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户ID',
`mainOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联主单ID',
`subOrderId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '关联子单ID',
`serviceType` int NOT NULL COMMENT '售后类型:4退货,5换货',
`serviceState` int NOT NULL DEFAULT 0 COMMENT '售后状态:0已创建,1处理中,2已完成,3已取消',
`amount` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '售后金额',
`reason` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '售后原因',
`requestBody` json NULL COMMENT '原始业务请求体',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_refund_exchange_serviceOrderId_key`(`serviceOrderId` ASC) USING BTREE,
INDEX `supplier_refund_exchange_supplierId_mainOrderId_idx`(`supplierId` ASC, `mainOrderId` ASC) USING BTREE,
INDEX `supplier_refund_exchange_subOrderId_idx`(`subOrderId` ASC) USING BTREE,
INDEX `supplier_refund_exchange_serviceType_serviceState_idx`(`serviceType` ASC, `serviceState` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商退货换货售后记录表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_refund_exchange
-- ----------------------------
-- ----------------------------
-- Table structure for supplier_user_address
-- ----------------------------
DROP TABLE IF EXISTS `supplier_user_address`;
CREATE TABLE `supplier_user_address` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '地址主键ID',
`addressId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '地址业务ID',
`supplierId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商ID',
`userId` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
`name` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货人姓名',
`tel` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货人手机号',
`province` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '省',
`city` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '市',
`county` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区县',
`addressDetail` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '详细地址',
`areaCode` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '地区编码',
`postalCode` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '邮政编码',
`isDefault` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否默认地址',
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted` int NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `supplier_user_address_addressId_key`(`addressId` ASC) USING BTREE,
INDEX `supplier_user_address_supplierId_userId_idx`(`supplierId` ASC, `userId` ASC) USING BTREE,
INDEX `supplier_user_address_supplierId_userId_isDefault_idx`(`supplierId` ASC, `userId` ASC, `isDefault` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '供应商用户收货地址表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of supplier_user_address
-- ----------------------------
INSERT INTO `supplier_user_address` VALUES (1, 'addr-1785395646503', '99999999', 'b2397ac9a67f476c9833471d27709db1', '谌盼兮', '13545478393', '吉林省', '白山市', '临江市', '未来科技城', '220681', '430213', 1, '2026-07-30 07:14:06.541', '2026-07-30 07:29:34.829', 0);
INSERT INTO `supplier_user_address` VALUES (2, 'addr-1788252731181', '99999999', 'c281e124321c8db3a14f0a7c86046c3c', '谌盼兮', '19090230905', '湖北省', '武汉市', '洪山区', '中国地质大学', '420111', '430074', 1, '2026-09-01 08:52:11.226', '2026-09-01 08:52:11.226', 0);
CREATE TABLE IF NOT EXISTS `supplied_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NULL,
`virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL,
`passwordHash` VARCHAR(255) NULL,
`nickname` VARCHAR(64) NOT NULL DEFAULT '管理员',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`tokenHash` CHAR(64) NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`failedLoginCount` INT NOT NULL DEFAULT 0,
`lockedUntil` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `supplied_user_username_key` (`username`),
UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
UNIQUE KEY `supplied_user_tokenHash_key` (`tokenHash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
@@ -113,3 +113,33 @@ CREATE TABLE IF NOT EXISTS `supplier_order_product` (
FOREIGN KEY (`subOrderId`) REFERENCES `supplier_order_sub` (`orderId`)
ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='供应商子订单商品表';
CREATE TABLE IF NOT EXISTS `supplier_reconciliation_order` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`supplierId` VARCHAR(191) NOT NULL COMMENT '供应商ID',
`orderId` VARCHAR(191) NOT NULL COMMENT '子订单ID',
`mainOrderId` VARCHAR(191) NULL COMMENT '主订单ID',
`userId` VARCHAR(191) NULL COMMENT '平台用户ID',
`reconciliationStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '对账状态:0未对账,1对账成功,2对账异常',
`diffType` INT NULL COMMENT '差异类型',
`diffReason` TEXT NULL COMMENT '差异说明',
`platformTotalPrice` DECIMAL(12, 2) NULL COMMENT '平台订单总金额',
`platformCashAmount` DECIMAL(12, 2) NULL COMMENT '平台现金支付金额',
`platformPointsPrice` DECIMAL(12, 2) NULL COMMENT '平台积分支付金额',
`platformRefundPrice` DECIMAL(12, 2) NULL COMMENT '平台退款金额',
`supplierTotalPrice` DECIMAL(12, 2) NULL COMMENT '供应商订单总金额',
`supplierCashAmount` DECIMAL(12, 2) NULL COMMENT '供应商现金支付金额',
`supplierPointsPrice` DECIMAL(12, 2) NULL COMMENT '供应商积分支付金额',
`supplierRefundPrice` DECIMAL(12, 2) NULL COMMENT '供应商退款金额',
`reconciliationBatchNo` VARCHAR(191) NULL COMMENT '对账批次号',
`reconciliationTime` DATETIME(3) NULL COMMENT '对账完成时间',
`resultPayload` JSON NULL COMMENT '对账回调原始结果',
`createAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
`updateAt` DATETIME(3) NOT NULL COMMENT '记录更新时间',
`deleted` INT NOT NULL DEFAULT 0 COMMENT '删除状态:0未删除,1已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `supplier_reconciliation_order_supplierId_orderId_key` (`supplierId`, `orderId`),
KEY `supplier_reconciliation_order_supplierId_reconciliationStatus_idx` (`supplierId`, `reconciliationStatus`),
KEY `supplier_reconciliation_order_reconciliationBatchNo_idx` (`reconciliationBatchNo`),
KEY `supplier_reconciliation_order_mainOrderId_idx` (`mainOrderId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='供应商子订单对账结果表';
@@ -28,12 +28,22 @@ CREATE TABLE IF NOT EXISTS `supplier_invoice_info` (
`mainOrderId` VARCHAR(191) NULL COMMENT '关联主单ID',
`subOrderId` VARCHAR(191) NULL COMMENT '关联子单ID',
`settlementId` VARCHAR(191) NULL COMMENT '结算单号',
`invoiceCode` VARCHAR(191) NULL COMMENT '发票代码或纳税识别号',
`invoiceCode` VARCHAR(191) NULL COMMENT '发票代码',
`taxIdNumber` VARCHAR(191) NULL COMMENT '购买方纳税人识别号',
`invoiceIds` VARCHAR(1000) NULL COMMENT '发票号码列表,多个用英文逗号分隔',
`orderIds` VARCHAR(1000) NULL COMMENT '关联订单号列表,多个用英文逗号分隔',
`invoiceStatus` INT NOT NULL DEFAULT 0 COMMENT '发票状态:0已记录,1已申请,2已开票,3失败',
`invoiceDate` DATETIME(3) NULL COMMENT '开票日期',
`invoiceType` INT NULL COMMENT '发票类型',
`invoiceNakeAmount` DECIMAL(12, 2) NULL COMMENT '不含税金额',
`invoiceTaxRate` DECIMAL(8, 4) NULL COMMENT '发票税率',
`invoiceTaxAmount` DECIMAL(12, 2) NULL COMMENT '发票税额',
`invoiceAmount` DECIMAL(12, 2) NULL COMMENT '价税合计',
`checkCode` VARCHAR(191) NULL COMMENT '发票校验码',
`fileType` VARCHAR(16) NULL COMMENT '发票文件类型',
`fileId` VARCHAR(191) NULL COMMENT '发票文件ID',
`filePath` VARCHAR(191) NULL COMMENT '发票文件路径',
`failureMessage` TEXT NULL COMMENT '开票失败原因',
`requestBody` JSON NULL COMMENT '原始业务请求体',
`createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updateTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
@@ -0,0 +1,5 @@
ALTER TABLE `supplied_user`
ADD COLUMN `virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER `username`,
ADD UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
MODIFY COLUMN `username` VARCHAR(64) NULL,
MODIFY COLUMN `passwordHash` VARCHAR(255) NULL;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `supplied_user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NULL,
`virtualId` VARCHAR(191) COLLATE utf8mb4_bin NULL,
`passwordHash` VARCHAR(255) NULL,
`nickname` VARCHAR(64) NOT NULL DEFAULT '管理员',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`tokenHash` CHAR(64) NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`failedLoginCount` INT NOT NULL DEFAULT 0,
`lockedUntil` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `supplied_user_username_key` (`username`),
UNIQUE KEY `supplied_user_virtualId_key` (`virtualId`),
UNIQUE KEY `supplied_user_tokenHash_key` (`tokenHash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,16 @@
CREATE TABLE `supplier_file` (
`id` int NOT NULL AUTO_INCREMENT,
`fileId` varchar(191) NOT NULL,
`supplierId` varchar(191) DEFAULT NULL,
`fileName` varchar(191) NOT NULL,
`objectName` varchar(500) NOT NULL,
`bucketName` varchar(191) NOT NULL,
`contentType` varchar(100) DEFAULT NULL,
`fileSize` int DEFAULT NULL,
`createTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updateTime` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`deleted` int NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `supplier_file_fileId_key` (`fileId`),
KEY `supplier_file_supplierId_deleted_idx` (`supplierId`,`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='供应商文件存储记录';
@@ -0,0 +1,12 @@
ALTER TABLE `supplier_invoice_info`
MODIFY COLUMN `invoiceCode` VARCHAR(191) NULL COMMENT '发票代码',
ADD COLUMN `taxIdNumber` VARCHAR(191) NULL COMMENT '购买方纳税人识别号' AFTER `invoiceCode`,
ADD COLUMN `invoiceDate` DATETIME(3) NULL COMMENT '开票日期' AFTER `invoiceStatus`,
ADD COLUMN `invoiceType` INT NULL COMMENT '发票类型' AFTER `invoiceDate`,
ADD COLUMN `invoiceNakeAmount` DECIMAL(12, 2) NULL COMMENT '不含税金额' AFTER `invoiceType`,
ADD COLUMN `invoiceTaxRate` DECIMAL(8, 4) NULL COMMENT '发票税率' AFTER `invoiceNakeAmount`,
ADD COLUMN `invoiceTaxAmount` DECIMAL(12, 2) NULL COMMENT '发票税额' AFTER `invoiceTaxRate`,
ADD COLUMN `invoiceAmount` DECIMAL(12, 2) NULL COMMENT '价税合计' AFTER `invoiceTaxAmount`,
ADD COLUMN `checkCode` VARCHAR(191) NULL COMMENT '发票校验码' AFTER `invoiceAmount`,
ADD COLUMN `fileType` VARCHAR(16) NULL COMMENT '发票文件类型' AFTER `checkCode`,
ADD COLUMN `failureMessage` TEXT NULL COMMENT '开票失败原因' AFTER `filePath`;
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS `supplier_reconciliation_order`
(
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`supplierId` VARCHAR(191) NOT NULL COMMENT '供应商ID',
`orderId` VARCHAR(191) NOT NULL COMMENT '子订单ID',
`mainOrderId` VARCHAR(191) DEFAULT NULL COMMENT '主订单ID',
`userId` VARCHAR(191) DEFAULT NULL COMMENT '用户ID',
`reconciliationStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '对账状态:0未对账,1对账成功,2对账异常',
`diffType` VARCHAR(64) DEFAULT NULL COMMENT '差异类型',
`diffReason` VARCHAR(1000) DEFAULT NULL COMMENT '差异说明',
`platformTotalPrice` DECIMAL(12, 2) DEFAULT NULL COMMENT '平台订单总金额',
`platformCashAmount` DECIMAL(12, 2) DEFAULT NULL COMMENT '平台现金支付金额',
`platformPointsPrice` DECIMAL(12, 2) DEFAULT NULL COMMENT '平台积分支付金额',
`platformRefundPrice` DECIMAL(12, 2) DEFAULT NULL COMMENT '平台退款金额',
`supplierTotalPrice` DECIMAL(12, 2) DEFAULT NULL COMMENT '供应商订单总金额',
`supplierCashAmount` DECIMAL(12, 2) DEFAULT NULL COMMENT '供应商现金支付金额',
`supplierPointsPrice` DECIMAL(12, 2) DEFAULT NULL COMMENT '供应商积分支付金额',
`supplierRefundPrice` DECIMAL(12, 2) DEFAULT NULL COMMENT '供应商退款金额',
`reconciliationBatchNo` VARCHAR(64) DEFAULT NULL COMMENT '对账批次号',
`reconciliationTime` DATETIME(3) DEFAULT NULL COMMENT '对账完成时间',
`resultPayload` JSON DEFAULT NULL COMMENT '对账回调原始结果',
`createAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updateAt` DATETIME(3) NOT NULL,
`deleted` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_supplier_order` (`supplierId`, `orderId`),
KEY `idx_supplier_status` (`supplierId`, `reconciliationStatus`),
KEY `idx_batch_no` (`reconciliationBatchNo`),
KEY `idx_main_order` (`mainOrderId`)
) ENGINE = InnoDB
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
COMMENT ='供应商子订单对账结果表';
@@ -3,14 +3,27 @@ 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 { getBearerToken, readJson } from '../utils/request.js'
import { adminAuthService, type AdminLoginInput } from '../services/admin-auth.service.js'
import { toHttpResponse } from './controller-response.js'
export const loginController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(basicService.login())
return toHttpResponse(await adminAuthService.login(await readJson<AdminLoginInput>(request)))
})
export const adminSessionController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await adminAuthService.session(getBearerToken(request)))
})
export const adminLogoutController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await adminAuthService.logout(getBearerToken(request)))
})
export const cartAddController = withErrorBoundary(async (request: Request, _context: Context) => {
@@ -27,7 +40,19 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request)))
})
export const fileController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['GET'])
if (methodResponse) return methodResponse
const result = await minioUploadService.imageUrl(new URL(request.url).searchParams.get('fid')?.trim() || '')
if (!result.success) return toHttpResponse(result)
return new Response(null, { status: 302, headers: { Location: result.data.url, 'Cache-Control': 'no-store' } })
})
export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => {
if (request.method === 'GET') {
const fid = new URL(request.url).searchParams.get('fid') || ''
return new Response(null, { status: 302, headers: { Location: `/api/file?fid=${encodeURIComponent(fid)}`, 'Cache-Control': 'no-store' } })
}
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
@@ -0,0 +1,28 @@
import { withErrorBoundary } from '../utils/handler.js'
import { guardMethod } from '../utils/method.js'
import { getBearerToken, readJson } from '../utils/request.js'
import { adminAuthService } from '../services/admin-auth.service.js'
import { serviceFail } from '../types/service-result.js'
import { merchantInvoiceService, type MerchantInvoiceListInput, type MerchantInvoiceFileInput } from '../services/merchant-invoice.service.js'
import { toHttpResponse } from './controller-response.js'
export const merchantInvoiceListController = withErrorBoundary(async request => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
return toHttpResponse(await merchantInvoiceService.list(await readJson<MerchantInvoiceListInput>(request)))
})
export const merchantInvoicePushController = withErrorBoundary(async request => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
return toHttpResponse(await merchantInvoiceService.push(await readJson<{ id?: number }>(request)))
})
export const merchantInvoiceFileController = withErrorBoundary(async request => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
return toHttpResponse(await merchantInvoiceService.file(await readJson<MerchantInvoiceFileInput>(request)))
})
@@ -23,6 +23,20 @@ export const merchantOrderListController = withErrorBoundary(async (request: Req
return toHttpResponse(await merchantService.listOrders(await readJson<MerchantOrderListInput>(request)))
})
export const merchantSupplierListController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.listSuppliers())
})
export const merchantOrderRepushSplitController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await merchantService.repushSplitOrder(await readJson<Pick<MerchantShipInput, 'supplierId' | 'userId' | 'orderId'>>(request)))
})
export const merchantOrderShipController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
@@ -14,7 +14,14 @@ import { orderActionService } from '../services/order-action.service.js'
import { orderCreateService, type CheckoutGoods } from '../services/order-create.service.js'
import { orderListService } from '../services/order-list.service.js'
import { orderPushEventService, type OrderPushEventInput } from '../services/order-push-event.service.js'
import { supplierOpenOrderService, type SupplierOrderQueryRequest } from '../services/supplier-open-order.service.js'
import {
supplierOpenOrderService,
type SupplierFileBase64QueryRequest,
type SupplierInvoiceApplyRequest,
type SupplierInvoiceQueryRequest,
type SupplierOrderQueryRequest,
type SupplierReconciliationQueryRequest
} from '../services/supplier-open-order.service.js'
import { toHttpResponse } from './controller-response.js'
type OrderListRequest = {
@@ -239,3 +246,74 @@ export const supplierOrderInfoController = withErrorBoundary(async (request: Req
return Response.json(await supplierOpenOrderService.queryOrderInfo(body))
})
export const supplierReconciliationInfoController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
let body: SupplierReconciliationQueryRequest
try {
body = supplierOpenOrderService.parseBody(await readJson<SupplierReconciliationQueryRequest>(request))
} catch (error) {
return Response.json({
resultCode: 400,
message: error instanceof Error ? error.message : '请求参数解密失败',
orderInfos: []
})
}
return Response.json(await supplierOpenOrderService.queryReconciliationInfo(body))
})
export const supplierInvoiceApplyController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
let body: SupplierInvoiceApplyRequest
try {
body = supplierOpenOrderService.parseBody(await readJson<SupplierInvoiceApplyRequest>(request))
} catch (error) {
return Response.json({
resultCode: 400,
message: error instanceof Error ? error.message : '请求参数解密失败'
})
}
return Response.json(await supplierOpenOrderService.applyForInvoices(body))
})
export const supplierInvoiceInfoController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
let body: SupplierInvoiceQueryRequest
try {
body = supplierOpenOrderService.parseBody(await readJson<SupplierInvoiceQueryRequest>(request))
} catch (error) {
return Response.json({
resultCode: 400,
message: error instanceof Error ? error.message : '请求参数解密失败'
})
}
return Response.json(await supplierOpenOrderService.queryInvoiceInfo(body))
})
export const supplierFileBase64InfoController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
let body: SupplierFileBase64QueryRequest
try {
body = supplierOpenOrderService.parseBody(await readJson<SupplierFileBase64QueryRequest>(request))
} catch (error) {
return Response.json({
resultCode: 400,
message: error instanceof Error ? error.message : '请求参数解密失败',
fileType: 'JPG',
imageEncodeMap: {}
})
}
return Response.json(await supplierOpenOrderService.queryFileBase64Info(body))
})
@@ -1,5 +1,6 @@
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 { productService } from '../services/product.service.js'
import { toHttpResponse } from './controller-response.js'
@@ -25,3 +26,28 @@ export const productDetailController = withErrorBoundary(async (request: Request
return toHttpResponse(await productService.getProductDetail(id, sku))
})
export const merchantProductListController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
const body = await readJson<{ keyword?: string; categoryId?: string; pageNumber?: number; pageSize?: number }>(request)
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim(), body.pageNumber, body.pageSize))
})
export const merchantProductSaveController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await productService.saveManageProduct(
await readJson<Parameters<typeof productService.saveManageProduct>[0]>(request)
))
})
export const merchantProductDeleteController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
const body = await readJson<{ id?: number }>(request)
return toHttpResponse(await productService.deleteManageProduct(Number(body.id || 0)))
})
@@ -0,0 +1,75 @@
import type { Context } from '../utils/handler.js'
import { withErrorBoundary } from '../utils/handler.js'
import { guardMethod } from '../utils/method.js'
import { readJson } from '../utils/request.js'
import { toHttpResponse } from './controller-response.js'
import { supplierOpenOrderService } from '../services/supplier-open-order.service.js'
import {
reconciliationResultService,
type ReconciliationResultCallbackInput,
type ReconciliationResultListInput,
type ReconciliationResultUpdateInput
} from '../services/reconciliation-result.service.js'
type UnReconciledMsgInput = {
supplierId?: string
token?: string
timestamp?: number
orderIds?: string
unReconciledType?: number
unReconciledMsg?: string
uniqueSeqNo?: string
AplParmObjct?: string
}
export const supplierReconciliationResultController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
let body: ReconciliationResultCallbackInput
try {
body = supplierOpenOrderService.parseBody(await readJson<ReconciliationResultCallbackInput>(request)) as ReconciliationResultCallbackInput
} catch (error) {
return Response.json({ resultCode: 400, message: error instanceof Error ? error.message : '请求参数解密失败' })
}
return Response.json(await reconciliationResultService.receiveCallback(body))
})
export const supplierUnReconciledMsgController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
let body: UnReconciledMsgInput
try {
body = supplierOpenOrderService.parseBody(await readJson<UnReconciledMsgInput>(request)) as UnReconciledMsgInput
} catch (error) {
return Response.json({ resultCode: 400, message: error instanceof Error ? error.message : '请求参数解密失败' })
}
const reconciliationResults = String(body.orderIds || '').split(',').map(orderId => orderId.trim()).filter(Boolean).map(orderId => ({
orderId,
reconciliationStatus: 2,
diffType: body.unReconciledType === undefined ? undefined : Number(body.unReconciledType),
diffReason: body.unReconciledMsg
}))
return Response.json(await reconciliationResultService.receiveCallback({
supplierId: body.supplierId,
token: body.token,
timestamp: body.timestamp,
reconciliationBatchNo: body.uniqueSeqNo,
reconciliationResults
}))
})
export const merchantReconciliationListController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await reconciliationResultService.list(await readJson<ReconciliationResultListInput>(request)))
})
export const merchantReconciliationUpdateController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
return toHttpResponse(await reconciliationResultService.update(await readJson<ReconciliationResultUpdateInput>(request)))
})
@@ -9,6 +9,10 @@ type SupplierInfoRequest = {
supplierIdentification?: string
}
type SupplierSessionRequest = {
keyword?: string
}
type SupplierTokenRequest = {
userId?: string
supplierIdentification?: string
@@ -34,6 +38,16 @@ export const supplierInfoController = withErrorBoundary(async (request: Request,
return toHttpResponse(await supplierService.getSupplierInfo(supplierIdentification))
})
export const supplierSessionController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
const body = await readJson<SupplierSessionRequest>(request)
const keyword = String(body.keyword || '')
return toHttpResponse(await supplierService.decryptSupplierSession(keyword))
})
export const supplierTokenController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
+55 -9
View File
@@ -5,7 +5,7 @@ export type PrismaTransaction = Prisma.TransactionClient
export const orderDao = {
transaction<T>(handler: (tx: PrismaTransaction) => Promise<T>) {
return prisma.$transaction(handler)
return prisma.$transaction(handler, { maxWait: 10000, timeout: 20000 })
},
findMainOrdersWithSubs(supplierId: string, userId: string) {
@@ -137,12 +137,29 @@ export const orderDao = {
})
},
findMerchantSubOrders(supplierId: string, orderState?: number) {
findInvoiceBySettlementId(supplierId: string, settlementId: string) {
return prisma.supplierInvoiceInfo.findFirst({
where: {
supplierId,
settlementId,
deleted: 0
}
})
},
findOpenApiReconciliationSubOrders(supplierId: string, orderIds: string[], startDate?: Date, endDate?: Date, orderState?: number) {
return prisma.supplierOrderSub.findMany({
where: {
supplierId,
deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {})
...(Number.isInteger(orderState) ? { orderState } : {}),
...(orderIds.length ? { orderId: { in: orderIds } } : {}),
...(startDate || endDate ? {
completeTime: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {})
}
} : {})
},
include: {
products: {
@@ -150,19 +167,48 @@ export const orderDao = {
orderBy: { createAt: 'asc' }
}
},
orderBy: { createAt: 'asc' }
})
},
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }, matchingUserIds?: string[]) {
const where: Prisma.SupplierOrderSubWhereInput = {
...(supplierId ? { supplierId } : {}),
deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {}),
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
...(userId ? { userId: { contains: userId } } : {}),
...(matchingUserIds !== undefined ? { AND: [{ userId: { in: matchingUserIds } }] } : {}),
...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
}
const [orders, totalCount] = await prisma.$transaction([
prisma.supplierOrderSub.findMany({
where,
include: {
products: {
where: { deleted: 0 },
orderBy: { createAt: 'asc' }
}
},
orderBy: [
{ createTime: 'desc' },
{ createAt: 'desc' }
]
})
{ createAt: 'desc' },
{ orderId: 'desc' }
],
...(pagination ? { skip: (pagination.pageNumber - 1) * pagination.pageSize, take: pagination.pageSize } : {})
}),
prisma.supplierOrderSub.count({ where })
])
return { orders, totalCount }
},
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
findMerchantAfterSales(supplierId: string, serviceStates?: number[], serviceOrderId = '') {
return prisma.supplierAfterSale.findMany({
where: {
supplierId,
...(supplierId ? { supplierId } : {}),
deleted: 0,
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}),
...(serviceOrderId ? { serviceOrderId: { contains: serviceOrderId } } : {})
},
orderBy: [
{ createTime: 'desc' },
+30
View File
@@ -33,5 +33,35 @@ export const productDao = {
]
}
})
},
async findManageProducts(keyword: string, categoryId: string, pageNumber = 1, pageSize = 10) {
const where = {
deleted: 0,
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
...(categoryId ? { categoryId } : {})
}
const [products, totalCount] = await prisma.$transaction([
prisma.supplierProduct.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (pageNumber - 1) * pageSize,
take: pageSize
}),
prisma.supplierProduct.count({ where })
])
return { products, totalCount }
},
createProduct(data: Parameters<typeof prisma.supplierProduct.create>[0]['data']) {
return prisma.supplierProduct.create({ data })
},
updateProduct(id: number, data: Parameters<typeof prisma.supplierProduct.update>[0]['data']) {
return prisma.supplierProduct.update({ where: { id }, data })
},
findManageProduct(id: number) {
return prisma.supplierProduct.findFirst({ where: { id, deleted: 0 } })
}
}
@@ -0,0 +1,76 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../utils/prisma.js'
export type SuppliedUser = {
id: number
username: string
passwordHash: string
nickname: string
enabled: boolean
tokenExpiresAt: Date | null
lockedUntil: Date | null
}
export type SuppliedCustomer = { id: number; virtualId: string; nickname: string; enabled: boolean }
export const suppliedUserDao = {
async findByVirtualId(virtualId: string) {
const rows = await prisma.$queryRaw<SuppliedCustomer[]>`
SELECT id, virtualId, nickname, enabled FROM supplied_user WHERE virtualId = ${virtualId} LIMIT 1`
return rows[0]
},
async findOrCreateByVirtualId(virtualId: string, nickname: string) {
const existing = await this.findByVirtualId(virtualId)
if (existing) return existing
// 并发进入时由虚拟 ID 唯一索引去重,不覆盖已有昵称。
await prisma.$executeRaw`
INSERT INTO supplied_user (virtualId, nickname, username, passwordHash)
VALUES (${virtualId}, ${nickname}, NULL, NULL)
ON DUPLICATE KEY UPDATE virtualId = VALUES(virtualId)`
const user = await this.findByVirtualId(virtualId)
if (!user) throw new Error('获取供应商用户信息失败')
return user
},
async findVirtualIdsByNickname(nickname: string) {
return prisma.$queryRaw<Array<{ virtualId: string }>>`
SELECT virtualId FROM supplied_user
WHERE virtualId IS NOT NULL AND LOCATE(${nickname}, nickname) > 0`
},
async findByVirtualIds(virtualIds: string[]) {
if (!virtualIds.length) return []
return prisma.$queryRaw<SuppliedCustomer[]>`
SELECT id, virtualId, nickname, enabled FROM supplied_user
WHERE virtualId IN (${Prisma.join(virtualIds)})`
},
async findByUsername(username: string) {
const rows = await prisma.$queryRaw<SuppliedUser[]>`
SELECT id, username, passwordHash, nickname, enabled, tokenExpiresAt, lockedUntil
FROM supplied_user WHERE username = ${username} AND virtualId IS NULL LIMIT 1`
return rows[0]
},
async findSession(tokenHash: string) {
const now = new Date()
const rows = await prisma.$queryRaw<SuppliedUser[]>`
SELECT id, username, nickname, tokenExpiresAt FROM supplied_user
WHERE tokenHash = ${tokenHash} AND virtualId IS NULL AND enabled = TRUE AND tokenExpiresAt > ${now} LIMIT 1`
return rows[0]
},
async recordFailure(id: number) {
await prisma.$executeRaw`
UPDATE supplied_user SET
failedLoginCount = IF(lockedUntil IS NOT NULL AND lockedUntil <= NOW(3), 1, failedLoginCount + 1),
lockedUntil = IF(failedLoginCount >= 5, DATE_ADD(NOW(3), INTERVAL 15 MINUTE), NULL),
updatedAt = NOW(3) WHERE id = ${id}`
},
async saveSession(id: number, tokenHash: string, expiresAt: Date) {
return prisma.$executeRaw`
UPDATE supplied_user SET tokenHash = ${tokenHash}, tokenExpiresAt = ${expiresAt},
failedLoginCount = 0, lockedUntil = NULL, updatedAt = NOW(3)
WHERE id = ${id} AND virtualId IS NULL AND enabled = TRUE AND (lockedUntil IS NULL OR lockedUntil <= NOW(3))`
},
async clearSession(tokenHash: string) {
await prisma.$executeRaw`
UPDATE supplied_user SET tokenHash = NULL, tokenExpiresAt = NULL, updatedAt = NOW(3)
WHERE tokenHash = ${tokenHash}`
}
}
+8
View File
@@ -11,5 +11,13 @@ export const supplierDao = {
return prisma.supplierInfo.findFirst({
where: { supplierId, deleted: 0 }
})
},
findActiveSuppliers() {
return prisma.supplierInfo.findMany({
where: { deleted: 0 },
select: { supplierId: true, supplierName: true },
orderBy: { supplierName: 'asc' }
})
}
}
+1
View File
@@ -0,0 +1 @@
export { fileController as default } from '../controllers/basic.controller.js'
@@ -0,0 +1 @@
export { merchantInvoiceFileController as default } from '../controllers/merchant-invoice.controller.js'
@@ -0,0 +1 @@
export { merchantInvoiceListController as default } from '../controllers/merchant-invoice.controller.js'
@@ -0,0 +1 @@
export { merchantInvoicePushController as default } from '../controllers/merchant-invoice.controller.js'
@@ -0,0 +1 @@
export { merchantOrderRepushSplitController as default } from '../controllers/merchant.controller.js'
+107 -7
View File
@@ -4,6 +4,7 @@ import Router from '@koa/router'
import bodyParser from 'koa-bodyparser'
import type { FunctionHandler } from './utils/handler.js'
import { logger } from './utils/logger.js'
import { adminAuthService } from './services/admin-auth.service.js'
import {
addressDefaultController,
addressDeleteController,
@@ -11,10 +12,13 @@ import {
addressSaveController
} from './controllers/address.controller.js'
import {
adminSessionController,
adminLogoutController,
cartAddController,
loginController,
uploadController,
uploadPreviewController,
fileController,
userInfoController
} from './controllers/basic.controller.js'
import {
@@ -33,8 +37,18 @@ import {
orderPayController,
orderPushEventController,
orderShipController,
supplierOrderInfoController
supplierFileBase64InfoController,
supplierInvoiceApplyController,
supplierInvoiceInfoController,
supplierOrderInfoController,
supplierReconciliationInfoController
} from './controllers/order.controller.js'
import {
merchantReconciliationListController,
merchantReconciliationUpdateController,
supplierReconciliationResultController,
supplierUnReconciledMsgController
} from './controllers/reconciliation.controller.js'
import {
merchantAfterSaleAuditController,
merchantAfterSaleDetailController,
@@ -42,13 +56,22 @@ import {
merchantAfterSaleReceiveController,
merchantAfterSaleShipController,
merchantOrderListController,
merchantOrderShipController
merchantOrderShipController,
merchantOrderRepushSplitController,
merchantSupplierListController
} from './controllers/merchant.controller.js'
import { productDetailController, productListController } from './controllers/product.controller.js'
import {
merchantProductDeleteController,
merchantProductListController,
merchantProductSaveController,
productDetailController,
productListController
} from './controllers/product.controller.js'
import {
supplierDebugTokensController,
supplierInfoController,
supplierPointsController,
supplierSessionController,
supplierTokenController
} from './controllers/supplier.controller.js'
@@ -56,7 +79,12 @@ const app = new Koa()
const router = new Router()
const port = Number(process.env.PORT || 8888)
import { merchantInvoiceListController, merchantInvoiceFileController, merchantInvoicePushController } from './controllers/merchant-invoice.controller.js'
const handlers: Record<string, FunctionHandler> = {
'merchant-invoice-list': merchantInvoiceListController,
'merchant-invoice-file': merchantInvoiceFileController,
'merchant-invoice-push': merchantInvoicePushController,
'address-default': addressDefaultController,
'address-delete': addressDeleteController,
'address-list': addressListController,
@@ -68,6 +96,8 @@ const handlers: Record<string, FunctionHandler> = {
'after-sale-logistics': afterSaleLogisticsController,
'cart-add': cartAddController,
login: loginController,
'admin-session': adminSessionController,
'admin-logout': adminLogoutController,
'merchant-after-sale-audit': merchantAfterSaleAuditController,
'merchant-after-sale-detail': merchantAfterSaleDetailController,
'merchant-after-sale-list': merchantAfterSaleListController,
@@ -75,6 +105,8 @@ const handlers: Record<string, FunctionHandler> = {
'merchant-after-sale-ship': merchantAfterSaleShipController,
'merchant-order-list': merchantOrderListController,
'merchant-order-ship': merchantOrderShipController,
'merchant-order-repush-split': merchantOrderRepushSplitController,
'merchant-supplier-list': merchantSupplierListController,
'order-cancel-main': orderCancelMainController,
'order-cancel-paid-main': orderCancelPaidMainController,
'order-cancel-sub': orderCancelSubController,
@@ -87,13 +119,26 @@ const handlers: Record<string, FunctionHandler> = {
'order-ship': orderShipController,
'product-detail': productDetailController,
'product-list': productListController,
'merchant-product-list': merchantProductListController,
'merchant-product-save': merchantProductSaveController,
'merchant-product-delete': merchantProductDeleteController,
'supplier-debug-tokens': supplierDebugTokensController,
'supplier-file-base64-info': supplierFileBase64InfoController,
'supplier-invoice-apply': supplierInvoiceApplyController,
'supplier-invoice-info': supplierInvoiceInfoController,
'supplier-info': supplierInfoController,
'supplier-order-info': supplierOrderInfoController,
'supplier-reconciliation-info': supplierReconciliationInfoController,
'supplier-reconciliation-result': supplierReconciliationResultController,
'supplier-unreconciled-msg': supplierUnReconciledMsgController,
'merchant-reconciliation-list': merchantReconciliationListController,
'merchant-reconciliation-update': merchantReconciliationUpdateController,
'supplier-points': supplierPointsController,
'supplier-session': supplierSessionController,
'supplier-token': supplierTokenController,
upload: uploadController,
'upload-preview': uploadPreviewController,
file: fileController,
'user-info': userInfoController
}
@@ -117,11 +162,34 @@ const toRequest = (ctx: Koa.Context) =>
body: requestBody(ctx)
})
const writeResponse = async (ctx: Koa.Context, response: Response) => {
const ba7ResponseBody = (content: string) => ({
status: 200,
msg: 'success',
data: {
Body: {
RspSvcHeader: {
ReturnCode: '000000000000',
ReturnMsg: '交易成功'
},
RspSvcBody: {
ReturnStCd: '200',
ReturnCntnt: content
}
}
}
})
const writeResponse = async (ctx: Koa.Context, response: Response, wrapBa7 = false) => {
ctx.status = response.status
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
response.headers.forEach((value, key) => ctx.set(key, value))
ctx.body = response.body ? await response.text() : undefined
if (response.status >= 300 && response.status < 400 && response.headers.has('Location')) {
ctx.body = ''
ctx.status = response.status
return
}
const responseText = response.body ? await response.text() : ''
ctx.body = wrapBa7 ? ba7ResponseBody(responseText) : responseText || undefined
}
const dispatch = (name: string) => async (ctx: Koa.Context) => {
@@ -134,16 +202,25 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
path: ctx.path,
url: ctx.originalUrl,
query: ctx.query,
body: ctx.request.body || {}
body: name === 'login' ? { password: '[REDACTED]' } : ctx.request.body || {}
})
if (name === 'login' || name === 'admin-session' || name === 'admin-logout') ctx.set('Cache-Control', 'no-store')
const handler = handlers[name]
if (!handler) {
ctx.status = 404
ctx.body = { success: false, error: { code: 'NOT_FOUND', message: '接口不存在' } }
return
}
if (name.startsWith('merchant-')) {
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
if (!await adminAuthService.findSession(token)) {
ctx.status = 401
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
return
}
}
const response = await handler(toRequest(ctx), {})
await writeResponse(ctx, response)
await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/'))
}
app.use(bodyParser({ jsonLimit: '10mb' }))
@@ -174,7 +251,30 @@ router.delete('/api/supplier-logs', ctx => {
}
})
router.all('/api/:name', async ctx => dispatch(ctx.params.name)(ctx))
router.all('/api/admin/:name', async ctx => {
if (ctx.params.name !== 'login') {
try {
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
if (!await adminAuthService.findSession(token)) {
ctx.status = 401
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
return
}
} catch {
ctx.status = 500
ctx.body = { success: false, error: { code: 'AUTH_SERVICE_ERROR', message: '登录状态校验失败,请稍后重试' } }
return
}
}
await dispatch(ctx.params.name)(ctx)
})
router.post('/supplierOpenApi/pointOrder/queryOrderInfo', dispatch('supplier-order-info'))
router.post('/supplierOpenApi/pointOrder/queryReconciliationInfo', dispatch('supplier-reconciliation-info'))
router.post('/supplierOpenApi/pointOrder/applyForInvoices', dispatch('supplier-invoice-apply'))
router.post('/supplierOpenApi/pointOrder/queryInvoicesInfo', dispatch('supplier-invoice-info'))
router.post('/supplierOpenApi/pointOrder/queryFileBase64Info', dispatch('supplier-file-base64-info'))
router.post('/supplierOpenApi/pointOrder/reconciliationResult', dispatch('supplier-reconciliation-result'))
router.post('/supplierOpenApi/pointOrder/unReconciledMsg', dispatch('supplier-unreconciled-msg'))
router.post('/supplierOpenApi/debug/tokens', dispatch('supplier-debug-tokens'))
app.use(router.routes())
@@ -0,0 +1,55 @@
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { suppliedUserDao, type SuppliedUser } from '../dao/supplied-user.dao.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type AdminLoginInput = { username?: unknown; password?: unknown }
const digest = (token: string) => createHash('sha256').update(token).digest('hex')
const publicUser = (user: SuppliedUser) => ({ id: user.id, username: user.username, nickname: user.nickname })
const derivePassword = (password: string, salt: string) => new Promise<Buffer>((resolve, reject) => {
scrypt(password, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }, (error, key) => {
if (error) reject(error)
else resolve(key)
})
})
export const adminAuthService = {
async login(input: AdminLoginInput) {
const username = typeof input?.username === 'string' ? input.username.trim() : ''
const password = typeof input?.password === 'string' ? input.password : ''
if (!username || username.length > 64 || !password || password.length > 128) {
return serviceFail('请输入有效的账号和密码', 400, 'INVALID_LOGIN_INPUT')
}
const user = await suppliedUserDao.findByUsername(username)
const [algorithm, salt, hash] = (user?.passwordHash || '').split('$')
const validHash = algorithm === 'scrypt' && /^[a-f0-9]{32}$/.test(salt || '') && /^[a-f0-9]{128}$/.test(hash || '')
const actual = await derivePassword(password, validHash ? salt : '00000000000000000000000000000000')
const expected = validHash ? Buffer.from(hash, 'hex') : Buffer.alloc(64)
const passwordMatches = timingSafeEqual(actual, expected) && validHash
if (user?.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
return serviceFail('登录尝试过多,请稍后重试', 429, 'LOGIN_LOCKED')
}
if (!user || !user.enabled || !passwordMatches) {
if (user?.enabled) await suppliedUserDao.recordFailure(user.id)
return serviceFail('账号或密码错误', 401, 'INVALID_CREDENTIALS')
}
const token = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000)
if (!await suppliedUserDao.saveSession(user.id, digest(token), expiresAt)) {
return serviceFail('账号暂时无法登录,请稍后重试', 401, 'LOGIN_UNAVAILABLE')
}
return serviceOk({ token, expiresAt: expiresAt.toISOString(), user: publicUser(user) })
},
async findSession(token: string) {
if (!/^[a-f0-9]{64}$/.test(token)) return undefined
return suppliedUserDao.findSession(digest(token))
},
async session(token: string) {
const user = await this.findSession(token)
return user ? serviceOk({ user: publicUser(user), expiresAt: user.tokenExpiresAt?.toISOString() })
: serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED')
},
async logout(token: string) {
if (/^[a-f0-9]{64}$/.test(token)) await suppliedUserDao.clearSession(digest(token))
return serviceOk({})
}
}
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import {
AFTER_SALE_LOGISTICS_STATE,
AFTER_SALE_SERVICE_STATE,
@@ -132,13 +133,13 @@ const toDateTimeText = (date?: Date | null) => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const createServiceOrderId = (subOrderId: string, serviceType: number) => {
return `AS${serviceType}_${subOrderId}_${Date.now()}`
const createServiceOrderId = (serviceType: number) => {
return `AS${serviceType}_${randomBytes(8).toString('hex')}`
}
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送退货单')
}
@@ -149,6 +150,7 @@ const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof ord
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -159,7 +161,7 @@ const pushReturnOrder = async (record: NonNullable<Awaited<ReturnType<typeof ord
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法推送换货单')
}
@@ -170,6 +172,7 @@ const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof o
orderId: record.serviceOrderId,
mainOrderId: record.subOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -180,7 +183,7 @@ const pushExchangeOrder = async (record: NonNullable<Awaited<ReturnType<typeof o
return serviceOk({ serviceOrderId: record.serviceOrderId })
}
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>) => {
const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof orderDao.findAfterSale>>>, operationTime: Date) => {
if (!record.supplierId || !record.userId || !record.subOrderId || !record.mainOrderId) {
return serviceFail('售后订单信息不完整,无法取消子单')
}
@@ -191,6 +194,7 @@ const pushCancelSubOrder = async (record: NonNullable<Awaited<ReturnType<typeof
orderId: record.subOrderId,
mainOrderId: record.mainOrderId,
userId: record.userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -320,7 +324,7 @@ export const afterSaleService = {
const now = new Date()
const amount = Number(input.amount || subOrder.totalPrice || 0)
const serviceOrderId = createServiceOrderId(subOrderId, serviceType)
const serviceOrderId = createServiceOrderId(serviceType)
const { isSigned: _isSigned, ...requestBody } = input
await orderDao.transaction(async tx => {
@@ -487,7 +491,7 @@ export const afterSaleService = {
const subOrders = record.subOrderId
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
: []
const isSigned = isOrderSigned(subOrders[0]?.orderState)
const isSigned = Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrders[0]?.orderState)
const approved = auditStatus === 1
const isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER
const finishUnsignedReturn = approved && !isSigned && record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER
@@ -498,14 +502,14 @@ export const afterSaleService = {
? (isSigned ? AFTER_SALE_LOGISTICS_STATE.WAIT_USER_SHIP : AFTER_SALE_LOGISTICS_STATE.NONE)
: AFTER_SALE_LOGISTICS_STATE.NONE
const now = new Date()
if (finishUnsignedReturn) {
const cancelSubOrder = await pushCancelSubOrder(record)
const cancelSubOrder = await pushCancelSubOrder(record, now)
if (!cancelSubOrder.success) {
return cancelSubOrder
}
}
const now = new Date()
await orderDao.transaction(async tx => {
await tx.supplierAfterSale.update({
where: { serviceOrderId },
@@ -550,20 +554,20 @@ export const afterSaleService = {
return serviceFail('退款/售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND')
}
const now = new Date()
if (record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED) {
const exchangeOrder = await pushExchangeOrder(record)
const exchangeOrder = await pushExchangeOrder(record, now)
if (!exchangeOrder.success) {
return exchangeOrder
}
}
if (record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED) {
const returnOrder = await pushReturnOrder(record)
const returnOrder = await pushReturnOrder(record, now)
if (!returnOrder.success) {
return returnOrder
}
}
const now = new Date()
const finish = logisticsState === AFTER_SALE_LOGISTICS_STATE.USER_RECEIVED ||
(record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER && logisticsState === AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED)
const finishReturn = record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER &&
@@ -2,11 +2,6 @@ import { logger } from '../utils/logger.js'
import { serviceOk } from '../types/service-result.js'
export const basicService = {
login() {
logger.info('login placeholder called')
return serviceOk({ message: '登录接口待实现' })
},
addCart() {
logger.info('cart add placeholder called')
return serviceOk({ message: '加入购物车接口待实现' })
@@ -0,0 +1,82 @@
import { bridgeFetchWithAuth } from '../utils/bridge-auth.js'
import { ORDER_EVENT_TYPE, ORDER_PUSH_STATUS } from '../constants/order.js'
import { prisma } from '../utils/prisma.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
import { supplierFileService } from './supplier-file.service.js'
export type MerchantInvoiceListInput = { settlementId?: string; invoiceStatus?: number; pageNumber?: number; pageSize?: number }
export type MerchantInvoiceFileInput = { id?: number; download?: boolean }
export const merchantInvoiceService = {
async list(input: MerchantInvoiceListInput) {
const settlementId = String(input.settlementId || '').trim()
const invoiceStatus = input.invoiceStatus === undefined || input.invoiceStatus === null ? undefined : Number(input.invoiceStatus)
if (invoiceStatus !== undefined && ![0, 1, 2, 3].includes(invoiceStatus)) return serviceFail('开票状态不正确')
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Math.min(Number(input.pageSize), 100) : 10
const where = { deleted: 0, ...(settlementId ? { settlementId: { contains: settlementId } } : {}), ...(invoiceStatus === undefined ? {} : { invoiceStatus }) }
const [records, totalCount] = await prisma.$transaction([
prisma.supplierInvoiceInfo.findMany({ where, orderBy: [{ createTime: 'desc' }, { id: 'desc' }], skip: (pageNumber - 1) * pageSize, take: pageSize }),
prisma.supplierInvoiceInfo.count({ where })
])
const invoiceItems = records.map(record => ({
id: record.id, settlementId: record.settlementId || record.invoiceBizId, invoiceIds: record.invoiceIds || '',
invoiceCode: record.invoiceCode || '', invoiceStatus: record.invoiceStatus, invoiceDate: record.invoiceDate,
invoiceType: record.invoiceType, invoiceAmount: record.invoiceAmount?.toFixed(2) || '0.00',
invoiceNakeAmount: record.invoiceNakeAmount?.toFixed(2) || '0.00', invoiceTaxAmount: record.invoiceTaxAmount?.toFixed(2) || '0.00',
invoiceTaxRate: record.invoiceTaxRate?.toString() || '', taxIdNumber: record.taxIdNumber || '', checkCode: record.checkCode || '',
orderIds: record.orderIds || '', failureMessage: record.failureMessage || '', createTime: record.createTime,
hasFile: record.invoiceStatus === 2 && Boolean(record.fileId)
}))
return serviceOk({ invoiceItems, totalCount, pageNumber, pageSize })
},
async push(input: { id?: number }) {
const id = Number(input.id)
if (!Number.isInteger(id) || id <= 0) return serviceFail('发票参数不正确')
const invoice = await prisma.supplierInvoiceInfo.findFirst({ where: { id, deleted: 0 } })
if (!invoice) return serviceFail('发票记录不存在', 404, 'INVOICE_NOT_FOUND')
if (![2, 3].includes(invoice.invoiceStatus)) return serviceFail('开票尚未结束,暂不能推送发票')
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
const body = {
supplierId: invoice.supplierId,
userId: invoice.userId || 'SYSTEM',
orderType: ORDER_EVENT_TYPE.INVOICE_INFO,
orderId: invoice.settlementId || invoice.invoiceBizId,
mainOrderId: invoice.mainOrderId || '',
operationTime: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
time: now.getTime()
}
const apiPath = '/pointOrder/pushOrderInfoMsg'
const log = await prisma.supplierOrderPushLog.create({ data: {
supplierId: body.supplierId, userId: body.userId, mainOrderId: invoice.mainOrderId,
orderId: body.orderId, orderType: body.orderType, apiPath, requestBody: body, pushStatus: ORDER_PUSH_STATUS.PENDING
} })
try {
const result = await bridgeFetchWithAuth<{ resultCode?: number; message?: string }>(invoice.supplierId, apiPath, body)
const success = result.response.ok && result.payload.resultCode === 0
const message = result.payload.message || '发票推送失败'
await prisma.supplierOrderPushLog.update({ where: { id: log.id }, data: {
responseBody: result.payload, httpStatus: result.response.status, resultCode: result.payload.resultCode,
pushStatus: success ? ORDER_PUSH_STATUS.SUCCESS : ORDER_PUSH_STATUS.FAILED,
errorMessage: success ? null : message, lastPushTime: new Date()
} })
return success ? serviceOk({ message: '发票推送成功' }) : serviceFail(message, 502, 'INVOICE_PUSH_FAILED')
} catch (error) {
const message = error instanceof Error ? error.message : '发票推送失败'
await prisma.supplierOrderPushLog.update({ where: { id: log.id }, data: {
pushStatus: ORDER_PUSH_STATUS.FAILED, errorMessage: message, lastPushTime: new Date()
} })
return serviceFail(message, 502, 'INVOICE_PUSH_FAILED')
}
},
async file(input: MerchantInvoiceFileInput) {
const id = Number(input.id)
if (!Number.isInteger(id) || id <= 0) return serviceFail('发票参数不正确')
const invoice = await prisma.supplierInvoiceInfo.findFirst({ where: { id, deleted: 0, invoiceStatus: 2 } })
if (!invoice?.fileId) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
const url = await supplierFileService.pdfUrl(invoice.fileId, invoice.supplierId, input.download === true)
if (!url) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
return serviceOk({ url })
}
}
@@ -2,17 +2,26 @@ import {
AFTER_SALE_LOGISTICS_STATE,
AFTER_SALE_SERVICE_STATE,
ORDER_EVENT_TYPE,
ORDER_SPLIT_STATE,
ORDER_STATE
} from '../constants/order.js'
import { orderDao } from '../dao/order.dao.js'
import { supplierDao } from '../dao/supplier.dao.js'
import { suppliedUserDao } from '../dao/supplied-user.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 { bridgeFetchWithAuth } from '../utils/bridge-auth.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type MerchantOrderListInput = {
supplierId?: string
orderState?: number
pageNumber?: number
pageSize?: number
userNickname?: string
orderId?: string
userId?: string
}
export type MerchantShipInput = {
@@ -26,6 +35,7 @@ export type MerchantShipInput = {
export type MerchantAfterSaleListInput = {
supplierId?: string
scene?: string
serviceOrderId?: string
}
const serviceStateText = (serviceState: number) => {
@@ -68,7 +78,7 @@ const firstImage = (picInfo: unknown) => {
}
const afterSaleStatesByScene = (scene: string) => {
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT]
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT, AFTER_SALE_SERVICE_STATE.APPROVED, AFTER_SALE_SERVICE_STATE.USER_SHIPPED, AFTER_SALE_SERVICE_STATE.RECEIVED]
if (scene === 'process') {
return [
AFTER_SALE_SERVICE_STATE.APPROVED,
@@ -105,20 +115,27 @@ const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Numb
export const merchantService = {
async listOrders(input: MerchantOrderListInput) {
const supplierId = String(input.supplierId || '').trim()
const orderState = Number(input.orderState ?? ORDER_STATE.FINISH_PAY)
const orderState = input.orderState == null || String(input.orderState) === '' ? undefined : Number(input.orderState)
const orderId = String(input.orderId || '').trim()
const userId = String(input.userId || '').trim()
logger.info('merchant order list query', { supplierId, orderState })
if (!supplierId) {
return serviceFail('供应商不能为空')
}
const orders = await orderDao.findMerchantSubOrders(supplierId, orderState)
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Number(input.pageSize) : 10
const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = new Set(afterSales.map(item => item.subOrderId).filter(Boolean))
const orderItems = orders.filter(order => !afterSaleSubOrderIds.has(order.orderId)).map(order => ({
const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
const userNickname = String(input.userNickname || '').trim()
const matchingUserIds = userNickname ? (await suppliedUserDao.findVirtualIdsByNickname(userNickname)).map(user => user.virtualId) : undefined
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination, matchingUserIds)
const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))]
const users = await suppliedUserDao.findByVirtualIds(virtualIds)
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
const orderItems = orders.map(order => ({
orderId: order.orderId,
mainOrderId: order.mainOrderId,
userId: order.userId || '',
userNickname: nicknames.get(order.userId || '') || '',
supplierId: order.supplierId,
orderState: order.orderState,
orderNumberId: order.orderNumberId || '',
@@ -136,7 +153,63 @@ export const merchantService = {
}))
}))
return serviceOk({ orderItems })
return serviceOk({ orderItems, totalCount, pageNumber, pageSize })
},
async listSuppliers() {
const suppliers = await supplierDao.findActiveSuppliers()
return serviceOk({ suppliers })
},
async repushSplitOrder(input: Pick<MerchantShipInput, 'supplierId' | 'userId' | 'orderId'>) {
const supplierId = String(input.supplierId || '').trim()
const userId = String(input.userId || '').trim()
const orderId = String(input.orderId || '').trim()
if (!supplierId || !userId || !orderId) {
return serviceFail('供应商、用户或子单信息不能为空')
}
const subOrder = await orderDao.findSubOrderWithProducts(supplierId, userId, orderId)
if (!subOrder) {
return serviceFail('子单不存在', 404, 'SUB_ORDER_NOT_FOUND')
}
const mainOrder = await orderDao.findMainOrder(supplierId, userId, subOrder.mainOrderId)
if (!mainOrder) {
return serviceFail('主单不存在', 404, 'MAIN_ORDER_NOT_FOUND')
}
if (mainOrder.orderState !== ORDER_STATE.FINISH_PAY || subOrder.orderState !== ORDER_STATE.FINISH_PAY) {
return serviceFail('仅支持已支付且待发货的订单重新推送拆单')
}
if (!subOrder.products.length) {
return serviceFail('子单缺少商品信息,无法推送拆单')
}
try {
const { response, payload } = await bridgeFetchWithAuth<{ resultCode?: number; message?: string }>(
supplierId,
'/pointOrder/pushOrderInfoMsg',
{
orderType: ORDER_EVENT_TYPE.SPLIT_SUB_ORDER,
orderId: subOrder.orderId,
mainOrderId: mainOrder.orderId,
userId: mainOrder.userId,
time: Date.now()
}
)
if (!response.ok || payload.resultCode !== 0) {
return serviceFail(payload.message || '重新推送拆单失败', 502, 'BRIDGE_SPLIT_ORDER_FAILED')
}
} catch (error) {
return serviceFail(error instanceof Error ? error.message : '重新推送拆单失败', 502, 'BRIDGE_SPLIT_ORDER_FAILED')
}
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrder.orderId },
data: { isSplit: ORDER_SPLIT_STATE.SPLIT, splitTime: mainOrder.splitTime || new Date(), updateTime: new Date() }
})
})
return serviceOk({ orderId, mainOrderId: mainOrder.orderId })
},
shipOrder(input: MerchantShipInput) {
@@ -155,13 +228,12 @@ export const merchantService = {
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 records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene), String(input.serviceOrderId || '').trim())
const supplierIds = [...new Set(records.map(item => item.supplierId))]
const subOrders = (await Promise.all(supplierIds.map(recordSupplierId => {
const subOrderIds = records.filter(item => item.supplierId === recordSupplierId).map(item => item.subOrderId).filter(Boolean) as string[]
return subOrderIds.length ? orderDao.findOpenApiSubOrders(recordSupplierId, subOrderIds) : []
}))).flat()
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
const afterSaleItems = records.map(record => {
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
@@ -177,7 +249,7 @@ export const merchantService = {
serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState),
logisticsState: record.logisticsState,
isSigned: isOrderSigned(subOrder?.orderState),
isSigned: Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrder?.orderState),
reason: record.reason || '',
problemDescription: record.problemDescription || '',
pickupMethod: record.pickupMethod || '',
@@ -1,4 +1,5 @@
import crypto from 'node:crypto'
import { prisma } from '../utils/prisma.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type UploadInput = {
@@ -36,9 +37,9 @@ const signingKey = (date: string) => {
return hmac(serviceKey, 'aws4_request')
}
const objectUrl = (objectName: string) => {
const objectUrl = (objectName: string, bucket = minioConfig.bucket) => {
const endpoint = new URL(minioConfig.endPoint)
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}`
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${bucket}/${encodePath(objectName)}`
return endpoint
}
@@ -49,12 +50,12 @@ const extName = (fileName: string) => {
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 presign = (method: 'GET', objectName: string, expires = 3600, bucket = minioConfig.bucket) => {
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 endpoint = objectUrl(objectName, bucket)
const params = new URLSearchParams({
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
@@ -147,8 +148,15 @@ export const minioUploadService = {
return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED')
}
const fileId = crypto.randomUUID()
await prisma.supplierFile.create({
data: { fileId, fileName, objectName, bucketName: minioConfig.bucket, contentType, fileSize: body.length }
})
const fileUrl = `/api/file?fid=${encodeURIComponent(fileId)}`
const previewUrl = presign('GET', objectName)
return serviceOk({
fileId,
fileUrl,
objectName,
fileName,
contentType,
@@ -158,6 +166,16 @@ export const minioUploadService = {
})
},
async imageUrl(fileId: string) {
if (!ensureConfig()) return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
if (!fileId) return serviceFail('文件ID不能为空', 400, 'FILE_ID_REQUIRED')
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } })
if (!file || !file.contentType?.startsWith('image/')) {
return serviceFail('图片不存在', 404, 'IMAGE_NOT_FOUND')
}
return serviceOk({ url: presign('GET', file.objectName, 3600, file.bucketName) })
},
preview(input: PreviewInput) {
if (!ensureConfig()) {
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
@@ -64,14 +64,15 @@ export const orderActionService = {
return serviceFail('已支付主单不能通过未支付取消接口取消', 400, 'MAIN_ORDER_ALREADY_PAID')
}
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId, supplierId, userId, deleted: 0 },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -80,6 +81,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -112,14 +114,15 @@ export const orderActionService = {
return serviceFail('该主单已拆单,不能按已支付未拆单整单取消', 400, 'MAIN_ORDER_ALREADY_SPLIT')
}
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId: mainOrderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
await tx.supplierOrderSub.updateMany({
where: { mainOrderId, supplierId, userId, deleted: 0 },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -128,6 +131,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -153,10 +157,11 @@ export const orderActionService = {
const mainOrderId = input.mainOrderId || subOrder.mainOrderId
const operationTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderSub.update({
where: { orderId },
data: { orderState: ORDER_STATE.CANCELLED, updateTime: new Date() }
data: { orderState: ORDER_STATE.CANCELLED, updateTime: operationTime }
})
})
@@ -165,6 +170,7 @@ export const orderActionService = {
orderId,
mainOrderId,
userId,
operationTime: toDateTimeText(operationTime),
time: Date.now()
})
@@ -198,9 +204,9 @@ export const orderActionService = {
}
const payTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId },
const paid = await orderDao.transaction(async tx => {
const changed = await tx.supplierOrderMain.updateMany({
where: { orderId, supplierId, deleted: 0, orderState: ORDER_STATE.PENDING_PAY },
data: {
orderState: ORDER_STATE.FINISH_PAY,
isSplit: ORDER_SPLIT_STATE.NOT_SPLIT,
@@ -211,11 +217,38 @@ export const orderActionService = {
updateTime: payTime
}
})
if (changed.count !== 1) return false
await tx.supplierOrderSub.updateMany({
where: { mainOrderId: orderId, deleted: 0 },
data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime }
})
const products = await tx.supplierOrderProduct.findMany({
where: { deleted: 0, subOrder: { mainOrderId: orderId, supplierId, deleted: 0 } },
select: { sku: true, number: true }
})
const quantities = new Map<string, number>()
products.forEach(product => {
if (product.number > 0) quantities.set(product.sku, (quantities.get(product.sku) || 0) + product.number)
})
// 锁定商品行后累加销量,与首次支付状态变更一起提交,避免并发丢失或重复累计。
for (const sku of [...quantities.keys()].sort()) {
const rows = await tx.$queryRaw<{ id: number; sales: string | null }[]>`
SELECT id, sales FROM supplier_product WHERE sku = ${sku} FOR UPDATE
`
const product = rows[0]
if (!product) continue
const text = String(product.sales || '').trim().replace(/,/g, '')
const match = text.match(/^(\d+(?:\.\d+)?)\s*([万千])?(?:件)?\+?$/)
if (text && !match) throw new Error(`商品销量格式不正确:${sku}`)
const sales = match ? Math.floor(Number(match[1]) * (match[2] === '万' ? 10000 : match[2] === '千' ? 1000 : 1)) : 0
await tx.supplierProduct.update({
where: { id: product.id },
data: { sales: String(sales + quantities.get(sku)!) }
})
}
return true
})
if (!paid) return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE')
const confirm = await bridgeFetchWithAuth<BridgeOrderResponse>(supplierId, '/pointOrder/confirmPurchase', {
orderId,
@@ -259,6 +292,7 @@ export const orderActionService = {
orderId: subOrderIds,
mainOrderId: orderId,
userId: order.userId,
operationTime: toDateTimeText(payTime),
time: Date.now()
})
if (!split.response.ok || split.payload.resultCode !== 0) {
@@ -376,6 +410,7 @@ export const orderActionService = {
deliveryTime: toDateTimeText(now),
logisticsOrderId,
crrgBsnNm,
operationTime: toDateTimeText(now),
time: Date.now()
})
if (!ship.response.ok || ship.payload.resultCode !== 0) {
@@ -17,6 +17,7 @@ export type OrderPushEventInput = {
mainOrderId?: string
subOrderId?: string
time?: number
operationTime?: string
amount?: number
reason?: string
settlementId?: string
@@ -61,12 +62,18 @@ const operateType = (orderType: number) => {
return orderType
}
const toDateTimeText = (date: Date) => {
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 buildBridgeBody = (body: OrderPushEventInput) => ({
supplierId: String(body.supplierId || '').trim(),
orderType: Number(body.orderType),
orderId: String(body.orderId || '').trim(),
mainOrderId: String(body.mainOrderId || '').trim(),
userId: String(body.userId || '').trim(),
operationTime: body.operationTime,
time: Number(body.time || Date.now())
})
@@ -87,8 +94,17 @@ export const orderPushEventService = {
return serviceFail('暂不支持该订单事件类型', 400, 'ORDER_EVENT_NOT_SUPPORTED')
}
// 操作时间在业务处理时确定,写入事件及推送记录;不得由接口 time 推导。
const operationTime = body.operationTime ?? toDateTimeText(new Date())
if (typeof operationTime !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(operationTime)) {
return serviceFail('业务操作时间格式必须为 yyyy-MM-dd HH:mm:ss')
}
const now = new Date(operationTime.replace(' ', 'T'))
if (Number.isNaN(now.getTime()) || toDateTimeText(now) !== operationTime) {
return serviceFail('业务操作时间无效')
}
body = { ...body, operationTime }
const bridgeBody = buildBridgeBody(body)
const now = new Date()
let pushLogId = 0
let affectedSubOrderIds: string[] = []
@@ -190,7 +206,6 @@ export const orderPushEventService = {
invoiceStatus: 2,
fileId: body.fileId || null,
filePath: body.filePath || null,
requestBody: body,
deleted: 0
},
create: {
@@ -34,8 +34,10 @@ const toProductItem = (product: ProductRecord) => {
categoryId: product.categoryId || '',
category: product.category || '',
tag: product.tag || '',
sales: product.sales || '',
stock: product.stock
sales: product.sales?.trim() || '0',
stock: product.stock,
sortOrder: product.sortOrder,
enabled: product.enabled
}
}
@@ -64,5 +66,81 @@ export const productService = {
}
return serviceOk(toProductItem(product))
},
async listManageProducts(keyword: string, categoryId: string, requestedPageNumber = 1, requestedPageSize = 10) {
const pageNumber = Number.isInteger(Number(requestedPageNumber)) && Number(requestedPageNumber) > 0 ? Number(requestedPageNumber) : 1
const pageSize = Number.isInteger(Number(requestedPageSize)) && Number(requestedPageSize) > 0 ? Math.min(Number(requestedPageSize), 100) : 10
const { products, totalCount } = await productDao.findManageProducts(keyword, categoryId, pageNumber, pageSize)
return serviceOk({ categories, products: products.map(product => ({ ...toProductItem(product), description: product.description || '', detail: product.detail || '' })), totalCount, pageNumber, pageSize })
},
async saveManageProduct(input: {
id?: number
sku?: string
name?: string
price?: number
number?: number
stock?: number
taxRate?: number
categoryId?: string
category?: string
imageUrl?: string
description?: string
detail?: string
originPrice?: number
tag?: string
sales?: string
sortOrder?: number
enabled?: boolean
}) {
const sku = String(input.sku || '').trim()
const name = String(input.name || '').trim()
const price = Number(input.price)
if (!sku || !name || Number.isNaN(price) || price < 0) {
return serviceFail('商品SKU、名称和价格不能为空')
}
let imageUrl = String(input.imageUrl || '').trim()
if (imageUrl) {
const image = new URL(imageUrl, 'http://supplier.local')
if ((image.pathname.endsWith('/api/file') || image.pathname.endsWith('/api/upload-preview')) && image.searchParams.get('fid')) {
imageUrl = `/api/file?fid=${encodeURIComponent(image.searchParams.get('fid')!)}`
}
}
const data = {
sku,
name,
price,
number: Math.max(Number(input.number || 0), 0),
stock: Math.max(Number(input.stock || 0), 0),
taxRate: Number(input.taxRate ?? 0.13),
categoryId: String(input.categoryId || '').trim() || null,
category: String(input.category || '').trim() || null,
imageUrl: imageUrl || null,
description: String(input.description || '').trim() || null,
detail: String(input.detail || '').trim() || null,
originPrice: input.originPrice === undefined || input.originPrice === null ? null : Number(input.originPrice),
tag: String(input.tag || '').trim() || null,
sales: '0',
sortOrder: Math.max(Number(input.sortOrder || 0), 0),
enabled: input.enabled !== false
}
const id = Number(input.id || 0)
const updateData: Partial<typeof data> = { ...data }
for (const field of Object.keys(data) as Array<keyof typeof data>) {
if (input[field] === undefined) delete updateData[field]
}
delete updateData.sales
const product = id > 0 ? await productDao.updateProduct(id, updateData) : await productDao.createProduct(data)
return serviceOk(toProductItem(product))
},
async deleteManageProduct(id: number) {
if (!Number.isInteger(id) || id <= 0) return serviceFail('商品ID不正确')
const product = await productDao.findManageProduct(id)
if (!product) return serviceFail('商品不存在')
await productDao.updateProduct(id, { deleted: 1 })
return serviceOk({})
}
}
@@ -0,0 +1,258 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../utils/prisma.js'
import { supplierDao } from '../dao/supplier.dao.js'
import { verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type ReconciliationResultItem = {
orderId?: string
mainOrderId?: string
userId?: string
reconciliationStatus?: number
diffType?: string | number
diffReason?: string
platformTotalPrice?: number
platformCashAmount?: number
platformPointsPrice?: number
platformRefundPrice?: number
supplierTotalPrice?: number
supplierCashAmount?: number
supplierPointsPrice?: number
supplierRefundPrice?: number
}
export type ReconciliationResultCallbackInput = {
supplierId?: string
token?: string
timestamp?: number
reconciliationBatchNo?: string
reconciliationTime?: string
reconciliationResults?: ReconciliationResultItem[]
AplParmObjct?: string
}
export type ReconciliationResultListInput = {
pageNumber?: number
pageSize?: number
supplierId?: string
reconciliationStatus?: number
orderId?: string
}
export type ReconciliationResultUpdateInput = {
supplierId?: string
orderId?: string
reconciliationStatus?: number
diffType?: string | number
diffReason?: string
}
const toNumber = (value?: number) => value === undefined || value === null ? null : Number(value)
const toTime = (value?: string) => {
if (!value) return new Date()
const time = new Date(value)
return Number.isNaN(time.getTime()) ? new Date() : time
}
type DecimalValue = { toFixed: (fractionDigits?: number) => string } | number | string
type SupplierReconciliationOrderRecord = {
supplierId: string
orderId: string
mainOrderId: string | null
userId: string | null
reconciliationStatus: number
diffType: string | null
diffReason: string | null
platformTotalPrice: DecimalValue | null
platformCashAmount: DecimalValue | null
platformPointsPrice: DecimalValue | null
platformRefundPrice: DecimalValue | null
supplierTotalPrice: DecimalValue | null
supplierCashAmount: DecimalValue | null
supplierPointsPrice: DecimalValue | null
supplierRefundPrice: DecimalValue | null
reconciliationBatchNo: string | null
reconciliationTime: Date | null
updateAt: Date
deleted?: number
}
const formatAmount = (value: DecimalValue | null) => {
if (value === undefined || value === null) return ''
if (typeof value === 'object') return value.toFixed(2)
const numberValue = Number(value)
return Number.isNaN(numberValue) ? '' : numberValue.toFixed(2)
}
const resultItem = (record: SupplierReconciliationOrderRecord) => ({
supplierId: record.supplierId,
orderId: record.orderId,
mainOrderId: record.mainOrderId || '',
userId: record.userId || '',
reconciliationStatus: record.reconciliationStatus,
diffType: record.diffType,
diffReason: record.diffReason || '',
platformTotalPrice: formatAmount(record.platformTotalPrice),
platformCashAmount: formatAmount(record.platformCashAmount),
platformPointsPrice: formatAmount(record.platformPointsPrice),
platformRefundPrice: formatAmount(record.platformRefundPrice),
supplierTotalPrice: formatAmount(record.supplierTotalPrice),
supplierCashAmount: formatAmount(record.supplierCashAmount),
supplierPointsPrice: formatAmount(record.supplierPointsPrice),
supplierRefundPrice: formatAmount(record.supplierRefundPrice),
reconciliationBatchNo: record.reconciliationBatchNo || '',
reconciliationTime: record.reconciliationTime,
updateAt: record.updateAt
})
export const reconciliationResultService = {
async receiveCallback(input: ReconciliationResultCallbackInput) {
const supplierId = String(input.supplierId || '').trim()
const token = String(input.token || '').trim()
const timestamp = Number(input.timestamp || 0)
const records = Array.isArray(input.reconciliationResults) ? input.reconciliationResults : []
if (!supplierId || !token || !timestamp || !records.length) {
return { resultCode: 400, message: '供应商、签名或对账结果不能为空' }
}
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
if (!supplier) return { resultCode: 404, message: '供应商信息不存在' }
if (!verifySupplierOpenSignature(supplier.supplierId, supplier.clientId, timestamp, token)) {
return { resultCode: 401, message: '签名无效或已过期' }
}
const reconciliationTime = toTime(input.reconciliationTime)
for (const record of records) {
const orderId = String(record.orderId || '').trim()
const status = Number(record.reconciliationStatus)
if (!orderId || ![0, 1, 2].includes(status)) {
return { resultCode: 400, message: '子订单ID或对账状态不正确' }
}
}
await prisma.$transaction(records.map(record => {
const orderId = String(record.orderId || '').trim()
const data = {
mainOrderId: String(record.mainOrderId || '').trim() || null,
userId: String(record.userId || '').trim() || null,
reconciliationStatus: Number(record.reconciliationStatus),
diffType: record.diffType === undefined || record.diffType === null ? null : String(record.diffType).trim() || null,
diffReason: String(record.diffReason || '').trim() || null,
platformTotalPrice: toNumber(record.platformTotalPrice),
platformCashAmount: toNumber(record.platformCashAmount),
platformPointsPrice: toNumber(record.platformPointsPrice),
platformRefundPrice: toNumber(record.platformRefundPrice),
supplierTotalPrice: toNumber(record.supplierTotalPrice),
supplierCashAmount: toNumber(record.supplierCashAmount),
supplierPointsPrice: toNumber(record.supplierPointsPrice),
supplierRefundPrice: toNumber(record.supplierRefundPrice),
reconciliationBatchNo: String(input.reconciliationBatchNo || '').trim() || null,
reconciliationTime,
resultPayload: JSON.stringify(record),
deleted: 0
}
return prisma.$executeRaw`
INSERT INTO supplier_reconciliation_order
(supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
reconciliationBatchNo, reconciliationTime, resultPayload, deleted, createAt, updateAt)
VALUES
(${supplierId}, ${orderId}, ${data.mainOrderId}, ${data.userId}, ${data.reconciliationStatus}, ${data.diffType}, ${data.diffReason},
${data.platformTotalPrice}, ${data.platformCashAmount}, ${data.platformPointsPrice}, ${data.platformRefundPrice},
${data.supplierTotalPrice}, ${data.supplierCashAmount}, ${data.supplierPointsPrice}, ${data.supplierRefundPrice},
${data.reconciliationBatchNo}, ${data.reconciliationTime}, ${data.resultPayload}, ${data.deleted}, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE
mainOrderId = VALUES(mainOrderId),
userId = VALUES(userId),
reconciliationStatus = VALUES(reconciliationStatus),
diffType = VALUES(diffType),
diffReason = VALUES(diffReason),
platformTotalPrice = VALUES(platformTotalPrice),
platformCashAmount = VALUES(platformCashAmount),
platformPointsPrice = VALUES(platformPointsPrice),
platformRefundPrice = VALUES(platformRefundPrice),
supplierTotalPrice = VALUES(supplierTotalPrice),
supplierCashAmount = VALUES(supplierCashAmount),
supplierPointsPrice = VALUES(supplierPointsPrice),
supplierRefundPrice = VALUES(supplierRefundPrice),
reconciliationBatchNo = VALUES(reconciliationBatchNo),
reconciliationTime = VALUES(reconciliationTime),
resultPayload = VALUES(resultPayload),
deleted = VALUES(deleted),
updateAt = CURRENT_TIMESTAMP(3)
`
}))
return { resultCode: 0, message: '成功' }
},
async list(input: ReconciliationResultListInput) {
const supplierId = String(input.supplierId || '').trim()
const reconciliationStatus = input.reconciliationStatus === undefined ? undefined : Number(input.reconciliationStatus)
if (reconciliationStatus !== undefined && ![0, 1, 2].includes(reconciliationStatus)) {
return serviceFail('对账状态不正确')
}
const orderId = String(input.orderId || '').trim()
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Math.min(Number(input.pageSize), 100) : 10
const condition = Prisma.sql`
WHERE deleted = 0
${supplierId ? Prisma.sql`AND supplierId = ${supplierId}` : Prisma.empty}
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
`
const totals = await prisma.$queryRaw<{ totalCount: bigint }[]>`
SELECT COUNT(*) AS totalCount FROM supplier_reconciliation_order ${condition}
`
const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>`
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
reconciliationBatchNo, reconciliationTime, updateAt
FROM supplier_reconciliation_order
${condition}
ORDER BY updateAt DESC, supplierId, orderId
LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}
`
return serviceOk({ reconciliationItems: records.map(resultItem), totalCount: Number(totals[0]?.totalCount || 0), pageNumber, pageSize })
},
async update(input: ReconciliationResultUpdateInput) {
const supplierId = String(input.supplierId || '').trim()
const orderId = String(input.orderId || '').trim()
const reconciliationStatus = Number(input.reconciliationStatus)
if (!supplierId || !orderId || ![0, 1, 2].includes(reconciliationStatus)) {
return serviceFail('供应商、子订单或对账状态不正确')
}
const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>`
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
reconciliationBatchNo, reconciliationTime, updateAt, deleted
FROM supplier_reconciliation_order
WHERE supplierId = ${supplierId}
AND orderId = ${orderId}
LIMIT 1
`
const record = records[0]
if (!record || record.deleted !== 0) return serviceFail('对账结果不存在')
const diffType = input.diffType === undefined || input.diffType === null ? null : String(input.diffType).trim() || null
const diffReason = String(input.diffReason || '').trim() || null
await prisma.$executeRaw`
UPDATE supplier_reconciliation_order
SET reconciliationStatus = ${reconciliationStatus},
diffType = ${diffType},
diffReason = ${diffReason},
updateAt = CURRENT_TIMESTAMP(3)
WHERE supplierId = ${supplierId}
AND orderId = ${orderId}
`
return serviceOk({})
}
}
@@ -0,0 +1,18 @@
import { randomBytes, randomInt } from 'node:crypto'
import { suppliedUserDao } from '../dao/supplied-user.dao.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export const suppliedUserService = {
async getSessionUser(userId: string) {
const virtualId = userId.trim()
if (!virtualId || virtualId.length > 191) return serviceFail('用户虚拟 ID 无效')
const letters = 'abcdefghijklmnopqrstuvwxyz'
const nickname = `用户${randomInt(10)}${letters[randomInt(letters.length)]}${randomBytes(4).toString('hex')}`
const user = await suppliedUserDao.findOrCreateByVirtualId(virtualId, nickname)
if (!user.enabled) return serviceFail('当前用户已停用', 403, 'USER_DISABLED')
return serviceOk({
userId: user.virtualId,
userInfo: { id: user.id, virtualId: user.virtualId, nickname: user.nickname }
})
}
}
@@ -0,0 +1,62 @@
import { Client } from 'minio'
import { statSync } from 'node:fs'
import { prisma } from '../utils/prisma.js'
const endpoint = new URL(process.env.MINIO_ENDPOINT || 'http://localhost:9000')
const bucketName = process.env.MINIO_BUCKET || 'unions'
const client = new Client({
endPoint: endpoint.hostname,
port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)),
useSSL: endpoint.protocol === 'https:',
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || '',
region: process.env.MINIO_REGION || 'us-east-1'
})
const ensureBucket = async () => {
if (!(await client.bucketExists(bucketName))) {
await client.makeBucket(bucketName, process.env.MINIO_REGION || 'us-east-1')
}
}
const streamToBuffer = async (stream: NodeJS.ReadableStream) => {
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk))
return Buffer.concat(chunks)
}
export const supplierFileService = {
async uploadPdf(fileId: string, supplierId: string, fileName: string, filePath: string) {
await ensureBucket()
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
const datePath = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())}`
const objectName = `supplied/invoices/${datePath}/${supplierId}/${fileName}`
const stat = statSync(filePath)
await client.fPutObject(bucketName, objectName, filePath, {
'Content-Type': 'application/pdf'
})
await prisma.supplierFile.upsert({
where: { fileId },
update: { supplierId, fileName, objectName, bucketName, contentType: 'application/pdf', fileSize: stat.size, deleted: 0 },
create: { fileId, supplierId, fileName, objectName, bucketName, contentType: 'application/pdf', fileSize: stat.size }
})
return { fileId, objectName }
},
async pdfUrl(fileId: string, supplierId: string, download = false) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0, contentType: 'application/pdf' } })
if (!file) return null
return client.presignedGetObject(file.bucketName, file.objectName, 3600, {
'response-content-type': 'application/pdf',
'response-content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`
})
},
async read(fileId: string, supplierId: string) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
if (!file) return null
const stream = await client.getObject(file.bucketName, file.objectName)
return streamToBuffer(stream)
}
}
@@ -1,8 +1,15 @@
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import path from 'node:path'
import { supplierDao } from '../dao/supplier.dao.js'
import { orderDao } from '../dao/order.dao.js'
import { ORDER_EVENT_TYPE, ORDER_STATE } from '../constants/order.js'
import { orderPushEventService } from './order-push-event.service.js'
import { logger } from '../utils/logger.js'
import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
import { generateMockInvoicePdf } from '../../tools/mock-invoice-pdf.js'
import { supplierFileService } from './supplier-file.service.js'
import { minioUploadService } from './minio-upload.service.js'
export type SupplierOrderQueryRequest = {
supplierId?: string
@@ -11,18 +18,90 @@ export type SupplierOrderQueryRequest = {
orderId?: string
timestamp?: number
AplParmObjct?: string
RsrvFld1?: string
}
const parseBody = (body: SupplierOrderQueryRequest) => {
if (!body.AplParmObjct) return body
export type SupplierReconciliationQueryRequest = {
supplierId?: string
token?: string
reconciliationType?: number
orderId?: string
startDate?: string
endDate?: string
page?: number
pageSize?: number
timestamp?: number
AplParmObjct?: string
RsrvFld1?: string
}
const keyHex = process.env.SUPPLIER_OPEN_SM4_KEY_HEX || process.env.SM4CBC_KEY_HEX || ''
const ivHex = process.env.SUPPLIER_OPEN_SM4_IV_HEX || process.env.SM4CBC_IV_HEX || ''
export type SupplierInvoiceApplyRequest = {
supplierId?: string
token?: string
settlementId?: string
orderIds?: string
orderNum?: number
orderTotalPrice?: number | string
invoiceDate?: string | number
invoiceTitle?: string
taxIdNumber?: string
taxIDNumber?: string
invoiceAddress?: string
invoiceContact?: string
invoiceNakedPrice?: number | string
invoiceTaxPrice?: number | string
invoiceType?: string
invoiceContent?: string
remark?: string
bankAccount?: string
registeredAddress?: string
bankName?: string
timestamp?: number
AplParmObjct?: string
RsrvFld1?: string
}
export type SupplierFileBase64QueryRequest = {
supplierId?: string
token?: string
queryType?: string
idNumber?: string
fileIdList?: string[]
timestamp?: number
ApiParamObject?: string
RsrvFld1?: string
}
export type SupplierInvoiceQueryRequest = {
retry?: boolean
supplierId?: string
token?: string
settlementId?: string
timestamp?: number
AplParmObjct?: string
RsrvFld1?: string
}
type SupplierOpenEncryptedRequest = {
AplParmObjct?: string
ApiParamObject?: string
RsrvFld1?: string
}
const parseBody = <T extends SupplierOpenEncryptedRequest>(body: T) => {
const encryptedData = body.AplParmObjct || body.ApiParamObject
if (!encryptedData) return body
const keyHex = process.env.SM4CBC_KEY_HEX || process.env.SUPPLIER_OPEN_SM4_KEY_HEX || ''
const ivHex = process.env.SM4CBC_IV_HEX || process.env.SUPPLIER_OPEN_SM4_IV_HEX || ''
if (!keyHex || !ivHex) {
throw new Error('供应商开放接口SM4配置缺失')
}
return JSON.parse(sm4CbcDecrypt(body.AplParmObjct, keyHex, ivHex)) as SupplierOrderQueryRequest
return {
...JSON.parse(sm4CbcDecrypt(encryptedData, keyHex, ivHex)),
RsrvFld1: body.RsrvFld1
} as T
}
const toText = (date?: Date | null) => {
@@ -33,6 +112,42 @@ const toText = (date?: Date | null) => {
const toMoney = (value: { toFixed: (digits: number) => string } | number | string | null | undefined) => Number(value || 0).toFixed(2)
const numericId = (seed: string, length: number) => BigInt(`0x${createHash('sha256').update(seed).digest('hex')}`)
.toString()
.padStart(length, '0')
.slice(0, length)
const invoiceOutputRoot = () => path.resolve(process.env.INVOICE_PDF_OUTPUT_DIR || path.join(process.cwd(), 'temp', 'pdf', 'invoices'))
const storedRequest = (value: unknown) => value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
const encodeImage = async (source?: string | null) => {
let imageSource = String(source || '').trim()
if (/^\/api\/(?:file|upload-preview)\?/.test(imageSource)) {
const fileId = new URL(imageSource, 'http://supplier.local').searchParams.get('fid') || ''
const result = await minioUploadService.imageUrl(fileId)
if (!result.success) return ''
imageSource = result.data.url
}
if (!imageSource) return ''
if (imageSource.startsWith('data:')) {
return imageSource.split(',')[1] || ''
}
if (!/^https?:\/\//i.test(imageSource)) {
return ''
}
try {
const response = await fetch(imageSource)
if (!response.ok) return ''
return Buffer.from(await response.arrayBuffer()).toString('base64')
} catch (error) {
logger.warn('query file base64 fetch image failed', { imageSource, message: error instanceof Error ? error.message : String(error) })
return ''
}
}
const productSku = (product: { sku: string; subOrderId: string; name: string }) => {
if (product.sku && product.sku !== '[object Object]') {
return product.sku
@@ -52,9 +167,392 @@ const toOrderProductInfo = (order: Awaited<ReturnType<typeof orderDao.findOpenAp
filePath: product.filePath
}))
const productImageSource = (product?: Awaited<ReturnType<typeof orderDao.findOpenApiSubOrders>>[number]['products'][number]) => {
if (!product) return ''
if (product.productImgUrl) return product.productImgUrl
if (product.filePath) return product.filePath
if (Array.isArray(product.picInfo)) return String(product.picInfo[0] || '')
return ''
}
export const supplierOpenOrderService = {
parseBody,
async applyForInvoices(body: SupplierInvoiceApplyRequest, notifyPlatform = true) {
const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim()
const settlementId = String(body.settlementId || '').trim()
const orderIds = [...new Set(String(body.orderIds || '')
.split(',')
.map(item => item.trim())
.filter(Boolean))]
const orderNum = Number(body.orderNum || 0)
const orderTotalPrice = Number(body.orderTotalPrice)
const timestamp = Number(body.timestamp || 0)
const taxIdNumber = String(body.taxIdNumber || body.taxIDNumber || '').trim()
const invoiceDateValue = body.invoiceDate
const invoiceDate = typeof invoiceDateValue === 'number' || /^\d+$/.test(String(invoiceDateValue || ''))
? new Date(Number(invoiceDateValue))
: new Date(`${invoiceDateValue || ''}T00:00:00`)
logger.info('supplier open invoice apply', { supplierId, settlementId, orderCount: orderIds.length, timestamp })
if (!supplierId || !token || !timestamp || !settlementId || !orderIds.length) {
return { resultCode: 400, message: '供应商、结算单、订单或签名信息不能为空' }
}
if (!body.invoiceDate || !body.invoiceTitle || !taxIdNumber || !body.invoiceAddress || !body.invoiceContact) {
return { resultCode: 400, message: '开票日期、抬头、纳税人识别号、地址和联系电话不能为空' }
}
if (Number.isNaN(invoiceDate.getTime())) {
return { resultCode: 400, message: '开票日期格式不正确' }
}
if (!Number.isInteger(orderNum) || orderNum <= 0 || !Number.isFinite(orderTotalPrice) || orderTotalPrice < 0) {
return { resultCode: 400, message: '订单数量或订单总金额不正确' }
}
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
if (!supplier) {
return { resultCode: 404, message: '供应商信息不存在' }
}
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
return { resultCode: 401, message: '签名无效或已过期' }
}
const orders = await orderDao.findOpenApiSubOrders(supplierId, orderIds)
if (orders.length !== orderIds.length || orderNum !== orderIds.length) {
return { resultCode: 400, message: '订单数量校验不通过' }
}
const actualTotalPrice = orders.reduce((total, order) => total + Number(order.totalPrice || 0), 0)
if (Math.abs(actualTotalPrice - orderTotalPrice) >= 0.01) {
return { resultCode: 400, message: '订单总金额校验不通过' }
}
const orderIdText = orderIds.join(',')
const firstOrder = orders[0]
const requestBody = JSON.parse(JSON.stringify(body))
await orderDao.transaction(async tx => {
await tx.supplierInvoiceInfo.upsert({
where: { invoiceBizId: settlementId },
update: {
supplierId,
userId: firstOrder?.userId || null,
mainOrderId: firstOrder?.mainOrderId || null,
subOrderId: firstOrder?.orderId || null,
settlementId,
invoiceCode: null,
taxIdNumber,
orderIds: orderIdText,
invoiceStatus: 1,
invoiceDate,
failureMessage: null,
requestBody,
deleted: 0
},
create: {
invoiceBizId: settlementId,
supplierId,
userId: firstOrder?.userId || null,
mainOrderId: firstOrder?.mainOrderId || null,
subOrderId: firstOrder?.orderId || null,
settlementId,
taxIdNumber,
orderIds: orderIdText,
invoiceStatus: 1,
invoiceDate,
requestBody
}
})
})
const invoiceId = numericId(`invoice-id:${supplierId}:${settlementId}`, 20)
const invoiceCode = numericId(`invoice-code:${supplierId}:${settlementId}`, 12)
const checkCode = numericId(`check-code:${supplierId}:${settlementId}`, 20)
const taxRate = Number(orders.flatMap(order => order.products)[0]?.taxRate || 0.13)
const invoiceNakeAmount = Number(body.invoiceNakedPrice ?? orderTotalPrice / (1 + taxRate))
const invoiceTaxAmount = Number(body.invoiceTaxPrice ?? orderTotalPrice - invoiceNakeAmount)
const requestedInvoiceType = Number(body.invoiceType || 10)
const invoiceType = Number.isInteger(requestedInvoiceType) ? requestedInvoiceType : 10
const absoluteFilePath = path.join(invoiceOutputRoot(), supplierId, `${invoiceId}.pdf`)
try {
generateMockInvoicePdf({
...body,
taxIdNumber,
settlementId,
orderIds: orderIdText,
orderNum: orders.length,
orderTotalPrice: actualTotalPrice,
invoiceContent: orders.flatMap(order => order.products.map(product => product.name)).filter(Boolean).join('、'),
orderProducts: orders.flatMap(order => order.products.map(product => ({
orderId: order.orderId,
sku: product.sku,
name: product.name,
number: product.number,
price: Number(product.price || 0)
}))),
documentInvoiceCode: invoiceCode,
invoiceNumber: invoiceId,
checkCode,
sellerName: supplier.supplierName,
sellerTaxId: supplier.supplierId,
invoiceNakedPrice: invoiceNakeAmount,
invoiceTaxPrice: invoiceTaxAmount,
taxRate
}, absoluteFilePath)
await supplierFileService.uploadPdf(invoiceId, supplierId, `${invoiceId}.pdf`, absoluteFilePath)
try {
unlinkSync(absoluteFilePath)
} catch (error) {
logger.error('supplier invoice temp file cleanup failed', { supplierId, settlementId, absoluteFilePath, error })
}
const mainOrderIds = [...new Set(orders.map(order => order.mainOrderId).filter(Boolean))]
await orderDao.transaction(async tx => {
await tx.supplierInvoiceInfo.update({
where: { invoiceBizId: settlementId },
data: {
invoiceCode,
invoiceIds: invoiceId,
invoiceStatus: 2,
invoiceDate,
invoiceType,
invoiceNakeAmount,
invoiceTaxRate: taxRate,
invoiceTaxAmount,
invoiceAmount: orderTotalPrice,
checkCode,
fileType: 'PDF',
fileId: invoiceId,
filePath: null,
failureMessage: null
}
})
await tx.supplierOrderSub.updateMany({
where: { supplierId, orderId: { in: orderIds }, deleted: 0 },
data: { invoiceCode }
})
if (mainOrderIds.length) {
await tx.supplierOrderMain.updateMany({
where: { supplierId, orderId: { in: mainOrderIds }, deleted: 0 },
data: { invoiceCode }
})
}
})
} catch (error) {
const failureMessage = error instanceof Error ? error.message : '模拟发票生成失败'
await orderDao.transaction(async tx => {
await tx.supplierInvoiceInfo.update({
where: { invoiceBizId: settlementId },
data: {
invoiceStatus: 3, failureMessage, invoiceIds: invoiceId, invoiceCode,
invoiceDate, invoiceType, invoiceNakeAmount, invoiceTaxRate: taxRate,
invoiceTaxAmount, invoiceAmount: orderTotalPrice, checkCode, fileType: 'PDF',
fileId: null, filePath: null
}
})
})
logger.error('supplier mock invoice generate failed', { supplierId, settlementId, failureMessage })
if (notifyPlatform) {
await orderPushEventService.pushEvent({
supplierId, userId: String(firstOrder?.userId || 'SYSTEM'),
orderType: ORDER_EVENT_TYPE.INVOICE_INFO, orderId: settlementId,
mainOrderId: firstOrder?.mainOrderId, settlementId, invoiceBizId: settlementId
})
}
return { resultCode: 500, message: failureMessage }
}
if (!notifyPlatform) {
return { resultCode: 0, message: '发票重新生成成功' }
}
const pushResult = await orderPushEventService.pushEvent({
supplierId,
userId: String(firstOrder?.userId || 'SYSTEM'),
orderType: ORDER_EVENT_TYPE.INVOICE_INFO,
orderId: settlementId,
mainOrderId: firstOrder?.mainOrderId,
settlementId,
invoiceBizId: settlementId,
invoiceCode,
invoiceIds: invoiceId,
orderIds: orderIdText,
fileId: invoiceId
})
const pushSuccess = pushResult.success && pushResult.data.pushSuccess
return {
resultCode: 0,
message: pushSuccess ? '开票完成并已通知平台' : '开票完成,平台通知已记录待重试'
}
},
async queryInvoiceInfo(body: SupplierInvoiceQueryRequest) {
const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim()
const settlementId = String(body.settlementId || '').trim()
const timestamp = Number(body.timestamp || 0)
if (!supplierId || !token || !timestamp || !settlementId) {
return { resultCode: 400, message: '供应商、结算单或签名信息不能为空' }
}
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
if (!supplier) {
return { resultCode: 404, message: '供应商信息不存在' }
}
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
return { resultCode: 401, message: '签名无效或已过期' }
}
let invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
if (!invoice) {
return { resultCode: 404, message: '未找到对应的开票记录' }
}
if (invoice.invoiceStatus === 1) {
return { resultCode: 409, message: '发票正在开具中' }
}
if (body.retry === true && invoice.invoiceStatus === 3) {
const previous = invoice
const claimed = await orderDao.transaction(tx => tx.supplierInvoiceInfo.updateMany({
where: { invoiceBizId: settlementId, supplierId, invoiceStatus: 3, deleted: 0 },
data: { invoiceStatus: 1 }
}))
if (!claimed.count) {
return { resultCode: 409, message: '发票正在重新生成,请勿重复操作' }
}
try {
const result = await this.applyForInvoices({
...storedRequest(previous.requestBody),
supplierId, settlementId, token, timestamp, RsrvFld1: body.RsrvFld1
}, false)
if (result.resultCode !== 0) {
await orderDao.transaction(tx => tx.supplierInvoiceInfo.updateMany({
where: { invoiceBizId: settlementId, supplierId, invoiceStatus: 1 },
data: { invoiceStatus: 3, failureMessage: result.message }
}))
}
} catch (error) {
const failureMessage = error instanceof Error ? error.message : '重新生成发票失败'
await orderDao.transaction(tx => tx.supplierInvoiceInfo.updateMany({
where: { invoiceBizId: settlementId, supplierId, invoiceStatus: 1 },
data: { invoiceStatus: 3, failureMessage }
}))
return { resultCode: 500, message: failureMessage }
}
invoice = await orderDao.findInvoiceBySettlementId(supplierId, settlementId)
if (!invoice) return { resultCode: 404, message: '未找到对应的开票记录' }
}
const request = storedRequest(invoice.requestBody)
const success = invoice.invoiceStatus === 2
return {
resultCode: 0,
message: '成功',
getInvoiceInfo: {
bSuccess: success ? 0 : 1,
settlementId,
sucOrderIds: success ? invoice.orderIds || '' : '',
failOrderIds: success ? '' : invoice.orderIds || '',
failMsg: success ? '' : invoice.failureMessage || '开票失败',
invoiceInfos: [{
status: success ? 1 : 2,
remark: String(request.remark || ''),
invoiceId: invoice.invoiceIds || numericId(`invoice-id:${supplierId}:${settlementId}`, 20),
invoiceCode: invoice.invoiceCode,
invoiceDate: invoice.invoiceDate?.toISOString().slice(0, 10),
invoiceNakeAmount: Number(invoice.invoiceNakeAmount || 0),
invoiceTaxRate: Number(invoice.invoiceTaxRate || 0),
invoiceTaxAmount: Number(invoice.invoiceTaxAmount || 0),
invoiceAmount: Number(invoice.invoiceAmount || 0),
invoiceType: invoice.invoiceType,
url: '',
imageEncode: '',
fileType: invoice.fileType || 'PDF',
checkCode: invoice.checkCode,
invoiceAddress: String(request.invoiceAddress || '')
}]
}
}
},
async queryFileBase64Info(body: SupplierFileBase64QueryRequest) {
const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim()
const queryType = String(body.queryType || '').trim()
const idNumber = String(body.idNumber || '').trim()
const fileIdList = Array.isArray(body.fileIdList) ? body.fileIdList.map(item => String(item || '').trim()).filter(Boolean) : []
const timestamp = Number(body.timestamp || 0)
logger.info('supplier open file base64 query', { supplierId, queryType, idNumber, fileCount: fileIdList.length })
if (!supplierId || !token || !timestamp || !queryType || !idNumber || !fileIdList.length) {
return { resultCode: 400, message: '供应商、查询类型、单号、文件ID或签名信息不能为空', fileType: 'JPG', imageEncodeMap: {} }
}
if (!['1', '2'].includes(queryType)) {
return { resultCode: 400, message: '查询类型不正确', fileType: 'JPG', imageEncodeMap: {} }
}
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
if (!supplier) {
return { resultCode: 404, message: '供应商信息不存在', fileType: 'JPG', imageEncodeMap: {} }
}
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
return { resultCode: 401, message: '签名无效或已过期', fileType: 'JPG', imageEncodeMap: {} }
}
const imageEncodeMap: Record<string, string> = {}
fileIdList.forEach(fileId => {
imageEncodeMap[fileId] = ''
})
if (queryType === '1') {
const orders = await orderDao.findOpenApiSubOrders(supplierId, [idNumber])
const products = orders.flatMap(order => order.products)
for (const fileId of fileIdList) {
const product = products.find(item => item.sku === fileId || item.fileId === fileId || item.filePath === fileId)
imageEncodeMap[fileId] = await encodeImage(productImageSource(product))
}
} else {
const invoice = await orderDao.findInvoiceBySettlementId(supplierId, idNumber)
const invoiceIds = String(invoice?.invoiceIds || '').split(',').map(item => item.trim()).filter(Boolean)
if (!invoice || invoice.invoiceStatus !== 2 || (!invoice.fileId && !invoice.filePath)) {
return { resultCode: 404, message: '未找到已开具的发票文件', fileType: 'PDF', imageEncodeMap }
}
if (!fileIdList.length || invoiceIds.length !== 1 || fileIdList.some(fileId => fileId !== invoiceIds[0]) || (invoice.fileId && invoice.fileId !== invoiceIds[0])) {
return { resultCode: 404, message: '发票号码与结算单不匹配', fileType: 'PDF', imageEncodeMap }
}
const pdf = invoice.fileId
? await supplierFileService.read(invoice.fileId, supplierId)
: null
if (!pdf && invoice.filePath) {
const absoluteFilePath = path.resolve(process.cwd(), invoice.filePath)
const outputRoot = invoiceOutputRoot()
const relativePath = path.relative(outputRoot, absoluteFilePath)
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath) && path.basename(absoluteFilePath) === `${invoiceIds[0]}.pdf` && existsSync(absoluteFilePath)) {
const localPdf = readFileSync(absoluteFilePath)
if (localPdf.length <= 3.5 * 1024 * 1024) {
fileIdList.forEach(fileId => {
imageEncodeMap[fileId] = localPdf.toString('base64')
})
return { resultCode: 0, message: '成功', fileType: 'PDF', imageEncodeMap }
}
}
}
if (!pdf) {
return { resultCode: 404, message: '发票文件不存在', fileType: 'PDF', imageEncodeMap }
}
if (pdf.length > 3.5 * 1024 * 1024) {
return { resultCode: 413, message: '发票文件超过Base64接口大小限制', fileType: 'PDF', imageEncodeMap }
}
const base64 = pdf.toString('base64')
fileIdList.forEach(fileId => {
imageEncodeMap[fileId] = base64
})
}
return { resultCode: 0, message: '成功', fileType: queryType === '2' ? 'PDF' : 'JPG', imageEncodeMap }
},
async queryOrderInfo(body: SupplierOrderQueryRequest) {
const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim()
@@ -75,7 +573,7 @@ export const supplierOpenOrderService = {
return { resultCode: 404, message: '供应商信息不存在', orderInfo: [] }
}
if (!verifySupplierOpenSignature(supplier.supplierId, supplier.clientId, timestamp, token)) {
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
return { resultCode: 401, message: '签名无效或已过期', orderInfo: [] }
}
@@ -149,5 +647,66 @@ export const supplierOpenOrderService = {
}))
return { resultCode: 0, message: '成功', orderInfo }
},
async queryReconciliationInfo(body: SupplierReconciliationQueryRequest) {
const supplierId = String(body.supplierId || '').trim()
const token = String(body.token || '').trim()
const reconciliationType = Number(body.reconciliationType || 0)
const orderIds = String(body.orderId || '')
.split(',')
.map(item => item.trim())
.filter(Boolean)
const timestamp = Number(body.timestamp || 0)
if (!supplierId || !token || !timestamp) {
return { resultCode: 400, message: '供应商或签名信息不能为空', orderInfos: [] }
}
if (reconciliationType === 1 && !body.startDate) {
return { resultCode: 400, message: '请选择开始日期', orderInfos: [] }
}
if (reconciliationType === 2 && !orderIds.length) {
return { resultCode: 400, message: '订单号不能为空', orderInfos: [] }
}
if (![1, 2].includes(reconciliationType)) {
return { resultCode: 400, message: '对账类型不正确', orderInfos: [] }
}
const supplier = await supplierDao.findActiveBySupplierId(supplierId)
if (!supplier) {
return { resultCode: 404, message: '供应商信息不存在', orderInfos: [] }
}
if (!verifySupplierOpenSignature(supplier.supplierId, String(body.RsrvFld1 || supplier.clientId), timestamp, token)) {
return { resultCode: 401, message: '签名无效或已过期', orderInfos: [] }
}
const startDate = body.startDate ? new Date(`${body.startDate}T00:00:00`) : undefined
const endDate = body.endDate ? new Date(`${body.endDate}T23:59:59`) : undefined
if ((startDate && Number.isNaN(startDate.getTime())) || (endDate && Number.isNaN(endDate.getTime()))) {
return { resultCode: 400, message: '对账日期格式不正确', orderInfos: [] }
}
const orders = await orderDao.findOpenApiReconciliationSubOrders(
supplierId,
orderIds,
startDate,
endDate,
reconciliationType === 1 ? ORDER_STATE.COMPLETED : undefined
)
const page = Math.max(Number(body.page || 1), 1)
const pageSize = Math.max(Number(body.pageSize || 100), 1)
const totalPage = Math.max(Math.ceil(orders.length / pageSize), 1)
const pagedOrders = orders.slice((page - 1) * pageSize, page * pageSize)
const orderInfos = pagedOrders.map(order => ({
orderId: order.orderId,
supplierId: order.supplierId,
totalPrice: toMoney(order.totalPrice),
pointsPrice: toMoney(order.pointsPrice),
wPayOrAPay: toMoney(order.wPayOrAPay),
invoiceNakedPrice: toMoney(order.totalPrice),
orderProInfos: toOrderProductInfo(order)
}))
return { resultCode: 0, message: '成功', totalPage, currentPage: page, orderInfos }
}
}
@@ -1,8 +1,9 @@
import { supplierDao } from '../dao/supplier.dao.js'
import { suppliedUserService } from './supplied-user.service.js'
import { bridgeFetchWithAuth, findSupplierByIdentification, getBridgeApiBase, getBridgeToken, warmBridgeToken } from '../utils/bridge-auth.js'
import { sha256 } from '../utils/crypto.js'
import { logger } from '../utils/logger.js'
import { createSupplierOpenSignature } from '../utils/supplier-open-crypto.js'
import { createSupplierOpenSignature, sm4CbcDecrypt } from '../utils/supplier-open-crypto.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
type BridgePointsResponse = {
@@ -12,6 +13,82 @@ type BridgePointsResponse = {
}
export const supplierService = {
async decryptSupplierSession(keyword: string) {
if (!keyword) {
return serviceFail('用户参数不能为空')
}
if (/^[a-f0-9]{32}$/i.test(keyword)) {
const apiBase = getBridgeApiBase()
if (!apiBase) {
return serviceFail('平台积分商城接口地址未配置', 502, 'BRIDGE_SESSION_FAILED')
}
try {
const response = await fetch(`${apiBase}/connect/supplier-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ keyword })
})
const payload = (await response.json()) as {
code?: number
msg?: string
data?: { userId?: unknown }
}
if (!response.ok || payload.code !== 0) {
return serviceFail(payload.msg || '获取用户会话失败')
}
const userId = String(payload.data?.userId || '').trim()
if (!userId) {
return serviceFail('用户参数无效')
}
return await suppliedUserService.getSessionUser(userId)
} catch (error) {
logger.warn('supplier session exchange failed', {
message: error instanceof Error ? error.message : String(error)
})
return serviceFail('获取用户会话失败', 502, 'BRIDGE_SESSION_FAILED')
}
}
const keyHex = process.env.SM4CBC_KEY_HEX || 'a1b2c3d4e5f60718293a4b5c6d7e8f90'
const ivHex = process.env.SM4CBC_IV_HEX || 'f9e8d7c6b5a49382716f0e1d2c3b4a5f'
const sessionKeyword = keyword.replace(/ /g, '+')
logger.info('supplier session decrypt input', {
keyword,
sessionKeyword,
hasKey: Boolean(keyHex),
hasIv: Boolean(ivHex),
keyLength: keyHex.length,
ivLength: ivHex.length
})
try {
const decryptedText = sm4CbcDecrypt(sessionKeyword, keyHex, ivHex)
logger.info('supplier session decrypt text', { decryptedText })
const payload = JSON.parse(decryptedText) as { userId?: unknown }
const userId = String(payload.userId || '').trim()
if (!userId) {
logger.warn('supplier session userId empty', { payload })
return serviceFail('用户参数无效')
}
return await suppliedUserService.getSessionUser(userId)
} catch (error) {
logger.warn('supplier session decrypt failed', {
message: error instanceof Error ? error.message : String(error)
})
return serviceFail('用户参数无效')
}
},
async getSupplierInfo(supplierIdentification: string) {
logger.info('supplier info query', { supplierIdentification })
+11 -1
View File
@@ -30,6 +30,7 @@ export type BridgeResponse<T> = {
const normalizeBase = (base: string) => base.replace(/\/$/, '')
const tokenKey = (supplierId: string) => `points-mall:bridge-token:${supplierId}`
const TOKEN_EXPIRE_BUFFER_MS = 60_000
const FALLBACK_BRIDGE_TOKEN = 'local-dev-token'
export const getBridgeApiBase = () => normalizeBase(process.env.POINTS_MALL_OPEN_API_BASE || '')
@@ -168,7 +169,16 @@ export const bridgeFetchWithAuth = async <T>(
}
const call = async (forceRefresh = false) => {
const token = await getBridgeToken(supplierId, forceRefresh)
let token = ''
try {
token = await getBridgeToken(supplierId, forceRefresh)
} catch (error) {
logger.warn('bridge token unavailable, use fallback token', {
supplierId,
message: error instanceof Error ? error.message : String(error)
})
token = FALLBACK_BRIDGE_TOKEN
}
const requestBody = {
...body,
supplierId,
@@ -13,14 +13,22 @@ export const verifySupplierOpenSignature = (
clientId: string,
timestamp: number | string,
token: string,
timeWindowMs = 300_000
) => {
const timestampValue = Number(timestamp || 0)
if (!timestampValue || Math.abs(Date.now() - timestampValue) > timeWindowMs) {
if (!timestampValue) {
return false
}
const expected = createSupplierOpenSignature(supplierId, clientId, timestampValue)
if (expected !== token) {
console.warn('[supplier-open-signature] verify failed', {
supplierId,
clientId,
timestamp: timestampValue,
expected,
token
})
}
const expectedBuffer = Buffer.from(expected)
const tokenBuffer = Buffer.from(String(token || ''))
return expectedBuffer.length === tokenBuffer.length && timingSafeEqual(expectedBuffer, tokenBuffer)
+11
View File
@@ -0,0 +1,11 @@
export type MockInvoicePdfInput = Record<string, unknown>
export type MockInvoicePdfOptions = {
fontPath?: string
}
export function generateMockInvoicePdf(
invoiceInput: MockInvoicePdfInput,
outputPath: string,
options?: MockInvoicePdfOptions
): string

Some files were not shown because too many files have changed in this diff Show More