feat(partner): complete proxy order with dual SMS and pickup
CI / verify (pull_request) Has been cancelled

Add partner/customer SMS confirm, address auto-receive or on-site
pickup, proxy placer fields, PARTNER_PROXY user source, and C-end badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-28 00:04:59 +08:00
parent cd23119470
commit b59d4484ec
21 changed files with 890 additions and 170 deletions
+6
View File
@@ -256,6 +256,7 @@ enum UserSourceType {
SHARE_LINK
FRIEND_REFERRAL
OFFLINE_EVENT
PARTNER_PROXY
OTHER
}
@@ -1153,6 +1154,10 @@ model Order {
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
/// 代下单操作人(与佣金归属 partnerAccountIdAtPay 分离)
proxyPartnerAccountId BigInt? @map("proxy_partner_account_id") @db.UnsignedBigInt
proxyPartnerName String? @map("proxy_partner_name") @db.VarChar(64)
proxyPartnerPhone String? @map("proxy_partner_phone") @db.VarChar(20)
fulfillmentWarehouseId BigInt? @map("fulfillment_warehouse_id") @db.UnsignedBigInt
/// 大单等场景拦截自动推承运商,待总部确认后推单或自配送
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
@@ -1181,6 +1186,7 @@ model Order {
@@index([ipCity])
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@map("user_order")
}
@@ -22,10 +22,19 @@ export function mapOrderItemCompat(order: OrderLike) {
};
}
export function mapOrderCompat<T extends OrderLike>(order: T) {
export function mapOrderCompat<T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
}>(order: T) {
const payStatus = order.payStatus ?? 'UNPAID';
const isProxyOrder = order.orderType === 'PROXY';
return {
...order,
isProxyOrder,
proxyPartnerName: order.proxyPartnerName ?? null,
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
items: [mapOrderItemCompat(order)],
payment: {
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
@@ -66,7 +66,7 @@ export class SmsAliyunProvider implements ISmsProvider {
return this.config.aliyunSmsRedeemConfirmTemplateCode;
}
if (
scene === 'PARTNER_PROXY_ORDER' &&
(scene === 'PARTNER_PROXY_ORDER' || scene === 'PARTNER_PROXY_CUSTOMER') &&
this.config.aliyunSmsProxyOrderTemplateCode
) {
return this.config.aliyunSmsProxyOrderTemplateCode;
@@ -97,6 +97,7 @@ export class AuthService {
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER:
case SmsScene.PARTNER_PROXY_CUSTOMER:
return ClientApp.PARTNER_H5;
default:
return ClientApp.USER_H5;
@@ -155,13 +156,20 @@ export class AuthService {
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
case SmsScene.PARTNER_PROXY_CUSTOMER: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
const partner = await this.prisma.partnerAccount.findUnique({
where: { phone },
select: { id: true },
});
return partner ? { refType: 'PARTNER', refId: partner.id } : undefined;
}
default:
return undefined;
}
@@ -334,7 +342,11 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
if (scene === SmsScene.PARTNER_PROXY_CUSTOMER) {
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
await this.assertPartnerAccountByPhone(phone);
return;
}
if (scene === SmsScene.PARTNER_STORE_OPEN) {
@@ -348,7 +360,14 @@ export class AuthService {
}
/** 合伙人代下单:按手机号查找或创建已验证用户 */
async findOrCreateUserByPhone(phone: string) {
async findOrCreateUserByPhone(
phone: string,
source?: {
sourceType?: 'PARTNER_PROXY';
sourceRefId?: bigint;
sourceLabel?: string;
},
) {
const normalizedPhone = this.assertMobilePhone(phone);
let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
@@ -362,6 +381,9 @@ export class AuthService {
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -1,5 +1,16 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
export class PartnerProxyOrderPreviewDto {
@IsString()
@@ -11,6 +22,14 @@ export class PartnerProxyOrderPreviewDto {
@Min(1)
quantity: number;
@IsOptional()
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
receiverCity?: string;
@@ -20,7 +39,7 @@ export class PartnerProxyOrderPreviewDto {
receiverDistrict?: string;
}
export class PartnerProxyOrderSendSmsDto {
export class PartnerProxyOrderSendCustomerSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@@ -33,28 +52,48 @@ export class PartnerProxyOrderCreateDto {
@IsString()
@IsNotEmpty()
smsCode: string;
customerSmsCode: string;
@IsString()
@IsNotEmpty()
partnerSmsCode: string;
@IsIn(['ADDRESS', 'ON_SITE_PICKUP'])
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsBoolean()
autoReceive?: boolean;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ON_SITE_PICKUP')
@IsString()
@IsNotEmpty()
storeId?: string;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
province: string;
province?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
city: string;
city?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(32)
district: string;
district?: string;
@ValidateIf((o: PartnerProxyOrderCreateDto) => o.deliveryMode === 'ADDRESS')
@IsString()
@MaxLength(256)
addressDetail: string;
addressDetail?: string;
@IsString()
@IsNotEmpty()
@@ -9,7 +9,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto,
PartnerProxyOrderSendCustomerSmsDto,
} from './dto/partner-proxy-order.dto';
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
@@ -204,8 +204,8 @@ export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getPartnerProxyOrderOptions();
options(@CurrentUser() user: AuthUser) {
return this.tradeService.getPartnerProxyOrderOptions(user.actorId);
}
@Post('preview')
@@ -213,9 +213,20 @@ export class PartnerProxyOrderController {
return this.tradeService.previewPartnerProxyOrder(dto);
}
/** @deprecated 兼容:转发客户短信 */
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
sendSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-customer-sms')
sendCustomerSms(@Body() dto: PartnerProxyOrderSendCustomerSmsDto) {
return this.tradeService.sendPartnerProxyCustomerSms(dto.phone);
}
@Post('send-partner-sms')
sendPartnerSms(@CurrentUser() user: AuthUser) {
return this.tradeService.sendPartnerProxyPartnerSms(user.actorId);
}
@Post()
@@ -1023,10 +1023,25 @@ export class TradeService {
});
}
async getPartnerProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
async getPartnerProxyOrderOptions(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const [products, promoCodes, stores] = await Promise.all([
this.catalogService.listProducts(),
this.promoCodeService.listActiveOptions(),
this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
address: true,
phone: true,
province: true,
cityName: true,
district: true,
status: true,
},
}),
]);
return serializeBigInt({
products: products.map((p) => ({
@@ -1035,14 +1050,27 @@ export class TradeService {
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: stores.map((s) => ({
id: s.id,
name: s.name,
address: s.address,
phone: s.phone,
province: s.province,
cityName: s.cityName,
district: s.district,
})),
});
}
async previewPartnerProxyOrder(body: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
}) {
@@ -1053,14 +1081,23 @@ export class TradeService {
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
const deliveryMode = body.deliveryMode ?? 'ADDRESS';
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
if (deliveryMode === 'ON_SITE_PICKUP') {
if (!product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场提货');
}
deliveryType = 'ON_SITE_PICKUP';
} else {
const receiverCity = body.receiverCity?.trim();
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
}
const check = validateMinPurchase(
deliveryType,
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
body.quantity,
city.localMinQty,
city.crossMinQty,
@@ -1083,9 +1120,9 @@ export class TradeService {
};
}
async sendPartnerProxyOrderSms(phone: string) {
async sendPartnerProxyCustomerSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, {
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
@@ -1095,16 +1132,41 @@ export class TradeService {
return { ok: true, maskedPhone: masked };
}
/** @deprecated 兼容旧前端:转发为客户短信 */
async sendPartnerProxyOrderSms(phone: string) {
return this.sendPartnerProxyCustomerSms(phone);
}
async sendPartnerProxyPartnerSms(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效,无法发送确认验证码');
}
await this.authService.sendSms(partnerPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
return { ok: true, maskedPhone: masked };
}
async createPartnerProxyOrder(
partnerAccountId: bigint,
body: {
phone: string;
smsCode: string;
customerSmsCode: string;
partnerSmsCode: string;
deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP';
autoReceive?: boolean;
storeId?: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
province?: string;
city?: string;
district?: string;
addressDetail?: string;
productId: string;
quantity: number;
promoCodeId?: string;
@@ -1112,16 +1174,43 @@ export class TradeService {
req: Request,
) {
const normalizedPhone = body.phone.trim();
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerPhone = primary.phone?.trim();
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
throw new BadRequestException('合伙人手机号无效');
}
await this.authService.verifySmsCode(
normalizedPhone,
body.smsCode.trim(),
body.customerSmsCode.trim(),
SmsScene.PARTNER_PROXY_CUSTOMER,
);
await this.authService.verifySmsCode(
partnerPhone,
body.partnerSmsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone);
if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) {
throw new BadRequestException('配送到址须勾选同意自动收货');
}
const maskedPartnerPhone =
partnerPhone.length >= 7
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
: partnerPhone;
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, {
sourceType: 'PARTNER_PROXY',
sourceRefId: primary.id,
sourceLabel: `代下单·${maskedPartnerPhone}`,
});
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
});
@@ -1130,8 +1219,37 @@ export class TradeService {
where: { id: BigInt(body.productId) },
});
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district);
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') {
if (!body.storeId?.trim()) throw new BadRequestException('请选择提货门店');
const store = await this.prisma.store.findFirst({
where: { id: BigInt(body.storeId.trim()), partnerAccountId: primary.id },
});
if (!store) throw new BadRequestException('门店不存在或不属于当前合伙人');
receiverName = store.name;
receiverProvince = store.province;
receiverCity = store.cityName;
receiverDistrict = store.district;
receiverAddress = `现场提货·${store.name}·${store.province}${store.cityName}${store.district}${store.address}`;
commissionDistrict = store.district;
} 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()) {
@@ -1139,11 +1257,8 @@ export class TradeService {
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
}
const receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
const receiverAddress = `${body.province}${body.city}${body.district}${body.addressDetail}`;
const orderNo = generateOrderNo();
const now = new Date();
const location = buildOrderClientLocationSnapshot(
req,
this.ipGeoService.resolve(extractClientIp(req)),
@@ -1178,9 +1293,9 @@ export class TradeService {
receiverName,
receiverPhone: normalizedPhone,
receiverAddress,
receiverProvince: body.province,
receiverCity: body.city,
receiverDistrict: body.district,
receiverProvince,
receiverCity,
receiverDistrict,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
@@ -1190,7 +1305,10 @@ export class TradeService {
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
remark: `合伙人代下单 partnerAccountId=${primary.id}`,
proxyPartnerAccountId: primary.id,
proxyPartnerName: primary.name,
proxyPartnerPhone: partnerPhone,
remark: `合伙人代下单 partnerAccountId=${primary.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`,
},
include: { product: true, imageResource: true },
});
@@ -1211,7 +1329,7 @@ export class TradeService {
fromStatus: 'PENDING_PAY',
toStatus: 'COMPLETED',
operator: 'PARTNER_PROXY',
remark: '合伙人线下代下单',
remark: `合伙人线下代下单 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`,
}),
});
@@ -1237,6 +1355,7 @@ export class TradeService {
userId: user.id.toString(),
productId: body.productId,
quantity: body.quantity,
deliveryMode: body.deliveryMode,
promoCodeId: promoCodeId?.toString() ?? null,
},
});