@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user