v3.5.1 版本更新
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-19 15:54:51 +08:00
parent 7dd5fdfb12
commit 233ed0af3b
103 changed files with 5764 additions and 185 deletions
@@ -2,6 +2,7 @@ import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
@@ -29,7 +30,7 @@ import { IpGeoService } from '../../common/geo/ip-geo.service';
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
import { extractClientIp } from '../../common/geo/client-ip.util';
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { mapOrderCompat, mapStatusLogCompat, mapUserOrderWithInvoice } from '../../common/compat/v31-compat';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import { AlertService } from '../../common/alert/alert.service';
@@ -57,6 +58,9 @@ export class TradeService {
private readonly alert: AlertService,
) {}
private readonly logger = new Logger(TradeService.name);
async preview(
userId: bigint,
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
@@ -753,14 +757,23 @@ export class TradeService {
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
include: { benefitCoupon: true, imageResource: true },
include: {
benefitCoupon: true,
imageResource: true,
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
return {
list: serializeBigInt(list.map(mapUserOrderWithInvoice)),
total,
page,
pageSize,
};
}
async getOrder(userId: bigint, orderId: bigint) {
@@ -772,6 +785,7 @@ export class TradeService {
imageResource: true,
product: true,
fulfillmentWarehouse: { select: { id: true, name: true } },
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
},
});
if (!order) throw new NotFoundException('订单不存在');
@@ -779,7 +793,10 @@ export class TradeService {
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
const mapped = mapUserOrderWithInvoice({
...order,
statusLogs: mapStatusLogCompat(statusLogs),
});
return serializeBigInt({
...mapped,
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
@@ -987,14 +1004,15 @@ export class TradeService {
userId: bigint,
orderId: bigint,
body: {
titleType: string;
invoiceKind: string;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
titleId?: string;
titleType?: string;
invoiceKind?: string;
titleName?: string;
taxNo?: string | null;
addressPhone?: string | null;
bankAccount?: string | null;
email?: string;
phone?: string;
remark?: string;
},
) {
@@ -1009,14 +1027,49 @@ export class TradeService {
});
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
let resolved = {
titleType: body.titleType || '',
titleName: body.titleName || '',
taxNo: body.taxNo ?? null,
addressPhone: body.addressPhone ?? null,
bankAccount: body.bankAccount ?? null,
email: body.email || '',
phone: body.phone || '',
};
if (body.titleId) {
const title = await this.prisma.userInvoiceTitle.findFirst({
where: { id: BigInt(body.titleId), userId },
});
if (!title) throw new BadRequestException('发票抬头不存在');
resolved = {
titleType: title.titleType,
titleName: title.titleName,
taxNo: title.taxNo,
addressPhone: title.addressPhone,
bankAccount: title.bankAccount,
email: title.email || body.email || '',
phone: title.phone || body.phone || '',
};
}
if (!resolved.titleName.trim()) {
throw new BadRequestException('请填写抬头名称');
}
if (!resolved.email.trim()) {
throw new BadRequestException('请填写接收邮箱');
}
if (!resolved.phone.trim()) {
throw new BadRequestException('请填写联系电话');
}
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
throw new BadRequestException('企业抬头须填写税号');
}
if (body.invoiceKind === 'SPECIAL') {
if (body.titleType !== 'ENTERPRISE') {
const invoiceKind = body.invoiceKind || 'NORMAL';
if (invoiceKind === 'SPECIAL') {
if (resolved.titleType !== 'ENTERPRISE') {
throw new BadRequestException('专用发票仅支持企业抬头');
}
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
if (!resolved.taxNo?.trim() || !resolved.addressPhone?.trim() || !resolved.bankAccount?.trim()) {
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
}
}
@@ -1026,20 +1079,29 @@ export class TradeService {
invoiceNo: this.generateInvoiceNo(),
orderId,
userId,
titleType: body.titleType as never,
invoiceKind: body.invoiceKind as never,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
email: body.email.trim(),
phone: body.phone.trim(),
titleType: resolved.titleType as never,
invoiceKind: invoiceKind as never,
titleName: resolved.titleName.trim(),
taxNo: resolved.taxNo?.trim() || null,
addressPhone: resolved.addressPhone?.trim() || null,
bankAccount: resolved.bankAccount?.trim() || null,
email: resolved.email.trim(),
phone: resolved.phone.trim(),
remark: body.remark?.trim() || null,
},
});
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
}
/** v3.5.1 #3:查询某订单是否已申请发票 */
async getOrderInvoiceStatus(userId: bigint, orderId: bigint) {
const inv = await this.prisma.userInvoice.findFirst({
where: { orderId, userId },
select: { id: true, status: true },
});
return { exists: !!inv, status: inv?.status ?? null };
}
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
const where = { userId };
const [items, total] = await Promise.all([
@@ -1096,14 +1158,15 @@ export class TradeService {
async adminCreateInvoice(
body: {
orderNo: string;
titleType: string;
invoiceKind: string;
titleName: string;
titleId?: string;
titleType?: string;
invoiceKind?: string;
titleName?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
email?: string;
phone?: string;
remark?: string;
},
) {
@@ -1819,6 +1882,59 @@ export class TradeService {
return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
}
/**
* v3.5.1 #8:订单 30 分钟未支付自动取消。
* 下单时已写入 payExpireAt = now+30min;此处将已过期且仍为待支付的订单翻为 CANCELLED。
* 建单/取消均无库存占用或权益发放,故仅翻状态 + 写状态日志,不回滚权益券。
*/
async cancelExpiredPendingOrders(limit = 200): Promise<number> {
const now = new Date();
const expired = await this.prisma.order.findMany({
where: {
status: 'PENDING_PAY',
payStatus: 'UNPAID',
payExpireAt: { lt: now },
isTest: false,
},
take: limit,
select: { id: true },
orderBy: { payExpireAt: 'asc' },
});
if (expired.length === 0) return 0;
let cancelled = 0;
for (const o of expired) {
try {
await this.prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: o.id, status: 'PENDING_PAY' },
data: { status: 'CANCELLED', cancelledAt: now },
});
if (updated.count === 0) return; // 已被并发处理
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId: o.id,
fromStatus: 'PENDING_PAY',
toStatus: 'CANCELLED',
operator: 'SYSTEM_AUTO_EXPIRE',
remark: '30 分钟未支付自动取消',
}),
});
});
cancelled++;
} catch (e) {
this.logger.error(
`cancelExpiredPendingOrders failed for order ${o.id}`,
e instanceof Error ? e.stack : e,
);
}
}
if (cancelled > 0) {
this.logger.log(`Auto-cancelled ${cancelled} expired pending orders`);
}
return cancelled;
}
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
async getHqProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([