门店开通流程
This commit is contained in:
@@ -6,7 +6,7 @@ import type {
|
||||
OssUploadTokenInput,
|
||||
OssUploadTokenResult,
|
||||
} from './oss.interface';
|
||||
import { buildOssObjectKey } from './oss.key.util';
|
||||
import { buildOssObjectKey, resolveOssUploadDir } from './oss.key.util';
|
||||
import { createAliyunOssClient, resolveOssUploadHost } from './oss.aliyun.client';
|
||||
|
||||
const DEFAULT_EXPIRE_SECONDS = 15 * 60;
|
||||
@@ -64,13 +64,14 @@ export class OssAliyunProvider implements IOssProvider {
|
||||
const ossKey = buildOssObjectKey(this.uploadPrefix, dto.bizType, dto.fileName);
|
||||
const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
|
||||
const host = resolveOssUploadHost(this.bucket, this.region);
|
||||
const keyPrefix = resolveOssUploadDir(this.uploadPrefix, dto.bizType);
|
||||
|
||||
const policy = {
|
||||
expiration: expireAt.toISOString(),
|
||||
conditions: [
|
||||
['content-length-range', 0, this.maxUploadBytes],
|
||||
['eq', '$bucket', this.bucket],
|
||||
['starts-with', '$key', `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`],
|
||||
['starts-with', '$key', keyPrefix],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/** 门店资源上传目录(OSS object key 前缀) */
|
||||
const STORE_UPLOAD_DIRS: Record<string, string> = {
|
||||
STORE_TITLE: 'store/title',
|
||||
STORE_ENV: 'store/env',
|
||||
STORE_CONTRACT: 'store/contract',
|
||||
};
|
||||
|
||||
export function resolveOssUploadDir(uploadPrefix: string, bizType: string): string {
|
||||
const storeDir = STORE_UPLOAD_DIRS[bizType];
|
||||
if (storeDir) return `${storeDir}/`;
|
||||
return `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
|
||||
}
|
||||
|
||||
export function buildOssObjectKey(uploadPrefix: string, bizType: string, fileName: string): string {
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
|
||||
const dir = `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
|
||||
const dir = resolveOssUploadDir(uploadPrefix, bizType);
|
||||
return `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature,
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'],
|
||||
jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay', 'chooseImage', 'getLocalImgData'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,38 @@ export class PartnerStoreController {
|
||||
return this.storeService.partnerListStores(user.actorId);
|
||||
}
|
||||
|
||||
@Get('cities')
|
||||
cities(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerListCities(user.actorId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeService.partnerGetStore(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.storeService.createStore(user.actorId, body);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreStatus(user.actorId, BigInt(id), body.status);
|
||||
}
|
||||
|
||||
@Put(':id/basic')
|
||||
updateBasic(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/dashboard')
|
||||
|
||||
@@ -51,10 +51,44 @@ export class StoreService {
|
||||
return serializeBigInt(stores.map(mapStoreCompat));
|
||||
}
|
||||
|
||||
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
include: { category: true, coverResource: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['ENV', 'CONTRACT'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
}
|
||||
|
||||
async partnerListCities(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true, name: true, code: true, province: true, partnerId: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { partnerId: account.partnerId } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
||||
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
|
||||
: [];
|
||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
@@ -63,12 +97,11 @@ export class StoreService {
|
||||
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
||||
name: String(body.name),
|
||||
phone: String(body.phone),
|
||||
province: String(body.province ?? '河南省'),
|
||||
cityName: String(body.city ?? '郑州市'),
|
||||
province: String(body.province ?? city.province ?? '河南省'),
|
||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
coverResourceId: body.coverResourceId ? BigInt(String(body.coverResourceId)) : null,
|
||||
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
|
||||
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
|
||||
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
|
||||
@@ -78,6 +111,52 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -95,7 +174,10 @@ export class StoreService {
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: String(body.accountPhone ?? body.phone),
|
||||
phone: await this.resolveStoreAccountPhone(
|
||||
String(body.accountPhone ?? body.phone),
|
||||
store.id,
|
||||
),
|
||||
name: String(body.accountName ?? body.name),
|
||||
},
|
||||
});
|
||||
@@ -103,6 +185,71 @@ export class StoreService {
|
||||
return serializeBigInt({ store, audit });
|
||||
}
|
||||
|
||||
async partnerUpdateStoreStatus(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||
) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可变更状态');
|
||||
}
|
||||
if (!['OPEN', 'PAUSED', 'CLOSED'].includes(status)) {
|
||||
throw new BadRequestException('无效的门店状态');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: { status },
|
||||
include: { coverResource: true },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat(updated));
|
||||
}
|
||||
|
||||
async partnerUpdateStoreBasic(
|
||||
partnerAccountId: bigint,
|
||||
storeId: bigint,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.status === 'CLOSED') {
|
||||
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;
|
||||
|
||||
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 < 10 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 10~500 字');
|
||||
}
|
||||
|
||||
await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||
},
|
||||
});
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
}
|
||||
|
||||
async getShopStore(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
@@ -150,4 +297,26 @@ export class StoreService {
|
||||
include: { partner: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePartnerCity(partnerId: bigint, cityId: unknown) {
|
||||
if (cityId) {
|
||||
const city = await this.prisma.commonCity.findFirst({
|
||||
where: { id: BigInt(String(cityId)), partnerId },
|
||||
});
|
||||
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
|
||||
return city;
|
||||
}
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { partnerId } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
return city;
|
||||
}
|
||||
|
||||
private async resolveStoreAccountPhone(phone: string, storeId: bigint): Promise<string> {
|
||||
const normalized = phone.trim();
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: normalized } });
|
||||
if (!existing) return normalized;
|
||||
const suffix = String(storeId).slice(-4);
|
||||
const candidate = `${normalized.slice(0, 15)}${suffix}`.slice(0, 20);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user