feat(store-account): add authorization status and header filters

This commit is contained in:
WuYongJie 2026-09-27 16:08:43 +08:00
parent ccf8b6480f
commit 2ba2a37232
7 changed files with 606 additions and 32 deletions

View File

@ -32,17 +32,22 @@ import type {
StoreAccountFilters,
StoreAccountProfileFieldKey,
} from "@/lib/store-account-management";
import type { ColumnHeaderFilterSelection } from "@/lib/column-header-filters";
import {
EMPTY_STORE_ACCOUNT_FILTERS,
formatStoreAccountAuthorizationStatus,
formatStoreAccountShopState,
formatStoreAccountType,
getStoreAccountProfilePlatformForStoreName,
STORE_ACCOUNT_FIELDS,
STORE_ACCOUNT_AUTHORIZATION_COLUMN,
STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS,
STORE_ACCOUNT_PROFILE_FIELDS,
STORE_ACCOUNT_PROFIT_CENTERS,
STORE_ACCOUNT_SHOP_STATE_COLUMN,
STORE_ACCOUNT_SHOP_STATE_FILTER_OPTIONS,
} from "@/lib/store-account-management";
import { ColumnHeaderFilter } from "@/components/common/ColumnHeaderFilter";
import { StoreAccountBatchUpdateDialog } from "./StoreAccountBatchUpdateDialog";
import { StoreAccountBatchDeleteDialog } from "./StoreAccountBatchDeleteDialog";
import { StoreAccountImportProgress } from "./StoreAccountImportProgress";
@ -60,6 +65,72 @@ type Props = {
};
type Draft = Partial<Record<StoreAccountFieldKey, string>> & { id?: string };
type ProfileDraft = Partial<Record<StoreAccountProfileFieldKey, string>>;
type StoreAccountTableColumn =
| (typeof STORE_ACCOUNT_FIELDS)[number]
| typeof STORE_ACCOUNT_SHOP_STATE_COLUMN
| typeof STORE_ACCOUNT_AUTHORIZATION_COLUMN;
type StoreAccountHeaderFilterOption = {
value: string;
label: string;
};
type StoreAccountHeaderFilterKey =
| "storeName"
| "operationOwner"
| "operationDepartmentHead"
| "operationDirector"
| "profitCenter"
| "accountType"
| "shopState"
| "authorizationStatus";
function toStoreAccountHeaderFilterSelection(
value: string,
options: readonly StoreAccountHeaderFilterOption[],
): ColumnHeaderFilterSelection {
const selectedValues = new Set(
value
.split(/[,,;;]+/)
.map((item) => item.trim())
.filter(Boolean),
);
return {
mode: "include",
values: options
.filter((option) => selectedValues.has(option.value))
.map((option) => option.label),
};
}
function fromStoreAccountHeaderFilterSelection(
selection: ColumnHeaderFilterSelection | null,
options: readonly StoreAccountHeaderFilterOption[],
) {
if (!selection || selection.mode !== "include") return "";
const selectedLabels = new Set(selection.values);
return options
.filter((option) => selectedLabels.has(option.label))
.map((option) => option.value)
.join(",");
}
const STORE_ACCOUNT_HEADER_FILTER_KEYS = new Set<StoreAccountHeaderFilterKey>([
"storeName",
"operationOwner",
"operationDepartmentHead",
"operationDirector",
"profitCenter",
"accountType",
"shopState",
"authorizationStatus",
]);
function getStoreAccountHeaderFilterStateKey(
key: StoreAccountHeaderFilterKey,
): "operationOwnerExact" | Exclude<StoreAccountHeaderFilterKey, "operationOwner"> {
return key === "operationOwner" ? "operationOwnerExact" : key;
}
async function readResponse(response: Response) {
const payload = await response.json().catch(() => null);
@ -127,10 +198,16 @@ export function StoreAccountManagementView({
),
[data.fieldAccess],
);
const renderColumns = useMemo(
const renderColumns = useMemo<StoreAccountTableColumn[]>(
() =>
visibleFields.flatMap((field) =>
field.key === "accountType" ? [field, STORE_ACCOUNT_SHOP_STATE_COLUMN] : [field],
visibleFields.flatMap((field): StoreAccountTableColumn[] =>
field.key === "accountType"
? [
field,
STORE_ACCOUNT_SHOP_STATE_COLUMN,
STORE_ACCOUNT_AUTHORIZATION_COLUMN,
]
: [field],
),
[visibleFields],
);
@ -214,6 +291,44 @@ export function StoreAccountManagementView({
void load(EMPTY_STORE_ACCOUNT_FILTERS, 1, data.pageInfo.pageSize);
};
const applyHeaderFilter = (
key: StoreAccountHeaderFilterKey,
selection: ColumnHeaderFilterSelection | null,
options: readonly StoreAccountHeaderFilterOption[],
) => {
const stateKey = getStoreAccountHeaderFilterStateKey(key);
const nextFilters = {
...filters,
[stateKey]: fromStoreAccountHeaderFilterSelection(selection, options),
};
setFilters(nextFilters);
void load(nextFilters, 1, data.pageInfo.pageSize);
};
const headerFilterOptions = (
key: StoreAccountHeaderFilterKey,
): StoreAccountHeaderFilterOption[] => {
if (key === "shopState") {
return STORE_ACCOUNT_SHOP_STATE_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}));
}
if (key === "authorizationStatus") {
return STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}));
}
if (key === "accountType") {
return data.filterOptions.accountType.map((value) => ({
value,
label: formatStoreAccountType(value),
}));
}
return data.filterOptions[key].map((value) => ({ value, label: value }));
};
const storeAccountImport = useStoreAccountImport({
onCompleted: () => load(filters, 1, data.pageInfo.pageSize),
});
@ -420,19 +535,6 @@ export function StoreAccountManagementView({
<option value="MAIN">主账号</option>
<option value="SUB">子账号</option>
</select>
<select
value={filters.shopState}
onChange={(e) =>
setFilters({ ...filters, shopState: e.target.value })
}
>
<option value="">全部店铺状态</option>
{STORE_ACCOUNT_SHOP_STATE_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={filters.operationOwner}
onChange={(e) =>
@ -541,7 +643,49 @@ export function StoreAccountManagementView({
</th>
)}
{renderColumns.map((field) => (
<th key={field.key}>{field.label}</th>
<th key={field.key}>
<span className="store-account-column-header">
<span>{field.label}</span>
{STORE_ACCOUNT_HEADER_FILTER_KEYS.has(
field.key as StoreAccountHeaderFilterKey,
) && (
<ColumnHeaderFilter
label={field.label}
active={Boolean(
filters[
getStoreAccountHeaderFilterStateKey(
field.key as StoreAccountHeaderFilterKey,
)
],
)}
loadValues={async () =>
headerFilterOptions(
field.key as StoreAccountHeaderFilterKey,
).map((option) => option.label)
}
selection={toStoreAccountHeaderFilterSelection(
filters[
getStoreAccountHeaderFilterStateKey(
field.key as StoreAccountHeaderFilterKey,
)
],
headerFilterOptions(
field.key as StoreAccountHeaderFilterKey,
),
)}
onApply={(selection) =>
applyHeaderFilter(
field.key as StoreAccountHeaderFilterKey,
selection,
headerFilterOptions(
field.key as StoreAccountHeaderFilterKey,
),
)
}
/>
)}
</span>
</th>
))}
<th className="actions-column">操作</th>
</tr>
@ -590,6 +734,10 @@ export function StoreAccountManagementView({
formatStoreAccountType(row.accountType)
) : field.key === "shopState" ? (
formatStoreAccountShopState(row.shopState)
) : field.key === "authorizationStatus" ? (
formatStoreAccountAuthorizationStatus(
row.authorizationStatus,
)
) : field.key === "password" ? (
<span className="password-cell">
{row.password || (row.passwordConfigured ? "••••••" : "未设置")}
@ -1134,5 +1282,5 @@ const styles = `
.modal-actions button.danger:disabled{opacity:.45;cursor:not-allowed}
.store-account-export-menu{position:relative}.store-account-export-options{position:absolute;top:calc(100% + 7px);right:0;z-index:90;width:230px;padding:6px;border:1px solid #d4d4d8;border-radius:6px;background:#fff;box-shadow:0 10px 24px rgba(0,0,0,.12)}.store-account-actions .store-account-export-option{display:flex;width:100%;height:auto;min-height:48px;justify-content:flex-start;gap:9px;padding:7px 9px;border:0;border-radius:4px;background:#fff;text-align:left}.store-account-actions .store-account-export-option:hover:not(:disabled){background:#f4f4f5}.store-account-actions .store-account-export-option>span{display:flex;min-width:0;flex-direction:column;gap:2px}.store-account-actions .store-account-export-option b{color:#27272a;font-size:12px;font-weight:600}.store-account-actions .store-account-export-option small{color:#71717a;font-size:10px;font-weight:400}.store-account-actions .store-account-export-option:disabled{opacity:.45}
.actions-column{width:1%;min-width:170px}.row-actions{display:flex;width:max-content;min-width:0;align-items:center;gap:8px;flex-wrap:nowrap;white-space:nowrap}.row-actions button,.row-actions a{display:inline-flex;flex:0 0 auto;align-items:center;white-space:nowrap}
.store-account-page{position:relative}.store-account-table-wrap{padding-bottom:96px;box-sizing:border-box;scrollbar-color:#a1a1aa #f4f4f5;scrollbar-width:auto}
.store-account-page{position:relative}.store-account-column-header{display:inline-flex;align-items:center;gap:4px;min-width:0}.store-account-table-wrap{padding-bottom:96px;box-sizing:border-box;scrollbar-color:#a1a1aa #f4f4f5;scrollbar-width:auto}
`;

View File

@ -1,6 +1,7 @@
export const STORE_ACCOUNT_FIELD_KEYS = [
"storeName",
"operationOwner",
"operationOwnerExact",
"operationDepartmentHead",
"operationDirector",
"profitCenter",
@ -124,8 +125,13 @@ export const STORE_ACCOUNT_FILTER_KEYS = [
"storeName",
"accountType",
"operationOwner",
"operationOwnerExact",
"operationDepartmentHead",
"operationDirector",
"profitCenter",
"accountGroupName",
"shopState",
"authorizationStatus",
"createdAtGranularity",
"createdAtValue",
] as const;
@ -137,8 +143,13 @@ export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = {
storeName: "",
accountType: "",
operationOwner: "",
operationOwnerExact: "",
operationDepartmentHead: "",
operationDirector: "",
profitCenter: "",
accountGroupName: "",
shopState: "",
authorizationStatus: "",
createdAtGranularity: "",
createdAtValue: "",
};
@ -205,6 +216,7 @@ export type StoreAccountRow = {
jstOrderService: string;
accountGroupName: string;
shopState: number | null;
authorizationStatus: StoreAccountAuthorizationStatus | null;
createdAt: string;
updatedAt: string;
};
@ -224,6 +236,16 @@ export type StoreAccountStoreProfile = {
export type StoreAccountPage = {
rows: StoreAccountRow[];
storeNames: string[];
filterOptions: {
storeName: string[];
operationOwner: string[];
operationDepartmentHead: string[];
operationDirector: string[];
profitCenter: string[];
accountType: string[];
shopState: string[];
authorizationStatus: string[];
};
pageInfo: {
page: number;
pageSize: number;
@ -275,6 +297,16 @@ export const STORE_ACCOUNT_SHOP_STATE_COLUMN = {
label: "店铺状态",
} as const;
export type StoreAccountAuthorizationStatus =
| "AUTHORIZED"
| "NOT_REQUIRED"
| "UNAUTHORIZED";
export const STORE_ACCOUNT_AUTHORIZATION_COLUMN = {
key: "authorizationStatus",
label: "授权状态",
} as const;
// 公司报表库 rpt.fys_shopInfo.shopState 是数字码;0/6/3 的中文含义是与维度表 status 行数对照推断的,不是官方字典
export const STORE_ACCOUNT_SHOP_STATE_LABELS: Record<number, string> = {
0: "正常",
@ -288,6 +320,12 @@ export const STORE_ACCOUNT_SHOP_STATE_FILTER_OPTIONS = [
{ value: "3", label: "已出售" },
] as const;
export const STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS = [
{ value: "AUTHORIZED", label: "已授权" },
{ value: "NOT_REQUIRED", label: "无需授权" },
{ value: "UNAUTHORIZED", label: "未授权" },
] as const;
export function formatStoreAccountShopState(
value: number | null | undefined,
) {
@ -295,6 +333,15 @@ export function formatStoreAccountShopState(
return STORE_ACCOUNT_SHOP_STATE_LABELS[value] ?? `状态${value}`;
}
export function formatStoreAccountAuthorizationStatus(
value: StoreAccountAuthorizationStatus | null | undefined,
) {
if (value === "AUTHORIZED") return "已授权";
if (value === "NOT_REQUIRED") return "无需授权";
if (value === "UNAUTHORIZED") return "未授权";
return "-";
}
export function getStoreAccountLogOperationLabel(operation: string) {
return STORE_ACCOUNT_LOG_OPERATION_LABELS[operation] ?? "其他操作";
}

View File

@ -0,0 +1,117 @@
import type sql from "mssql";
export type JstShopAuthorizationRecord = {
shopName: string;
sessionExpired: string;
sessionUid: string;
};
export interface JstShopAuthorizationRepository {
listByNames(names: readonly string[]): Promise<JstShopAuthorizationRecord[]>;
close(): Promise<void>;
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
function booleanEnv(name: string, fallback: boolean) {
const value = process.env[name]?.trim().toLowerCase();
if (!value) return fallback;
return value === "true" || value === "1";
}
function safeIdentifier(value: string, label: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
throw new Error(`${label} is not a valid SQL Server identifier`);
}
return value;
}
function normalizedNames(names: readonly string[]) {
return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(
0,
2000,
);
}
class JstShopAuthorizationRepositoryClass
implements JstShopAuthorizationRepository
{
private pool: sql.ConnectionPool | null = null;
private async loadSql() {
return (await import("mssql")).default;
}
private async getPool() {
if (this.pool?.connected) return this.pool;
const sql = await this.loadSql();
this.pool = await new sql.ConnectionPool({
server: requiredEnv("JST_REPORT_DB_HOST"),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || "1433"),
database: requiredEnv("JST_REPORT_DB_NAME"),
user: requiredEnv("JST_REPORT_DB_USER"),
password: requiredEnv("JST_REPORT_DB_PASSWORD"),
options: {
encrypt: booleanEnv("JST_REPORT_DB_ENCRYPT", true),
trustServerCertificate: booleanEnv(
"JST_REPORT_DB_TRUST_SERVER_CERTIFICATE",
true,
),
},
pool: {
min: 0,
max: 4,
idleTimeoutMillis: 30_000,
},
requestTimeout: 30_000,
connectionTimeout: 15_000,
}).connect();
return this.pool;
}
async listByNames(names: readonly string[]) {
const shopNames = normalizedNames(names);
if (shopNames.length === 0) return [];
const schema = safeIdentifier(
process.env.JST_REPORT_DB_SCHEMA?.trim() || "rpt",
"JST_REPORT_DB_SCHEMA",
);
const sql = await this.loadSql();
const pool = await this.getPool();
const request = pool.request();
const parameters = shopNames.map((name, index) => {
const parameter = `shopName${index}`;
request.input(parameter, sql.NVarChar(255), name);
return `@${parameter}`;
});
const result = await request.query<JstShopAuthorizationRecord>(`
SELECT
LTRIM(RTRIM(CAST(shop_name AS nvarchar(255)))) AS shopName,
LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(50), session_expired, 126), N''))) AS sessionExpired,
LTRIM(RTRIM(COALESCE(CAST(session_uid AS nvarchar(255)), N''))) AS sessionUid
FROM [${schema}].[jst_shopInfo]
WHERE LTRIM(RTRIM(CAST(shop_name AS nvarchar(255)))) IN (${parameters.join(
", ",
)})
`);
return result.recordset;
}
async close() {
if (!this.pool) return;
await this.pool.close();
this.pool = null;
}
}
export const jstShopAuthorizationRepository =
new JstShopAuthorizationRepositoryClass();
export { JstShopAuthorizationRepositoryClass };

View File

@ -93,6 +93,10 @@ function splitStoreNames(value: string) {
return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))].slice(0, 500)
}
function splitFilterValues(value: string) {
return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))].slice(0, 500)
}
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000
const DAY_MS = 24 * 60 * 60 * 1000
@ -157,18 +161,27 @@ function buildWhere(filters: StoreAccountFilters) {
}
const map: Partial<Record<StoreAccountFilterKey, string>> = {
accountType: 'accountType',
operationOwner: 'operationOwner',
operationOwnerExact: 'operationOwner',
operationDepartmentHead: 'operationDepartmentHead',
operationDirector: 'operationDirector',
profitCenter: 'profitCenter',
accountGroupName: 'accountGroupName',
}
const selectedStoreNames = splitStoreNames(filters.storeName)
if (selectedStoreNames.length > 0) and.push({ storeName: { in: selectedStoreNames } })
for (const [key, field] of Object.entries(map)) {
const value = filters[key as StoreAccountFilterKey]
if (value) and.push({ [field]: contains(value) })
if (!value) continue
const values = splitFilterValues(value)
and.push({ [field]: { in: values } })
}
const shopStateCode = Number(filters.shopState)
if (filters.operationOwner) and.push({ operationOwner: contains(filters.operationOwner) })
const shopStateCodes = filters.shopState
.split(/[,,;;]+/)
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value))
if (filters.shopState) {
and.push(Number.isFinite(shopStateCode) ? { shopState: { equals: shopStateCode } } : { id: { in: [] } })
and.push(shopStateCodes.length > 0 ? { shopState: { in: shopStateCodes } } : { id: { in: [] } })
}
const createdAt = buildStoreAccountCreatedAtFilter(filters)
if (createdAt) and.push({ createdAt })
@ -185,6 +198,15 @@ export class StoreAccountManagementRepository {
return { rows, total }
}
async listAll(params: { filters: StoreAccountFilters }) {
const where = buildWhere(params.filters)
return prisma.storeAccount.findMany({
where,
select: ACCOUNT_SELECT,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
})
}
async listStoreNames() {
const rows = await prisma.storeAccount.findMany({
select: { storeName: true },
@ -194,6 +216,31 @@ export class StoreAccountManagementRepository {
return rows.map((row) => row.storeName.trim()).filter(Boolean)
}
async listFilterOptions() {
const rows = await prisma.storeAccount.findMany({
select: {
storeName: true,
operationOwner: true,
operationDepartmentHead: true,
operationDirector: true,
profitCenter: true,
accountType: true,
shopState: true,
},
orderBy: { storeName: 'asc' },
})
const unique = (values: string[]) => [...new Set(values.map((value) => value.trim()).filter(Boolean))]
return {
storeName: unique(rows.map((row) => row.storeName)),
operationOwner: unique(rows.map((row) => row.operationOwner)),
operationDepartmentHead: unique(rows.map((row) => row.operationDepartmentHead)),
operationDirector: unique(rows.map((row) => row.operationDirector)),
profitCenter: unique(rows.map((row) => row.profitCenter)),
accountType: unique(rows.map((row) => row.accountType)),
shopState: [...new Set(rows.map((row) => row.shopState).filter((value): value is number => value !== null).map(String))],
}
}
async findById(id: string) {
return prisma.storeAccount.findUnique({ where: { id }, select: ACCOUNT_SELECT })
}

View File

@ -0,0 +1,61 @@
import {
deriveStoreAccountAuthorizationStatus,
StoreAccountAuthorizationService,
} from "../store-account-authorization.service";
describe("store account authorization status", () => {
const now = new Date("2026-09-27T00:00:00.000Z");
it("treats a future expiration or the sentinel expiration as authorized", () => {
expect(
deriveStoreAccountAuthorizationStatus(
"2026-09-28T00:00:00.000Z",
"",
now,
),
).toBe("AUTHORIZED");
expect(
deriveStoreAccountAuthorizationStatus("9999-12-31 23:59:59", "", now),
).toBe("AUTHORIZED");
});
it("distinguishes no-auth-required and unauthorized records", () => {
expect(deriveStoreAccountAuthorizationStatus("", "session-1", now)).toBe(
"NOT_REQUIRED",
);
expect(deriveStoreAccountAuthorizationStatus("", "", now)).toBe(
"UNAUTHORIZED",
);
expect(
deriveStoreAccountAuthorizationStatus(
"2026-09-26T23:59:59.000Z",
"session-1",
now,
),
).toBe("UNAUTHORIZED");
});
it("matches names and caches missing source rows", async () => {
const listByNames = jest.fn().mockResolvedValue([
{
shopName: "店铺A",
sessionExpired: "",
sessionUid: "session-a",
},
]);
const service = new StoreAccountAuthorizationService({
listByNames,
close: jest.fn(),
});
await expect(service.getStatusMap([" 店铺A ", "店铺B"])).resolves.toEqual(
new Map([
["店铺A", "NOT_REQUIRED"],
["店铺B", null],
]),
);
await service.getStatusMap(["店铺A", "店铺B"]);
expect(listByNames).toHaveBeenCalledTimes(1);
expect(listByNames).toHaveBeenCalledWith(["店铺A", "店铺B"]);
});
});

View File

@ -0,0 +1,104 @@
import {
type StoreAccountAuthorizationStatus,
} from "@/lib/store-account-management";
import {
jstShopAuthorizationRepository,
type JstShopAuthorizationRepository,
} from "@/server/repositories/jst-shop-authorization.repository";
const CACHE_TTL_MS = 5 * 60 * 1000;
type CacheEntry = {
status: StoreAccountAuthorizationStatus | null;
expiresAt: number;
};
export type StoreAccountAuthorizationReader = Pick<
StoreAccountAuthorizationService,
"getStatusMap"
>;
function normalizeStoreName(value: unknown) {
return String(value ?? "").trim();
}
export function deriveStoreAccountAuthorizationStatus(
sessionExpired: unknown,
sessionUid: unknown,
now = new Date(),
): StoreAccountAuthorizationStatus {
const expiration = String(sessionExpired ?? "").trim();
const uid = String(sessionUid ?? "").trim();
if (expiration) {
if (expiration.startsWith("9999-12-31")) return "AUTHORIZED";
const timestamp = Date.parse(expiration);
if (Number.isFinite(timestamp) && timestamp > now.getTime()) {
return "AUTHORIZED";
}
return "UNAUTHORIZED";
}
return uid ? "NOT_REQUIRED" : "UNAUTHORIZED";
}
export class StoreAccountAuthorizationService {
private readonly cache = new Map<string, CacheEntry>();
constructor(
private readonly repository: JstShopAuthorizationRepository =
jstShopAuthorizationRepository,
) {}
async getStatusMap(storeNames: readonly string[]) {
const names = Array.from(
new Set(storeNames.map(normalizeStoreName).filter(Boolean)),
);
if (names.length === 0) return new Map<string, StoreAccountAuthorizationStatus | null>();
const now = Date.now();
const missing = names.filter((name) => {
const entry = this.cache.get(name);
return !entry || entry.expiresAt <= now;
});
if (missing.length > 0) {
try {
const rows = await this.repository.listByNames(missing);
const seen = new Set<string>();
for (const row of rows) {
const name = normalizeStoreName(row.shopName);
if (!name || seen.has(name)) continue;
seen.add(name);
this.cache.set(name, {
status: deriveStoreAccountAuthorizationStatus(
row.sessionExpired,
row.sessionUid,
),
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
for (const name of missing) {
if (!seen.has(name)) {
this.cache.set(name, {
status: null,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
}
} catch (error) {
console.warn(
"[store-account-authorization] 报表库查询失败,授权状态降级为空:",
error instanceof Error ? error.message : error,
);
}
}
return new Map(
names.map((name) => [name, this.cache.get(name)?.status ?? null]),
);
}
}
export const storeAccountAuthorizationService =
new StoreAccountAuthorizationService();

View File

@ -2,6 +2,7 @@ import type { AuthUser } from "@/server/core/types";
import { AppError, ErrorCode } from "@/server/core/errors";
import {
EMPTY_STORE_ACCOUNT_FILTERS,
STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS,
getStoreAccountProfilePlatformForStoreName,
STORE_ACCOUNT_PROFIT_CENTERS,
STORE_ACCOUNT_PROFILE_PLATFORMS,
@ -15,6 +16,7 @@ import {
resolveStoreAccountFieldVisibility,
type StoreAccountBatchEditableFieldKey,
type StoreAccountBatchPreview,
type StoreAccountAuthorizationStatus,
type StoreAccountFieldAccess,
type StoreAccountFieldKey,
type StoreAccountFieldPermissionUpdate,
@ -50,8 +52,17 @@ import {
type StoreAccountPasswordAccessService,
type StoreAccountPasswordScope,
} from "@/server/services/store-account-password-access.service";
import {
storeAccountAuthorizationService,
type StoreAccountAuthorizationReader,
} from "@/server/services/store-account-authorization.service";
const DEFAULT_MAX_PAGE_SIZE = 200;
const STORE_ACCOUNT_AUTHORIZATION_STATUSES = new Set([
"AUTHORIZED",
"NOT_REQUIRED",
"UNAUTHORIZED",
]);
function clean(value: unknown, max = 240) {
return String(value ?? "")
@ -103,6 +114,10 @@ function normalizeFilters(input?: Partial<StoreAccountFilters>) {
) as StoreAccountFilters;
}
function splitFilterValues(value: string) {
return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))];
}
function defaultFieldAccess(canEdit: boolean): StoreAccountFieldAccess {
return Object.fromEntries(
STORE_ACCOUNT_FIELDS.map((field) => [
@ -138,6 +153,7 @@ export class StoreAccountManagementService {
private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository,
private readonly permissionRoleReader: StoreAccountPermissionRoleReader = permissionRoleRepository,
private readonly passwordAccessReader: StoreAccountPasswordAccessReader = storeAccountPasswordAccessService,
private readonly authorizationReader: StoreAccountAuthorizationReader = storeAccountAuthorizationService,
) {}
private async getPasswordViewContext(
@ -237,26 +253,58 @@ export class StoreAccountManagementService {
ErrorCode.AUTH_PERMISSION_DENIED,
"当前账号没有店铺账号管理权限",
);
const [result, storeNames, passwordView] = await Promise.all([
this.repository.listPage({
skip: (page - 1) * pageSize,
take: pageSize,
filters,
}),
const authorizationFilter = splitFilterValues(filters.authorizationStatus);
const hasAuthorizationFilter = authorizationFilter.length > 0;
const [result, storeNames, filterOptions, passwordView] = await Promise.all([
hasAuthorizationFilter
? this.repository.listAll({ filters }).then((rows) => ({ rows, total: rows.length }))
: this.repository.listPage({
skip: (page - 1) * pageSize,
take: pageSize,
filters,
}),
this.repository.listStoreNames(),
this.repository.listFilterOptions(),
this.getPasswordViewContext(params.user, access),
]);
const rows = result.rows.map((record) =>
this.toRow(record, passwordView.rowAccess, passwordView.scope),
const authorizationStatusByStoreName = await this.authorizationReader.getStatusMap(
result.rows.map((record) => record.storeName),
);
const filteredRows = hasAuthorizationFilter
? result.rows.filter((record) => {
const status = authorizationStatusByStoreName.get(record.storeName.trim());
return STORE_ACCOUNT_AUTHORIZATION_STATUSES.has(status ?? "")
&& authorizationFilter.includes(status as string);
})
: result.rows;
const pageRows = hasAuthorizationFilter
? filteredRows.slice((page - 1) * pageSize, page * pageSize)
: filteredRows;
const rows = pageRows.map((record) =>
this.toRow(
record,
passwordView.rowAccess,
passwordView.scope,
authorizationStatusByStoreName.get(record.storeName.trim()) ?? null,
),
);
return {
rows,
storeNames,
filterOptions: {
...filterOptions,
authorizationStatus: STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS.map(
(option) => option.value,
),
},
pageInfo: {
page,
pageSize,
total: result.total,
totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
total: hasAuthorizationFilter ? filteredRows.length : result.total,
totalPages: Math.max(
1,
Math.ceil((hasAuthorizationFilter ? filteredRows.length : result.total) / pageSize),
),
},
fieldAccess: passwordView.rowAccess,
};
@ -956,6 +1004,7 @@ export class StoreAccountManagementService {
record: StoreAccountRecord,
access: StoreAccountFieldAccess,
passwordScope: StoreAccountPasswordScope = "DEFAULT",
authorizationStatus: StoreAccountAuthorizationStatus | null = null,
) {
const value = (key: StoreAccountFieldKey, fallback = "") =>
access[key] === "HIDDEN" ? "" : fallback;
@ -1000,6 +1049,7 @@ export class StoreAccountManagementService {
jstOrderService: value("jstOrderService", record.jstOrderService),
accountGroupName: value("accountGroupName", record.accountGroupName),
shopState: record.shopState,
authorizationStatus,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};