@@ -0,0 +1,3 @@
|
||||
-- v3.5.9: HQ 账号列表显示列 / 顺序偏好
|
||||
ALTER TABLE `hq_account`
|
||||
ADD COLUMN `list_column_prefs` JSON NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v3.5.8 补丁:删除门店分类权限(可对已执行过 migrate-hq-permissions-v358.sql 的库单独跑)
|
||||
-- 城市门店服务可新增分类,不可删除
|
||||
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_categories_delete', NOW(3)
|
||||
FROM `hq_role_permission`
|
||||
WHERE `permission_key` = 'store_categories'
|
||||
AND `admin_role` <> 'CITY_STORE_SERVICE';
|
||||
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT p.`hq_account_id`, 'store_categories_delete', p.`effect`, NOW(3)
|
||||
FROM `hq_account_permission` p
|
||||
INNER JOIN `hq_account` a ON a.`id` = p.`hq_account_id`
|
||||
WHERE p.`permission_key` = 'store_categories'
|
||||
AND a.`admin_role` <> 'CITY_STORE_SERVICE';
|
||||
@@ -0,0 +1,71 @@
|
||||
-- v3.5.8: HQ 权限追加/撤销、城市门店服务、账号城市范围
|
||||
|
||||
ALTER TABLE `hq_account`
|
||||
MODIFY COLUMN `admin_role` ENUM(
|
||||
'SUPER_ADMIN',
|
||||
'OPS',
|
||||
'FINANCE',
|
||||
'CUSTOMER_SERVICE',
|
||||
'CITY_STORE_SERVICE'
|
||||
) NOT NULL DEFAULT 'OPS';
|
||||
|
||||
ALTER TABLE `hq_role_permission`
|
||||
MODIFY COLUMN `admin_role` ENUM(
|
||||
'SUPER_ADMIN',
|
||||
'OPS',
|
||||
'FINANCE',
|
||||
'CUSTOMER_SERVICE',
|
||||
'CITY_STORE_SERVICE'
|
||||
) NOT NULL;
|
||||
|
||||
ALTER TABLE `hq_account_permission`
|
||||
ADD COLUMN `effect` ENUM('GRANT', 'DENY') NOT NULL DEFAULT 'GRANT' AFTER `permission_key`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `hq_account_city` (
|
||||
`hq_account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`city_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`hq_account_id`, `city_id`),
|
||||
INDEX `hq_account_city_city_id_idx` (`city_id`),
|
||||
CONSTRAINT `hq_account_city_hq_account_id_fkey`
|
||||
FOREIGN KEY (`hq_account_id`) REFERENCES `hq_account` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `hq_account_city_city_id_fkey`
|
||||
FOREIGN KEY (`city_id`) REFERENCES `common_city` (`id`) ON DELETE CASCADE
|
||||
) DEFAULT CHARSET = utf8mb4;
|
||||
|
||||
-- 存量 stores 拆键:补齐审核/评价/分类/账户/资源,保持与拆分前菜单一致
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_audits', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_ratings', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_categories', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_accounts', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_media', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores';
|
||||
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT `hq_account_id`, 'store_audits', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT `hq_account_id`, 'store_ratings', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT `hq_account_id`, 'store_categories', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT `hq_account_id`, 'store_accounts', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT `hq_account_id`, 'store_media', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores';
|
||||
|
||||
-- 删除分类:有分类权限的角色/账号补齐,城市门店服务除外(可新增、不可删除)
|
||||
INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`)
|
||||
SELECT `admin_role`, 'store_categories_delete', NOW(3)
|
||||
FROM `hq_role_permission`
|
||||
WHERE `permission_key` = 'store_categories'
|
||||
AND `admin_role` <> 'CITY_STORE_SERVICE';
|
||||
|
||||
INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`)
|
||||
SELECT p.`hq_account_id`, 'store_categories_delete', p.`effect`, NOW(3)
|
||||
FROM `hq_account_permission` p
|
||||
INNER JOIN `hq_account` a ON a.`id` = p.`hq_account_id`
|
||||
WHERE p.`permission_key` = 'store_categories'
|
||||
AND a.`admin_role` <> 'CITY_STORE_SERVICE';
|
||||
@@ -263,6 +263,12 @@ enum HqAdminRole {
|
||||
OPS
|
||||
FINANCE
|
||||
CUSTOMER_SERVICE
|
||||
CITY_STORE_SERVICE
|
||||
}
|
||||
|
||||
enum HqPermissionEffect {
|
||||
GRANT
|
||||
DENY
|
||||
}
|
||||
|
||||
enum PartnerBillStatus {
|
||||
@@ -1031,6 +1037,7 @@ model CommonCity {
|
||||
partnerAccounts PartnerAccount[] @relation("PartnerAccountCity")
|
||||
stores Store[]
|
||||
orders Order[]
|
||||
hqAccountCities HqAccountCity[]
|
||||
|
||||
@@map("common_city")
|
||||
}
|
||||
@@ -1244,10 +1251,13 @@ model HqAccount {
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
/// HQ 各列表的显示列与顺序(按 listKey)
|
||||
listColumnPrefs Json? @map("list_column_prefs")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
permissions HqAccountPermission[]
|
||||
cities HqAccountCity[]
|
||||
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
|
||||
|
||||
@@map("hq_account")
|
||||
@@ -1263,9 +1273,10 @@ model HqRolePermission {
|
||||
}
|
||||
|
||||
model HqAccountPermission {
|
||||
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
|
||||
permissionKey String @map("permission_key") @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
|
||||
permissionKey String @map("permission_key") @db.VarChar(64)
|
||||
effect HqPermissionEffect @default(GRANT)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1273,6 +1284,19 @@ model HqAccountPermission {
|
||||
@@map("hq_account_permission")
|
||||
}
|
||||
|
||||
model HqAccountCity {
|
||||
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
|
||||
cityId BigInt @map("city_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade)
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([hqAccountId, cityId])
|
||||
@@index([cityId])
|
||||
@@map("hq_account_city")
|
||||
}
|
||||
|
||||
// ─── USER ─────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
|
||||
@@ -3,16 +3,20 @@ import {
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
computeHqEffectivePermissionKeys,
|
||||
expandHqPermissionKeys,
|
||||
hasAnySystemSettingsPermission,
|
||||
type HqAdminRoleValue,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
import type { AuthUser } from './jwt-auth.guard';
|
||||
|
||||
@@ -23,6 +27,37 @@ export const RequireHqPermissions = (...keys: string[]) =>
|
||||
export const RequireAnySystemSettings = () =>
|
||||
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
|
||||
|
||||
/** null = 全国;[] = 无可见城市 */
|
||||
export type HqCityScope = bigint[] | null;
|
||||
|
||||
export function hqStoreCityWhere(scope: HqCityScope): Prisma.StoreWhereInput | undefined {
|
||||
if (scope === null) return undefined;
|
||||
if (!scope.length) return { id: { equals: BigInt(0) } };
|
||||
return { cityId: { in: scope } };
|
||||
}
|
||||
|
||||
export function mergeHqStoreCityWhere(
|
||||
where: Prisma.StoreWhereInput,
|
||||
scope: HqCityScope,
|
||||
requestedCityId?: string,
|
||||
): Prisma.StoreWhereInput {
|
||||
if (requestedCityId) {
|
||||
const cityId = BigInt(requestedCityId);
|
||||
assertHqCityInScope(scope, cityId);
|
||||
return { AND: [where, { cityId }] };
|
||||
}
|
||||
const scoped = hqStoreCityWhere(scope);
|
||||
if (!scoped) return where;
|
||||
return { AND: [where, scoped] };
|
||||
}
|
||||
|
||||
export function assertHqCityInScope(scope: HqCityScope, cityId: bigint) {
|
||||
if (scope === null) return;
|
||||
if (!scope.some((id) => id === cityId)) {
|
||||
throw new ForbiddenException('无权访问该城市的门店');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HqPermissionsResolver {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -41,39 +76,45 @@ export class HqPermissionsResolver {
|
||||
async resolveAccess(actorId: bigint): Promise<{
|
||||
keys: HqPermissionKey[];
|
||||
isSuperAdmin: boolean;
|
||||
adminRole: HqAdminRoleValue;
|
||||
}> {
|
||||
const account = await this.loadActiveAccount(actorId);
|
||||
const adminRole = account.adminRole as HqAdminRoleValue;
|
||||
|
||||
const userRows = await this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: actorId },
|
||||
select: { permissionKey: true },
|
||||
select: { permissionKey: true, effect: true },
|
||||
});
|
||||
const userKeys = userRows.map((r) => r.permissionKey);
|
||||
const grantKeys = userRows
|
||||
.filter((r) => r.effect !== 'DENY')
|
||||
.map((r) => r.permissionKey);
|
||||
const denyKeys = userRows.filter((r) => r.effect === 'DENY').map((r) => r.permissionKey);
|
||||
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
// 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
|
||||
if (adminRole === 'SUPER_ADMIN') {
|
||||
return {
|
||||
isSuperAdmin: true,
|
||||
adminRole,
|
||||
keys: expandHqPermissionKeys([
|
||||
...HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||
...userKeys,
|
||||
...grantKeys,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const roleRows = await this.prisma.hqRolePermission.findMany({
|
||||
where: { adminRole: account.adminRole },
|
||||
where: { adminRole },
|
||||
select: { permissionKey: true },
|
||||
});
|
||||
|
||||
const roleKeys =
|
||||
roleRows.length > 0
|
||||
? roleRows.map((r) => r.permissionKey)
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[adminRole] ?? [])];
|
||||
|
||||
return {
|
||||
isSuperAdmin: false,
|
||||
keys: expandHqPermissionKeys([...roleKeys, ...userKeys]),
|
||||
adminRole,
|
||||
keys: computeHqEffectivePermissionKeys(roleKeys, grantKeys, denyKeys),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +122,35 @@ export class HqPermissionsResolver {
|
||||
const { keys } = await this.resolveAccess(actorId);
|
||||
return keys;
|
||||
}
|
||||
|
||||
async resolveCityScope(actorId: bigint): Promise<HqCityScope> {
|
||||
const account = await this.loadActiveAccount(actorId);
|
||||
if (account.adminRole === 'SUPER_ADMIN') return null;
|
||||
const rows = await this.prisma.hqAccountCity.findMany({
|
||||
where: { hqAccountId: actorId },
|
||||
select: { cityId: true },
|
||||
});
|
||||
if (!rows.length) {
|
||||
return account.adminRole === 'CITY_STORE_SERVICE' ? [] : null;
|
||||
}
|
||||
return rows.map((r) => r.cityId);
|
||||
}
|
||||
|
||||
async assertStoreCityInScope(actorId: bigint, cityId: bigint) {
|
||||
const scope = await this.resolveCityScope(actorId);
|
||||
assertHqCityInScope(scope, cityId);
|
||||
}
|
||||
|
||||
async assertStoreIdInScope(actorId: bigint, storeId: bigint) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { cityId: true },
|
||||
});
|
||||
if (!store) {
|
||||
throw new NotFoundException('门店不存在');
|
||||
}
|
||||
await this.assertStoreCityInScope(actorId, store.cityId);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -17,6 +17,7 @@ export const HqOperationAction = {
|
||||
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
|
||||
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
|
||||
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
|
||||
USER_UPDATE: 'USER_UPDATE',
|
||||
USER_DELETE: 'USER_DELETE',
|
||||
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
|
||||
ORDER_SHIP: 'ORDER_SHIP',
|
||||
@@ -141,6 +142,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
|
||||
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
|
||||
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
|
||||
[HqOperationAction.USER_UPDATE]: '修改用户昵称',
|
||||
[HqOperationAction.USER_DELETE]: '删除用户',
|
||||
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
|
||||
[HqOperationAction.ORDER_SHIP]: '订单发货',
|
||||
|
||||
@@ -87,7 +87,9 @@ export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
|
||||
'运营:商品/开城/门店/订单/配送/权益。',
|
||||
'财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。',
|
||||
'客服:用户/订单、售后工单、发票协助。',
|
||||
'城市门店服务:门店列表/审核/评价/分类(可新增不可删除),且限制负责城市;概览只显示有权限的数据和负责城市。',
|
||||
'超管:权限分配、技术支持评审、系统设置。',
|
||||
'单账号可在角色之上追加或撤销权限;切换角色会清空账号级权限。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Param, Put, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SaveHqListColumnPrefsDto } 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';
|
||||
|
||||
@Controller('admin/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AdminMeController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Put('list-columns/:listKey')
|
||||
saveListColumns(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('listKey') listKey: string,
|
||||
@Body() dto: SaveHqListColumnPrefsDto,
|
||||
) {
|
||||
return this.authService.updateMyListColumnPrefs(user.actorType, user.actorId, listKey, dto);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
import { ClientApp, SmsScene, isHqListColumnKey, type HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||
import { generateUserNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
@@ -55,6 +55,20 @@ type UserRow = Pick<
|
||||
avatar?: { url: string } | null;
|
||||
};
|
||||
|
||||
function parseListColumnPrefs(raw: unknown): HqListColumnPrefsMap {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const out: HqListColumnPrefsMap = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!isHqListColumnKey(key) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const row = value as { order?: unknown; hidden?: unknown };
|
||||
out[key] = {
|
||||
order: Array.isArray(row.order) ? row.order.filter((v): v is string => typeof v === 'string') : [],
|
||||
hidden: Array.isArray(row.hidden) ? row.hidden.filter((v): v is string => typeof v === 'string') : [],
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@@ -1207,11 +1221,48 @@ export class AuthService {
|
||||
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
|
||||
if (!account) return null;
|
||||
const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId);
|
||||
return serializeBigInt({ ...account, permissionKeys });
|
||||
const cityScope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
const { passwordHash: _passwordHash, ...safeAccount } = account;
|
||||
return serializeBigInt({
|
||||
...safeAccount,
|
||||
listColumnPrefs: parseListColumnPrefs(account.listColumnPrefs),
|
||||
permissionKeys,
|
||||
cityIds: (cityScope ?? []).map((id) => id.toString()),
|
||||
cityScoped: cityScope !== null,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateMyListColumnPrefs(
|
||||
actorType: string,
|
||||
actorId: bigint,
|
||||
listKey: string,
|
||||
dto: { reset?: boolean; order?: string[]; hidden?: string[] },
|
||||
) {
|
||||
if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置');
|
||||
if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表');
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { id: true, listColumnPrefs: true },
|
||||
});
|
||||
if (!account) throw new NotFoundException('账号不存在');
|
||||
const current = parseListColumnPrefs(account.listColumnPrefs);
|
||||
if (dto.reset) {
|
||||
delete current[listKey];
|
||||
} else {
|
||||
current[listKey] = {
|
||||
order: (dto.order ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
||||
hidden: (dto.hidden ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
||||
};
|
||||
}
|
||||
const updated = await this.prisma.hqAccount.update({
|
||||
where: { id: actorId },
|
||||
data: { listColumnPrefs: current },
|
||||
});
|
||||
return { listColumnPrefs: parseListColumnPrefs(updated.listColumnPrefs) };
|
||||
}
|
||||
|
||||
wechatDisabled() {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
|
||||
export class SendSmsDto {
|
||||
@@ -129,3 +129,19 @@ export class CheckPartnerPhoneDto {
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export class SaveHqListColumnPrefsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reset?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
order?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
hidden?: string[];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PartnerStaffService } from './partner-staff.service';
|
||||
import { StoreStaffController } from './store-staff.controller';
|
||||
import { StoreStaffService } from './store-staff.service';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminMeController } from './admin-me.controller';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
@@ -50,6 +51,7 @@ import { CommonModule } from '../common/common.module';
|
||||
UserProfileController,
|
||||
UserAddressController,
|
||||
AdminAuthController,
|
||||
AdminMeController,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminCitiesService } from './admin-cities.service';
|
||||
import { AdminCitiesQueryDto } from './dto/admin-query.dto';
|
||||
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
|
||||
@@ -23,8 +25,8 @@ export class AdminCitiesController {
|
||||
constructor(private readonly service: AdminCitiesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminCitiesQueryDto) {
|
||||
return this.service.list(query);
|
||||
list(@CurrentUser() user: AuthUser, @Query() query: AdminCitiesQueryDto) {
|
||||
return this.service.list(query, user.actorId);
|
||||
}
|
||||
|
||||
@Get(':id/delete-preview')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
||||
import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
|
||||
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
|
||||
|
||||
@@ -12,9 +13,10 @@ export class AdminCitiesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
async list(query: AdminCitiesQueryDto) {
|
||||
async list(query: AdminCitiesQueryDto, actorId?: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonCityWhereInput = {};
|
||||
@@ -24,6 +26,12 @@ export class AdminCitiesService {
|
||||
if (query.partnerId) {
|
||||
where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } };
|
||||
}
|
||||
if (actorId) {
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
if (scope !== null) {
|
||||
where.id = { in: scope.length ? scope : [BigInt(0)] };
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonCity.findMany({
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/dashboard')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('dashboard')
|
||||
export class AdminDashboardController {
|
||||
constructor(private readonly dashboardService: AdminDashboardService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats() {
|
||||
return this.dashboardService.getStats();
|
||||
stats(@CurrentUser() user: AuthUser) {
|
||||
return this.dashboardService.getStats(user.actorId);
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
analytics(@Query() query: AdminDashboardAnalyticsQueryDto) {
|
||||
return this.dashboardService.getAnalytics(query);
|
||||
analytics(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: AdminDashboardAnalyticsQueryDto,
|
||||
) {
|
||||
return this.dashboardService.getAnalytics(user.actorId, query);
|
||||
}
|
||||
|
||||
@Get('version')
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { HqPermissionKey } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import {
|
||||
HqPermissionsResolver,
|
||||
assertHqCityInScope,
|
||||
hqStoreCityWhere,
|
||||
type HqCityScope,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
function startOfDay(d: Date) {
|
||||
@@ -55,14 +62,63 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
||||
return now.getTime() > deadline.getTime();
|
||||
}
|
||||
|
||||
function cityIdFilter(scope: HqCityScope): Prisma.BigIntFilter | undefined {
|
||||
if (scope === null) return undefined;
|
||||
if (!scope.length) return { equals: BigInt(0) };
|
||||
return { in: scope };
|
||||
}
|
||||
|
||||
function emptyRows<T>(): Promise<T[]> {
|
||||
return Promise.resolve([] as T[]);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
async getStats(actorId: bigint) {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const [{ keys, isSuperAdmin }, scope] = await Promise.all([
|
||||
this.hqPermissions.resolveAccess(actorId),
|
||||
this.hqPermissions.resolveCityScope(actorId),
|
||||
]);
|
||||
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
|
||||
const storeWhere = hqStoreCityWhere(scope) ?? {};
|
||||
const cityFilter = cityIdFilter(scope);
|
||||
const orderWhere: Prisma.OrderWhereInput = cityFilter ? { cityId: cityFilter } : {};
|
||||
const partnerWhere: Prisma.PartnerAccountWhereInput = {
|
||||
isPrimary: 1,
|
||||
...(cityFilter ? { cityId: cityFilter } : {}),
|
||||
};
|
||||
|
||||
let userWhere: Prisma.UserWhereInput = { status: 1, mergedIntoUserId: null };
|
||||
if (scope !== null) {
|
||||
if (!scope.length) {
|
||||
userWhere = { id: { equals: BigInt(0) } };
|
||||
} else {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { id: { in: scope } },
|
||||
select: { code: true },
|
||||
});
|
||||
const codes = cities.map((c) => c.code);
|
||||
userWhere = {
|
||||
status: 1,
|
||||
mergedIntoUserId: null,
|
||||
cityPreference: { selectedCityCode: { in: codes.length ? codes : [''] } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const redeemTodayWhere: Prisma.RedeemRecordWhereInput = {
|
||||
createdAt: { gte: todayStart },
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
};
|
||||
|
||||
const [
|
||||
usersTotal,
|
||||
guestUsers,
|
||||
@@ -79,32 +135,112 @@ export class AdminDashboardService {
|
||||
pendingPartnerDraftBills,
|
||||
openTickets,
|
||||
pendingWithdrawRows,
|
||||
pendingStoreOnboard,
|
||||
pendingStorePackageAudits,
|
||||
pendingStoreInfoChanges,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||
this.prisma.user.count({
|
||||
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null },
|
||||
}),
|
||||
this.prisma.user.count({
|
||||
where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } },
|
||||
}),
|
||||
this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }),
|
||||
this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
}),
|
||||
this.prisma.store.count(),
|
||||
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.orderDelivery.count(),
|
||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||
this.prisma.storeWithdrawRequest.findMany({
|
||||
where: { status: 'PENDING_REVIEW' },
|
||||
select: { appliedAt: true },
|
||||
}),
|
||||
can('users')
|
||||
? this.prisma.user.count({ where: userWhere })
|
||||
: Promise.resolve(0),
|
||||
can('users')
|
||||
? this.prisma.user.count({
|
||||
where: { ...userWhere, phoneVerifiedAt: null },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('users')
|
||||
? this.prisma.user.count({
|
||||
where: { ...userWhere, phoneVerifiedAt: { not: null } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('users')
|
||||
? this.prisma.user.count({
|
||||
where:
|
||||
scope === null
|
||||
? { mergedIntoUserId: { not: null } }
|
||||
: { id: { equals: BigInt(0) } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('orders')
|
||||
? this.prisma.order.count({
|
||||
where: { ...orderWhere, createdAt: { gte: todayStart } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('orders')
|
||||
? this.prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
where: orderWhere,
|
||||
_count: { status: true },
|
||||
})
|
||||
: emptyRows<{ status: string; _count: { status: number } }>(),
|
||||
can('stores') ? this.prisma.store.count({ where: storeWhere }) : Promise.resolve(0),
|
||||
can('partners')
|
||||
? this.prisma.partnerAccount.count({ where: partnerWhere })
|
||||
: Promise.resolve(0),
|
||||
can('benefit')
|
||||
? this.prisma.redeemRecord.count({ where: redeemTodayWhere })
|
||||
: Promise.resolve(0),
|
||||
can('deliveries')
|
||||
? this.prisma.orderDelivery.count({
|
||||
where: cityFilter ? { order: { cityId: cityFilter } } : undefined,
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.storePayout.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.partnerBill.count({
|
||||
where: {
|
||||
status: 'UNPAID',
|
||||
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.partnerBill.count({
|
||||
where: {
|
||||
status: 'PENDING_REVIEW',
|
||||
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('tickets') && scope === null
|
||||
? this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } })
|
||||
: Promise.resolve(0),
|
||||
can('finance')
|
||||
? this.prisma.storeWithdrawRequest.findMany({
|
||||
where: {
|
||||
status: 'PENDING_REVIEW',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
select: { appliedAt: true },
|
||||
})
|
||||
: emptyRows<{ appliedAt: Date }>(),
|
||||
can('stores')
|
||||
? this.prisma.store.count({
|
||||
where: { auditStatus: 'PENDING', ...storeWhere },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('store_audits')
|
||||
? this.prisma.storePackageChangeRequest.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
can('store_audits')
|
||||
? this.prisma.storeInfoChangeRequest.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
...(Object.keys(storeWhere).length ? { store: storeWhere } : {}),
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const now = new Date();
|
||||
@@ -129,6 +265,9 @@ export class AdminDashboardService {
|
||||
openTickets,
|
||||
pendingStoreWithdrawals,
|
||||
overdueStoreWithdrawals,
|
||||
pendingStoreOnboard,
|
||||
pendingStorePackageAudits,
|
||||
pendingStoreInfoChanges,
|
||||
ordersByStatus: ordersByStatus.map((row) => ({
|
||||
status: row.status,
|
||||
count: row._count.status,
|
||||
@@ -152,7 +291,7 @@ export class AdminDashboardService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAnalytics(query: AdminDashboardAnalyticsQueryDto) {
|
||||
async getAnalytics(actorId: bigint, query: AdminDashboardAnalyticsQueryDto) {
|
||||
const today = startOfDay(new Date());
|
||||
const defaultFrom = new Date(today);
|
||||
defaultFrom.setDate(defaultFrom.getDate() - 29);
|
||||
@@ -164,9 +303,18 @@ export class AdminDashboardService {
|
||||
const rangeStart = startOfDay(from <= to ? from : to);
|
||||
const rangeEnd = endOfDay(from <= to ? to : from);
|
||||
|
||||
let filterCityCode: string | null | undefined;
|
||||
let filterCityId: bigint | null | undefined;
|
||||
const [{ keys, isSuperAdmin }, scope] = await Promise.all([
|
||||
this.hqPermissions.resolveAccess(actorId),
|
||||
this.hqPermissions.resolveCityScope(actorId),
|
||||
]);
|
||||
const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k);
|
||||
|
||||
let filterCityCode: string | string[] | null | undefined;
|
||||
let filterCityId: bigint | bigint[] | null | undefined;
|
||||
if (query.cityId === 'none') {
|
||||
if (scope !== null) {
|
||||
throw new ForbiddenException('无权按未选城筛选');
|
||||
}
|
||||
filterCityCode = null;
|
||||
filterCityId = null;
|
||||
} else if (query.cityId) {
|
||||
@@ -174,20 +322,47 @@ export class AdminDashboardService {
|
||||
where: { id: BigInt(query.cityId) },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
if (city) {
|
||||
filterCityCode = city.code;
|
||||
filterCityId = city.id;
|
||||
if (!city) {
|
||||
throw new ForbiddenException('无权访问该城市的门店');
|
||||
}
|
||||
assertHqCityInScope(scope, city.id);
|
||||
filterCityCode = city.code;
|
||||
filterCityId = city.id;
|
||||
} else if (scope !== null) {
|
||||
if (!scope.length) {
|
||||
filterCityId = [];
|
||||
filterCityCode = [];
|
||||
} else {
|
||||
const scopedCities = await this.prisma.commonCity.findMany({
|
||||
where: { id: { in: scope } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
filterCityId = scope;
|
||||
filterCityCode = scopedCities.map((c) => c.code);
|
||||
}
|
||||
}
|
||||
|
||||
const filterPromoNone = query.promoCodeId === 'none';
|
||||
const filterPromoId =
|
||||
query.promoCodeId && query.promoCodeId !== 'none'
|
||||
can('promo_codes') && query.promoCodeId && query.promoCodeId !== 'none'
|
||||
? BigInt(query.promoCodeId)
|
||||
: undefined;
|
||||
const filterPartnerId = query.partnerAccountId
|
||||
? BigInt(query.partnerAccountId)
|
||||
: undefined;
|
||||
const filterPartnerId =
|
||||
can('partners') && query.partnerAccountId
|
||||
? BigInt(query.partnerAccountId)
|
||||
: undefined;
|
||||
|
||||
if (filterPartnerId !== undefined && scope !== null) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: filterPartnerId },
|
||||
select: { cityId: true },
|
||||
});
|
||||
if (partner?.cityId) {
|
||||
assertHqCityInScope(scope, partner.cityId);
|
||||
} else if (scope !== null) {
|
||||
throw new ForbiddenException('无权访问该城市的门店');
|
||||
}
|
||||
}
|
||||
|
||||
const userWhere: Prisma.UserWhereInput = {
|
||||
status: 1,
|
||||
@@ -199,10 +374,14 @@ export class AdminDashboardService {
|
||||
{ cityPreference: null },
|
||||
{ cityPreference: { selectedCityCode: null } },
|
||||
];
|
||||
} else if (Array.isArray(filterCityCode)) {
|
||||
userWhere.cityPreference = {
|
||||
selectedCityCode: { in: filterCityCode.length ? filterCityCode : [''] },
|
||||
};
|
||||
} else if (filterCityCode) {
|
||||
userWhere.cityPreference = { selectedCityCode: filterCityCode };
|
||||
}
|
||||
if (filterPromoNone) {
|
||||
if (can('promo_codes') && filterPromoNone) {
|
||||
userWhere.promoTouch = { is: null };
|
||||
} else if (filterPromoId !== undefined) {
|
||||
userWhere.promoTouch = { promoCodeId: filterPromoId };
|
||||
@@ -211,10 +390,14 @@ export class AdminDashboardService {
|
||||
const orderWhere: Prisma.OrderWhereInput = {
|
||||
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||
};
|
||||
if (filterCityId !== undefined && filterCityId !== null) {
|
||||
if (filterCityId === null) {
|
||||
orderWhere.id = { in: [] };
|
||||
} else if (Array.isArray(filterCityId)) {
|
||||
orderWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
orderWhere.cityId = filterCityId;
|
||||
}
|
||||
if (filterPromoNone) {
|
||||
if (can('promo_codes') && filterPromoNone) {
|
||||
orderWhere.promoCodeId = null;
|
||||
} else if (filterPromoId !== undefined) {
|
||||
orderWhere.promoCodeId = filterPromoId;
|
||||
@@ -226,6 +409,8 @@ export class AdminDashboardService {
|
||||
};
|
||||
if (filterCityId === null) {
|
||||
partnerWhere.cityId = null;
|
||||
} else if (Array.isArray(filterCityId)) {
|
||||
partnerWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
partnerWhere.cityId = filterCityId;
|
||||
}
|
||||
@@ -237,8 +422,9 @@ export class AdminDashboardService {
|
||||
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||
};
|
||||
if (filterCityId === null) {
|
||||
// 门店必有 cityId
|
||||
storeWhere.id = { in: [] };
|
||||
} else if (Array.isArray(filterCityId)) {
|
||||
storeWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
storeWhere.cityId = filterCityId;
|
||||
}
|
||||
@@ -253,28 +439,56 @@ export class AdminDashboardService {
|
||||
redeemWhere.id = { in: [] };
|
||||
} else {
|
||||
const storeFilter: Prisma.StoreWhereInput = {};
|
||||
if (filterCityId !== undefined) storeFilter.cityId = filterCityId;
|
||||
if (Array.isArray(filterCityId)) {
|
||||
storeFilter.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] };
|
||||
} else if (filterCityId !== undefined) {
|
||||
storeFilter.cityId = filterCityId;
|
||||
}
|
||||
if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId;
|
||||
if (Object.keys(storeFilter).length) {
|
||||
redeemWhere.store = storeFilter;
|
||||
}
|
||||
}
|
||||
|
||||
const skipOrders = filterCityId === null;
|
||||
const skipOrders = filterCityId === null || !can('orders');
|
||||
const cityListWhere: Prisma.CommonCityWhereInput =
|
||||
scope === null ? {} : { id: { in: scope.length ? scope : [BigInt(0)] } };
|
||||
|
||||
const [users, orders, partners, stores, redeems, cities, promos, partnerNames] =
|
||||
await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where: userWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityPreference: { select: { selectedCityCode: true } },
|
||||
promoTouch: { select: { promoCodeId: true } },
|
||||
},
|
||||
}),
|
||||
can('users')
|
||||
? this.prisma.user.findMany({
|
||||
where: userWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityPreference: { select: { selectedCityCode: true } },
|
||||
promoTouch: { select: { promoCodeId: true } },
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.UserGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
createdAt: true;
|
||||
cityPreference: { select: { selectedCityCode: true } };
|
||||
promoTouch: { select: { promoCodeId: true } };
|
||||
};
|
||||
}>
|
||||
>(),
|
||||
skipOrders
|
||||
? Promise.resolve([])
|
||||
? emptyRows<
|
||||
Prisma.OrderGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
userId: true;
|
||||
createdAt: true;
|
||||
cityId: true;
|
||||
promoCodeId: true;
|
||||
payStatus: true;
|
||||
};
|
||||
}>
|
||||
>()
|
||||
: this.prisma.order.findMany({
|
||||
where: orderWhere,
|
||||
select: {
|
||||
@@ -286,45 +500,81 @@ export class AdminDashboardService {
|
||||
payStatus: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where: partnerWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityId: true,
|
||||
companyName: true,
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.store.findMany({
|
||||
where: storeWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityId: true,
|
||||
partnerAccountId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.redeemRecord.findMany({
|
||||
where: redeemWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
amount: true,
|
||||
settleAmount: true,
|
||||
store: { select: { cityId: true, partnerAccountId: true } },
|
||||
},
|
||||
}),
|
||||
can('partners')
|
||||
? this.prisma.partnerAccount.findMany({
|
||||
where: partnerWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityId: true,
|
||||
companyName: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.PartnerAccountGetPayload<{
|
||||
select: { id: true; createdAt: true; cityId: true; companyName: true; name: true };
|
||||
}>
|
||||
>(),
|
||||
can('stores')
|
||||
? this.prisma.store.findMany({
|
||||
where: storeWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
cityId: true,
|
||||
partnerAccountId: true,
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.StoreGetPayload<{
|
||||
select: { id: true; createdAt: true; cityId: true; partnerAccountId: true };
|
||||
}>
|
||||
>(),
|
||||
can('benefit')
|
||||
? this.prisma.redeemRecord.findMany({
|
||||
where: redeemWhere,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
amount: true,
|
||||
settleAmount: true,
|
||||
store: { select: { cityId: true, partnerAccountId: true } },
|
||||
},
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.RedeemRecordGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
createdAt: true;
|
||||
amount: true;
|
||||
settleAmount: true;
|
||||
store: { select: { cityId: true; partnerAccountId: true } };
|
||||
};
|
||||
}>
|
||||
>(),
|
||||
this.prisma.commonCity.findMany({
|
||||
where: cityListWhere,
|
||||
select: { id: true, code: true, name: true },
|
||||
}),
|
||||
this.prisma.commonPromoCode.findMany({
|
||||
select: { id: true, code: true, name: true },
|
||||
}),
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where: { isPrimary: 1 },
|
||||
select: { id: true, companyName: true, name: true },
|
||||
}),
|
||||
can('promo_codes')
|
||||
? this.prisma.commonPromoCode.findMany({
|
||||
select: { id: true, code: true, name: true },
|
||||
})
|
||||
: emptyRows<Prisma.CommonPromoCodeGetPayload<{ select: { id: true; code: true; name: true } }>>(),
|
||||
can('partners') || can('stores') || can('benefit')
|
||||
? this.prisma.partnerAccount.findMany({
|
||||
where: {
|
||||
isPrimary: 1,
|
||||
...(cityIdFilter(scope) ? { cityId: cityIdFilter(scope) } : {}),
|
||||
},
|
||||
select: { id: true, companyName: true, name: true },
|
||||
})
|
||||
: emptyRows<
|
||||
Prisma.PartnerAccountGetPayload<{
|
||||
select: { id: true; companyName: true; name: true };
|
||||
}>
|
||||
>(),
|
||||
]);
|
||||
|
||||
const cityByCode = new Map(cities.map((c) => [c.code, c]));
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { HqAdminRoleValue } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto';
|
||||
import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto';
|
||||
import { hashPassword } from '../../common/crypto/password.util';
|
||||
|
||||
const HQ_ACCOUNT_SELECT = {
|
||||
id: true,
|
||||
phone: true,
|
||||
loginName: true,
|
||||
passwordHash: true,
|
||||
name: true,
|
||||
adminRole: true,
|
||||
status: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
cities: { select: { cityId: true } },
|
||||
} satisfies Prisma.HqAccountSelect;
|
||||
|
||||
function mapHqAccountRow(account: {
|
||||
id: bigint;
|
||||
phone: string;
|
||||
@@ -16,6 +30,7 @@ function mapHqAccountRow(account: {
|
||||
status: string;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
cities?: { cityId: bigint }[];
|
||||
}) {
|
||||
return {
|
||||
id: account.id,
|
||||
@@ -27,6 +42,7 @@ function mapHqAccountRow(account: {
|
||||
status: account.status,
|
||||
lastLoginAt: account.lastLoginAt,
|
||||
createdAt: account.createdAt,
|
||||
cityIds: (account.cities ?? []).map((c) => c.cityId.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +50,31 @@ function mapHqAccountRow(account: {
|
||||
export class AdminHqAccountsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private async assertCityIds(cityIds: string[]) {
|
||||
const unique = [...new Set(cityIds.map((id) => id.trim()).filter(Boolean))];
|
||||
if (!unique.length) return [] as bigint[];
|
||||
const ids = unique.map((id) => BigInt(id));
|
||||
const count = await this.prisma.commonCity.count({ where: { id: { in: ids } } });
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('存在无效城市');
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private assertCityRequirement(adminRole: string, cityIds: bigint[]) {
|
||||
if (adminRole === 'CITY_STORE_SERVICE' && !cityIds.length) {
|
||||
throw new BadRequestException('城市门店服务须至少勾选一个负责城市');
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceCities(tx: Prisma.TransactionClient, hqAccountId: bigint, cityIds: bigint[]) {
|
||||
await tx.hqAccountCity.deleteMany({ where: { hqAccountId } });
|
||||
if (!cityIds.length) return;
|
||||
await tx.hqAccountCity.createMany({
|
||||
data: cityIds.map((cityId) => ({ hqAccountId, cityId })),
|
||||
});
|
||||
}
|
||||
|
||||
async list(query: AdminHqAccountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
@@ -48,17 +89,7 @@ export class AdminHqAccountsService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
loginName: true,
|
||||
passwordHash: true,
|
||||
name: true,
|
||||
adminRole: true,
|
||||
status: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: HQ_ACCOUNT_SELECT,
|
||||
}),
|
||||
this.prisma.hqAccount.count({ where }),
|
||||
]);
|
||||
@@ -73,34 +104,31 @@ export class AdminHqAccountsService {
|
||||
async detail(id: bigint) {
|
||||
const account = await this.prisma.hqAccount.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
loginName: true,
|
||||
passwordHash: true,
|
||||
name: true,
|
||||
adminRole: true,
|
||||
status: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: HQ_ACCOUNT_SELECT,
|
||||
});
|
||||
if (!account) throw new NotFoundException('HQ 账号不存在');
|
||||
return serializeBigInt(mapHqAccountRow(account));
|
||||
}
|
||||
|
||||
async create(dto: CreateHqAccountDto) {
|
||||
const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
||||
const adminRole = (dto.adminRole ?? 'OPS') as HqAdminRoleValue;
|
||||
const cityIds = await this.assertCityIds(dto.cityIds ?? []);
|
||||
this.assertCityRequirement(adminRole, cityIds);
|
||||
|
||||
if (dto.credentialType === 'phone') {
|
||||
if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号');
|
||||
const phone = dto.phone.trim();
|
||||
const exists = await this.prisma.hqAccount.findUnique({ where: { phone } });
|
||||
if (exists) throw new BadRequestException('手机号已存在');
|
||||
const account = await this.prisma.hqAccount.create({
|
||||
data: { phone, name: dto.name, adminRole },
|
||||
const account = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.hqAccount.create({
|
||||
data: { phone, name: dto.name, adminRole },
|
||||
select: HQ_ACCOUNT_SELECT,
|
||||
});
|
||||
await this.replaceCities(tx, created.id, cityIds);
|
||||
return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT });
|
||||
});
|
||||
return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null }));
|
||||
return serializeBigInt(mapHqAccountRow(account));
|
||||
}
|
||||
|
||||
if (!dto.loginName?.trim() || !dto.password) {
|
||||
@@ -114,27 +142,34 @@ export class AdminHqAccountsService {
|
||||
const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } });
|
||||
if (phoneTaken) throw new BadRequestException('手机号已存在');
|
||||
|
||||
const account = await this.prisma.hqAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
loginName,
|
||||
passwordHash: hashPassword(dto.password),
|
||||
name: dto.name,
|
||||
adminRole,
|
||||
},
|
||||
const account = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.hqAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
loginName,
|
||||
passwordHash: hashPassword(dto.password!),
|
||||
name: dto.name,
|
||||
adminRole,
|
||||
},
|
||||
select: HQ_ACCOUNT_SELECT,
|
||||
});
|
||||
await this.replaceCities(tx, created.id, cityIds);
|
||||
return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT });
|
||||
});
|
||||
return serializeBigInt(mapHqAccountRow(account));
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateHqAccountDto) {
|
||||
const current = await this.prisma.hqAccount.findUnique({ where: { id } });
|
||||
const current = await this.prisma.hqAccount.findUnique({
|
||||
where: { id },
|
||||
include: { cities: { select: { cityId: true } } },
|
||||
});
|
||||
if (!current) throw new NotFoundException('HQ 账号不存在');
|
||||
|
||||
if (dto.loginName !== undefined) {
|
||||
const loginName = dto.loginName.trim();
|
||||
if (!loginName) throw new BadRequestException('用户名不能为空');
|
||||
const loginNameInput = dto.loginName === undefined ? undefined : dto.loginName.trim();
|
||||
if (loginNameInput) {
|
||||
const conflict = await this.prisma.hqAccount.findFirst({
|
||||
where: { loginName, id: { not: id } },
|
||||
where: { loginName: loginNameInput, id: { not: id } },
|
||||
});
|
||||
if (conflict) throw new BadRequestException('用户名已存在');
|
||||
}
|
||||
@@ -150,29 +185,34 @@ export class AdminHqAccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
const account = await this.prisma.hqAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
|
||||
...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}),
|
||||
...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}),
|
||||
...(dto.adminRole !== undefined
|
||||
? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }
|
||||
: {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
loginName: true,
|
||||
passwordHash: true,
|
||||
name: true,
|
||||
adminRole: true,
|
||||
status: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
const nextRole = (dto.adminRole ?? current.adminRole) as HqAdminRoleValue;
|
||||
const nextCityIds =
|
||||
dto.cityIds !== undefined
|
||||
? await this.assertCityIds(dto.cityIds)
|
||||
: current.cities.map((c) => c.cityId);
|
||||
this.assertCityRequirement(nextRole, nextCityIds);
|
||||
|
||||
const roleChanged = dto.adminRole !== undefined && dto.adminRole !== current.adminRole;
|
||||
|
||||
const account = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.hqAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
|
||||
...(loginNameInput ? { loginName: loginNameInput } : {}),
|
||||
...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}),
|
||||
...(dto.adminRole !== undefined ? { adminRole: nextRole } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
||||
},
|
||||
});
|
||||
if (dto.cityIds !== undefined) {
|
||||
await this.replaceCities(tx, id, nextCityIds);
|
||||
}
|
||||
if (roleChanged) {
|
||||
await tx.hqAccountPermission.deleteMany({ where: { hqAccountId: id } });
|
||||
}
|
||||
return tx.hqAccount.findUniqueOrThrow({ where: { id }, select: HQ_ACCOUNT_SELECT });
|
||||
});
|
||||
return serializeBigInt(mapHqAccountRow(account));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export class AdminHqPermissionsController {
|
||||
includeBody: true,
|
||||
})
|
||||
saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) {
|
||||
return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys);
|
||||
const grantKeys = dto.grantKeys ?? dto.permissionKeys ?? [];
|
||||
return this.service.saveAccountPermissions(BigInt(id), grantKeys, dto.denyKeys ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
HQ_ADMIN_ROLE_VALUES,
|
||||
HQ_PERMISSION_CATALOG,
|
||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||
computeHqEffectivePermissionKeys,
|
||||
expandHqPermissionKeys,
|
||||
type HqAdminRoleValue,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -14,6 +17,10 @@ const VALID_PERMISSION_KEYS = new Set<string>([
|
||||
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||
]);
|
||||
|
||||
const EDITABLE_ROLES = new Set<HqAdminRoleValue>(
|
||||
HQ_ADMIN_ROLE_VALUES.filter((r) => r !== 'SUPER_ADMIN'),
|
||||
);
|
||||
|
||||
function assertPermissionKeys(keys: string[]) {
|
||||
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
|
||||
if (invalid.length) {
|
||||
@@ -21,6 +28,13 @@ function assertPermissionKeys(keys: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function asAdminRole(role: string): HqAdminRoleValue {
|
||||
if (!(HQ_ADMIN_ROLE_VALUES as readonly string[]).includes(role)) {
|
||||
throw new BadRequestException(`无效角色: ${role}`);
|
||||
}
|
||||
return role as HqAdminRoleValue;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminHqPermissionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -28,32 +42,36 @@ export class AdminHqPermissionsService {
|
||||
catalog() {
|
||||
return {
|
||||
permissions: HQ_PERMISSION_CATALOG,
|
||||
roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({
|
||||
roles: HQ_ADMIN_ROLE_VALUES.map((role) => ({
|
||||
role,
|
||||
permissionKeys,
|
||||
permissionKeys: HQ_ROLE_DEFAULT_PERMISSIONS[role],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getRolePermissions(role: string) {
|
||||
const adminRole = asAdminRole(role);
|
||||
const rows = await this.prisma.hqRolePermission.findMany({
|
||||
where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' },
|
||||
where: { adminRole },
|
||||
select: { permissionKey: true },
|
||||
});
|
||||
const permissionKeys =
|
||||
rows.length > 0
|
||||
? expandHqPermissionKeys(rows.map((r) => r.permissionKey))
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
|
||||
return { role, permissionKeys };
|
||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[adminRole] ?? [])];
|
||||
return { role: adminRole, permissionKeys };
|
||||
}
|
||||
|
||||
async saveRolePermissions(role: string, permissionKeys: string[]) {
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
const adminRole = asAdminRole(role);
|
||||
if (adminRole === 'SUPER_ADMIN') {
|
||||
throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权');
|
||||
}
|
||||
if (!EDITABLE_ROLES.has(adminRole)) {
|
||||
throw new BadRequestException(`无效角色: ${role}`);
|
||||
}
|
||||
assertPermissionKeys(permissionKeys);
|
||||
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
|
||||
...(normalized.length
|
||||
@@ -76,51 +94,84 @@ export class AdminHqPermissionsService {
|
||||
|
||||
const userPerms = await this.prisma.hqAccountPermission.findMany({
|
||||
where: { hqAccountId: accountId },
|
||||
select: { permissionKey: true },
|
||||
select: { permissionKey: true, effect: true },
|
||||
});
|
||||
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
||||
const grantKeys = expandHqPermissionKeys(
|
||||
userPerms.filter((p) => p.effect !== 'DENY').map((p) => p.permissionKey),
|
||||
);
|
||||
const denyKeys = expandHqPermissionKeys(
|
||||
userPerms.filter((p) => p.effect === 'DENY').map((p) => p.permissionKey),
|
||||
);
|
||||
|
||||
if (account.adminRole === 'SUPER_ADMIN') {
|
||||
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
|
||||
(p) => p.key,
|
||||
) as HqPermissionKey[];
|
||||
const effectivePermissionKeys = [
|
||||
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
|
||||
] as HqPermissionKey[];
|
||||
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map((p) => p.key) as HqPermissionKey[];
|
||||
const effectivePermissionKeys = computeHqEffectivePermissionKeys(
|
||||
rolePermissionKeys,
|
||||
grantKeys,
|
||||
[],
|
||||
);
|
||||
return serializeBigInt({
|
||||
account,
|
||||
permissionKeys: userPermissionKeys,
|
||||
permissionKeys: grantKeys,
|
||||
rolePermissionKeys,
|
||||
userPermissionKeys,
|
||||
grantKeys,
|
||||
denyKeys: [] as HqPermissionKey[],
|
||||
userPermissionKeys: grantKeys,
|
||||
effectivePermissionKeys,
|
||||
});
|
||||
}
|
||||
|
||||
const rolePerms = await this.getRolePermissions(account.adminRole);
|
||||
const effectivePermissionKeys = [
|
||||
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
|
||||
] as HqPermissionKey[];
|
||||
const effectivePermissionKeys = computeHqEffectivePermissionKeys(
|
||||
rolePerms.permissionKeys,
|
||||
grantKeys,
|
||||
denyKeys,
|
||||
);
|
||||
|
||||
return serializeBigInt({
|
||||
account,
|
||||
permissionKeys: userPermissionKeys,
|
||||
permissionKeys: grantKeys,
|
||||
rolePermissionKeys: rolePerms.permissionKeys,
|
||||
userPermissionKeys,
|
||||
grantKeys,
|
||||
denyKeys,
|
||||
userPermissionKeys: grantKeys,
|
||||
effectivePermissionKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) {
|
||||
async saveAccountPermissions(
|
||||
accountId: bigint,
|
||||
grantKeysInput: string[],
|
||||
denyKeysInput: string[] = [],
|
||||
) {
|
||||
const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } });
|
||||
if (!account) throw new NotFoundException('HQ 账号不存在');
|
||||
assertPermissionKeys(permissionKeys);
|
||||
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||
assertPermissionKeys(grantKeysInput);
|
||||
assertPermissionKeys(denyKeysInput);
|
||||
const grantKeys = expandHqPermissionKeys(grantKeysInput);
|
||||
const denyKeys =
|
||||
account.adminRole === 'SUPER_ADMIN' ? [] : expandHqPermissionKeys(denyKeysInput);
|
||||
const overlap = grantKeys.filter((k) => denyKeys.includes(k));
|
||||
if (overlap.length) {
|
||||
throw new BadRequestException(`同一权限不能同时追加和撤销: ${overlap.join(', ')}`);
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
|
||||
...(normalized.length
|
||||
...(grantKeys.length || denyKeys.length
|
||||
? [
|
||||
this.prisma.hqAccountPermission.createMany({
|
||||
data: normalized.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
|
||||
data: [
|
||||
...grantKeys.map((permissionKey) => ({
|
||||
hqAccountId: accountId,
|
||||
permissionKey,
|
||||
effect: 'GRANT' as const,
|
||||
})),
|
||||
...denyKeys.map((permissionKey) => ({
|
||||
hqAccountId: accountId,
|
||||
permissionKey,
|
||||
effect: 'DENY' as const,
|
||||
})),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { StoreCategoryService } from '../store/store-category.service';
|
||||
@@ -9,7 +13,8 @@ import {
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/store-categories')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('store_categories')
|
||||
export class AdminStoreCategoriesController {
|
||||
constructor(private readonly categories: StoreCategoryService) {}
|
||||
|
||||
@@ -24,6 +29,7 @@ export class AdminStoreCategoriesController {
|
||||
}
|
||||
|
||||
@Post('ensure-defaults')
|
||||
@RequireHqPermissions('store_categories_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_ENSURE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
@@ -57,6 +63,7 @@ export class AdminStoreCategoriesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequireHqPermissions('store_categories_delete')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_DELETE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminStoreRatingsService } from './admin-store-ratings.service';
|
||||
import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/store-ratings')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('store_ratings')
|
||||
export class AdminStoreRatingsController {
|
||||
constructor(private readonly service: AdminStoreRatingsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreRatingsQueryDto) {
|
||||
return this.service.list(query);
|
||||
list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreRatingsQueryDto) {
|
||||
return this.service.list(query, user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,26 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto';
|
||||
import { HqPermissionsResolver, mergeHqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoreRatingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
async list(query: AdminStoreRatingsQueryDto) {
|
||||
async list(query: AdminStoreRatingsQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreRatingWhereInput = {};
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
if (query.storeId) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
|
||||
}
|
||||
const storeWhere = mergeHqStoreCityWhere({}, scope, query.cityId);
|
||||
const where: Prisma.StoreRatingWhereInput = {
|
||||
store: storeWhere,
|
||||
};
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.redeemNo) {
|
||||
where.redeemRecord = { redeemNo: { contains: query.redeemNo } };
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AdminStoresService } from './admin-stores.service';
|
||||
import {
|
||||
AdminStoreAccountsQueryDto,
|
||||
@@ -19,58 +25,64 @@ import {
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/stores')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('stores')
|
||||
export class AdminStoresController {
|
||||
constructor(private readonly service: AdminStoresService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoresQueryDto) {
|
||||
return this.service.listStores(query);
|
||||
list(@CurrentUser() user: AuthUser, @Query() query: AdminStoresQueryDto) {
|
||||
return this.service.listStores(query, user.actorId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detailStore(BigInt(id));
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.detailStore(BigInt(id), user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({ action: HqOperationAction.STORE_CREATE, refType: 'STORE', refIdField: 'id', includeBody: true })
|
||||
create(@Body() dto: CreateStoreDto) {
|
||||
return this.service.createStore(dto);
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreDto) {
|
||||
return this.service.createStore(dto, user.actorId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({ action: HqOperationAction.STORE_UPDATE, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreDto) {
|
||||
return this.service.updateStore(BigInt(id), dto);
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreDto) {
|
||||
return this.service.updateStore(BigInt(id), dto, user.actorId);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||
return this.service.updateStoreStatus(BigInt(id), dto);
|
||||
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||
return this.service.updateStoreStatus(BigInt(id), dto, user.actorId);
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
@HqOperation({ action: HqOperationAction.STORE_AUDIT, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
|
||||
return this.service.auditStore(BigInt(id), body);
|
||||
audit(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { approved: boolean; remark?: string },
|
||||
) {
|
||||
return this.service.auditStore(BigInt(id), body, user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-accounts')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('store_accounts')
|
||||
export class AdminStoreAccountsController {
|
||||
constructor(private readonly service: AdminStoresService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreAccountsQueryDto) {
|
||||
return this.service.listStoreAccounts(query);
|
||||
list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreAccountsQueryDto) {
|
||||
return this.service.listStoreAccounts(query, user.actorId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detailStoreAccount(BigInt(id));
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.detailStoreAccount(BigInt(id), user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -80,8 +92,8 @@ export class AdminStoreAccountsController {
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateStoreAccountDto) {
|
||||
return this.service.createStoreAccount(dto);
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreAccountDto) {
|
||||
return this.service.createStoreAccount(dto, user.actorId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@@ -91,8 +103,8 @@ export class AdminStoreAccountsController {
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
|
||||
return this.service.updateStoreAccount(BigInt(id), dto);
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreAccountDto) {
|
||||
return this.service.updateStoreAccount(BigInt(id), dto, user.actorId);
|
||||
}
|
||||
|
||||
@Delete(':id/staff/:staffId')
|
||||
@@ -101,19 +113,20 @@ export class AdminStoreAccountsController {
|
||||
refType: 'STORE_ACCOUNT',
|
||||
refIdParam: 'staffId',
|
||||
})
|
||||
deleteStaff(@Param('id') id: string, @Param('staffId') staffId: string) {
|
||||
return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId));
|
||||
deleteStaff(@CurrentUser() user: AuthUser, @Param('id') id: string, @Param('staffId') staffId: string) {
|
||||
return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId), user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-media')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('store_media')
|
||||
export class AdminStoreMediaController {
|
||||
constructor(private readonly service: AdminStoresService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminStoreMediaQueryDto) {
|
||||
return this.service.listStoreMedia(query);
|
||||
list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreMediaQueryDto) {
|
||||
return this.service.listStoreMedia(query, user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -123,8 +136,8 @@ export class AdminStoreMediaController {
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateStoreMediaDto) {
|
||||
return this.service.createStoreMedia(dto);
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreMediaDto) {
|
||||
return this.service.createStoreMedia(dto, user.actorId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@@ -132,15 +145,14 @@ export class AdminStoreMediaController {
|
||||
action: HqOperationAction.STORE_MEDIA_UPDATE,
|
||||
refType: 'STORE_MEDIA',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreMediaDto) {
|
||||
return this.service.updateStoreMedia(BigInt(id), dto);
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreMediaDto) {
|
||||
return this.service.updateStoreMedia(BigInt(id), dto, user.actorId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({ action: HqOperationAction.STORE_MEDIA_DELETE, refType: 'STORE_MEDIA', refIdParam: 'id' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.deleteStoreMedia(BigInt(id));
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.service.deleteStoreMedia(BigInt(id), user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
UpdateStoreStatusDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard';
|
||||
|
||||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||
@@ -54,21 +55,54 @@ export class AdminStoresService {
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> {
|
||||
const where = hqStoreCityWhere(scope);
|
||||
if (!where) return null;
|
||||
const rows = await this.prisma.store.findMany({ where, select: { id: true } });
|
||||
return rows.length ? rows.map((r) => r.id) : [BigInt(0)];
|
||||
}
|
||||
|
||||
private async assertStoreAccountInScope(actorId: bigint, accountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { bindings: { include: { store: { select: { cityId: true } } } } },
|
||||
});
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
if (scope === null) return account;
|
||||
const ok = account.bindings.some((b) => scope.some((id) => id === b.store.cityId));
|
||||
if (!ok) throw new ForbiddenException('无权访问该城市的门店');
|
||||
return account;
|
||||
}
|
||||
|
||||
private async assertMediaInScope(actorId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.commonResource.findUnique({
|
||||
where: { id: mediaId },
|
||||
select: { ownerType: true, ownerId: true },
|
||||
});
|
||||
if (!media) throw new NotFoundException('资源不存在');
|
||||
if (media.ownerType === 'STORE') {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, media.ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreWhereInput = {};
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
let where: Prisma.StoreWhereInput = {};
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
|
||||
if (query.auditStatus) {
|
||||
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
|
||||
}
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
where = mergeHqStoreCityWhere(where, scope, query.cityId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
@@ -98,9 +132,10 @@ export class AdminStoresService {
|
||||
const pendingByStore = new Map<string, string>();
|
||||
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
|
||||
const pendingInfoByStore = new Map<string, string>();
|
||||
const redeemedByStore = new Map<string, number>();
|
||||
if (items.length) {
|
||||
const storeIds = items.map((s) => s.id);
|
||||
const [pendingReqs, pendingInfoReqs] = await Promise.all([
|
||||
const [pendingReqs, pendingInfoReqs, redeemSums] = await Promise.all([
|
||||
this.prisma.storePackageChangeRequest.findMany({
|
||||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
@@ -109,9 +144,17 @@ export class AdminStoresService {
|
||||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
}),
|
||||
this.prisma.redeemRecord.groupBy({
|
||||
by: ['storeId'],
|
||||
where: { storeId: { in: storeIds } },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
|
||||
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
|
||||
for (const r of redeemSums) {
|
||||
redeemedByStore.set(r.storeId.toString(), r._sum.amount != null ? Number(r._sum.amount) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
@@ -124,6 +167,7 @@ export class AdminStoresService {
|
||||
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
||||
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
||||
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
|
||||
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
|
||||
partner: s.partnerAccount,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
@@ -135,7 +179,8 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
async detailStore(id: bigint) {
|
||||
async detailStore(id: bigint, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -184,7 +229,8 @@ export class AdminStoresService {
|
||||
}));
|
||||
}
|
||||
|
||||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
|
||||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||||
@@ -192,7 +238,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
|
||||
@@ -258,7 +305,8 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const current = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('门店不存在');
|
||||
|
||||
@@ -524,10 +572,11 @@ export class AdminStoresService {
|
||||
}
|
||||
});
|
||||
|
||||
return this.detailStore(id);
|
||||
return this.detailStore(id, actorId);
|
||||
}
|
||||
|
||||
async createStore(dto: CreateStoreDto) {
|
||||
async createStore(dto: CreateStoreDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreCityInScope(actorId, BigInt(dto.cityId));
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!isMobilePhone(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
@@ -727,10 +776,11 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.detailStore(store.id);
|
||||
return this.detailStore(store.id, actorId);
|
||||
}
|
||||
|
||||
async createStoreAccount(dto: CreateStoreAccountDto) {
|
||||
async createStoreAccount(dto: CreateStoreAccountDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId));
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: BigInt(dto.storeId) },
|
||||
include: { bindings: true },
|
||||
@@ -768,15 +818,24 @@ export class AdminStoresService {
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
async listStoreMedia(query: AdminStoreMediaQueryDto) {
|
||||
async listStoreMedia(query: AdminStoreMediaQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
if (query.storeId) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
|
||||
}
|
||||
const where: Prisma.CommonResourceWhereInput = {
|
||||
ownerType: 'STORE',
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
if (query.storeId) where.ownerId = BigInt(query.storeId);
|
||||
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
|
||||
const storeScope = hqStoreCityWhere(scope);
|
||||
if (storeScope && !query.storeId) {
|
||||
const ids = await this.storeIdsInScope(scope);
|
||||
if (ids) where.ownerId = { in: ids };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonResource.findMany({
|
||||
@@ -790,7 +849,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async createStoreMedia(dto: CreateStoreMediaDto) {
|
||||
async createStoreMedia(dto: CreateStoreMediaDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId));
|
||||
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
|
||||
if (!store) throw new BadRequestException('门店不存在');
|
||||
const media = await this.prisma.commonResource.create({
|
||||
@@ -808,7 +868,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(media);
|
||||
}
|
||||
|
||||
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
|
||||
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto, actorId: bigint) {
|
||||
await this.assertMediaInScope(actorId, id);
|
||||
const media = await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -820,7 +881,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(media);
|
||||
}
|
||||
|
||||
async deleteStoreMedia(id: bigint) {
|
||||
async deleteStoreMedia(id: bigint, actorId: bigint) {
|
||||
await this.assertMediaInScope(actorId, id);
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: { status: 'DELETED' },
|
||||
@@ -828,13 +890,20 @@ export class AdminStoresService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
if (query.storeId) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
|
||||
}
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.storeId) {
|
||||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||||
} else {
|
||||
const storeWhere = hqStoreCityWhere(scope);
|
||||
if (storeWhere) where.bindings = { some: { store: storeWhere } };
|
||||
}
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
@@ -866,7 +935,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt({ items: mapped, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detailStoreAccount(id: bigint) {
|
||||
async detailStoreAccount(id: bigint, actorId: bigint) {
|
||||
await this.assertStoreAccountInScope(actorId, id);
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -895,7 +965,8 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
|
||||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto, actorId: bigint) {
|
||||
await this.assertStoreAccountInScope(actorId, id);
|
||||
const account = await this.prisma.storeAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -908,7 +979,8 @@ export class AdminStoresService {
|
||||
}
|
||||
|
||||
/** HQ 删除门店子账号(非主账号) */
|
||||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
|
||||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
|
||||
await this.assertStoreAccountInScope(actorId, parentAccountId);
|
||||
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
|
||||
if (!parent || parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('主账号不存在');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
@@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AdminUsersQueryDto } from './dto/admin-query.dto';
|
||||
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto';
|
||||
import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto, UpdateAdminUserDto } from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/users')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -48,6 +48,17 @@ export class AdminUsersController {
|
||||
return this.usersService.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.USER_UPDATE,
|
||||
refType: 'USER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateAdminUserDto) {
|
||||
return this.usersService.updateUser(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('users_delete')
|
||||
|
||||
@@ -188,6 +188,18 @@ export class AdminUsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(id: bigint, dto: { nickname: string }) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
const nickname = dto.nickname.trim() || null;
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { nickname },
|
||||
select: { id: true, userNo: true, nickname: true },
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async previewBatchDelete(ids: bigint[]) {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (!uniqueIds.length) {
|
||||
|
||||
@@ -949,6 +949,12 @@ export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto {
|
||||
confirmRisk: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAdminUserDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
/** HQ 订单发货(目前仅小飞侠 XFX) */
|
||||
export class AdminShipOrderDto {
|
||||
@IsIn(['XFX'])
|
||||
@@ -1109,8 +1115,13 @@ export class CreateHqAccountDto {
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE'])
|
||||
@IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE'])
|
||||
adminRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
cityIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateHqAccountDto {
|
||||
@@ -1131,12 +1142,17 @@ export class UpdateHqAccountDto {
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE'])
|
||||
@IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE'])
|
||||
adminRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
cityIds?: string[];
|
||||
}
|
||||
|
||||
export class SaveHqRolePermissionsDto {
|
||||
@@ -1146,9 +1162,20 @@ export class SaveHqRolePermissionsDto {
|
||||
}
|
||||
|
||||
export class SaveHqAccountPermissionsDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissionKeys: string[];
|
||||
permissionKeys?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
grantKeys?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
denyKeys?: string[];
|
||||
}
|
||||
|
||||
export class CreateProductDto {
|
||||
|
||||
@@ -352,6 +352,10 @@ export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redeemNo?: string;
|
||||
|
||||
@@ -13,6 +13,10 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreInfoChangeService } from './store-info-change.service';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -56,17 +60,19 @@ export class ShopStoreInfoChangeController {
|
||||
}
|
||||
|
||||
@Controller('admin/store-info-change-requests')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('store_audits')
|
||||
export class AdminStoreInfoChangeController {
|
||||
constructor(private readonly svc: StoreInfoChangeService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary() {
|
||||
return this.svc.adminSummary();
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.svc.adminSummary(user.actorId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -75,12 +81,13 @@ export class AdminStoreInfoChangeController {
|
||||
status: (status as 'PENDING' | 'APPROVED' | 'REJECTED') || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
actorId: user.actorId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.svc.adminDetail(BigInt(id));
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.svc.adminDetail(BigInt(id), user.actorId);
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
|
||||
@@ -21,6 +21,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { StoreService } from './store.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
||||
|
||||
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
||||
|
||||
@@ -145,6 +146,7 @@ export class StoreInfoChangeService {
|
||||
private readonly storeService: StoreService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) {
|
||||
@@ -373,10 +375,16 @@ export class StoreInfoChangeService {
|
||||
status?: StoreInfoChangeStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
actorId: bigint;
|
||||
}): Promise<{ items: StoreInfoChangeRequestDto[]; total: number; page: number; pageSize: number }> {
|
||||
const page = Math.max(1, opts.page || 1);
|
||||
const pageSize = Math.min(Math.max(opts.pageSize || 20, 1), 100);
|
||||
const where = opts.status ? { status: opts.status } : {};
|
||||
const scope = await this.hqPermissions.resolveCityScope(opts.actorId);
|
||||
const storeWhere = hqStoreCityWhere(scope);
|
||||
const where = {
|
||||
...(opts.status ? { status: opts.status } : {}),
|
||||
...(storeWhere ? { store: storeWhere } : {}),
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.storeInfoChangeRequest.findMany({
|
||||
where,
|
||||
@@ -395,20 +403,24 @@ export class StoreInfoChangeService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> {
|
||||
async adminSummary(actorId: bigint): Promise<{ pendingCount: number; packagePendingCount: number }> {
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
const storeWhere = hqStoreCityWhere(scope);
|
||||
const cityFilter = storeWhere ? { store: storeWhere } : {};
|
||||
const [infoPending, packagePending] = await Promise.all([
|
||||
this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING', ...cityFilter } }),
|
||||
this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING', ...cityFilter } }),
|
||||
]);
|
||||
return { pendingCount: infoPending, packagePendingCount: packagePending };
|
||||
}
|
||||
|
||||
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
|
||||
async adminDetail(id: bigint, actorId: bigint): Promise<StoreInfoChangeRequestDto> {
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: { store: { select: { name: true } } },
|
||||
include: { store: { select: { name: true, cityId: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('变更请求不存在');
|
||||
await this.hqPermissions.assertStoreCityInScope(actorId, row.store.cityId);
|
||||
const dto = this.toDto(
|
||||
row as unknown as Record<string, unknown>,
|
||||
(row as { store?: { name?: string } }).store?.name,
|
||||
@@ -430,8 +442,12 @@ export class StoreInfoChangeService {
|
||||
rejectReason?: string;
|
||||
reviewerId: bigint;
|
||||
}): Promise<StoreInfoChangeRequestDto> {
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id: input.id } });
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { store: { select: { cityId: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('变更请求不存在');
|
||||
await this.hqPermissions.assertStoreCityInScope(input.reviewerId, row.store.cityId);
|
||||
if (row.status !== 'PENDING') {
|
||||
throw new BadRequestException('该变更请求已处理');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StorePackageService } from './store-package.service';
|
||||
import {
|
||||
HqPermissionGuard,
|
||||
RequireHqPermissions,
|
||||
} from '../../common/guards/hq-permission.guard';
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -49,48 +53,58 @@ export class ShopStorePackageController {
|
||||
}
|
||||
|
||||
@Controller('admin/stores')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('stores')
|
||||
export class AdminStorePackageController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get(':storeId/packages')
|
||||
getPackages(@Param('storeId') storeId: string) {
|
||||
return this.packages.adminGetPackages(BigInt(storeId));
|
||||
getPackages(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
|
||||
return this.packages.adminGetPackages(BigInt(storeId), user.actorId);
|
||||
}
|
||||
|
||||
@Put(':storeId/packages')
|
||||
savePackages(@Param('storeId') storeId: string, @Body() body: { packages: unknown }) {
|
||||
savePackages(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('storeId') storeId: string,
|
||||
@Body() body: { packages: unknown },
|
||||
) {
|
||||
const normalized = this.packages.normalizePackages(body.packages);
|
||||
return this.packages.adminDirectSave(BigInt(storeId), normalized);
|
||||
return this.packages.adminDirectSave(BigInt(storeId), normalized, user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-package-audits')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||
@RequireHqPermissions('store_audits')
|
||||
export class AdminStorePackageAuditController {
|
||||
constructor(private readonly packages: StorePackageService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary() {
|
||||
return this.packages.adminAuditSummary();
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.packages.adminAuditSummary(user.actorId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.packages.adminListAudits({
|
||||
status: status || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
return this.packages.adminListAudits(
|
||||
{
|
||||
status: status || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
},
|
||||
user.actorId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':requestId')
|
||||
detail(@Param('requestId') requestId: string) {
|
||||
return this.packages.adminGetAuditDetail(BigInt(requestId));
|
||||
detail(@CurrentUser() user: AuthUser, @Param('requestId') requestId: string) {
|
||||
return this.packages.adminGetAuditDetail(BigInt(requestId), user.actorId);
|
||||
}
|
||||
|
||||
@Put(':requestId/audit')
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreService } from './store.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard';
|
||||
|
||||
type PackageInput = Record<string, unknown>;
|
||||
|
||||
@@ -26,6 +27,7 @@ export class StorePackageService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storeService: StoreService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
normalizePackages(raw: unknown): StorePackageItemDto[] {
|
||||
@@ -237,14 +239,16 @@ export class StorePackageService {
|
||||
);
|
||||
}
|
||||
|
||||
async adminGetPackages(storeId: bigint) {
|
||||
async adminGetPackages(storeId: bigint, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, storeId);
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const live = await this.listLivePackages(storeId);
|
||||
return serializeBigInt({ live });
|
||||
}
|
||||
|
||||
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[]) {
|
||||
async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[], actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, storeId);
|
||||
const store = await this.prisma.store.findUnique({ where: { id: storeId } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
await this.replaceLivePackages(storeId, packages);
|
||||
@@ -285,12 +289,13 @@ export class StorePackageService {
|
||||
]);
|
||||
}
|
||||
|
||||
async adminGetAuditDetail(requestId: bigint) {
|
||||
async adminGetAuditDetail(requestId: bigint, actorId: bigint) {
|
||||
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { store: { select: { id: true, name: true } } },
|
||||
include: { store: { select: { id: true, name: true, cityId: true } } },
|
||||
});
|
||||
if (!req) throw new NotFoundException('审核记录不存在');
|
||||
await this.hqPermissions.assertStoreCityInScope(actorId, req.store.cityId);
|
||||
const livePackages = await this.listLivePackages(req.storeId);
|
||||
return serializeBigInt({
|
||||
id: req.id.toString(),
|
||||
@@ -307,13 +312,19 @@ export class StorePackageService {
|
||||
});
|
||||
}
|
||||
|
||||
async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) {
|
||||
async adminListAudits(
|
||||
query: { status?: string; page?: number; pageSize?: number },
|
||||
actorId: bigint,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
const storeWhere = hqStoreCityWhere(scope);
|
||||
const where: Prisma.StorePackageChangeRequestWhereInput = {};
|
||||
if (query.status) {
|
||||
where.status = query.status as Prisma.EnumStorePackageChangeStatusFilter['equals'];
|
||||
}
|
||||
if (storeWhere) where.store = storeWhere;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePackageChangeRequest.findMany({
|
||||
where,
|
||||
@@ -350,8 +361,10 @@ export class StorePackageService {
|
||||
) {
|
||||
const req = await this.prisma.storePackageChangeRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { store: { select: { cityId: true } } },
|
||||
});
|
||||
if (!req) throw new NotFoundException('审核记录不存在');
|
||||
await this.hqPermissions.assertStoreCityInScope(reviewerId, req.store.cityId);
|
||||
if (req.status !== 'PENDING') {
|
||||
throw new BadRequestException('该记录已处理');
|
||||
}
|
||||
@@ -400,9 +413,11 @@ export class StorePackageService {
|
||||
return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' });
|
||||
}
|
||||
|
||||
async adminAuditSummary() {
|
||||
async adminAuditSummary(actorId: bigint) {
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
const storeWhere = hqStoreCityWhere(scope);
|
||||
const pendingCount = await this.prisma.storePackageChangeRequest.count({
|
||||
where: { status: 'PENDING' },
|
||||
where: { status: 'PENDING', ...(storeWhere ? { store: storeWhere } : {}) },
|
||||
});
|
||||
return { pendingCount };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user