Merge commit 'fcf1d45522e3ba6e6faf2590b60f2db1ef90e73c' into dev_jacy

This commit is contained in:
2026-07-07 13:55:10 +08:00
85 changed files with 11210 additions and 164 deletions
+8 -1
View File
@@ -20,11 +20,18 @@ ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 且 WECHAT_AUTH_ENABLED=true。
MOCK_WECHAT=true
# C 端 H5 落地页(推广码二维码链接前缀)
USER_H5_URL=http://localhost:5173
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
# 微信SDKWECHAT_AUTH_ENABLED=true 时生效
# 微信 SDK生产:WECHAT_AUTH_ENABLED=true,配置 WX_APP_ID / WX_APP_SECRET
# OAuth 授权页由 /common/wechat/oauth-url 生成;C/合伙人/总部 H5 均须在微信内置浏览器内授权。
WX_APP_ID=
WX_APP_SECRET=
WECHAT_AUTH_ENABLED=false
+16
View File
@@ -228,6 +228,22 @@ async function main() {
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKHQ001',
name: '总部品鉴会',
status: 'ACTIVE',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKDEMO1',
name: '郑州品鉴会演示',
status: 'ACTIVE',
},
});
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
@@ -9,6 +9,7 @@ import { PayWechatProvider } from './pay/pay.wechat.provider';
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
import { WechatApiProvider } from './wechat/wechat.api.provider';
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
import { WechatMockProvider } from './wechat/wechat.mock.provider';
import { OssMockProvider } from './oss/oss.mock.provider';
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
import { TencentLbsProvider } from './map/tencent-lbs.provider';
@@ -47,16 +48,24 @@ import type { ISmsProvider } from './sms/sms.interface';
},
WechatApiProvider,
WechatDisabledProvider,
WechatMockProvider,
{
provide: WECHAT_PROVIDER,
useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => {
useFactory: (
api: WechatApiProvider,
disabled: WechatDisabledProvider,
mock: WechatMockProvider,
): IWechatProvider => {
const cfg = loadAppConfig();
const enabled =
(cfg.wechatAuthEnabled || cfg.wechatPayEnabled) &&
(!!cfg.wxAppId || !!process.env.WX_MCH_ID);
return enabled ? api : disabled;
if (enabled) return api;
// preV1:真实微信未配置但开启 Mock 授权登录
if (cfg.mockWechat) return mock;
return disabled;
},
inject: [WechatApiProvider, WechatDisabledProvider],
inject: [WechatApiProvider, WechatDisabledProvider, WechatMockProvider],
},
PayMockProvider,
PayWechatProvider,
@@ -39,6 +39,10 @@ export class WechatApiProvider implements IWechatProvider {
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
}
isMock() {
return false;
}
isPayEnabled() {
return (
this.config.wechatPayEnabled &&
@@ -7,6 +7,10 @@ export class WechatDisabledProvider implements IWechatProvider {
return false;
}
isMock() {
return false;
}
isPayEnabled() {
return false;
}
@@ -31,6 +31,9 @@ export type WechatPayNotifyResult = {
export interface IWechatProvider {
isEnabled(): boolean;
/** 是否为 preV1 Mock 实现(登录时可回落到演示账号) */
isMock(): boolean;
/** 微信支付是否已配置(商户号 + 证书) */
isPayEnabled(): boolean;
@@ -0,0 +1,84 @@
import { createHash } from 'crypto';
import { Injectable, NotImplementedException } from '@nestjs/common';
import type {
IWechatProvider,
WechatCodeSession,
WechatOAuthSession,
} from './wechat.interface';
/**
* preV1 Mock 微信 Provider。
*
* 目的:让「微信授权登录」按钮在不接真实微信的情况下走通。前端仍按真实 OAuth 流程
* (跳转 oauth-url → 回调携带 code),Mock 端将授权 URL 直接回跳并返回稳定 openId。
* 后续填入 WX_APP_ID/WX_APP_SECRET 并置 MOCK_WECHAT=false 即切换到真实实现。
*/
@Injectable()
export class WechatMockProvider implements IWechatProvider {
isEnabled() {
return true;
}
isMock() {
return true;
}
isPayEnabled() {
return false;
}
getMchId() {
return '';
}
/** 由 code 派生稳定 openId,保证同一 code 多次授权指向同一账号 */
private openIdFromCode(code: string): string {
return `mockwx_${createHash('md5').update(code).digest('hex').slice(0, 24)}`;
}
async code2Session(code: string): Promise<WechatCodeSession> {
return { openId: this.openIdFromCode(code), sessionKey: 'mock-session-key' };
}
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
return { openId: this.openIdFromCode(code), accessToken: 'mock-access-token' };
}
async fetchOAuthUserInfo(accessToken: string, openId: string) {
return {
openId,
nickname: 'Mock微信用户',
headImgUrl: undefined,
};
}
async createJssdkConfig(url: string) {
return {
appId: 'mock-appid',
timestamp: Math.floor(Date.now() / 1000),
nonceStr: 'mocknonce',
signature: 'mocksignature',
url,
jsApiList: ['getLocation', 'scanQRCode', 'chooseImage'],
} as unknown as Awaited<ReturnType<IWechatProvider['createJssdkConfig']>>;
}
/** 直接把授权链接回跳到 redirectUri 并附带 mock code,模拟微信授权完成 */
buildOAuthUrl(redirectUri: string, state: string): string {
const sep = redirectUri.includes('?') ? '&' : '?';
const code = `mockcode_${state || 'default'}`;
return `${redirectUri}${sep}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
}
async getPhoneNumberByCode(): Promise<string> {
throw new NotImplementedException('Mock 微信不支持获取手机号,请用短信绑定');
}
createJsapiPrepay(): never {
throw new NotImplementedException('FEATURE_DISABLED');
}
parsePayNotification(): never {
throw new NotImplementedException('FEATURE_DISABLED');
}
}
@@ -1,15 +1,30 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.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')
@UseGuards(JwtAuthGuard)
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('events')
@UseGuards(JwtAuthGuard)
track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record<string, unknown> }> }) {
return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
}
}
@Controller('promo')
export class PromoController {
constructor(private readonly analyticsService: AnalyticsService) {}
@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);
}
}
@@ -1,11 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [forwardRef(() => IamModule)],
controllers: [AnalyticsController],
imports: [IamModule],
controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
export type TrackEventInput = {
eventName: string;
@@ -84,4 +85,42 @@ 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,
});
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class PromoTouchDto {
@IsString()
@IsNotEmpty()
promoCode: string;
}
@@ -10,6 +10,7 @@ export class ClientConfigController {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
mockSms: cfg.mockSms,
mockWechat: cfg.mockWechat,
};
}
}
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginPasswordDto, LoginSmsDto, SendSmsDto } from './dto/auth.dto';
import { LoginPasswordDto, LoginSmsDto, LoginWechatDto, SendSmsDto } from './dto/auth.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
@@ -25,6 +25,11 @@ export class AdminAuthController {
return this.authService.loginHqPassword(dto.loginName, dto.password, ClientApp.HQ_WEB);
}
@Post('login/wechat')
wechatLogin(@Body() dto: LoginWechatDto) {
return this.authService.loginHqWechat(dto.code, ClientApp.HQ_WEB, dto.platform ?? 'h5');
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
@@ -567,6 +567,47 @@ export class AuthService {
});
}
async loginHqWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let account = await this.prisma.hqAccount.findFirst({
where: { wxOpenId: session.openId },
});
if (!account && this.wechatProvider.isMock()) {
account = await this.prisma.hqAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ adminRole: 'asc' }, { id: 'asc' }],
});
}
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
account = await this.prisma.hqAccount.update({
where: { id: account.id },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
});
return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, {
id: account.id.toString(),
phone: account.phone,
name: account.name,
adminRole: account.adminRole,
status: account.status,
});
}
async getMe(actorType: string, actorId: bigint) {
if (actorType === 'USER') {
const user = await this.assertActiveUser(actorId);
@@ -993,6 +1034,15 @@ export class AuthService {
include: { partner: true },
});
if (!account && this.wechatProvider.isMock()) {
// preV1 Mock:无绑定微信时回落到演示主账号,方便一键授权登录
account = await this.prisma.partnerAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
include: { partner: true },
});
}
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
@@ -0,0 +1,36 @@
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);
}
}
@@ -0,0 +1,133 @@
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').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 };
}
}
@@ -650,3 +650,19 @@ export class UpdateProductDetailTemplateDto {
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class CreatePromoCodeDto {
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
code?: string;
}
export class UpdatePromoCodeStatusDto {
@IsString()
@IsIn(['ACTIVE', 'DISABLED'])
status: 'ACTIVE' | 'DISABLED';
}
@@ -340,3 +340,17 @@ export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsString()
mediaType?: string;
}
export class AdminPromoCodesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
@@ -29,6 +29,8 @@ 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';
@@ -66,6 +68,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
AdminPromoCodesController,
],
providers: [
AdminDashboardService,
@@ -86,6 +89,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
AdminPromoCodesService,
SuperAdminGuard,
],
})
@@ -118,46 +118,65 @@ export class TradeService {
body.clientLocation,
);
const order = await this.prisma.order.create({
data: {
orderNo,
userId,
cityId: city.id,
status: 'PENDING_PAY',
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
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,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
receiverProvince: address.province,
receiverCity: address.city,
receiverDistrict: address.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
gpsProvince: location.gpsProvince,
gpsCity: location.gpsCity,
gpsDistrict: location.gpsDistrict,
gpsLatitude: location.gpsLatitude,
gpsLongitude: location.gpsLongitude,
gpsAddress: location.gpsAddress,
freightAmount: preview.freightAmount,
freightPayType: preview.freightPayType,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
payExpireAt,
},
include: { product: true, imageResource: true },
const attribution = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
include: { promoCode: true },
});
const promoCodeId =
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNo,
userId,
cityId: city.id,
status: 'PENDING_PAY',
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
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,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
receiverProvince: address.province,
receiverCity: address.city,
receiverDistrict: address.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
gpsProvince: location.gpsProvince,
gpsCity: location.gpsCity,
gpsDistrict: location.gpsDistrict,
gpsLatitude: location.gpsLatitude,
gpsLongitude: location.gpsLongitude,
gpsAddress: location.gpsAddress,
freightAmount: preview.freightAmount,
freightPayType: preview.freightPayType,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
payExpireAt,
promoCodeId,
},
include: { product: true, imageResource: true },
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
this.analyticsService.trackOneSafe(userId, 'USER_H5', {