feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerAssocService } from './partner-assoc.service';
|
||||
|
||||
class BindPartnerAssocDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scene?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
class PartnerAssocRemarkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
@Controller('user/partner-assoc')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserPartnerAssocController {
|
||||
constructor(private readonly assoc: PartnerAssocService) {}
|
||||
|
||||
@Post('bind')
|
||||
bind(@CurrentUser() user: AuthUser, @Body() dto: BindPartnerAssocDto) {
|
||||
return this.assoc.bindUser(user.actorId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/assoc')
|
||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||
export class PartnerAssocController {
|
||||
constructor(private readonly assoc: PartnerAssocService) {}
|
||||
|
||||
@Get()
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.assoc.getSummary(user.actorId);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
stats(@CurrentUser() user: AuthUser) {
|
||||
return this.assoc.getStats(user.actorId);
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
assocOrders(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listAssocOrders(user.actorId, Number(page) || 1, Number(pageSize) || 20);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
users(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
const allowed = sort === 'createdAt' || sort === 'boundAt' || sort === 'orderCount' ? sort : undefined;
|
||||
return this.assoc.listUsers(user.actorId, Number(page) || 1, Number(pageSize) || 20, true, {
|
||||
keyword,
|
||||
sort: allowed,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('users/:userId/orders')
|
||||
userOrders(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('userId') userId: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listAssocOrders(
|
||||
user.actorId,
|
||||
Number(page) || 1,
|
||||
Number(pageSize) || 20,
|
||||
BigInt(userId),
|
||||
);
|
||||
}
|
||||
|
||||
@Put('users/:userId/remark')
|
||||
setRemark(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: PartnerAssocRemarkDto,
|
||||
) {
|
||||
return this.assoc.setUserRemark(user.actorId, BigInt(userId), dto.remark);
|
||||
}
|
||||
|
||||
@Get('qrcode')
|
||||
async qrcode(@CurrentUser() user: AuthUser, @Res() res: Response) {
|
||||
const { buffer, fileName } = await this.assoc.getQrcodeBuffer(user.actorId);
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/commissions')
|
||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||
export class PartnerCommissionController {
|
||||
constructor(private readonly assoc: PartnerAssocService) {}
|
||||
|
||||
@Get('orders')
|
||||
orders(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.assoc.listCommissionOrders(user.actorId, Number(page) || 1, Number(pageSize) || 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
|
||||
const ASSOC_SCENE_PREFIX = 'pa_';
|
||||
|
||||
function maskPhoneNumber(phone: string | null) {
|
||||
if (!phone || phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function dayBounds(now = new Date()) {
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return { todayStart, monthStart };
|
||||
}
|
||||
|
||||
export function parseAssocScene(raw?: string | null): string | null {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return null;
|
||||
if (s.startsWith(ASSOC_SCENE_PREFIX)) {
|
||||
const id = s.slice(ASSOC_SCENE_PREFIX.length);
|
||||
return /^\d+$/.test(id) ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PartnerAssocService {
|
||||
private readonly logger = new Logger(PartnerAssocService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
) {}
|
||||
|
||||
async bindUser(userId: bigint, input: { scene?: string; partnerId?: string }) {
|
||||
const partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
|
||||
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
|
||||
throw new BadRequestException('关联码无效');
|
||||
}
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(BigInt(partnerIdRaw));
|
||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('合伙人不存在或已停用');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
|
||||
if (user.assocPartnerAccountId) {
|
||||
if (user.assocPartnerAccountId === primary.id) {
|
||||
return {
|
||||
bound: true,
|
||||
alreadyBound: true,
|
||||
partnerId: primary.id.toString(),
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
throw new BadRequestException('您已关联其他合伙人,无法更换');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
assocPartnerAccountId: primary.id,
|
||||
assocBoundAt: new Date(),
|
||||
...(user.sourceType === 'ORGANIC'
|
||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bound: true,
|
||||
alreadyBound: false,
|
||||
partnerId: primary.id.toString(),
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
|
||||
async unbindUser(userId: bigint) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { assocPartnerAccountId: null, assocBoundAt: null },
|
||||
});
|
||||
return { ok: true, partnerId: null, partnerName: null };
|
||||
}
|
||||
|
||||
/** HQ 改绑:可换绑或清空;不影响已支付订单快照 */
|
||||
async setUserAssoc(userId: bigint, partnerAccountId: bigint | null) {
|
||||
if (!partnerAccountId) {
|
||||
return this.unbindUser(userId);
|
||||
}
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('合伙人不存在或已停用');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
assocPartnerAccountId: primary.id,
|
||||
assocBoundAt: new Date(),
|
||||
...(user.sourceType === 'ORGANIC'
|
||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
partnerId: primary.id.toString(),
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
|
||||
async getSummary(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const ensured = await this.ensureQrcode(primary.id);
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { assocPartnerAccountId: primary.id },
|
||||
});
|
||||
return {
|
||||
partnerId: primary.id.toString(),
|
||||
qrcodeUrl: ensured.qrcodeUrl,
|
||||
userCount,
|
||||
companyName: primary.companyName,
|
||||
name: primary.name,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const { todayStart, monthStart } = dayBounds();
|
||||
const userWhere = { assocPartnerAccountId: primary.id };
|
||||
const orderWhere = {
|
||||
payStatus: 'PAID' as const,
|
||||
user: { assocPartnerAccountId: primary.id },
|
||||
};
|
||||
const [userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth] = await Promise.all([
|
||||
this.prisma.user.count({ where: userWhere }),
|
||||
this.prisma.user.count({ where: { ...userWhere, assocBoundAt: { gte: todayStart } } }),
|
||||
this.prisma.user.count({ where: { ...userWhere, assocBoundAt: { gte: monthStart } } }),
|
||||
this.prisma.order.count({ where: orderWhere }),
|
||||
this.prisma.order.count({ where: { ...orderWhere, paidAt: { gte: todayStart } } }),
|
||||
this.prisma.order.count({ where: { ...orderWhere, paidAt: { gte: monthStart } } }),
|
||||
]);
|
||||
return { userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth };
|
||||
}
|
||||
|
||||
async listUsers(
|
||||
partnerAccountId: bigint,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
maskPhone = false,
|
||||
opts: { keyword?: string; sort?: 'createdAt' | 'boundAt' | 'orderCount' } = {},
|
||||
) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const keyword = opts.keyword?.trim();
|
||||
const where: Prisma.UserWhereInput = { assocPartnerAccountId: primary.id };
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ userNo: { contains: keyword } },
|
||||
{ nickname: { contains: keyword } },
|
||||
{ phone: { contains: keyword } },
|
||||
{
|
||||
partnerNotes: {
|
||||
some: { partnerAccountId: primary.id, remark: { contains: keyword } },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
const sort = opts.sort ?? 'boundAt';
|
||||
const orderBy: Prisma.UserOrderByWithRelationInput =
|
||||
sort === 'createdAt'
|
||||
? { createdAt: 'desc' }
|
||||
: sort === 'orderCount'
|
||||
? { orders: { _count: 'desc' } }
|
||||
: { assocBoundAt: 'desc' };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
nickname: true,
|
||||
hqRemark: true,
|
||||
phone: true,
|
||||
createdAt: true,
|
||||
assocBoundAt: true,
|
||||
partnerNotes: {
|
||||
where: { partnerAccountId: primary.id },
|
||||
select: { remark: true },
|
||||
take: 1,
|
||||
},
|
||||
_count: { select: { orders: { where: { payStatus: 'PAID' } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((u) => ({
|
||||
id: u.id.toString(),
|
||||
userNo: u.userNo,
|
||||
nickname: u.nickname,
|
||||
...(maskPhone
|
||||
? { partnerRemark: u.partnerNotes[0]?.remark ?? null }
|
||||
: { hqRemark: u.hqRemark }),
|
||||
phone: maskPhone ? maskPhoneNumber(u.phone) : u.phone,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
boundAt: u.assocBoundAt?.toISOString() ?? '',
|
||||
orderCount: u._count.orders,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async listAssocOrders(partnerAccountId: bigint, page = 1, pageSize = 20, userId?: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (userId) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assocPartnerAccountId: true },
|
||||
});
|
||||
if (!user || user.assocPartnerAccountId !== primary.id) {
|
||||
throw new NotFoundException('用户未关联本合伙人');
|
||||
}
|
||||
}
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
payStatus: 'PAID',
|
||||
user: { assocPartnerAccountId: primary.id },
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { paidAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
productName: true,
|
||||
quantity: true,
|
||||
payAmount: true,
|
||||
paidAt: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
payAmount: Number(o.payAmount),
|
||||
paidAt: o.paidAt?.toISOString() ?? null,
|
||||
status: o.status,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async setUserRemark(partnerAccountId: bigint, userId: bigint, remark?: string | null) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assocPartnerAccountId: true },
|
||||
});
|
||||
if (!user || user.assocPartnerAccountId !== primary.id) {
|
||||
throw new NotFoundException('用户未关联本合伙人');
|
||||
}
|
||||
const text = remark?.trim() || '';
|
||||
if (!text) {
|
||||
await this.prisma.partnerUserNote.deleteMany({
|
||||
where: { partnerAccountId: primary.id, userId },
|
||||
});
|
||||
return { ok: true, remark: null };
|
||||
}
|
||||
if (text.length > 128) {
|
||||
throw new BadRequestException('备注最多 128 字');
|
||||
}
|
||||
const row = await this.prisma.partnerUserNote.upsert({
|
||||
where: { partnerAccountId_userId: { partnerAccountId: primary.id, userId } },
|
||||
create: { partnerAccountId: primary.id, userId, remark: text },
|
||||
update: { remark: text },
|
||||
});
|
||||
return { ok: true, remark: row.remark };
|
||||
}
|
||||
|
||||
async listCommissionOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const where = { partnerAccountIdAtPay: primary.id, payStatus: 'PAID' as const };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { paidAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { user: { select: { phone: true } } },
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((o) => {
|
||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||
return {
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
payAmount: Number(o.payAmount),
|
||||
rate,
|
||||
commission: Math.round(Number(o.payAmount) * rate * 100) / 100,
|
||||
paidAt: o.paidAt?.toISOString() ?? null,
|
||||
userPhone: o.user.phone,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async ensureQrcode(partnerAccountId: bigint, force = false) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!force && primary.assocQrcodeResourceId) {
|
||||
const resource = await this.prisma.commonResource.findUnique({
|
||||
where: { id: primary.assocQrcodeResourceId },
|
||||
});
|
||||
if (resource?.url) {
|
||||
return { qrcodeId: primary.assocQrcodeId, qrcodeUrl: resource.url };
|
||||
}
|
||||
}
|
||||
|
||||
const scene = `${ASSOC_SCENE_PREFIX}${primary.id.toString()}`;
|
||||
if (scene.length > 32) {
|
||||
throw new BadRequestException('合伙人 ID 过长,无法写入小程序码');
|
||||
}
|
||||
const page = (process.env.WX_MINI_PROMO_PAGE || 'pages/home/index').replace(/^\//, '');
|
||||
let pngBuffer: Buffer;
|
||||
try {
|
||||
pngBuffer = await this.wechat.getWxaCodeUnlimited({
|
||||
scene,
|
||||
page,
|
||||
width: 430,
|
||||
checkPath: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`assoc qrcode failed partner=${primary.id}: ${err instanceof Error ? err.message : err}`);
|
||||
throw new BadRequestException('生成关联码失败,请稍后重试');
|
||||
}
|
||||
|
||||
const fileName = `partner-assoc-${primary.id}.png`;
|
||||
const uploaded = await this.oss.putObject({
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
fileName,
|
||||
buffer: pngBuffer,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'PARTNER',
|
||||
ownerId: primary.id,
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: uploaded.bucket,
|
||||
ossKey: uploaded.ossKey,
|
||||
url: uploaded.url,
|
||||
fileName,
|
||||
fileSize: BigInt(pngBuffer.length),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
});
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: primary.id },
|
||||
data: { assocQrcodeId: scene, assocQrcodeResourceId: resource.id },
|
||||
});
|
||||
return { qrcodeId: scene, qrcodeUrl: resource.url };
|
||||
}
|
||||
|
||||
async getQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string }> {
|
||||
const summary = await this.getSummary(partnerAccountId);
|
||||
if (!summary.qrcodeUrl) {
|
||||
throw new NotFoundException('关联码尚未生成');
|
||||
}
|
||||
const res = await fetch(summary.qrcodeUrl);
|
||||
if (!res.ok) throw new BadRequestException('下载关联码失败');
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
return { buffer, fileName: `partner-assoc-${summary.partnerId}.png` };
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
ShopStoreInfoChangeController,
|
||||
} from './store-info-change.controller';
|
||||
import { StoreInfoChangeService } from './store-info-change.service';
|
||||
import { PartnerAssocService } from './partner-assoc.service';
|
||||
import {
|
||||
PartnerAssocController,
|
||||
PartnerCommissionController,
|
||||
UserPartnerAssocController,
|
||||
} from './partner-assoc.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -56,8 +62,11 @@ import { StoreInfoChangeService } from './store-info-change.service';
|
||||
PartnerStoreInfoChangeController,
|
||||
ShopStoreInfoChangeController,
|
||||
AdminStoreInfoChangeController,
|
||||
UserPartnerAssocController,
|
||||
PartnerAssocController,
|
||||
PartnerCommissionController,
|
||||
],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService, PartnerAssocService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService, PartnerAssocService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
@@ -184,7 +184,7 @@ export class StoreService {
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: where as never,
|
||||
include: {
|
||||
category: true,
|
||||
category: { include: { parent: true } },
|
||||
coverResource: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -206,8 +206,21 @@ export class StoreService {
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
sortOrder?: number;
|
||||
redeemCount: number;
|
||||
};
|
||||
|
||||
const redeemGroups =
|
||||
visible.length === 0
|
||||
? []
|
||||
: await this.prisma.redeemRecord.groupBy({
|
||||
by: ['storeId'],
|
||||
where: { storeId: { in: visible.map((s) => s.id) } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const redeemCountByStore = new Map(
|
||||
redeemGroups.map((g) => [g.storeId.toString(), g._count._all]),
|
||||
);
|
||||
|
||||
const items: StoreListItem[] = [];
|
||||
for (const store of visible) {
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
@@ -224,7 +237,11 @@ export class StoreService {
|
||||
hasUser && coords
|
||||
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
||||
: null;
|
||||
items.push({ ...mapped, distanceMeters });
|
||||
items.push({
|
||||
...mapped,
|
||||
distanceMeters,
|
||||
redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
@@ -245,7 +262,7 @@ export class StoreService {
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id, status: 'OPEN' },
|
||||
include: {
|
||||
category: true,
|
||||
category: { include: { parent: true } },
|
||||
coverResource: true,
|
||||
},
|
||||
});
|
||||
@@ -256,14 +273,17 @@ export class StoreService {
|
||||
throw new NotFoundException('门店不存在');
|
||||
}
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const packageRows = await this.prisma.storePackage.findMany({
|
||||
where: { storeId: id },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const [media, packageRows, redeemCount] = await Promise.all([
|
||||
this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
}),
|
||||
this.prisma.storePackage.findMany({
|
||||
where: { storeId: id },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: id } }),
|
||||
]);
|
||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||
return serializeBigInt(
|
||||
mapStoreCompat(
|
||||
@@ -271,6 +291,7 @@ export class StoreService {
|
||||
...rest,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
redeemCount,
|
||||
media,
|
||||
packages: packageRows.map((p) => {
|
||||
const imageUrls = normalizeStorePackageImageUrls({
|
||||
|
||||
Reference in New Issue
Block a user