feat(ops): add HQ admin proxy order and mini-user store session fixes

Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 09:18:19 +08:00
parent 1a0afb6d39
commit 2cd4e25682
21 changed files with 1438 additions and 143 deletions
@@ -22,6 +22,7 @@ export const HqOperationAction = {
ORDER_SHIP: 'ORDER_SHIP',
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
STORE_STATUS: 'STORE_STATUS',
@@ -122,6 +123,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.ORDER_SHIP]: '订单发货',
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
@@ -0,0 +1,59 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { TradeService } from '../trade/trade.service';
import {
HqProxyOrderCreateDto,
HqProxyOrderPreviewDto,
HqProxyOrderSendCustomerSmsDto,
} from './dto/hq-proxy-order.dto';
@Controller('admin/proxy-orders')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('orders')
export class AdminProxyOrdersController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getHqProxyOrderOptions();
}
@Post('preview')
preview(@Body() dto: HqProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto);
}
@Post('send-customer-sms')
sendCustomerSms(@Body() dto: HqProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendHqProxyCustomerSms(dto.phone);
}
@Post('send-operator-sms')
sendOperatorSms(@CurrentUser() user: AuthUser) {
return this.tradeService.sendHqProxyOperatorSms(user.actorId);
}
@Post()
@HqOperation({
action: HqOperationAction.ORDER_PROXY_CREATE,
refType: 'ORDER',
batch: true,
includeBody: true,
})
create(
@CurrentUser() user: AuthUser,
@Body() dto: HqProxyOrderCreateDto,
@Req() req: Request,
) {
return this.tradeService.createHqProxyOrder(user.actorId, dto, req);
}
}
@@ -0,0 +1,102 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
export class HqProxyOrderPreviewDto {
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
@IsOptional()
@IsString()
receiverCity?: string;
@IsOptional()
@IsString()
receiverDistrict?: string;
}
export class HqProxyOrderSendCustomerSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
}
export class HqProxyOrderCreateDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@IsString()
@IsNotEmpty()
customerSmsCode: string;
/** 发至当前 HQ 登录手机号的确认验证码 */
@IsString()
@IsNotEmpty()
operatorSmsCode: string;
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsBoolean()
autoReceive?: boolean;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
province?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
city?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
district?: string;
@ValidateIf((o: HqProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(256)
addressDetail?: string;
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
promoCodeId?: string;
}
@@ -10,6 +10,7 @@ import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { AdminOrdersController } from './admin-orders.controller';
import { AdminOrdersService } from './admin-orders.service';
import { AdminProxyOrdersController } from './admin-proxy-orders.controller';
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
import { AdminStoresService } from './admin-stores.service';
@@ -76,6 +77,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminDeployController,
AdminUsersController,
AdminOrdersController,
AdminProxyOrdersController,
AdminStoresController,
AdminStoreAccountsController,
AdminStoreMediaController,
@@ -1383,4 +1383,255 @@ export class TradeService {
return this.getPartnerOrder(partnerAccountId, order.id);
}
/** HQ 代下单:商品/推广码选项(不绑定合伙人门店) */
async getHqProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
this.promoCodeService.listActiveOptions(),
]);
return serializeBigInt({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
})),
promoCodes,
stores: [],
});
}
async sendHqProxyCustomerSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
clientApp: ClientApp.HQ_WEB,
});
const masked =
normalizedPhone.length >= 7
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
: normalizedPhone;
return { ok: true, maskedPhone: masked };
}
async sendHqProxyOperatorSms(hqAccountId: bigint) {
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
if (!hq || hq.status !== 'ACTIVE') {
throw new BadRequestException('总部账号无效');
}
const operatorPhone = hq.phone?.trim();
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
throw new BadRequestException('总部账号手机号无效,无法发送确认验证码');
}
await this.authService.sendSms(operatorPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.HQ_WEB,
});
const masked =
operatorPhone.length >= 7
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
: operatorPhone;
return { ok: true, maskedPhone: masked };
}
async createHqProxyOrder(
hqAccountId: bigint,
body: {
phone: string;
customerSmsCode: string;
operatorSmsCode: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
autoReceive?: boolean;
receiverName?: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
},
req: Request,
) {
const normalizedPhone = body.phone.trim();
const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } });
if (!hq || hq.status !== 'ACTIVE') {
throw new BadRequestException('总部账号无效');
}
const operatorPhone = hq.phone?.trim();
if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) {
throw new BadRequestException('总部账号手机号无效');
}
await this.authService.verifySmsCode(
normalizedPhone,
body.customerSmsCode.trim(),
SmsScene.PARTNER_PROXY_CUSTOMER,
);
await this.authService.verifySmsCode(
operatorPhone,
body.operatorSmsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
throw new BadRequestException('配送到址须勾选同意自动收货');
}
const maskedOperatorPhone =
operatorPhone.length >= 7
? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}`
: operatorPhone;
const proxyDisplayName = `总部·${hq.name}`;
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
sourceType: 'PARTNER_PROXY',
sourceRefId: hq.id,
sourceLabel: `总部代下单·${maskedOperatorPhone}`,
});
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
receiverCity: body.city,
receiverDistrict: body.district,
});
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
let receiverProvince = body.province?.trim() || '';
let receiverCity = body.city?.trim() || '';
let receiverDistrict = body.district?.trim() || '';
let receiverAddress = '';
let commissionDistrict = receiverDistrict;
if (body.deliveryMode === 'ON_SITE_PICKUP') {
receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
receiverProvince = '现场';
receiverCity = '现场';
receiverDistrict = '取货';
receiverAddress = '现场提货';
commissionDistrict = '';
} else {
if (!receiverProvince || !receiverCity || !receiverDistrict) {
throw new BadRequestException('请选择省市区');
}
if (!body.addressDetail?.trim()) {
throw new BadRequestException('请填写详细地址');
}
receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`;
}
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) {
promoCodeId = BigInt(body.promoCodeId.trim());
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
}
const orderNo = generateOrderNo();
const now = new Date();
const location = buildOrderClientLocationSnapshot(
req,
this.ipGeoService.resolve(extractClientIp(req)),
undefined,
);
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNo,
orderType: 'PROXY',
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: preview.deliveryType,
channelSource: 'OFFLINE_PROXY',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
freightAmount: 0,
freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null,
receiverName,
receiverPhone: normalizedPhone,
receiverAddress,
receiverProvince,
receiverCity,
receiverDistrict,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
paidAt: now,
shippedAt: now,
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
proxyPartnerAccountId: null,
proxyPartnerName: proxyDisplayName,
proxyPartnerPhone: operatorPhone,
remark: `总部代下单 hqAccountId=${hq.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
},
include: { product: true, imageResource: true },
});
await tx.orderDelivery.create({
data: {
orderId: created.id,
provider: 'MANUAL',
outWarehouseAt: now,
shippingAt: now,
deliveredAt: now,
},
});
await tx.commonEvent.create({
data: buildOrderStatusEvent({
orderId: created.id,
fromStatus: 'PENDING_PAY',
toStatus: 'COMPLETED',
operator: 'HQ_PROXY',
remark: `总部线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`,
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
await this.benefitService.grantOnOrderPaid(order.id);
return serializeBigInt({
id: order.id,
orderNo: order.orderNo,
status: order.status,
payAmount: Number(order.payAmount),
benefitAmount: Number(order.benefitAmount),
proxyPartnerName: proxyDisplayName,
});
}
}