feat(store-account): add authorization status and header filters
This commit is contained in:
parent
5a59e727c0
commit
68b310f646
@ -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}
|
||||
`;
|
||||
|
||||
@ -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] ?? "其他操作";
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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"]);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user