代下单功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
@@ -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[]
|
||||
|
||||
@@ -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',
|
||||
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user