v3.5.8和v3.5.9版本更新
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-25 09:20:32 +08:00
parent 7304c7a8e1
commit 5935024ea8
101 changed files with 7640 additions and 5364 deletions
@@ -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';
+27 -3
View File
@@ -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 };
}