From 2ba2a372322c4ff354c689382897e2f6f7e9bec2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sun, 27 Sep 2026 16:08:43 +0800 Subject: [PATCH] feat(store-account): add authorization status and header filters --- .../StoreAccountManagementView.tsx | 184 ++++++++++++++++-- .../src/lib/store-account-management.ts | 47 +++++ .../jst-shop-authorization.repository.ts | 117 +++++++++++ .../store-account-management.repository.ts | 55 +++++- ...tore-account-authorization.service.test.ts | 61 ++++++ .../store-account-authorization.service.ts | 104 ++++++++++ .../store-account-management.service.ts | 70 ++++++- 7 files changed, 606 insertions(+), 32 deletions(-) create mode 100644 furniture-3d-system/src/server/repositories/jst-shop-authorization.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/store-account-authorization.service.test.ts create mode 100644 furniture-3d-system/src/server/services/store-account-authorization.service.ts diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx index 714a808b..5b9a9627 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx @@ -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> & { id?: string }; type ProfileDraft = Partial>; +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([ + "storeName", + "operationOwner", + "operationDepartmentHead", + "operationDirector", + "profitCenter", + "accountType", + "shopState", + "authorizationStatus", +]); + +function getStoreAccountHeaderFilterStateKey( + key: StoreAccountHeaderFilterKey, +): "operationOwnerExact" | Exclude { + 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( () => - 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({ - @@ -541,7 +643,49 @@ export function StoreAccountManagementView({ )} {renderColumns.map((field) => ( - {field.label} + + + {field.label} + {STORE_ACCOUNT_HEADER_FILTER_KEYS.has( + field.key as StoreAccountHeaderFilterKey, + ) && ( + + 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, + ), + ) + } + /> + )} + + ))} 操作 @@ -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" ? ( {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} `; diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts index 01208f68..ef360a21 100644 --- a/furniture-3d-system/src/lib/store-account-management.ts +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -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 = { 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] ?? "其他操作"; } diff --git a/furniture-3d-system/src/server/repositories/jst-shop-authorization.repository.ts b/furniture-3d-system/src/server/repositories/jst-shop-authorization.repository.ts new file mode 100644 index 00000000..4f4bcec6 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/jst-shop-authorization.repository.ts @@ -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; + close(): Promise; +} + +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(` + 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 }; diff --git a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts index 763c48dd..5e890fbb 100644 --- a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts +++ b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts @@ -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> = { 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 }) } diff --git a/furniture-3d-system/src/server/services/__tests__/store-account-authorization.service.test.ts b/furniture-3d-system/src/server/services/__tests__/store-account-authorization.service.test.ts new file mode 100644 index 00000000..5a72588d --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/store-account-authorization.service.test.ts @@ -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"]); + }); +}); diff --git a/furniture-3d-system/src/server/services/store-account-authorization.service.ts b/furniture-3d-system/src/server/services/store-account-authorization.service.ts new file mode 100644 index 00000000..f827585a --- /dev/null +++ b/furniture-3d-system/src/server/services/store-account-authorization.service.ts @@ -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(); + + 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(); + + 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(); + 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(); diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts index b8197a03..acf690dd 100644 --- a/furniture-3d-system/src/server/services/store-account-management.service.ts +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -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) { ) 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(), };