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:
2026-07-06 22:35:16 +08:00
22 changed files with 689 additions and 92 deletions
+13
View File
@@ -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: '暂停',
+71 -15
View File
@@ -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) {
setLoading(true);
try {
const data = await request<{ accessToken: string; refreshToken: string }>(
'/admin/auth/login/sms',
{
method: 'POST',
body: JSON.stringify(values),
},
);
saveAuth(data); saveAuth(data);
message.success('登录成功'); message.success('登录成功');
navigate('/'); navigate('/');
}
async function onSmsFinish(values: { phone: string; code: string }) {
setLoading(true);
try {
const data = await request<LoginResult>('/admin/auth/login/sms', {
method: 'POST',
body: JSON.stringify(values),
});
await finishLogin(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
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,7 +85,40 @@ 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
items={[
{
key: 'password',
label: '账号密码',
children: (
<Form
form={passwordForm}
layout="vertical"
onFinish={onPasswordFinish}
initialValues={{ loginName: 'admin' }}
>
<Form.Item name="loginName" label="账号" rules={[{ required: true, message: '请输入账号' }]}>
<Input placeholder="admin" autoComplete="username" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
</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: '请输入手机号' }]}> <Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="13600000001" maxLength={11} /> <Input placeholder="13600000001" maxLength={11} />
</Form.Item> </Form.Item>
@@ -84,6 +136,10 @@ export default function LoginPage() {
</Button> </Button>
</Form> </Form>
),
},
]}
/>
</Card> </Card>
</div> </div>
); );
+12 -4
View File
@@ -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 },
+9 -11
View File
@@ -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> = {
-8
View File
@@ -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>
+1
View File
@@ -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": {
+2
View File
@@ -495,6 +495,8 @@ model PartnerBill {
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)
loginName String? @unique @map("login_name") @db.VarChar(64)
passwordHash String? @map("password_hash") @db.VarChar(255)
name String @db.VarChar(64) name String @db.VarChar(64)
adminRole HqAdminRole @default(OPS) @map("admin_role") 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)
@@ -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(
`小飞侠响应非 JSONHTTP ${response.status}: ${rawText.slice(0, 200) || '(空)'}`, `小飞侠响应非 JSONHTTP ${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,