代下单功能

This commit is contained in:
2026-07-12 11:40:04 +08:00
parent d949301bc1
commit de01d36cdb
46 changed files with 2657 additions and 253 deletions
+5 -3
View File
@@ -16,6 +16,8 @@ ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE=
# 合伙人代下单「线下代发货」短信模板(PARTNER_PROXY_ORDER);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_PROXY_ORDER_TEMPLATE_CODE=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
@@ -27,9 +29,9 @@ MOCK_WECHAT=true
# 登录后是否走微信 SDK OAuth 授权(本地 false 可仅用短信登录/核销,不影响支付 Mock)
WX_AUTHORIZE=false
# C 端 H5 落地页(推广码二维码链接前缀)
# 本地开发:http://localhost:5173/user 生产统一入口:https://user.runxian.top/user
USER_H5_URL=http://localhost:5173/user
# C 端 H5 落地页(推广码二维码链接前缀USER_H5_URL
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
# USER_H5_URL=https://user.runxian.top/user
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
+2
View File
@@ -36,6 +36,7 @@
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -46,6 +47,7 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
+17 -8
View File
@@ -136,16 +136,25 @@ CREATE TABLE common_store_category (
DROP TABLE IF EXISTS common_promo_code;
CREATE TABLE common_promo_code (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK',
qrcode_id VARCHAR(64) NOT NULL COMMENT '二维码唯一识别码',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
owner_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '关联用户(统计/归因)',
remark VARCHAR(256) DEFAULT NULL,
qrcode_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '小程序码 common_resource.id',
scan_count INT NOT NULL DEFAULT 0,
order_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
scan_count INT NOT NULL DEFAULT 0,
order_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_promo_code_code (code)
UNIQUE KEY uk_common_promo_code_code (code),
UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id),
KEY idx_common_promo_code_owner (owner_user_id),
KEY idx_common_promo_code_scene_status (scene, status),
CONSTRAINT fk_common_promo_code_owner_user FOREIGN KEY (owner_user_id) REFERENCES user_user(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER(城市合伙人主账号 + 子账号) =============================
@@ -0,0 +1,28 @@
-- 推广码表扩展:scene / qrcode_id / owner_user_id / remark / updated_at
-- 已有数据会先回填 qrcode_id 再设 NOT NULL
ALTER TABLE common_promo_code
ADD COLUMN IF NOT EXISTS scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name,
ADD COLUMN IF NOT EXISTS qrcode_id VARCHAR(64) NULL AFTER scene,
ADD COLUMN IF NOT EXISTS owner_user_id BIGINT UNSIGNED NULL AFTER status,
ADD COLUMN IF NOT EXISTS remark VARCHAR(256) NULL AFTER owner_user_id,
ADD COLUMN IF NOT EXISTS updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at;
UPDATE common_promo_code
SET qrcode_id = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
WHERE code = 'DKHQ001' AND (qrcode_id IS NULL OR qrcode_id = '');
UPDATE common_promo_code
SET qrcode_id = 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678'
WHERE code = 'DKDEMO1' AND (qrcode_id IS NULL OR qrcode_id = '');
UPDATE common_promo_code
SET qrcode_id = LOWER(REPLACE(UUID(), '-', ''))
WHERE qrcode_id IS NULL OR qrcode_id = '';
UPDATE common_promo_code
SET updated_at = created_at
WHERE updated_at IS NULL;
ALTER TABLE common_promo_code
MODIFY qrcode_id VARCHAR(64) NOT NULL;
@@ -0,0 +1,106 @@
/**
* 一次性迁移:common_promo_code 扩展字段(scene / qrcode_id / owner_user_id / remark / updated_at
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/migrate-promo-code-v31.ts
*/
import { PrismaClient } from '@prisma/client';
import { randomBytes } from 'crypto';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ Field: string }>>(
`SHOW COLUMNS FROM ${table} LIKE '${column}'`,
);
return rows.length > 0;
}
async function addColumn(sql: string) {
try {
await prisma.$executeRawUnsafe(sql);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate column')) throw e;
}
}
async function main() {
if (!(await columnExists('common_promo_code', 'scene'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name`,
);
}
if (!(await columnExists('common_promo_code', 'qrcode_id'))) {
await addColumn(`ALTER TABLE common_promo_code ADD COLUMN qrcode_id VARCHAR(64) NULL AFTER scene`);
}
if (!(await columnExists('common_promo_code', 'owner_user_id'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN owner_user_id BIGINT UNSIGNED NULL AFTER status`,
);
}
if (!(await columnExists('common_promo_code', 'remark'))) {
await addColumn(`ALTER TABLE common_promo_code ADD COLUMN remark VARCHAR(256) NULL AFTER owner_user_id`);
}
if (!(await columnExists('common_promo_code', 'updated_at'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at`,
);
}
const rows = await prisma.$queryRawUnsafe<Array<{ id: bigint; code: string; qrcode_id: string | null }>>(
`SELECT id, code, qrcode_id FROM common_promo_code`,
);
const preset: Record<string, string> = {
DKHQ001: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
DKDEMO1: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
};
for (const row of rows) {
if (row.qrcode_id) continue;
const qrcodeId = preset[row.code] ?? randomBytes(32).toString('hex');
await prisma.$executeRawUnsafe(
`UPDATE common_promo_code SET qrcode_id = ? WHERE id = ?`,
qrcodeId,
row.id,
);
}
await prisma.$executeRawUnsafe(`UPDATE common_promo_code SET updated_at = created_at WHERE updated_at IS NULL`);
await prisma.$executeRawUnsafe(`ALTER TABLE common_promo_code MODIFY qrcode_id VARCHAR(64) NOT NULL`);
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_owner (owner_user_id)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_scene_status (scene, status)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
console.log('migrate-promo-code-v31: OK');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+18
View File
@@ -100,6 +100,14 @@ enum PromoCodeStatus {
DISABLED
}
enum PromoCodeScene {
ONLINE_LINK
OFFLINE_PICKUP
PARTNER_CHANNEL
EVENT
OTHER
}
enum CityStatus {
PENDING
ACTIVE
@@ -168,6 +176,7 @@ enum UserSourceType {
enum OrderType {
NORMAL
RESHIPMENT
PROXY
}
enum OrderStatus {
@@ -392,16 +401,24 @@ model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
scene PromoCodeScene @default(ONLINE_LINK)
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
status PromoCodeStatus @default(ACTIVE)
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
scanCount Int @default(0) @map("scan_count")
orderCount Int @default(0) @map("order_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[]
orders Order[]
@@index([ownerUserId])
@@index([scene, status])
@@map("common_promo_code")
}
@@ -587,6 +604,7 @@ model User {
addresses UserAddress[]
cityPreference UserCityPreference?
promoTouch UserPromoAttribution?
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
orders Order[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
+8
View File
@@ -589,6 +589,10 @@ async function main() {
name: '总部品鉴会',
scene: 'EVENT',
qrcodeId: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
status: 'ACTIVE',
},
@@ -605,6 +609,10 @@ async function main() {
name: '郑州品鉴会演示',
scene: 'OFFLINE_PICKUP',
qrcodeId: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
status: 'ACTIVE',
},
+2
View File
@@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { PromoModule } from './modules/promo/promo.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
@@ -42,6 +43,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
AnalyticsModule,
JobsModule,
OpsModule,
PromoModule,
CityScopeModule,
CommonModule,
HqOperationModule,
@@ -47,6 +47,9 @@ export const HqOperationAction = {
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
@@ -99,6 +102,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
STORE_PAYOUT: '门店打款确认',
};
@@ -65,6 +65,12 @@ export class SmsAliyunProvider implements ISmsProvider {
) {
return this.config.aliyunSmsRedeemConfirmTemplateCode;
}
if (
scene === 'PARTNER_PROXY_ORDER' &&
this.config.aliyunSmsProxyOrderTemplateCode
) {
return this.config.aliyunSmsProxyOrderTemplateCode;
}
return this.config.aliyunSmsTemplateCode;
}
@@ -1,11 +1,11 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoTouchDto } from './dto/promo.dto';
import { ActorType } from '@dukang/shared-types';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@@ -19,12 +19,15 @@ export class AnalyticsController {
@Controller('promo')
export class PromoController {
constructor(private readonly analyticsService: AnalyticsService) {}
constructor(private readonly promoCodeService: PromoCodeService) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.analyticsService.touchPromo(dto.promoCode, userId);
return this.promoCodeService.touch(
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId },
userId,
);
}
}
}
@@ -1,11 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { PromoModule } from '../promo/promo.module';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [forwardRef(() => IamModule)],
controllers: [AnalyticsController, PromoController],
imports: [forwardRef(() => IamModule), PromoModule], controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -123,43 +123,5 @@ export class AnalyticsService {
extraJson: event.extraJson as never,
};
}
/** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */
async touchPromo(promoCode: string, userId?: bigint) {
const code = promoCode.trim().toUpperCase();
const promo = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (!promo || promo.status !== 'ACTIVE') {
throw new NotFoundException('推广码无效或已停用');
}
await this.prisma.commonPromoCode.update({
where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
let attributed = false;
if (userId) {
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
attributed = true;
}
}
return serializeBigInt({
promoCode: promo.code,
channelName: promo.name,
attributed,
});
}
}
@@ -1,7 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class PromoTouchDto {
@IsOptional()
@IsString()
@IsNotEmpty()
promoCode: string;
promoCode?: string;
@IsOptional()
@IsString()
qrcodeId?: string;
}
@@ -90,6 +90,8 @@ export class AuthService {
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER:
return ClientApp.PARTNER_H5;
default:
return ClientApp.USER_H5;
}
@@ -140,6 +142,13 @@ export class AuthService {
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
default:
return undefined;
}
@@ -279,6 +288,9 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
return;
}
}
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
@@ -286,6 +298,44 @@ export class AuthService {
await this.smsProvider.verify(normalizedPhone, code, scene);
}
/** 合伙人代下单:按手机号查找或创建已验证用户 */
async findOrCreateUserByPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
include: { avatar: true },
});
if (!user) {
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
});
} else {
if (!user.phoneVerifiedAt) {
user = await this.prisma.user.update({
where: { id: user.id },
data: { phoneVerifiedAt: new Date() },
include: { avatar: true },
});
}
await this.assertActiveUser(user.id);
}
return user;
}
private async verifySmsForUser(
phone: string,
code: string,
@@ -1261,6 +1311,21 @@ export class AuthService {
}
}
if (
guest.sourceType === 'PROMO_CODE' &&
guest.sourceRefId &&
primary.sourceType === 'ORGANIC'
) {
await tx.user.update({
where: { id: primaryId },
data: {
sourceType: 'PROMO_CODE',
sourceRefId: guest.sourceRefId,
sourceLabel: guest.sourceLabel ?? primary.sourceLabel,
},
});
}
const deviceKeyToTransfer =
guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
if (deviceKeyToTransfer) {
@@ -1,36 +0,0 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodesService } from './admin-promo-codes.service';
import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
import { CreatePromoCodeDto, UpdatePromoCodeStatusDto } from './dto/admin-mutate.dto';
@Controller('admin/promo-codes')
@UseGuards(HqAuthGuard)
export class AdminPromoCodesController {
constructor(private readonly service: AdminPromoCodesService) {}
@Get()
list(@Query() query: AdminPromoCodesQueryDto) {
return this.service.list(query);
}
@Get(':id/stats')
stats(@Param('id') id: string) {
return this.service.stats(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
create(@Body() dto: CreatePromoCodeDto) {
return this.service.create(dto);
}
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
return this.service.updateStatus(BigInt(id), dto.status);
}
}
@@ -1,133 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CreatePromoCodeDto } from './dto/admin-mutate.dto';
import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
function userH5Base(): string {
return (process.env.USER_H5_URL || 'http://localhost:5173/user').replace(/\/$/, '');
}
function buildLandingUrl(code: string): string {
return `${userH5Base()}/?promo=${encodeURIComponent(code)}`;
}
function randomCode(): string {
const n = Math.random().toString(36).slice(2, 8).toUpperCase();
return `DK${n}`;
}
@Injectable()
export class AdminPromoCodesService {
constructor(private readonly prisma: PrismaService) {}
private mapRow(row: {
id: bigint;
code: string;
name: string;
status: string;
scanCount: number;
orderCount: number;
createdAt: Date;
}) {
return serializeBigInt({
id: row.id,
code: row.code,
name: row.name,
status: row.status,
scanCount: row.scanCount,
orderCount: row.orderCount,
landingUrl: buildLandingUrl(row.code),
createdAt: row.createdAt,
});
}
async list(query: AdminPromoCodesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
status?: 'ACTIVE' | 'DISABLED';
name?: { contains: string };
code?: { contains: string };
} = {};
if (query.status) where.status = query.status as 'ACTIVE' | 'DISABLED';
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonPromoCode.count({ where }),
]);
return serializeBigInt({
items: items.map((r) => this.mapRow(r)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
const stats = this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row), stats });
}
async create(dto: CreatePromoCodeDto) {
let code = dto.code?.trim().toUpperCase();
if (code) {
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (exists) throw new BadRequestException('推广码已存在');
} else {
for (let i = 0; i < 5; i++) {
const candidate = randomCode();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
if (!exists) {
code = candidate;
break;
}
}
if (!code) throw new BadRequestException('生成推广码失败,请重试');
}
const row = await this.prisma.commonPromoCode.create({
data: {
code,
name: dto.name.trim(),
status: 'ACTIVE',
},
});
return this.mapRow(row);
}
async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
const row = await this.prisma.commonPromoCode.update({
where: { id },
data: { status },
});
return this.mapRow(row);
}
async stats(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
return serializeBigInt(this.statsFromRow(row));
}
private statsFromRow(row: { scanCount: number; orderCount: number }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
return { scanCount, orderCount, conversionRate };
}
}
@@ -16,6 +16,9 @@ function mapAdminUserRow(u: {
wxOpenId: string | null;
nickname: string | null;
status: number;
sourceType: string;
sourceRefId: bigint | null;
sourceLabel: string | null;
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
@@ -31,6 +34,9 @@ function mapAdminUserRow(u: {
wechatVerified: !!u.wxOpenId,
nickname: u.nickname,
status: u.status,
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
sourceLabel: u.sourceLabel,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
@@ -69,6 +75,9 @@ export class AdminUsersService {
wxOpenId: true,
nickname: true,
status: true,
sourceType: true,
sourceRefId: true,
sourceLabel: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
@@ -107,12 +116,21 @@ export class AdminUsersService {
});
if (!user) throw new NotFoundException('用户不存在');
let sourcePromo: { id: bigint; code: string; name: string } | null = null;
if (user.sourceType === 'PROMO_CODE' && user.sourceRefId) {
sourcePromo = await this.prisma.commonPromoCode.findUnique({
where: { id: user.sourceRefId },
select: { id: true, code: true, name: true },
});
}
return serializeBigInt({
...user,
wechatVerified: !!user.wxOpenId,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
addressCount: user._count.addresses,
sourcePromo,
_count: undefined,
});
}
@@ -33,8 +33,6 @@ import { AdminHqLogsController } from './admin-hq-logs.controller';
import { AdminHqLogsService } from './admin-hq-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { AdminPromoCodesController } from './admin-promo-codes.controller';
import { AdminPromoCodesService } from './admin-promo-codes.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
@@ -79,7 +77,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
AdminPromoCodesController,
AdminWechatBindingsController,
AdminHqPermissionsController,
],
@@ -103,7 +100,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
AdminPromoCodesService,
AdminWechatBindingsService,
AdminHqPermissionsService,
SuperAdminGuard,
@@ -0,0 +1,84 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { PromoCodeService } from './promo-code.service';
import {
CreatePromoCodeDto,
PromoCodeListQueryDto,
UpdatePromoCodeDto,
UpdatePromoCodeStatusDto,
} from './dto/promo-code.dto';
@Controller('admin/promo-codes')
@UseGuards(HqAuthGuard)
export class AdminPromoCodeController {
constructor(private readonly service: PromoCodeService) {}
@Get('scenes')
listScenes() {
return this.service.listScenes();
}
@Get()
list(@Query() query: PromoCodeListQueryDto) {
return this.service.list(query);
}
@Get(':id/users')
listUsers(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listUsers(
BigInt(id),
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
);
}
@Get(':id/stats')
stats(@Param('id') id: string) {
return this.service.stats(BigInt(id));
}
@Get(':id/qrcode')
qrcode(@Param('id') id: string) {
return this.service.getQrcodeUrl(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
@HqOperation({
action: HqOperationAction.PROMO_CODE_CREATE,
refType: 'PROMO_CODE',
batch: true,
includeBody: true,
})
create(@Body() dto: CreatePromoCodeDto) {
return this.service.create(dto);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.PROMO_CODE_UPDATE,
refType: 'PROMO_CODE',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdatePromoCodeDto) {
return this.service.update(BigInt(id), dto);
}
@Put(':id/status')
@HqOperation({
action: HqOperationAction.PROMO_CODE_UPDATE_STATUS,
refType: 'PROMO_CODE',
refIdParam: 'id',
includeBody: true,
})
updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
return this.service.updateStatus(BigInt(id), dto.status);
}
}
@@ -0,0 +1,83 @@
import { Type } from 'class-transformer';
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
export class PromoCodeListQueryDto {
@IsOptional()
@Type(() => Number)
page?: number;
@IsOptional()
@Type(() => Number)
pageSize?: number;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: PromoCodeStatus;
@IsOptional()
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
scene?: PromoCodeScene;
@IsOptional()
@IsString()
ownerUserId?: string;
}
export class CreatePromoCodeDto {
@IsString()
@IsNotEmpty()
@MaxLength(128)
name: string;
@IsOptional()
@IsString()
@MaxLength(32)
code?: string;
@IsOptional()
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
scene?: PromoCodeScene;
@IsOptional()
@IsString()
ownerUserId?: string;
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string;
}
export class UpdatePromoCodeDto {
@IsOptional()
@IsString()
@MaxLength(128)
name?: string;
@IsOptional()
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
scene?: PromoCodeScene;
@IsOptional()
@IsString()
ownerUserId?: string | null;
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string | null;
}
export class UpdatePromoCodeStatusDto {
@IsIn(['ACTIVE', 'DISABLED'])
status: PromoCodeStatus;
}
@@ -0,0 +1,515 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import * as QRCode from 'qrcode';
import {
PROMO_CODE_SCENE_LABELS,
PromoCodeScene,
buildPromoLandingUrl,
loadAppConfig,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import type {
CreatePromoCodeDto,
PromoCodeListQueryDto,
UpdatePromoCodeDto,
} from './dto/promo-code.dto';
type PromoRow = {
id: bigint;
code: string;
name: string;
scene: string;
qrcodeId: string;
status: string;
remark: string | null;
scanCount: number;
orderCount: number;
createdAt: Date;
updatedAt: Date;
ownerUser?: {
id: bigint;
userNo: string | null;
nickname: string | null;
phone: string | null;
} | null;
qrcodeResource?: { url: string } | null;
};
function userH5Base(): string {
return loadAppConfig().userH5Url;
}
function buildLandingUrl(code: string, qrcodeId: string): string {
return buildPromoLandingUrl(userH5Base(), code, qrcodeId);
}
function randomPromoCode(): string {
const n = Math.random().toString(36).slice(2, 8).toUpperCase();
return `DK${n}`;
}
function randomQrcodeId(): string {
return randomBytes(32).toString('hex');
}
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? null;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
@Injectable()
export class PromoCodeService {
constructor(
private readonly prisma: PrismaService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
) {}
listScenes() {
return Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }));
}
async listActiveOptions() {
const rows = await this.prisma.commonPromoCode.findMany({
where: { status: 'ACTIVE' },
select: { id: true, code: true, name: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
return serializeBigInt(rows);
}
/** 代下单绑定推广码:归因 + 用户来源(若可写) */
async attributeUserToPromo(userId: bigint, promoId: bigint) {
const promo = await this.prisma.commonPromoCode.findUnique({
where: { id: promoId },
select: { id: true, name: true, status: true },
});
if (!promo || promo.status !== 'ACTIVE') {
throw new BadRequestException('推广码无效或已停用');
}
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
}
await this.applyPromoSourceToUser(userId, promo);
return promo;
}
private mapOwnerUser(user: PromoRow['ownerUser']) {
if (!user) return null;
return serializeBigInt({
id: user.id,
userNo: user.userNo,
nickname: user.nickname,
phone: maskPhone(user.phone),
});
}
private mapRow(row: PromoRow) {
return serializeBigInt({
id: row.id,
code: row.code,
name: row.name,
scene: row.scene,
qrcodeId: row.qrcodeId,
status: row.status,
remark: row.remark,
scanCount: row.scanCount,
orderCount: row.orderCount,
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
qrcodeUrl: row.qrcodeResource?.url ?? null,
ownerUser: this.mapOwnerUser(row.ownerUser),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
private includeRelations = {
ownerUser: {
select: { id: true, userNo: true, nickname: true, phone: true },
},
qrcodeResource: {
select: { url: true },
},
} as const;
async list(query: PromoCodeListQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
status?: 'ACTIVE' | 'DISABLED';
scene?: PromoCodeScene;
name?: { contains: string };
code?: { contains: string };
ownerUserId?: bigint;
} = {};
if (query.status) where.status = query.status;
if (query.scene) where.scene = query.scene;
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code.toUpperCase() };
if (query.ownerUserId?.trim()) {
where.ownerUserId = BigInt(query.ownerUserId.trim());
}
const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
where,
include: this.includeRelations,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonPromoCode.count({ where }),
]);
return serializeBigInt({
items: items.map((r) => this.mapRow(r)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({
where: { id },
include: this.includeRelations,
});
if (!row) throw new NotFoundException('推广码不存在');
const stats = await this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row), stats });
}
private async resolveOwnerUserId(ownerUserId?: string) {
if (!ownerUserId?.trim()) return undefined;
const user = await this.prisma.user.findFirst({
where: { id: BigInt(ownerUserId.trim()), mergedIntoUserId: null, status: 1 },
select: { id: true },
});
if (!user) throw new BadRequestException('关联用户不存在');
return user.id;
}
private async generateUniqueCode(custom?: string) {
let code = custom?.trim().toUpperCase();
if (code) {
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (exists) throw new BadRequestException('推广码已存在');
return code;
}
for (let i = 0; i < 8; i++) {
const candidate = randomPromoCode();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
if (!exists) return candidate;
}
throw new BadRequestException('生成推广码失败,请重试');
}
private async generateUniqueQrcodeId() {
for (let i = 0; i < 8; i++) {
const candidate = randomQrcodeId();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { qrcodeId: candidate } });
if (!exists) return candidate;
}
throw new BadRequestException('生成二维码 ID 失败,请重试');
}
private async createQrcodeResource(promoId: bigint, code: string, qrcodeId: string) {
const landingUrl = buildLandingUrl(code, qrcodeId);
const pngBuffer = await QRCode.toBuffer(landingUrl, {
width: 512,
margin: 1,
type: 'png',
color: { dark: '#1f1a17', light: '#ffffff' },
});
const uploaded = await this.oss.putObject({
bizType: 'QRCODE',
mediaType: 'IMAGE',
fileName: `promo-${code}.png`,
buffer: pngBuffer,
mimeType: 'image/png',
});
const resource = await this.prisma.commonResource.create({
data: {
ownerType: 'PROMO',
ownerId: promoId,
bizType: 'QRCODE',
mediaType: 'IMAGE',
ossBucket: uploaded.bucket,
ossKey: uploaded.ossKey,
url: uploaded.url,
fileName: `promo-${code}.png`,
fileSize: BigInt(pngBuffer.length),
mimeType: 'image/png',
},
});
return resource;
}
async create(dto: CreatePromoCodeDto) {
const code = await this.generateUniqueCode(dto.code);
const qrcodeId = await this.generateUniqueQrcodeId();
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
const row = await this.prisma.commonPromoCode.create({
data: {
code,
name: dto.name.trim(),
scene,
qrcodeId,
status: 'ACTIVE',
ownerUserId,
remark: dto.remark?.trim() || null,
},
});
const resource = await this.createQrcodeResource(row.id, code, qrcodeId);
const updated = await this.prisma.commonPromoCode.update({
where: { id: row.id },
data: { qrcodeResourceId: resource.id },
include: this.includeRelations,
});
return this.mapRow(updated);
}
async update(id: bigint, dto: UpdatePromoCodeDto) {
await this.detail(id);
const data: {
name?: string;
scene?: PromoCodeScene;
remark?: string | null;
ownerUserId?: bigint | null;
} = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if (dto.scene !== undefined) data.scene = dto.scene;
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
if (dto.ownerUserId !== undefined) {
if (dto.ownerUserId === null || dto.ownerUserId === '') {
data.ownerUserId = null;
} else {
data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
}
}
const row = await this.prisma.commonPromoCode.update({
where: { id },
data,
include: this.includeRelations,
});
return this.mapRow(row);
}
async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
const row = await this.prisma.commonPromoCode.update({
where: { id },
data: { status },
include: this.includeRelations,
});
return this.mapRow(row);
}
async stats(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
return serializeBigInt(await this.statsFromRow(row));
}
async getQrcodeUrl(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({
where: { id },
include: { qrcodeResource: { select: { url: true } } },
});
if (!row) throw new NotFoundException('推广码不存在');
if (!row.qrcodeResource?.url) {
throw new NotFoundException('二维码资源不存在,请重新生成推广码');
}
return {
qrcodeUrl: row.qrcodeResource.url,
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
name: row.name,
code: row.code,
};
}
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string }) {
const code = input.code?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
if (code) {
return this.prisma.commonPromoCode.findUnique({ where: { code } });
}
if (qrcodeId) {
return this.prisma.commonPromoCode.findUnique({ where: { qrcodeId } });
}
return null;
}
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
async touch(input: { promoCode?: string; qrcodeId?: string }, userId?: bigint) {
const promoCode = input.promoCode?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
if (!promoCode && !qrcodeId) {
throw new BadRequestException('请提供 promoCode 或 qrcodeId');
}
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId });
if (!promo || promo.status !== 'ACTIVE') {
throw new NotFoundException('推广码无效或已停用');
}
await this.prisma.commonPromoCode.update({
where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
let attributed = false;
let sourceApplied = false;
if (userId) {
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
attributed = true;
}
sourceApplied = await this.applyPromoSourceToUser(userId, promo);
}
return serializeBigInt({
promoCode: promo.code,
promoCodeId: promo.id,
channelName: promo.name,
attributed,
sourceApplied,
});
}
/** 用户来源:PROMO_CODE + 推广码 ID(仅 ORGANIC 可写入,不覆盖已有来源) */
async applyPromoSourceToUser(
userId: bigint,
promo: { id: bigint; name: string },
): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { sourceType: true, mergedIntoUserId: true, status: true },
});
if (!user || user.mergedIntoUserId || user.status !== 1 || user.sourceType !== 'ORGANIC') {
return false;
}
await this.prisma.user.update({
where: { id: userId },
data: {
sourceType: 'PROMO_CODE',
sourceRefId: promo.id,
sourceLabel: promo.name,
},
});
return true;
}
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
const [attributionCount, sourceMarkedCount] = await Promise.all([
this.prisma.userPromoAttribution.count({
where: { promoCodeId: row.id },
}),
this.prisma.user.count({
where: {
sourceType: 'PROMO_CODE',
sourceRefId: row.id,
mergedIntoUserId: null,
},
}),
]);
return { scanCount, orderCount, conversionRate, attributionCount, sourceMarkedCount };
}
/** 推广码关联用户:归因记录或用户来源指向本码 */
async listUsers(promoId: bigint, page = 1, pageSize = 20) {
const promo = await this.prisma.commonPromoCode.findUnique({
where: { id: promoId },
select: { id: true },
});
if (!promo) throw new NotFoundException('推广码不存在');
const where = {
mergedIntoUserId: null,
OR: [
{ promoTouch: { promoCodeId: promoId } },
{ sourceType: 'PROMO_CODE' as const, sourceRefId: promoId },
],
};
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
userNo: true,
nickname: true,
phone: true,
phoneVerifiedAt: true,
sourceType: true,
sourceRefId: true,
createdAt: true,
promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
_count: { select: { orders: true } },
},
}),
this.prisma.user.count({ where }),
]);
return serializeBigInt({
items: items.map((u) => ({
id: u.id,
userNo: u.userNo,
nickname: u.nickname,
phone: maskPhone(u.phone),
phoneVerifiedAt: u.phoneVerifiedAt,
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
firstTouchAt: u.promoTouch?.promoCodeId.toString() === promoId.toString()
? u.promoTouch.firstTouchAt
: null,
orderCount: u._count.orders,
createdAt: u.createdAt,
})),
total,
page,
pageSize,
});
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodeController } from './admin-promo-code.controller';
import { PromoCodeService } from './promo-code.service';
@Module({
imports: [
IntegrationsModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}),
],
controllers: [AdminPromoCodeController],
providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
exports: [PromoCodeService],
})
export class PromoModule {}
@@ -0,0 +1,71 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
export class PartnerProxyOrderPreviewDto {
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
receiverCity?: string;
@IsOptional()
@IsString()
receiverDistrict?: string;
}
export class PartnerProxyOrderSendSmsDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
}
export class PartnerProxyOrderCreateDto {
@IsString()
@Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
phone: string;
@IsString()
@IsNotEmpty()
smsCode: string;
@IsOptional()
@IsString()
@MaxLength(32)
receiverName?: string;
@IsString()
@MaxLength(32)
province: string;
@IsString()
@MaxLength(32)
city: string;
@IsString()
@MaxLength(32)
district: string;
@IsString()
@MaxLength(256)
addressDetail: string;
@IsString()
@IsNotEmpty()
productId: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
promoCodeId?: string;
}
@@ -5,6 +5,11 @@ import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
PartnerProxyOrderCreateDto,
PartnerProxyOrderPreviewDto,
PartnerProxyOrderSendSmsDto,
} from './dto/partner-proxy-order.dto';
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
@@ -106,3 +111,33 @@ export class PartnerReshipmentController {
return this.tradeService.listPartnerReshipments(user.actorId);
}
}
@Controller('partner/proxy-orders')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerProxyOrderController {
constructor(private readonly tradeService: TradeService) {}
@Get('options')
options() {
return this.tradeService.getPartnerProxyOrderOptions();
}
@Post('preview')
preview(@Body() dto: PartnerProxyOrderPreviewDto) {
return this.tradeService.previewPartnerProxyOrder(dto);
}
@Post('send-sms')
sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
}
@Post()
create(
@CurrentUser() user: AuthUser,
@Body() dto: PartnerProxyOrderCreateDto,
@Req() req: Request,
) {
return this.tradeService.createPartnerProxyOrder(user.actorId, dto, req);
}
}
@@ -6,12 +6,32 @@ import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { PromoModule } from '../promo/promo.module';
import {
TradeController,
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
} from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
imports: [
IntegrationsModule,
IamModule,
CatalogModule,
AnalyticsModule,
CityScopeModule,
PromoModule,
forwardRef(() => BenefitModule),
CommonModule,
],
controllers: [
TradeController,
PartnerOrderController,
PartnerProxyOrderController,
PartnerReshipmentController,
],
providers: [TradeService],
exports: [TradeService],
})
@@ -11,13 +11,15 @@ import {
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { loadAppConfig, ClientApp, SmsScene, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -41,6 +43,8 @@ export class TradeService {
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly authService: AuthService,
private readonly promoCodeService: PromoCodeService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -584,4 +588,225 @@ export class TradeService {
});
});
}
async getPartnerProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
this.catalogService.listProducts(),
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,
})),
promoCodes,
});
}
async previewPartnerProxyOrder(body: {
productId: string;
quantity: number;
receiverCity?: string;
receiverDistrict?: string;
}) {
const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
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 check = validateMinPurchase(
deliveryType,
body.quantity,
city.localMinQty,
city.crossMinQty,
);
if (!check.ok) throw new BadRequestException(check.message);
const unitPrice = Number(product.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
return {
productAmount,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
deliveryType,
unitPrice,
};
}
async sendPartnerProxyOrderSms(phone: string) {
const normalizedPhone = phone.trim();
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, {
clientApp: ClientApp.PARTNER_H5,
});
const masked =
normalizedPhone.length >= 7
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
: normalizedPhone;
return { ok: true, maskedPhone: masked };
}
async createPartnerProxyOrder(
partnerAccountId: bigint,
body: {
phone: string;
smsCode: string;
receiverName?: string;
province: string;
city: string;
district: string;
addressDetail: string;
productId: string;
quantity: number;
promoCodeId?: string;
},
req: Request,
) {
const normalizedPhone = body.phone.trim();
await this.authService.verifySmsCode(
normalizedPhone,
body.smsCode.trim(),
SmsScene.PARTNER_PROXY_ORDER,
);
const user = await this.authService.findOrCreateUserByPhone(normalizedPhone);
const preview = await this.previewPartnerProxyOrder({
productId: body.productId,
quantity: body.quantity,
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' } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district);
let promoCodeId: bigint | undefined;
if (body.promoCodeId?.trim()) {
promoCodeId = BigInt(body.promoCodeId.trim());
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)),
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: body.province,
receiverCity: body.city,
receiverDistrict: body.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
paidAt: now,
shippedAt: now,
completedAt: now,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
remark: `合伙人代下单 partnerAccountId=${primary.id}`,
},
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: 'PARTNER_PROXY',
remark: '合伙人线下代下单',
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
await this.benefitService.grantOnOrderPaid(order.id);
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_proxy_order_create',
refType: 'ORDER',
refId: order.id,
extraJson: {
orderId: order.id.toString(),
userId: user.id.toString(),
productId: body.productId,
quantity: body.quantity,
promoCodeId: promoCodeId?.toString() ?? null,
},
});
return this.getPartnerOrder(partnerAccountId, order.id);
}
}