Files
dukang/server/dukang-api/src/modules/store/store.service.ts
T
jacy 34a1897c09 feat(store): add store sortOrder and swipeable package gallery
Enable HQ-controlled store list ordering and horizontal multi-image browsing on package detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:58:38 +08:00

1395 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
import { parseBigIntParam } from '../../common/parse-bigint';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
/** 选填文案:空 / null / "null" 存库为 null,避免 String(null)==="null" */
function normalizeOptionalTextField(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
return s;
}
export type StoreViewer = {
/** C 端用户手机号;无则无法看到白名单门店 */
phone?: string | null;
/** 运营/代下单等场景跳过白名单 */
bypassWhitelist?: boolean;
};
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return null;
if (kind === 'lat' && (n < -90 || n > 90)) return null;
if (kind === 'lng' && (n < -180 || n > 180)) return null;
return n;
}
@Injectable()
export class StoreService {
private readonly config = loadAppConfig();
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly authService: AuthService,
private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
private storeAddressText(store: {
province?: string | null;
cityName?: string | null;
district?: string | null;
address?: string | null;
}) {
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
}
/** 缺坐标时用地址正向地理编码并回写 */
private async ensureStoreCoordinates(store: {
id: bigint;
latitude?: unknown;
longitude?: unknown;
province?: string | null;
cityName?: string | null;
district?: string | null;
address?: string | null;
}): Promise<{ latitude: number; longitude: number } | null> {
const lat = store.latitude != null ? Number(store.latitude) : NaN;
const lng = store.longitude != null ? Number(store.longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
return { latitude: lat, longitude: lng };
}
const address = this.storeAddressText(store);
if (!address) return null;
const geo = await this.tencentLbs.geocodeAddress(address, {
refType: 'STORE',
refId: store.id,
});
if (!geo) return null;
await this.prisma.store.update({
where: { id: store.id },
data: { latitude: geo.latitude, longitude: geo.longitude },
});
return { latitude: geo.latitude, longitude: geo.longitude };
}
async resolveUserPhone(userId: bigint): Promise<string | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { phone: true },
});
return user?.phone ?? null;
}
isVisibleToViewer(
store: { visibilityWhitelistEnabled: boolean },
viewer?: StoreViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!store.visibilityWhitelistEnabled) return true;
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listOpenStores(
cityCode?: string,
userLat?: number,
userLng?: number,
viewer?: StoreViewer,
) {
const where: Record<string, unknown> = { status: 'OPEN' };
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
if (city) where.cityId = city.id;
}
const stores = await this.prisma.store.findMany({
where: where as never,
include: {
category: true,
coverResource: true,
},
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones));
const hasUser =
userLat != null &&
userLng != null &&
Number.isFinite(userLat) &&
Number.isFinite(userLng);
type StoreListItem = ReturnType<typeof mapStoreCompat> & {
distanceMeters: number | null;
latitude?: unknown;
longitude?: unknown;
sortOrder?: number;
};
const items: StoreListItem[] = [];
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
});
const distanceMeters =
hasUser && coords
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
: null;
items.push({ ...mapped, distanceMeters });
}
items.sort((a, b) => {
const so = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
if (so !== 0) return so;
if (hasUser) {
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
return da - db;
}
return 0;
});
return serializeBigInt(items);
}
async getStore(id: bigint, viewer?: StoreViewer) {
const store = await this.prisma.store.findFirst({
where: { id, status: 'OPEN' },
include: {
category: true,
coverResource: true,
},
});
const whitelistPhones = store?.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) {
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 { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,
packages: packageRows.map((p) => {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: p.imageUrl,
imageUrls: p.imageUrls,
});
return {
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null,
sortOrder: p.sortOrder,
};
}),
}),
);
}
private async resolvePartnerScope(actorAccountId: bigint) {
const account = await this.getPartnerAccount(actorAccountId);
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
return { account, primaryId };
}
async partnerListStores(partnerAccountId: bigint) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId };
if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) return [];
where.id = { in: storeIds };
}
const stores = await this.prisma.store.findMany({
where,
include: { category: true, coverResource: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return serializeBigInt(stores.map(mapStoreCompat));
}
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
if (this.isSubAccount(account)) {
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
const media = await this.loadPartnerStoreMedia(storeId);
return serializeBigInt(mapStoreCompat({ ...store, media }));
}
async partnerListCities(partnerAccountId: bigint) {
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const cityWhere = await this.partnerCityService.buildPartnerCityWhere(primaryId);
const cities = await this.prisma.commonCity.findMany({
where: cityWhere,
select: { id: true, name: true, code: true, province: true },
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(cities);
}
async partnerCheckStorePhone(phone: string) {
const normalizedPhone = phone.trim();
if (!normalizedPhone) {
return { available: false, message: '请填写联系电话' };
}
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
return { available: false, message: '联系电话须为11位手机号' };
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
include: { _count: { select: { bindings: true } } },
});
if (!existingAccount) {
return { available: true, existingStoreCount: 0, needConfirm: false };
}
if (existingAccount.isPrimary !== 1) {
return { available: false, message: '该手机号已是门店子账号,不可作为负责人' };
}
if (existingAccount.status !== 'ACTIVE') {
return { available: false, message: '该手机号对应门店账号已停用' };
}
const existingStoreCount = existingAccount._count.bindings;
return {
available: true,
existingStoreCount,
needConfirm: existingStoreCount > 0,
message:
existingStoreCount > 0
? `该手机号已是门店主账号(已绑 ${existingStoreCount} 家店),确认后将追加绑定新店`
: undefined,
};
}
async sendPartnerStorePhoneSms(phone: string) {
const check = await this.partnerCheckStorePhone(phone);
if (!check.available) {
throw new BadRequestException(check.message ?? '该手机号不可用于门店账号');
}
return this.authService.sendSms(phone.trim(), SmsScene.PARTNER_STORE_OPEN, {
clientApp: ClientApp.PARTNER_H5,
}).then(() => {
const normalizedPhone = phone.trim();
const masked =
normalizedPhone.length >= 7
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
: normalizedPhone;
return { ok: true, maskedPhone: masked };
});
}
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const normalizedPhone = String(body.phone).trim();
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
if (!coverUrl) throw new BadRequestException('请上传门头照');
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
if (!contractUrl) throw new BadRequestException('请上传签约合同');
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
if (!body.categoryId) {
throw new BadRequestException('请选择店铺类型');
}
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const openTime = body.openTime ? String(body.openTime).trim() : '10:00';
const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00';
const openTime2 = body.openTime2 ? String(body.openTime2).trim() : '';
const closeTime2 = body.closeTime2 ? String(body.closeTime2).trim() : '';
const hoursCheck = validateBusinessHours([
{ open: openTime, close: closeTime },
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
]);
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
const avgPriceRaw = body.avgPrice != null && body.avgPrice !== '' ? Number(body.avgPrice) : null;
if (avgPriceRaw != null && (Number.isNaN(avgPriceRaw) || avgPriceRaw < 0)) {
throw new BadRequestException('人均费用须为非负数字');
}
const introRaw = body.intro != null ? String(body.intro).trim() : '';
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule);
if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const latitude = parseOptionalCoord(body.latitude, 'lat');
const longitude = parseOptionalCoord(body.longitude, 'lng');
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException('经纬度须同时提供');
}
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerAccountId: primaryId,
categoryId,
name: String(body.name),
phone: normalizedPhone,
province: String(body.province ?? city.province ?? '河南省'),
cityName: String(body.city ?? city.name ?? '郑州市'),
district: String(body.district ?? ''),
address: String(body.address),
intro: introRaw || null,
benefitUsageRule: benefitUsageRuleRaw,
avgPrice: avgPriceRaw,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
auditedAt: this.config.autoApproveStore ? new Date() : null,
rejectReason: null,
},
});
if (latitude == null || longitude == null) {
await this.ensureStoreCoordinates(store);
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
if (coverUrl) {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket,
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
}
for (let i = 0; i < envPhotoUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket,
ossKey: envPhotoUrls[i],
url: envPhotoUrls[i],
sortOrder: i,
},
});
}
if (contractUrl) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'CONTRACT',
mediaType: 'FILE',
ossBucket,
ossKey: contractUrl,
url: contractUrl,
},
});
}
const audit = await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: store.id,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
param1: 'NEW',
param1Desc: 'audit_type',
extraJson: body as never,
},
});
void audit;
const existingPrimary = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (existingPrimary && existingPrimary.isPrimary === 1) {
await this.prisma.storeAccountStore.create({
data: { storeAccountId: existingPrimary.id, storeId: store.id },
});
if (bankAccountName || bankAccountNo || bankBranch) {
await this.prisma.storeAccount.update({
where: { id: existingPrimary.id },
data: {
...(bankAccountName != null ? { bankAccountName } : {}),
...(bankAccountNo != null ? { bankAccountNo } : {}),
...(bankBranch != null ? { bankBranch } : {}),
},
});
}
} else {
await this.prisma.storeAccount.create({
data: {
phone: normalizedPhone,
name: String(body.name),
isPrimary: 1,
bankAccountName,
bankAccountNo,
bankBranch,
bindings: { create: [{ storeId: store.id }] },
},
});
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primaryId,
eventName: 'partner_store_create',
refType: 'STORE',
refId: store.id,
extraJson: { storeName: store.name, phone: normalizedPhone },
});
return serializeBigInt({
store: mapStoreCompat({
...store,
coverResource: coverUrl ? { url: coverUrl } : null,
}),
});
}
async partnerUpdateStoreStatus(
partnerAccountId: bigint,
storeId: bigint,
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可变更状态');
}
if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
throw new BadRequestException('无效的门店状态');
}
if (status === 'OPEN') {
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店尚在总部审核中,通过后方可开门');
}
if (store.auditStatus === 'REJECTED') {
throw new BadRequestException(
store.rejectReason
? `门店审核未通过:${store.rejectReason}`
: '门店审核未通过,请查看驳回原因并重新提交',
);
}
}
const updated = await this.prisma.store.update({
where: { id: storeId },
data: { status },
include: { coverResource: true },
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primaryId,
eventName: 'partner_store_status_change',
refType: 'STORE',
refId: storeId,
extraJson: {
status,
previousStatus: store.status,
},
});
return serializeBigInt(mapStoreCompat(updated));
}
async partnerUpdateStoreBasic(
partnerAccountId: bigint,
storeId: bigint,
body: Record<string, unknown>,
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可编辑');
}
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
const name = body.name !== undefined ? String(body.name).trim() : undefined;
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
const address = body.address !== undefined ? String(body.address).trim() : undefined;
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
const benefitUsageRuleRaw =
body.benefitUsageRule !== undefined
? normalizeOptionalTextField(body.benefitUsageRule)
: undefined;
const latitude =
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
const longitude =
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
throw new BadRequestException('联系电话须为11位手机号');
}
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
if (
(latitude !== undefined || longitude !== undefined) &&
(latitude == null || longitude == null)
) {
throw new BadRequestException('经纬度须同时提供');
}
const resubmitAudit = store.auditStatus === 'REJECTED';
const hasCoordsUpdate = latitude != null && longitude != null;
const updated = await this.prisma.store.update({
where: { id: storeId },
data: {
...(name !== undefined ? { name } : {}),
...(phone !== undefined ? { phone } : {}),
...(address !== undefined
? hasCoordsUpdate
? { address }
: { address, latitude: null, longitude: null }
: {}),
...(hasCoordsUpdate ? { latitude, longitude } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(benefitUsageRuleRaw !== undefined
? { benefitUsageRule: benefitUsageRuleRaw }
: {}),
...(resubmitAudit
? {
auditStatus: 'PENDING' as const,
rejectReason: null,
auditedAt: null,
status: store.status === 'OPEN' ? ('PAUSED' as const) : store.status,
}
: {}),
},
});
if (address !== undefined && !hasCoordsUpdate) {
await this.ensureStoreCoordinates(updated);
}
if (resubmitAudit) {
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: storeId,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: 'PENDING',
param1: 'RESUBMIT',
param1Desc: 'audit_type',
remark: '合伙人修改资料后重新提交审核',
},
});
}
return this.partnerGetStore(partnerAccountId, storeId);
}
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(至少 3 张,不设上限) */
async partnerUpdateStoreMedia(
partnerAccountId: bigint,
storeId: bigint,
body: Record<string, unknown>,
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可编辑');
}
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
const hasEnv = body.envPhotoUrls !== undefined;
const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined;
if (coverUrl !== undefined && !coverUrl) {
throw new BadRequestException('请上传门头照');
}
if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) {
throw new BadRequestException('请上传至少 3 张环境照片');
}
if (coverUrl === undefined && envPhotoUrls === undefined) {
throw new BadRequestException('请至少更新门头照或环境照片');
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
if (coverUrl !== undefined) {
if (store.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: store.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket,
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.store.update({
where: { id: storeId },
data: { coverResourceId: cover.id },
});
}
}
if (envPhotoUrls !== undefined) {
await this.prisma.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
for (let i = 0; i < envPhotoUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket,
ossKey: envPhotoUrls[i],
url: envPhotoUrls[i],
sortOrder: i,
},
});
}
}
const resubmitAudit = store.auditStatus === 'REJECTED';
if (resubmitAudit) {
await this.prisma.store.update({
where: { id: storeId },
data: {
auditStatus: 'PENDING',
rejectReason: null,
auditedAt: null,
status: store.status === 'OPEN' ? 'PAUSED' : store.status,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: storeId,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: 'PENDING',
param1: 'RESUBMIT',
param1Desc: 'audit_type',
remark: '合伙人重新上传资料后重新提交审核',
},
});
}
return this.partnerGetStore(partnerAccountId, storeId);
}
async getShopStore(storeAccountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: { include: { category: true, coverResource: true } } },
});
return serializeBigInt(mapStoreCompat(binding.store));
}
async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true },
});
if (binding.store.status === 'CLOSED') {
throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态');
}
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
throw new BadRequestException(
binding.store.auditStatus === 'REJECTED'
? `门店审核未通过${binding.store.rejectReason ? `${binding.store.rejectReason}` : ''}`
: '门店尚在总部审核中,通过后方可营业',
);
}
const previousStatus = binding.store.status;
const store = await this.prisma.store.update({
where: { id: storeId },
data: { status },
});
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
storeId,
eventName: 'store_status_change',
extraJson: {
status,
previousStatus,
actor: 'STORE',
},
});
return serializeBigInt(store);
}
async partnerDashboard(partnerAccountId: bigint) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const primaryAccount = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: primaryId },
});
const orderCount = await this.prisma.order.count({
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
});
let storeWhere: { partnerAccountId: bigint; id?: { in: bigint[] } } = {
partnerAccountId: primaryId,
};
if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) {
return {
storeCount: 0,
orderCount,
companyName: primaryAccount.companyName ?? '',
recentStores: [],
pendingAuditCount: 0,
notifications: [],
};
}
storeWhere = { partnerAccountId: primaryId, id: { in: storeIds } };
}
const storeIdsForNotices = (
await this.prisma.store.findMany({
where: storeWhere,
select: { id: true },
})
).map((s) => s.id);
const [storeCount, recentStores, pendingAuditCount, recentNotices] = await Promise.all([
this.prisma.store.count({ where: storeWhere }),
this.prisma.store.findMany({
where: storeWhere,
select: { id: true, name: true, status: true, auditStatus: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
this.prisma.store.count({
where: { ...storeWhere, auditStatus: 'PENDING' },
}),
storeIdsForNotices.length === 0
? Promise.resolve([])
: this.prisma.commonEvent.findMany({
where: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: { in: storeIdsForNotices },
status: { in: ['APPROVED', 'REJECTED'] },
actorType: 'HQ',
},
orderBy: { createdAt: 'desc' },
take: 20,
}),
]);
const storeNameMap = new Map(
(
await this.prisma.store.findMany({
where: { id: { in: recentNotices.map((n) => n.refId) } },
select: { id: true, name: true },
})
).map((s) => [s.id.toString(), s.name]),
);
return {
storeCount,
orderCount,
companyName: primaryAccount.companyName ?? '',
recentStores: serializeBigInt(recentStores),
pendingAuditCount,
notifications: serializeBigInt(
recentNotices.map((n) => ({
id: n.id,
storeId: n.refId,
storeName: storeNameMap.get(n.refId.toString()) ?? '门店',
status: n.status,
remark: n.remark,
createdAt: n.createdAt,
title:
n.status === 'APPROVED'
? '门店审核已通过'
: n.status === 'REJECTED'
? '门店审核已驳回'
: '门店审核更新',
content:
n.status === 'APPROVED'
? `${storeNameMap.get(n.refId.toString()) ?? '门店'} 已通过总部审核,可开门营业`
: `${storeNameMap.get(n.refId.toString()) ?? '门店'} 未通过审核${n.remark ? `${n.remark}` : ''}`,
})),
),
};
}
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const accounts = await this.prisma.partnerAccount.findMany({
where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] },
orderBy: { id: 'asc' },
});
const { periodStart, periodEnd } = this.resolveLeaderboardPeriodRange(period);
const entries = await Promise.all(
accounts.map(async (row) => {
const totalStores = await this.prisma.commonEvent.count({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: row.id,
refType: 'STORE',
},
});
const periodStores =
period === 'total'
? totalStores
: await this.prisma.commonEvent.count({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: row.id,
refType: 'STORE',
createdAt: { gte: periodStart, lt: periodEnd },
},
});
const staffRole = row.staffRole as PartnerStaffRole | null;
const roleLabel =
staffRole != null
? PARTNER_STAFF_ROLE_LABELS[staffRole]
: row.isPrimary === 1
? PARTNER_STAFF_ROLE_LABELS.PARTNER
: PARTNER_STAFF_ROLE_LABELS.INTERNAL;
return {
accountId: row.id.toString(),
name: row.name,
staffRole: staffRole ?? undefined,
roleLabel,
totalStores,
periodStores,
isSelf: row.id === partnerAccountId,
};
}),
);
entries.sort((a, b) => {
if (b.periodStores !== a.periodStores) return b.periodStores - a.periodStores;
if (b.totalStores !== a.totalStores) return b.totalStores - a.totalStores;
return a.accountId.localeCompare(b.accountId);
});
const list = entries.map((entry, index) => ({
...entry,
rank: index + 1,
}));
const selfEntry = list.find((entry) => entry.isSelf);
let self: (typeof list)[number] & { beatPercent?: number } | undefined;
if (selfEntry) {
const below = list.filter((entry) => entry.rank > selfEntry.rank).length;
const beatPercent =
list.length <= 1 ? 0 : Math.round((below / (list.length - 1)) * 100);
self = { ...selfEntry, beatPercent };
}
return { period, list, self };
}
async partnerWeeklyReport(actorAccountId: bigint, startDate?: string) {
const { account, primaryId } = await this.resolvePartnerScope(actorAccountId);
this.assertPrimaryAccount(account);
const currentWeekStart = this.startOfWeekMonday(new Date());
const periodStart =
startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate)
? this.parseLocalDate(startDate)
: currentWeekStart;
const periodEnd = this.addDays(periodStart, 7);
const prevPeriodStart = this.addDays(periodStart, -7);
const prevPeriodEnd = periodStart;
const newStoreTarget =
account.weeklyStoreTarget ??
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
const orderWhere = {
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const,
paidAt: { gte: periodStart, lt: periodEnd },
};
const prevOrderWhere = {
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const,
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
};
const [
gmvAgg,
orderCount,
totalStoreCount,
newStoreCount,
prevGmvAgg,
redeemGroups,
activeRedeems,
paidOrders,
] = await Promise.all([
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
this.prisma.order.count({ where: orderWhere }),
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.store.count({
where: { partnerAccountId: primaryId, createdAt: { gte: periodStart, lt: periodEnd } },
}),
this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerAccountId: primaryId },
},
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
take: 10,
}),
this.prisma.redeemRecord.findMany({
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerAccountId: primaryId },
},
select: { storeId: true },
distinct: ['storeId'],
}),
this.prisma.order.findMany({
where: orderWhere,
select: { payAmount: true, paidAt: true },
}),
]);
const gmv = Number(gmvAgg._sum.payAmount ?? 0);
const prevGmv = Number(prevGmvAgg._sum.payAmount ?? 0);
const gmvGrowthPercent =
prevGmv === 0 ? 0 : Math.round(((gmv - prevGmv) / prevGmv) * 1000) / 10;
const newStoreProgressPercent =
newStoreTarget <= 0
? 0
: Math.min(100, Math.round((newStoreCount / newStoreTarget) * 100));
const dailyGmvMap = new Map<string, number>();
for (let i = 0; i < 7; i += 1) {
dailyGmvMap.set(this.formatDateKey(this.addDays(periodStart, i)), 0);
}
for (const order of paidOrders) {
if (!order.paidAt) continue;
const key = this.formatDateKey(order.paidAt);
if (dailyGmvMap.has(key)) {
dailyGmvMap.set(key, (dailyGmvMap.get(key) ?? 0) + Number(order.payAmount));
}
}
const dailyGmv = Array.from({ length: 7 }, (_, i) => {
const day = this.addDays(periodStart, i);
const date = this.formatDateKey(day);
return {
date,
weekdayLabel: this.weekdayLabel(i),
amount: dailyGmvMap.get(date) ?? 0,
};
});
const rankStoreIds = redeemGroups.map((group) => group.storeId);
const rankStores =
rankStoreIds.length > 0
? await this.prisma.store.findMany({
where: { id: { in: rankStoreIds } },
select: { id: true, name: true, intro: true, address: true },
})
: [];
const storeMap = new Map(rankStores.map((store) => [store.id.toString(), store]));
const storeRanking = redeemGroups.map((group, index) => {
const store = storeMap.get(group.storeId.toString());
const subtitleSource = store?.intro?.trim() || store?.address?.trim() || '';
const subtitle =
subtitleSource.length > 30 ? `${subtitleSource.slice(0, 30)}…` : subtitleSource;
return {
rank: index + 1,
storeId: group.storeId.toString(),
name: store?.name ?? '未知门店',
subtitle: subtitle || undefined,
redeemAmount: Number(group._sum.amount ?? 0),
};
});
const topStore = storeRanking[0];
let insight: string;
if (topStore && topStore.redeemAmount > 0) {
if (gmvGrowthPercent > 0) {
insight = `本周 GMV 增长 ${gmvGrowthPercent}%,主要得益于「${topStore.name}」的核销表现。建议关注同类高潜力门店。`;
} else if (gmvGrowthPercent < 0) {
insight = `本周 GMV 较上期下降 ${Math.abs(gmvGrowthPercent)}%,「${topStore.name}」仍为核销领先门店。建议复盘低效门店并复制头部经验。`;
} else {
insight = `本周 GMV 与上期持平,「${topStore.name}」核销表现领先。建议持续推动门店活跃。`;
}
} else {
insight = '本周暂无核销数据,建议关注门店培训与权益推广,激活辖区门店。';
}
const availablePeriods = [0, 1, 2, 3].map((offset) => {
const start = this.addDays(currentWeekStart, -7 * offset);
const end = this.addDays(start, 7);
return {
startDate: this.formatDateKey(start),
endDate: this.formatDateKey(this.addDays(end, -1)),
label: this.formatPeriodLabel(start, end),
};
});
return {
period: {
startDate: this.formatDateKey(periodStart),
endDate: this.formatDateKey(this.addDays(periodEnd, -1)),
label: this.formatPeriodLabel(periodStart, periodEnd),
},
availablePeriods,
summary: {
gmv,
gmvGrowthPercent,
activeStoreCount: activeRedeems.length,
totalStoreCount,
orderCount,
newStoreCount,
newStoreTarget,
newStoreProgressPercent,
},
dailyGmv,
storeRanking,
insight,
};
}
private resolveLeaderboardPeriodRange(period: PartnerLeaderboardPeriod) {
const now = new Date();
if (period === 'month') {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
return { periodStart, periodEnd: now };
}
if (period === 'lastMonth') {
const periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth(), 1);
return { periodStart, periodEnd };
}
return { periodStart: new Date(0), periodEnd: now };
}
private startOfWeekMonday(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
return d;
}
private addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
private parseLocalDate(dateKey: string): Date {
const [year, month, day] = dateKey.split('-').map(Number);
return new Date(year, month - 1, day, 0, 0, 0, 0);
}
private formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
private formatPeriodLabel(start: Date, endExclusive: Date): string {
const end = this.addDays(endExclusive, -1);
return `${start.getMonth() + 1}${start.getDate()}日 - ${end.getMonth() + 1}${end.getDate()}日`;
}
private weekdayLabel(index: number): string {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
}
private async getPartnerPrimaryId(actorAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId);
return primary.id;
}
private async getPartnerAccount(partnerAccountId: bigint) {
return this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
}
private async resolvePartnerCity(actorAccountId: bigint, cityId: unknown) {
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
if (cityId) {
const id = parseBigIntParam(cityId, '城市ID');
await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id);
const city = await this.prisma.commonCity.findUnique({ where: { id } });
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
return city;
}
const cityIds = await this.partnerCityService.listCityIdsForPartnerAccount(primaryId);
if (!cityIds.length) throw new BadRequestException('合伙人未绑定开城');
const city = await this.prisma.commonCity.findFirst({ where: { id: { in: cityIds } } });
if (!city) throw new BadRequestException('合伙人未绑定开城');
return city;
}
private isSubAccount(account: { isPrimary: number }) {
return account.isPrimary !== 1;
}
private partnerPermissionList(account: { permissions?: unknown }): string[] {
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
}
/** 主账号,或门店类子账号(含历史空权限)可改门店 */
private async assertCanMutateStore(
account: { isPrimary: number; permissions?: unknown },
partnerAccountId: bigint,
storeId: bigint,
) {
if (!this.isSubAccount(account)) return;
const perms = this.partnerPermissionList(account);
const canManage = perms.includes('store:manage');
const canCreate = perms.includes('store:create');
const legacyStoreStaff = perms.length === 0;
const warehouseOnly =
!canManage &&
!canCreate &&
!legacyStoreStaff &&
(perms.includes('warehouse:manage') ||
(perms.includes('order:view') && !perms.includes('store:create') && !perms.includes('store:manage')));
if (warehouseOnly || (!canManage && !canCreate && !legacyStoreStaff)) {
throw new ForbiddenException('子账号无门店管理权限');
}
// store:manage 可管团队门店;仅 store:create / 历史空权限只能改自己录入的店
if (canManage) return;
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
private normalizeEnvPhotoUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const urls: string[] = [];
for (const item of raw) {
const url = String(item ?? '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
if (urls.length >= 20) break;
}
return urls;
}
/** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */
private async loadPartnerStoreMedia(storeId: bigint) {
const media = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
status: 'ACTIVE',
bizType: { in: ['ENV', 'CONTRACT'] },
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const seenEnv = new Set<string>();
const duplicateEnvIds: bigint[] = [];
const kept: typeof media = [];
for (const row of media) {
if (row.bizType !== 'ENV') {
kept.push(row);
continue;
}
const key = row.url.trim();
if (seenEnv.has(key)) {
duplicateEnvIds.push(row.id);
continue;
}
seenEnv.add(key);
kept.push(row);
}
if (duplicateEnvIds.length > 0) {
await this.prisma.commonResource.updateMany({
where: { id: { in: duplicateEnvIds } },
data: { status: 'DELETED' },
});
}
return kept;
}
private assertPrimaryAccount(account: { isPrimary: number }) {
if (this.isSubAccount(account)) {
throw new ForbiddenException('子账号无权执行此操作');
}
}
private async assertStorePhoneAvailable(phone: string, confirmBindExisting = false) {
const result = await this.partnerCheckStorePhone(phone);
if (!result.available) {
throw new BadRequestException(result.message ?? '该手机号不可用');
}
if (result.needConfirm && !confirmBindExisting) {
throw new BadRequestException(result.message ?? '该手机号已绑定门店,请确认后重试');
}
}
private async getStoreIdsCreatedByAccount(partnerAccountId: bigint): Promise<bigint[]> {
const events = await this.prisma.commonEvent.findMany({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: partnerAccountId,
refType: 'STORE',
},
select: { refId: true },
});
return events.map((event) => event.refId).filter((id): id is bigint => id != null);
}
private async assertStoreOwnedByAccount(partnerAccountId: bigint, storeId: bigint) {
const event = await this.prisma.commonEvent.findFirst({
where: {
eventType: 'STORE_AUDIT',
param1: 'NEW',
actorType: 'PARTNER',
actorId: partnerAccountId,
refType: 'STORE',
refId: storeId,
},
});
if (!event) throw new ForbiddenException('无权查看该门店');
}
}