Merge #16 into dev from dev_jacy
超管用户登录; * dev_jacy: (4 commits) 小飞侠的日志 核销代码 核销功能 超管用户登录; Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/16
This commit is contained in:
@@ -19,6 +19,19 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
|
|||||||
REFUNDED: '已退款',
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||||
|
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||||
|
PENDING_PAY: 'red',
|
||||||
|
PENDING_SHIP: 'green',
|
||||||
|
OUT_WAREHOUSE: 'green',
|
||||||
|
SHIPPING: 'processing',
|
||||||
|
PENDING_RECEIVE: 'cyan',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
CANCELLED: 'default',
|
||||||
|
REFUNDING: 'orange',
|
||||||
|
REFUNDED: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
export const STORE_STATUS_LABELS: Record<string, string> = {
|
export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||||
OPEN: '营业中',
|
OPEN: '营业中',
|
||||||
PAUSED: '暂停',
|
PAUSED: '暂停',
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Card, Form, Input, message, Typography } from 'antd';
|
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
||||||
import { saveAuth, request } from '../lib/api';
|
import { saveAuth, request } from '../lib/api';
|
||||||
|
|
||||||
|
type LoginResult = { accessToken: string; refreshToken: string };
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [smsForm] = Form.useForm();
|
||||||
|
const [passwordForm] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const phone = Form.useWatch('phone', form);
|
const phone = Form.useWatch('phone', smsForm);
|
||||||
|
|
||||||
async function sendCode() {
|
async function sendCode() {
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
@@ -19,7 +22,7 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
|
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
|
||||||
});
|
});
|
||||||
message.success('验证码已发送(Mock: 123456)');
|
message.success('验证码已发送');
|
||||||
setCodeCooldown(60);
|
setCodeCooldown(60);
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCodeCooldown((c) => {
|
setCodeCooldown((c) => {
|
||||||
@@ -32,19 +35,35 @@ export default function LoginPage() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFinish(values: { phone: string; code: string }) {
|
async function finishLogin(data: LoginResult) {
|
||||||
|
saveAuth(data);
|
||||||
|
message.success('登录成功');
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSmsFinish(values: { phone: string; code: string }) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await request<{ accessToken: string; refreshToken: string }>(
|
const data = await request<LoginResult>('/admin/auth/login/sms', {
|
||||||
'/admin/auth/login/sms',
|
method: 'POST',
|
||||||
{
|
body: JSON.stringify(values),
|
||||||
method: 'POST',
|
});
|
||||||
body: JSON.stringify(values),
|
await finishLogin(data);
|
||||||
},
|
} catch (e) {
|
||||||
);
|
message.error(e instanceof Error ? e.message : '登录失败');
|
||||||
saveAuth(data);
|
} finally {
|
||||||
message.success('登录成功');
|
setLoading(false);
|
||||||
navigate('/');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPasswordFinish(values: { loginName: string; password: string }) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<LoginResult>('/admin/auth/login/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
await finishLogin(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '登录失败');
|
message.error(e instanceof Error ? e.message : '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,24 +85,61 @@ export default function LoginPage() {
|
|||||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
HQ 管理后台
|
HQ 管理后台
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Form form={form} layout="vertical" onFinish={onFinish} initialValues={{ phone: '13600000001', code: '123456' }}>
|
<Tabs
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
items={[
|
||||||
<Input placeholder="13600000001" maxLength={11} />
|
{
|
||||||
</Form.Item>
|
key: 'password',
|
||||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
label: '账号密码',
|
||||||
<Input
|
children: (
|
||||||
placeholder="123456"
|
<Form
|
||||||
addonAfter={
|
form={passwordForm}
|
||||||
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
layout="vertical"
|
||||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
onFinish={onPasswordFinish}
|
||||||
</Button>
|
initialValues={{ loginName: 'admin' }}
|
||||||
}
|
>
|
||||||
/>
|
<Form.Item name="loginName" label="账号" rules={[{ required: true, message: '请输入账号' }]}>
|
||||||
</Form.Item>
|
<Input placeholder="admin" autoComplete="username" />
|
||||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
</Form.Item>
|
||||||
登录
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
</Button>
|
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
|
||||||
</Form>
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sms',
|
||||||
|
label: '短信验证码',
|
||||||
|
children: (
|
||||||
|
<Form
|
||||||
|
form={smsForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onSmsFinish}
|
||||||
|
initialValues={{ phone: '13600000001', code: '123456' }}
|
||||||
|
>
|
||||||
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
|
<Input placeholder="13600000001" maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||||
|
<Input
|
||||||
|
placeholder="123456"
|
||||||
|
addonAfter={
|
||||||
|
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
||||||
|
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
|
||||||
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
type ShipDefaults = {
|
type ShipDefaults = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -172,7 +172,9 @@ export default function OrdersPage() {
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => (
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '配送',
|
title: '配送',
|
||||||
@@ -293,7 +295,11 @@ export default function OrdersPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" title="基本信息">
|
<Descriptions column={1} bordered size="small" title="基本信息">
|
||||||
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{ORDER_STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
|
||||||
|
{ORDER_STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||||
@@ -435,7 +441,9 @@ export default function OrdersPage() {
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => (
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
@@ -56,13 +56,6 @@ export default function RedeemDebugPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
message="仅用于 preV1 联调"
|
|
||||||
description="模拟 C 端生成核销码、门店预览与确认核销。确认核销会真实扣减权益并写入核销记录。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
@@ -74,8 +67,12 @@ export default function RedeemDebugPage() {
|
|||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Card size="small" title="参数">
|
<Card size="small" title="参数">
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical">
|
||||||
<Form.Item name="userId" label="用户 ID" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input placeholder="用户表 id" />
|
name="userId"
|
||||||
|
label="用户"
|
||||||
|
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||||
@@ -90,8 +87,9 @@ export default function RedeemDebugPage() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const values = createForm.getFieldsValue();
|
void createForm.validateFields().then((values) => {
|
||||||
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
生成核销码
|
生成核销码
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const PROVIDER_OPTIONS = [
|
|||||||
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
{ value: 'WECHAT_MAP', label: 'WECHAT_MAP' },
|
||||||
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
{ value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' },
|
||||||
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
{ value: 'MOCK_SMS', label: 'MOCK_SMS' },
|
||||||
{ value: 'XIAOFEIXIA', label: 'XIAOFEIXIA' },
|
{ value: 'XFX', label: '小飞侠 (XFX)' },
|
||||||
|
{ value: 'LOGISTICS', label: 'LOGISTICS' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
|||||||
@@ -102,14 +102,6 @@ export default function MinePage() {
|
|||||||
<span className="mine-member-tag">{hasWechat ? '微信会员' : '好客会员'}</span>
|
<span className="mine-member-tag">{hasWechat ? '微信会员' : '好客会员'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mine-settings-btn"
|
|
||||||
aria-label="设置"
|
|
||||||
onClick={() => showToast('preV1:账号设置即将开放')}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mine-header-glow" aria-hidden />
|
<div className="mine-header-glow" aria-hidden />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"prisma:validate": "prisma validate",
|
"prisma:validate": "prisma validate",
|
||||||
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
||||||
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
|
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
|
||||||
|
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -493,10 +493,12 @@ model PartnerBill {
|
|||||||
// ─── HQ ───────────────────────────────────────────────
|
// ─── HQ ───────────────────────────────────────────────
|
||||||
|
|
||||||
model HqAccount {
|
model HqAccount {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
phone String @unique @db.VarChar(20)
|
phone String @unique @db.VarChar(20)
|
||||||
name String @db.VarChar(64)
|
loginName String? @unique @map("login_name") @db.VarChar(64)
|
||||||
adminRole HqAdminRole @default(OPS) @map("admin_role")
|
passwordHash String? @map("password_hash") @db.VarChar(255)
|
||||||
|
name String @db.VarChar(64)
|
||||||
|
adminRole HqAdminRole @default(OPS) @map("admin_role")
|
||||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||||
status AccountStatus @default(ACTIVE)
|
status AccountStatus @default(ACTIVE)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import '../src/load-env';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { PrismaService } from '../src/common/prisma/prisma.module';
|
||||||
|
import { RedeemService } from '../src/modules/redeem/redeem.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||||
|
const prisma = app.get(PrismaService);
|
||||||
|
const redeem = app.get(RedeemService);
|
||||||
|
|
||||||
|
let coupon = await prisma.benefitCoupon.findFirst({
|
||||||
|
where: { status: 'ACTIVE', balance: { gt: 10 } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (!coupon) {
|
||||||
|
const user = await prisma.user.findFirst({ orderBy: { id: 'asc' } });
|
||||||
|
const city = await prisma.commonCity.findFirst();
|
||||||
|
const product = await prisma.commonProductItem.findFirst();
|
||||||
|
if (!user || !city || !product) throw new Error('seed base data missing');
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
orderNo: `T${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
cityId: city.id,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
payStatus: 'PAID',
|
||||||
|
deliveryType: 'LOCAL',
|
||||||
|
productId: product.id,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
productName: product.name,
|
||||||
|
productSpec: product.spec ?? '500ml',
|
||||||
|
quantity: 2,
|
||||||
|
listUnitPrice: 100,
|
||||||
|
listAmount: 200,
|
||||||
|
productAmount: 200,
|
||||||
|
payAmount: 200,
|
||||||
|
benefitAmount: 200,
|
||||||
|
receiverName: 'test',
|
||||||
|
receiverPhone: user.phone ?? '13800000001',
|
||||||
|
receiverProvince: '河南',
|
||||||
|
receiverCity: '郑州',
|
||||||
|
receiverDistrict: '金水',
|
||||||
|
receiverAddress: 'test addr',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
coupon = await prisma.benefitCoupon.create({
|
||||||
|
data: {
|
||||||
|
couponNo: `TEST${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
orderId: order.id,
|
||||||
|
totalAmount: 200,
|
||||||
|
usedAmount: 0,
|
||||||
|
balance: 200,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sourceProduct: product.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('created test coupon', coupon.id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeAccount = await prisma.storeAccount.findFirst({
|
||||||
|
where: { status: 'ACTIVE', store: { status: 'OPEN' } },
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
if (!storeAccount) throw new Error('no active store account on OPEN store');
|
||||||
|
|
||||||
|
console.log('userId', coupon.userId.toString());
|
||||||
|
console.log('storeId', storeAccount.storeId.toString());
|
||||||
|
console.log('coupon balance', coupon.balance.toString());
|
||||||
|
|
||||||
|
const tokenRes = await redeem.createToken(coupon.userId, {
|
||||||
|
amount: 10,
|
||||||
|
storeId: storeAccount.storeId.toString(),
|
||||||
|
});
|
||||||
|
console.log('token created', tokenRes.token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preview = await redeem.previewRedeem(storeAccount.id, tokenRes.token);
|
||||||
|
console.log('preview ok', JSON.stringify(preview));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('preview failed:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await redeem.confirmRedeem(storeAccount.id, { token: tokenRes.token });
|
||||||
|
console.log('confirm ok', JSON.stringify(record));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('confirm failed:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import '../src/load-env';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { hashPassword } from '../src/common/crypto/password.util';
|
||||||
|
|
||||||
|
const LOGIN_NAME = process.env.SUPER_ADMIN_LOGIN ?? 'admin';
|
||||||
|
const PASSWORD = process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!';
|
||||||
|
const PLACEHOLDER_PHONE = process.env.SUPER_ADMIN_PHONE ?? '19900000001';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const passwordHash = hashPassword(PASSWORD);
|
||||||
|
try {
|
||||||
|
const deleted = await prisma.$executeRaw`
|
||||||
|
DELETE FROM hq_account WHERE admin_role = 'SUPER_ADMIN'
|
||||||
|
`;
|
||||||
|
console.log(`Deleted ${deleted} SUPER_ADMIN account row(s).`);
|
||||||
|
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
INSERT INTO hq_account (
|
||||||
|
phone, login_name, password_hash, name, admin_role, status, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
${PLACEHOLDER_PHONE},
|
||||||
|
${LOGIN_NAME},
|
||||||
|
${passwordHash},
|
||||||
|
${'超级管理员'},
|
||||||
|
${'SUPER_ADMIN'},
|
||||||
|
${'ACTIVE'},
|
||||||
|
NOW(3),
|
||||||
|
NOW(3)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Created SUPER_ADMIN:', {
|
||||||
|
loginName: LOGIN_NAME,
|
||||||
|
phone: PLACEHOLDER_PHONE,
|
||||||
|
password: '(hidden)',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Post } from '@nestjs/common';
|
|||||||
import { TradeService } from '../modules/trade/trade.service';
|
import { TradeService } from '../modules/trade/trade.service';
|
||||||
import { CourierService } from '../integrations/courier/courier.service';
|
import { CourierService } from '../integrations/courier/courier.service';
|
||||||
import { PrismaService } from '../common/prisma/prisma.module';
|
import { PrismaService } from '../common/prisma/prisma.module';
|
||||||
|
import { logCourierCall } from '../integrations/courier/courier-log.util';
|
||||||
|
|
||||||
@Controller('callbacks/delivery')
|
@Controller('callbacks/delivery')
|
||||||
export class DeliveryCallbackController {
|
export class DeliveryCallbackController {
|
||||||
@@ -13,13 +14,34 @@ export class DeliveryCallbackController {
|
|||||||
|
|
||||||
@Post('track')
|
@Post('track')
|
||||||
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
||||||
|
const baseLog = {
|
||||||
|
scene: 'TRACK_CALLBACK',
|
||||||
|
requestUrl: '/api/v1/callbacks/delivery/track',
|
||||||
|
requestBody: body as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
if (!body.orderId && !body.orderNo) {
|
if (!body.orderId && !body.orderNo) {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
...baseLog,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: '缺少 orderId / orderNo',
|
||||||
|
});
|
||||||
return this.courier.buildTrackCallbackResponse(false);
|
return this.courier.buildTrackCallbackResponse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = body.orderId
|
const order = body.orderId
|
||||||
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
|
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
|
||||||
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
|
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
|
||||||
if (!order) return this.courier.buildTrackCallbackResponse(false);
|
|
||||||
|
if (!order) {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
...baseLog,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: '订单不存在',
|
||||||
|
externalNo: body.orderNo,
|
||||||
|
});
|
||||||
|
return this.courier.buildTrackCallbackResponse(false);
|
||||||
|
}
|
||||||
|
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
SHIPPED: 'SHIPPING',
|
SHIPPED: 'SHIPPING',
|
||||||
@@ -28,9 +50,22 @@ export class DeliveryCallbackController {
|
|||||||
COMPLETED: 'COMPLETED',
|
COMPLETED: 'COMPLETED',
|
||||||
};
|
};
|
||||||
const target = statusMap[body.status ?? ''] ?? body.status;
|
const target = statusMap[body.status ?? ''] ?? body.status;
|
||||||
|
let applied = false;
|
||||||
if (target && target !== order.status) {
|
if (target && target !== order.status) {
|
||||||
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
|
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
|
||||||
|
applied = true;
|
||||||
}
|
}
|
||||||
return this.courier.buildTrackCallbackResponse(true);
|
|
||||||
|
const response = this.courier.buildTrackCallbackResponse(true);
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
...baseLog,
|
||||||
|
responseBody: response,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
externalNo: order.orderNo,
|
||||||
|
ref: { refType: 'ORDER', refId: order.id },
|
||||||
|
errorMessage: applied ? undefined : '状态未变更',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const KEY_LEN = 64;
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(SALT_LEN);
|
||||||
|
const hash = scryptSync(password, salt, KEY_LEN);
|
||||||
|
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [saltHex, hashHex] = stored.split(':');
|
||||||
|
if (!saltHex || !hashHex) return false;
|
||||||
|
const salt = Buffer.from(saltHex, 'hex');
|
||||||
|
const expected = Buffer.from(hashHex, 'hex');
|
||||||
|
const actual = scryptSync(password, salt, expected.length);
|
||||||
|
if (actual.length !== expected.length) return false;
|
||||||
|
return timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
@@ -5,6 +5,23 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
|
||||||
|
switch (exception.code) {
|
||||||
|
case 'P2002':
|
||||||
|
return '数据冲突,请刷新后重试';
|
||||||
|
case 'P2003':
|
||||||
|
return '关联数据不存在,请检查门店/权益配置';
|
||||||
|
case 'P2021':
|
||||||
|
case 'P2022':
|
||||||
|
return '数据库表结构未同步,请在服务器执行 prisma db push';
|
||||||
|
case 'P2025':
|
||||||
|
return '记录不存在或已被删除';
|
||||||
|
default:
|
||||||
|
return exception.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
@@ -27,10 +44,29 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
console.error(exception);
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
code: 400,
|
||||||
|
message: prismaErrorMessage(exception),
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof SyntaxError && /BigInt/i.test(exception.message)) {
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
code: 400,
|
||||||
|
message: 'ID 格式无效',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(exception);
|
console.error(exception);
|
||||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||||
code: 500,
|
code: 500,
|
||||||
message: 'Internal server error',
|
message: exception instanceof Error ? exception.message : 'Internal server error',
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { XIAOFEIXIA_CMD } from './xiaofeixia/xiaofeixia.constants';
|
||||||
|
|
||||||
|
const CMD_SCENE: Record<string, string> = {
|
||||||
|
[XIAOFEIXIA_CMD.CREATE_ORDER]: 'CREATE_SHIPMENT',
|
||||||
|
[XIAOFEIXIA_CMD.TRACK_ROUTE]: 'GET_TRACK',
|
||||||
|
[XIAOFEIXIA_CMD.CANCEL_ORDER]: 'CANCEL_SHIPMENT',
|
||||||
|
[XIAOFEIXIA_CMD.GET_ORDER]: 'GET_SHIPMENT',
|
||||||
|
[XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT',
|
||||||
|
[XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT',
|
||||||
|
[XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourierLogRef = {
|
||||||
|
refType?: string;
|
||||||
|
refId?: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogCourierCallInput = {
|
||||||
|
scene: string;
|
||||||
|
requestUrl: string;
|
||||||
|
requestBody?: Record<string, unknown>;
|
||||||
|
responseBody?: unknown;
|
||||||
|
externalNo?: string;
|
||||||
|
status: 'SUCCESS' | 'FAILED' | 'PENDING';
|
||||||
|
errorMessage?: string;
|
||||||
|
ref?: CourierLogRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
function maskMchId(mchId?: string) {
|
||||||
|
if (!mchId) return mchId;
|
||||||
|
if (mchId.length <= 4) return '****';
|
||||||
|
return `${mchId.slice(0, 4)}****`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求体入库前脱敏(去掉 sign,商户号打码) */
|
||||||
|
export function sanitizeXfxRequestBody(body: Record<string, unknown>) {
|
||||||
|
const { sign: _sign, mchId, ...rest } = body;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(mchId != null ? { mchId: maskMchId(String(mchId)) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneForXfxCmd(cmd: string) {
|
||||||
|
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
|
||||||
|
const responseBody =
|
||||||
|
input.responseBody === undefined
|
||||||
|
? undefined
|
||||||
|
: typeof input.responseBody === 'object' && input.responseBody !== null
|
||||||
|
? (input.responseBody as Record<string, unknown>)
|
||||||
|
: { value: input.responseBody };
|
||||||
|
|
||||||
|
const row = await prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'XFX',
|
||||||
|
scene: input.scene,
|
||||||
|
refType: input.ref?.refType,
|
||||||
|
refId: input.ref?.refId,
|
||||||
|
requestUrl: input.requestUrl.slice(0, 512),
|
||||||
|
requestBody: input.requestBody as never,
|
||||||
|
responseBody: responseBody as never,
|
||||||
|
externalNo: input.externalNo?.slice(0, 128),
|
||||||
|
status: input.status,
|
||||||
|
errorMessage: input.errorMessage?.slice(0, 512),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveOrderRefByOutNumber(
|
||||||
|
prisma: PrismaService,
|
||||||
|
outNumber?: string,
|
||||||
|
): Promise<CourierLogRef | undefined> {
|
||||||
|
if (!outNumber?.trim()) return undefined;
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { orderNo: outNumber.trim() },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!order) return undefined;
|
||||||
|
return { refType: 'ORDER', refId: order.id };
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CourierApiError } from '../courier.error';
|
import { CourierApiError } from '../courier.error';
|
||||||
import { CourierConfigService } from '../courier.config';
|
import { CourierConfigService } from '../courier.config';
|
||||||
|
import { PrismaService } from '../../../common/prisma/prisma.module';
|
||||||
|
import {
|
||||||
|
logCourierCall,
|
||||||
|
resolveOrderRefByOutNumber,
|
||||||
|
sanitizeXfxRequestBody,
|
||||||
|
sceneForXfxCmd,
|
||||||
|
} from '../courier-log.util';
|
||||||
import { buildXiaofeixiaSign } from './xiaofeixia.sign';
|
import { buildXiaofeixiaSign } from './xiaofeixia.sign';
|
||||||
import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants';
|
import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants';
|
||||||
import type { XiaofeixiaApiResponse } from './xiaofeixia.types';
|
import type { XiaofeixiaApiResponse } from './xiaofeixia.types';
|
||||||
@@ -9,12 +16,25 @@ type RequestParams = Record<string, string | number | undefined>;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class XiaofeixiaClient {
|
export class XiaofeixiaClient {
|
||||||
constructor(private readonly courierConfig: CourierConfigService) {}
|
constructor(
|
||||||
|
private readonly courierConfig: CourierConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> {
|
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> {
|
||||||
const cfg = this.courierConfig.load().xiaofeixia;
|
const cfg = this.courierConfig.load().xiaofeixia;
|
||||||
|
const scene = sceneForXfxCmd(cmd);
|
||||||
|
const externalNo = this.pickExternalNo(bizParams);
|
||||||
|
|
||||||
if (!cfg.mchId || !cfg.apiKey) {
|
if (!cfg.mchId || !cfg.apiKey) {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
scene,
|
||||||
|
requestUrl: cfg.apiUrl || '(未配置)',
|
||||||
|
requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }),
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: '小飞侠商户配置不完整',
|
||||||
|
externalNo,
|
||||||
|
});
|
||||||
throw new CourierApiError(
|
throw new CourierApiError(
|
||||||
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY',
|
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY',
|
||||||
'CONFIG_ERROR',
|
'CONFIG_ERROR',
|
||||||
@@ -42,6 +62,12 @@ export class XiaofeixiaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logRequestBody = sanitizeXfxRequestBody({ ...baseParams, sign: '[REDACTED]' });
|
||||||
|
const orderRef = await resolveOrderRefByOutNumber(
|
||||||
|
this.prisma,
|
||||||
|
typeof bizParams.outNumber === 'string' ? bizParams.outNumber : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(cfg.apiUrl, {
|
response = await fetch(cfg.apiUrl, {
|
||||||
@@ -50,15 +76,32 @@ export class XiaofeixiaClient {
|
|||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new CourierApiError(
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
'小飞侠接口网络异常',
|
await logCourierCall(this.prisma, {
|
||||||
'200000',
|
scene,
|
||||||
'XIAOFEIXIA',
|
requestUrl: cfg.apiUrl,
|
||||||
error,
|
requestBody: logRequestBody,
|
||||||
);
|
status: 'FAILED',
|
||||||
|
errorMessage: `网络异常: ${message}`,
|
||||||
|
externalNo,
|
||||||
|
ref: orderRef,
|
||||||
|
});
|
||||||
|
throw new CourierApiError('小飞侠接口网络异常', '200000', 'XIAOFEIXIA', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawText = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
scene,
|
||||||
|
requestUrl: cfg.apiUrl,
|
||||||
|
requestBody: logRequestBody,
|
||||||
|
responseBody: { httpStatus: response.status, body: rawText.slice(0, 500) },
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: `HTTP ${response.status}`,
|
||||||
|
externalNo,
|
||||||
|
ref: orderRef,
|
||||||
|
});
|
||||||
throw new CourierApiError(
|
throw new CourierApiError(
|
||||||
`小飞侠 HTTP 请求失败: ${response.status}`,
|
`小飞侠 HTTP 请求失败: ${response.status}`,
|
||||||
'200000',
|
'200000',
|
||||||
@@ -66,11 +109,22 @@ export class XiaofeixiaClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawText = await response.text();
|
|
||||||
let payload: XiaofeixiaApiResponse<T>;
|
let payload: XiaofeixiaApiResponse<T>;
|
||||||
try {
|
try {
|
||||||
payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>) : (null as unknown as XiaofeixiaApiResponse<T>);
|
payload = rawText
|
||||||
|
? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>)
|
||||||
|
: (null as unknown as XiaofeixiaApiResponse<T>);
|
||||||
} catch {
|
} catch {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
scene,
|
||||||
|
requestUrl: cfg.apiUrl,
|
||||||
|
requestBody: logRequestBody,
|
||||||
|
responseBody: { raw: rawText.slice(0, 500) },
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: '响应非 JSON',
|
||||||
|
externalNo,
|
||||||
|
ref: orderRef,
|
||||||
|
});
|
||||||
throw new CourierApiError(
|
throw new CourierApiError(
|
||||||
`小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`,
|
`小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`,
|
||||||
'200000',
|
'200000',
|
||||||
@@ -80,10 +134,29 @@ export class XiaofeixiaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
scene,
|
||||||
|
requestUrl: cfg.apiUrl,
|
||||||
|
requestBody: logRequestBody,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: '小飞侠返回空响应',
|
||||||
|
externalNo,
|
||||||
|
ref: orderRef,
|
||||||
|
});
|
||||||
throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA');
|
throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
scene,
|
||||||
|
requestUrl: cfg.apiUrl,
|
||||||
|
requestBody: logRequestBody,
|
||||||
|
responseBody: payload as unknown as Record<string, unknown>,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: payload.message || '业务失败',
|
||||||
|
externalNo: externalNo || payload.data?.toString(),
|
||||||
|
ref: orderRef,
|
||||||
|
});
|
||||||
throw new CourierApiError(
|
throw new CourierApiError(
|
||||||
payload.message || '小飞侠接口业务失败',
|
payload.message || '小飞侠接口业务失败',
|
||||||
payload.code,
|
payload.code,
|
||||||
@@ -92,6 +165,28 @@ export class XiaofeixiaClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await logCourierCall(this.prisma, {
|
||||||
|
scene,
|
||||||
|
requestUrl: cfg.apiUrl,
|
||||||
|
requestBody: logRequestBody,
|
||||||
|
responseBody: payload as unknown as Record<string, unknown>,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
externalNo: externalNo || this.pickExternalNoFromData(payload.data),
|
||||||
|
ref: orderRef,
|
||||||
|
});
|
||||||
|
|
||||||
return payload.data as T;
|
return payload.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickExternalNo(params: RequestParams) {
|
||||||
|
const outNumber = params.outNumber != null ? String(params.outNumber) : undefined;
|
||||||
|
const number = params.number != null ? String(params.number) : undefined;
|
||||||
|
return outNumber || number;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickExternalNoFromData(data: unknown) {
|
||||||
|
if (!data || typeof data !== 'object') return undefined;
|
||||||
|
const row = data as { number?: string; outNumber?: string; id?: string };
|
||||||
|
return row.number || row.outNumber || row.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ export class BenefitService {
|
|||||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||||
where: { id: BigInt(alloc.couponId) },
|
where: { id: BigInt(alloc.couponId) },
|
||||||
});
|
});
|
||||||
const allocAmount = alloc.amount;
|
const allocAmount = Number(alloc.amount);
|
||||||
|
if (!Number.isFinite(allocAmount) || allocAmount <= 0) {
|
||||||
|
throw new Error('BENEFIT_ALLOC_INVALID');
|
||||||
|
}
|
||||||
const updated = await tx.benefitCoupon.updateMany({
|
const updated = await tx.benefitCoupon.updateMany({
|
||||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginSmsDto, SendSmsDto } from './dto/auth.dto';
|
import { LoginPasswordDto, LoginSmsDto, SendSmsDto } from './dto/auth.dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
@@ -20,6 +20,11 @@ export class AdminAuthController {
|
|||||||
return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB);
|
return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('login/password')
|
||||||
|
loginPassword(@Body() dto: LoginPasswordDto) {
|
||||||
|
return this.authService.loginHqPassword(dto.loginName, dto.password, ClientApp.HQ_WEB);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
me(@CurrentUser() user: AuthUser) {
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { SmsActorRef } from '../../integrations/sms/sms.interface';
|
|||||||
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { verifyPassword } from '../../common/crypto/password.util';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { UserAddressService } from './user-address.service';
|
import { UserAddressService } from './user-address.service';
|
||||||
|
|
||||||
@@ -450,6 +451,30 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loginHqPassword(loginName: string, password: string, clientApp: ClientApp) {
|
||||||
|
const normalizedLogin = loginName.trim();
|
||||||
|
if (!normalizedLogin) throw new BadRequestException('请输入账号');
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { loginName: normalizedLogin },
|
||||||
|
});
|
||||||
|
if (!account?.passwordHash) throw new BadRequestException('账号或密码错误');
|
||||||
|
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
|
||||||
|
if (!verifyPassword(password, account.passwordHash)) {
|
||||||
|
throw new BadRequestException('账号或密码错误');
|
||||||
|
}
|
||||||
|
await this.prisma.hqAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, {
|
||||||
|
id: account.id.toString(),
|
||||||
|
phone: account.phone,
|
||||||
|
name: account.name,
|
||||||
|
adminRole: account.adminRole,
|
||||||
|
status: account.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getMe(actorType: string, actorId: bigint) {
|
async getMe(actorType: string, actorId: bigint) {
|
||||||
if (actorType === 'USER') {
|
if (actorType === 'USER') {
|
||||||
const user = await this.assertActiveUser(actorId);
|
const user = await this.assertActiveUser(actorId);
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ export class BindWechatPhoneDto {
|
|||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LoginPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
loginName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class BindWechatDto {
|
export class BindWechatDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { RedeemService } from '../redeem/redeem.service';
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
import type {
|
import type {
|
||||||
@@ -13,9 +13,46 @@ export class AdminRedeemDebugService {
|
|||||||
private readonly redeemService: RedeemService,
|
private readonly redeemService: RedeemService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private parseStoreId(value: string): bigint {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
if (!normalized || !/^\d+$/.test(normalized)) {
|
||||||
|
throw new BadRequestException('门店 ID 格式无效');
|
||||||
|
}
|
||||||
|
return BigInt(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveUserId(identifier: string): Promise<bigint> {
|
||||||
|
const normalized = String(identifier ?? '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new BadRequestException('请填写用户 ID、用户编号或手机号');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(normalized)) {
|
||||||
|
const byId = await this.prisma.user.findUnique({
|
||||||
|
where: { id: BigInt(normalized) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (byId) return byId.id;
|
||||||
|
|
||||||
|
const byPhone = await this.prisma.user.findFirst({
|
||||||
|
where: { phone: normalized },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (byPhone) return byPhone.id;
|
||||||
|
} else {
|
||||||
|
const byNo = await this.prisma.user.findFirst({
|
||||||
|
where: { userNo: normalized },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (byNo) return byNo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号');
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
||||||
const account = await this.prisma.storeAccount.findFirst({
|
const account = await this.prisma.storeAccount.findFirst({
|
||||||
where: { storeId: BigInt(storeId), status: 'ACTIVE' },
|
where: { storeId: this.parseStoreId(storeId), status: 'ACTIVE' },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, store: { select: { name: true } } },
|
select: { id: true, store: { select: { name: true } } },
|
||||||
});
|
});
|
||||||
@@ -26,10 +63,11 @@ export class AdminRedeemDebugService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
||||||
return this.redeemService.createToken(BigInt(dto.userId), {
|
const userId = await this.resolveUserId(dto.userId);
|
||||||
|
return this.redeemService.createToken(userId, {
|
||||||
amount: dto.amount,
|
amount: dto.amount,
|
||||||
couponId: dto.couponId,
|
couponId: dto.couponId?.trim() || undefined,
|
||||||
storeId: dto.storeId,
|
storeId: dto.storeId?.trim() || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,23 +150,38 @@ export class RedeemService {
|
|||||||
throw new BadRequestException('核销码数据异常');
|
throw new BadRequestException('核销码数据异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allocSum = allocations.reduce((sum, item) => sum + item.amount, 0);
|
const normalizedAllocations = allocations.map((item) => ({
|
||||||
if (Math.abs(allocSum - cached.amount) > 0.001) {
|
couponId: String(item.couponId),
|
||||||
|
amount: Number(item.amount),
|
||||||
|
}));
|
||||||
|
const tokenAmount = Number(cached.amount);
|
||||||
|
if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
|
||||||
throw new BadRequestException('核销码数据异常');
|
throw new BadRequestException('核销码数据异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const alloc of allocations) {
|
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
if (Math.abs(allocSum - tokenAmount) > 0.001) {
|
||||||
|
throw new BadRequestException('核销码数据异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const alloc of normalizedAllocations) {
|
||||||
|
let couponId: bigint;
|
||||||
|
try {
|
||||||
|
couponId = BigInt(alloc.couponId);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('核销码数据异常');
|
||||||
|
}
|
||||||
const coupon = await this.prisma.benefitCoupon.findUnique({
|
const coupon = await this.prisma.benefitCoupon.findUnique({
|
||||||
where: { id: BigInt(alloc.couponId) },
|
where: { id: couponId },
|
||||||
});
|
});
|
||||||
if (!coupon) throw new BadRequestException('券不存在');
|
if (!coupon) throw new BadRequestException('券不存在');
|
||||||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance));
|
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance));
|
||||||
if (!check.ok) throw new BadRequestException(check.message);
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = Number(cached.amount);
|
const amount = tokenAmount;
|
||||||
const cityRule = await this.prisma.commonCityCommissionRule.findFirst({
|
const cityRule = await this.prisma.commonCityCommissionRule.findUnique({
|
||||||
where: { city: { stores: { some: { id: account.storeId } } } },
|
where: { cityId: account.store.cityId },
|
||||||
});
|
});
|
||||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||||
@@ -174,29 +189,40 @@ export class RedeemService {
|
|||||||
let record;
|
let record;
|
||||||
try {
|
try {
|
||||||
record = await this.prisma.$transaction(async (tx) => {
|
record = await this.prisma.$transaction(async (tx) => {
|
||||||
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
|
await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId);
|
||||||
|
|
||||||
const redeemRecord = await tx.redeemRecord.create({
|
const redeemRecord = await tx.redeemRecord.create({
|
||||||
data: {
|
data: {
|
||||||
redeemNo: generateRedeemNo(),
|
redeemNo: generateRedeemNo(),
|
||||||
userId: BigInt(cached.userId),
|
userId: BigInt(cached.userId),
|
||||||
couponId: BigInt(allocations[0].couponId),
|
couponId: BigInt(normalizedAllocations[0].couponId),
|
||||||
storeId: account.storeId,
|
storeId: account.storeId,
|
||||||
amount,
|
amount,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.settlementService.createStorePayout(
|
||||||
|
redeemRecord.id,
|
||||||
|
account.storeId,
|
||||||
|
amount,
|
||||||
|
settleAmount,
|
||||||
|
settlementRate,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
|
||||||
return redeemRecord;
|
return redeemRecord;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
||||||
throw new BadRequestException('核销失败,请重试');
|
throw new BadRequestException('核销失败,请重试');
|
||||||
}
|
}
|
||||||
|
if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') {
|
||||||
|
throw new BadRequestException('核销码数据异常');
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
|
||||||
await this.redis.del(`redeem:token:${body.token}`);
|
await this.redis.del(`redeem:token:${body.token}`);
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
|
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ export class SettlementService {
|
|||||||
redeemAmount: number,
|
redeemAmount: number,
|
||||||
payoutAmount: number,
|
payoutAmount: number,
|
||||||
settlementRate: number,
|
settlementRate: number,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
) {
|
) {
|
||||||
const expectedPayAt = new Date();
|
const expectedPayAt = new Date();
|
||||||
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
||||||
const payout = await this.prisma.storePayout.create({
|
const client = tx ?? this.prisma;
|
||||||
|
const payout = await client.storePayout.create({
|
||||||
data: {
|
data: {
|
||||||
redeemRecordId,
|
redeemRecordId,
|
||||||
storeId,
|
storeId,
|
||||||
|
|||||||
Reference in New Issue
Block a user