feat: 新增客服备用金台账与后台用户中文显示名

- 新增备用金账户、流水、支出、对账与权限
- 后台用户支持中文显示名并同步备用金姓名
- 审计、登录记录、任务及工单操作人优先展示中文名
- 补充备用金与人工支出方案文档
This commit is contained in:
yml2213
2026-10-18 13:55:24 +08:00
parent 7bacb7d5eb
commit 7542d547bf
55 changed files with 6557 additions and 105 deletions
@@ -0,0 +1,224 @@
-- 100_fund_accounts.sql —— 客服备用金台账(主管池 / 客服余额 / 支出 / 对账)。
--
-- 业务口径(2026-10-18 确认):
-- 公司把钱交给主管,主管发给客服;客服用于充值/采购/赔偿;充值后立即消耗,直接记支出。
-- 系统要回答:钱在谁手里、每笔花到哪、每个客服还剩多少、账实是否对平。
--
-- 设计约束:
-- 1. 余额只能由 fund_entries 产生,不允许手工改;
-- 2. 发放/缴回必须同时写主管与客服两条流水,金额相反;
-- 3. 支出必须同时写客服负数流水和 manual_expenses 明细;
-- 4. 已记账流水不修改、不删除,差异走 adjustment。
CREATE TABLE IF NOT EXISTS fund_accounts (
id BIGSERIAL PRIMARY KEY,
account_no TEXT NOT NULL,
account_type TEXT NOT NULL,
owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
owner_name TEXT NOT NULL DEFAULT '',
responsible_supervisor_id BIGINT REFERENCES fund_accounts(id) ON DELETE SET NULL,
balance_fen BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_accounts_account_no
ON fund_accounts (account_no);
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_accounts_owner_type
ON fund_accounts (owner_admin_user_id, account_type);
CREATE INDEX IF NOT EXISTS idx_fund_accounts_type_status
ON fund_accounts (account_type, status, owner_name);
CREATE INDEX IF NOT EXISTS idx_fund_accounts_supervisor
ON fund_accounts (responsible_supervisor_id, status);
CREATE TABLE IF NOT EXISTS fund_entries (
id BIGSERIAL PRIMARY KEY,
entry_no TEXT NOT NULL,
account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT,
entry_type TEXT NOT NULL,
amount_fen BIGINT NOT NULL,
balance_after_fen BIGINT NOT NULL,
transfer_no TEXT NOT NULL DEFAULT '',
counterparty_account_id BIGINT REFERENCES fund_accounts(id) ON DELETE SET NULL,
reconciliation_id BIGINT,
occurred_at TIMESTAMPTZ NOT NULL,
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_entries_entry_no
ON fund_entries (entry_no);
CREATE INDEX IF NOT EXISTS idx_fund_entries_account_time
ON fund_entries (account_id, occurred_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_fund_entries_type_time
ON fund_entries (entry_type, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_fund_entries_transfer
ON fund_entries (transfer_no) WHERE transfer_no <> '';
CREATE INDEX IF NOT EXISTS idx_fund_entries_counterparty
ON fund_entries (counterparty_account_id);
CREATE TABLE IF NOT EXISTS manual_expenses (
id BIGSERIAL PRIMARY KEY,
expense_no TEXT NOT NULL,
account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT,
amount_fen BIGINT NOT NULL,
category TEXT NOT NULL,
payee_type TEXT NOT NULL,
payee_name TEXT NOT NULL,
payee_account TEXT NOT NULL DEFAULT '',
platform_name TEXT NOT NULL DEFAULT '',
platform_account TEXT NOT NULL DEFAULT '',
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
paid_at TIMESTAMPTZ NOT NULL,
evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb,
note TEXT NOT NULL DEFAULT '',
fund_entry_id BIGINT NOT NULL REFERENCES fund_entries(id) ON DELETE RESTRICT,
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_expense_no
ON manual_expenses (expense_no);
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expenses_fund_entry
ON manual_expenses (fund_entry_id);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_account_time
ON manual_expenses (account_id, paid_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_category_time
ON manual_expenses (category, paid_at DESC);
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order
ON manual_expenses (order_id) WHERE order_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_manual_expenses_payee
ON manual_expenses (payee_name, paid_at DESC);
CREATE TABLE IF NOT EXISTS fund_reconciliations (
id BIGSERIAL PRIMARY KEY,
reconciliation_no TEXT NOT NULL,
account_id BIGINT NOT NULL REFERENCES fund_accounts(id) ON DELETE RESTRICT,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
system_balance_fen BIGINT NOT NULL,
actual_balance_fen BIGINT NOT NULL,
difference_fen BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
evidence_json JSONB NOT NULL DEFAULT '[]'::jsonb,
confirmed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
confirmed_at TIMESTAMPTZ,
created_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_fund_reconciliations_no
ON fund_reconciliations (reconciliation_no);
CREATE INDEX IF NOT EXISTS idx_fund_reconciliations_account_time
ON fund_reconciliations (account_id, period_end DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_fund_reconciliations_status_time
ON fund_reconciliations (status, period_end DESC);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_type') THEN
ALTER TABLE fund_accounts
ADD CONSTRAINT chk_fund_accounts_type
CHECK (account_type IN ('supervisor', 'agent'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_status') THEN
ALTER TABLE fund_accounts
ADD CONSTRAINT chk_fund_accounts_status
CHECK (status IN ('active', 'frozen', 'closed'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_accounts_balance') THEN
ALTER TABLE fund_accounts
ADD CONSTRAINT chk_fund_accounts_balance
CHECK (balance_fen >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_type') THEN
ALTER TABLE fund_entries
ADD CONSTRAINT chk_fund_entries_type
CHECK (entry_type IN ('opening', 'funding', 'issue', 'expense', 'return', 'adjustment'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_amount') THEN
ALTER TABLE fund_entries
ADD CONSTRAINT chk_fund_entries_amount
CHECK (amount_fen <> 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_entries_balance') THEN
ALTER TABLE fund_entries
ADD CONSTRAINT chk_fund_entries_balance
CHECK (balance_after_fen >= 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_amount') THEN
ALTER TABLE manual_expenses
ADD CONSTRAINT chk_manual_expenses_amount
CHECK (amount_fen > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_category') THEN
ALTER TABLE manual_expenses
ADD CONSTRAINT chk_manual_expenses_category
CHECK (category IN ('platform_recharge', 'purchase', 'compensation', 'freight', 'other'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expenses_payee_type') THEN
ALTER TABLE manual_expenses
ADD CONSTRAINT chk_manual_expenses_payee_type
CHECK (payee_type IN ('platform', 'supplier', 'customer', 'other'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_reconciliations_status') THEN
ALTER TABLE fund_reconciliations
ADD CONSTRAINT chk_fund_reconciliations_status
CHECK (status IN ('pending', 'confirmed', 'discrepancy'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_fund_reconciliations_difference') THEN
ALTER TABLE fund_reconciliations
ADD CONSTRAINT chk_fund_reconciliations_difference
CHECK (difference_fen = actual_balance_fen - system_balance_fen);
END IF;
END $$;
COMMENT ON TABLE fund_accounts IS '备用金资金账户:主管池与客服备用金账户';
COMMENT ON COLUMN fund_accounts.responsible_supervisor_id IS '客服的责任主管账户;对账由该主管确认';
COMMENT ON COLUMN fund_accounts.balance_fen IS '当前余额,只能由 fund_entries 变动';
COMMENT ON TABLE fund_entries IS '备用金流水:期初/拨付/发放/支出/缴回/调整,只追加';
COMMENT ON COLUMN fund_entries.amount_fen IS '有符号金额:正数流入,负数流出';
COMMENT ON COLUMN fund_entries.balance_after_fen IS '记账后的账户余额快照';
COMMENT ON COLUMN fund_entries.transfer_no IS '发放/缴回双边流水批次号,主管与客服两条共用';
COMMENT ON TABLE manual_expenses IS '客服备用金支出明细;充值后立即消耗,直接记支出';
COMMENT ON TABLE fund_reconciliations IS '备用金对账:系统余额与实际余额差异,差异通过 adjustment 流水修正';
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
VALUES
('fund.view', '查看备用金', '备用金管理', '查看备用金账户、流水和支出明细'),
('fund.issue', '发放/缴回备用金', '备用金管理', '主管向客服发放或收回备用金'),
('fund.expense', '登记备用金支出', '备用金管理', '客服登记备用金支出'),
('fund.reconcile', '备用金对账', '备用金管理', '发起和确认备用金对账'),
('fund.adjust', '调整备用金余额', '备用金管理', '对账差异确认后调整账户余额'),
('fund.export', '导出备用金数据', '备用金管理', '导出备用金账户、流水和支出')
ON CONFLICT (permission_key) DO UPDATE SET
permission_name = EXCLUDED.permission_name,
permission_group = EXCLUDED.permission_group,
description = EXCLUDED.description,
updated_at = NOW();
INSERT INTO admin_role_permissions (role_key, permission_key)
SELECT 'admin', permission_key
FROM admin_permissions
WHERE permission_key LIKE 'fund.%'
ON CONFLICT DO NOTHING;
INSERT INTO admin_role_permissions (role_key, permission_key)
VALUES
('finance', 'fund.view'),
('finance', 'fund.reconcile'),
('finance', 'fund.adjust'),
('finance', 'fund.export'),
('operator', 'fund.view'),
('operator', 'fund.issue'),
('operator', 'fund.reconcile'),
('support', 'fund.view'),
('support', 'fund.expense')
ON CONFLICT DO NOTHING;
@@ -0,0 +1,45 @@
-- 101_admin_display_name.sql —— 后台用户增加中文姓名/显示名。
--
-- 账号 username 仍然是登录凭证,只能使用字母、数字和 ._-;
-- display_name 只用于展示,可输入中文姓名。为空时回退显示 username。
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';
UPDATE admin_users
SET display_name = username
WHERE display_name = '';
CREATE INDEX IF NOT EXISTS idx_admin_users_display_name
ON admin_users (display_name);
COMMENT ON COLUMN admin_users.display_name IS '后台用户显示名(中文姓名等),为空时回退 username';
-- 备用金账户里的 owner_name 是姓名快照;改名后同步,避免还要逐表手工改。
CREATE OR REPLACE FUNCTION sync_admin_display_name_to_fund_accounts()
RETURNS TRIGGER AS $$
BEGIN
UPDATE fund_accounts
SET
owner_name = COALESCE(NULLIF(NEW.display_name, ''), NEW.username),
updated_at = NOW()
WHERE owner_admin_user_id = NEW.id
AND owner_name IS DISTINCT FROM COALESCE(NULLIF(NEW.display_name, ''), NEW.username);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_admin_users_display_name_fund_accounts ON admin_users;
CREATE TRIGGER trg_admin_users_display_name_fund_accounts
AFTER UPDATE OF display_name, username ON admin_users
FOR EACH ROW
EXECUTE FUNCTION sync_admin_display_name_to_fund_accounts();
UPDATE fund_accounts fa
SET
owner_name = COALESCE(NULLIF(au.display_name, ''), au.username),
updated_at = NOW()
FROM admin_users au
WHERE fa.owner_admin_user_id = au.id
AND fa.owner_name IS DISTINCT FROM COALESCE(NULLIF(au.display_name, ''), au.username);
@@ -5,6 +5,7 @@ type AdminAuditLogRow = {
id: number
actor_user_id: number | null
actor_username: string
actor_display_name: string | null
actor_role: string
action: string
target_type: string
@@ -88,28 +89,30 @@ export async function listAdminAuditLogs(
const params: unknown[] = []
if (queryInput.actorUsername) {
params.push(queryInput.actorUsername)
conditions.push(`actor_username = $${params.length}`)
params.push(`%${queryInput.actorUsername}%`)
conditions.push(
`(aal.actor_username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`,
)
}
if (queryInput.action) {
params.push(queryInput.action)
conditions.push(`action = $${params.length}`)
conditions.push(`aal.action = $${params.length}`)
}
if (queryInput.targetType) {
params.push(queryInput.targetType)
conditions.push(`target_type = $${params.length}`)
conditions.push(`aal.target_type = $${params.length}`)
}
if (queryInput.dateFrom) {
params.push(queryInput.dateFrom)
conditions.push(`created_at >= $${params.length}`)
conditions.push(`aal.created_at >= $${params.length}`)
}
if (queryInput.dateTo) {
params.push(queryInput.dateTo)
conditions.push(`created_at <= $${params.length}`)
conditions.push(`aal.created_at <= $${params.length}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
@@ -118,7 +121,12 @@ export async function listAdminAuditLogs(
const offset = (page - 1) * pageSize
const totalResult = await query<{ [column: string]: unknown; total: number }>(
`SELECT COUNT(*)::int AS total FROM admin_audit_logs ${whereClause}`,
`
SELECT COUNT(*)::int AS total
FROM admin_audit_logs aal
LEFT JOIN admin_users au ON au.id = aal.actor_user_id
${whereClause}
`,
params,
)
@@ -126,10 +134,13 @@ export async function listAdminAuditLogs(
params.push(offset)
const itemsResult = await query<AdminAuditLogRow>(
`
SELECT *
FROM admin_audit_logs
SELECT
aal.*,
COALESCE(NULLIF(au.display_name, ''), au.username) AS actor_display_name
FROM admin_audit_logs aal
LEFT JOIN admin_users au ON au.id = aal.actor_user_id
${whereClause}
ORDER BY id DESC
ORDER BY aal.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
@@ -5,6 +5,7 @@ export type AdminLoginLogRow = {
id: number
user_id: number | null
username: string
display_name: string | null
role: string
ip: string
location: string
@@ -75,18 +76,20 @@ export async function listAdminLoginLogs(
const params: unknown[] = []
if (queryInput.username) {
params.push(String(queryInput.username).trim())
conditions.push(`username = $${params.length}`)
params.push(`%${String(queryInput.username).trim()}%`)
conditions.push(
`(ll.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`,
)
}
if (queryInput.dateFrom) {
params.push(queryInput.dateFrom)
conditions.push(`created_at >= $${params.length}`)
conditions.push(`ll.created_at >= $${params.length}`)
}
if (queryInput.dateTo) {
params.push(queryInput.dateTo)
conditions.push(`created_at <= $${params.length}`)
conditions.push(`ll.created_at <= $${params.length}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
@@ -95,7 +98,12 @@ export async function listAdminLoginLogs(
const offset = (page - 1) * pageSize
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM admin_login_logs ${whereClause}`,
`
SELECT COUNT(*)::int AS total
FROM admin_login_logs ll
LEFT JOIN admin_users au ON au.id = ll.user_id
${whereClause}
`,
params,
)
@@ -103,10 +111,13 @@ export async function listAdminLoginLogs(
params.push(offset)
const itemsResult = await query<AdminLoginLogRow>(
`
SELECT *
FROM admin_login_logs
SELECT
ll.*,
COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name
FROM admin_login_logs ll
LEFT JOIN admin_users au ON au.id = ll.user_id
${whereClause}
ORDER BY id DESC
ORDER BY ll.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
@@ -3,6 +3,7 @@ import { query } from '../db/client.js'
type AdminUserRow = {
id: number
username: string
display_name: string
password_hash: string
role: string
status: string
@@ -13,6 +14,7 @@ type AdminUserRow = {
type AdminUserCreateInput = {
username: string
displayName: string
passwordHash: string
role: string
status: string
@@ -23,14 +25,20 @@ type AdminUserCreateInput = {
type AdminUserPatch = Partial<
Pick<
AdminUserRow,
'username' | 'password_hash' | 'role' | 'status' | 'session_version' | 'updated_at'
| 'username'
| 'display_name'
| 'password_hash'
| 'role'
| 'status'
| 'session_version'
| 'updated_at'
>
>
type AdminUserListInput = {
page?: number
pageSize?: number
username?: string
keyword?: string
role?: string
status?: string
}
@@ -66,17 +74,19 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
`
INSERT INTO admin_users (
username,
display_name,
password_hash,
role,
status,
session_version,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`,
[
input.username,
input.displayName,
input.passwordHash,
input.role,
input.status,
@@ -104,16 +114,18 @@ export async function updateAdminUser(
UPDATE admin_users
SET
username = $1,
password_hash = $2,
role = $3,
status = $4,
session_version = $5,
updated_at = $6
WHERE id = $7
display_name = $2,
password_hash = $3,
role = $4,
status = $5,
session_version = $6,
updated_at = $7
WHERE id = $8
RETURNING id
`,
[
next.username,
next.display_name,
next.password_hash,
next.role,
next.status,
@@ -129,7 +141,7 @@ export async function updateAdminUser(
export async function listAdminUsers({
page = 1,
pageSize = 20,
username = '',
keyword = '',
role = '',
status = '',
}: AdminUserListInput = {}): Promise<AdminUserListResult> {
@@ -137,24 +149,24 @@ export async function listAdminUsers({
const filters: string[] = []
const params: unknown[] = []
if (username) {
params.push(`%${username}%`)
filters.push(`username ILIKE $${params.length}`)
if (keyword) {
params.push(`%${keyword}%`)
filters.push(`(au.username ILIKE $${params.length} OR au.display_name ILIKE $${params.length})`)
}
if (role) {
params.push(role)
filters.push(`role = $${params.length}`)
filters.push(`au.role = $${params.length}`)
}
if (status) {
params.push(status)
filters.push(`status = $${params.length}`)
filters.push(`au.status = $${params.length}`)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const totalResult = await query<{ [column: string]: unknown; total: number }>(
`SELECT COUNT(*)::int AS total FROM admin_users ${whereClause}`,
`SELECT COUNT(*)::int AS total FROM admin_users au ${whereClause}`,
params,
)
+2
View File
@@ -6,6 +6,7 @@ import dashboardRouter from './admin/dashboard.js'
import devMockRouter from './admin/dev-mock.js'
import douyinMashidaRouter from './admin/douyin-mashida.js'
import financeRouter from './admin/finance.js'
import fundRouter from './admin/fund.js'
import filesRouter from './admin/files.js'
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
import ordersRouter from './admin/orders.js'
@@ -26,6 +27,7 @@ router.use(requireAdminSession)
router.use(filesRouter)
router.use(dashboardRouter)
router.use(financeRouter)
router.use(fundRouter)
router.use(usersRouter)
router.use(rbacRouter)
router.use(auditLogsRouter)
+328
View File
@@ -0,0 +1,328 @@
import type { Request } from 'express'
import { Router } from 'express'
import {
confirmAdminFundReconciliation,
createAdminFundAccount,
createAdminFundExpense,
createAdminFundFunding,
createAdminFundIssue,
createAdminFundReconciliation,
createAdminFundReturn,
ensureAdminFundAgentAccounts,
getAdminFundAccountDetail,
getAdminFundExpenseDetail,
getAdminFundMyAccount,
getAdminFundOverview,
getAdminFundSupervisorOptions,
listAdminFundAccounts,
listAdminFundExpenses,
listAdminFundReconciliations,
listAdminFundSetupOptions,
updateAdminFundAccount,
} from '../../services/admin/admin-fund-service.js'
import {
createJsonHandler,
requireAdminPermission,
requireAdminRoles,
requireAnyAdminPermission,
} from './session.js'
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
import type { JsonObject } from '../../types/json.js'
const router = Router()
function sessionOf(req: Request): AdminSession {
return req.adminSession as AdminSession
}
function accountIdOf(req: Request): number {
return Number(req.params.accountId)
}
function expenseIdOf(req: Request): number {
return Number(req.params.expenseId)
}
function reconciliationIdOf(req: Request): number {
return Number(req.params.reconciliationId)
}
function bodyWithAccountId(req: Request, key: string): JsonObject {
const body = req.body && typeof req.body === 'object' ? (req.body as JsonObject) : {}
return { ...body, [key]: accountIdOf(req) }
}
router.use('/fund', requireAdminRoles(['admin', 'operator', 'support', 'finance']))
router.get(
'/fund/overview',
requireAdminPermission('fund.view'),
createJsonHandler((req) => getAdminFundOverview(sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取备用金总览失败',
scope: '[admin/fund/overview]',
}),
)
router.get(
'/fund/my',
requireAdminPermission('fund.view'),
createJsonHandler((req) => getAdminFundMyAccount(sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取我的备用金失败',
scope: '[admin/fund/my]',
}),
)
router.get(
'/fund/supervisors',
requireAdminPermission('fund.view'),
createJsonHandler((req) => getAdminFundSupervisorOptions(sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取主管资金池失败',
scope: '[admin/fund/supervisors]',
}),
)
router.get(
'/fund/options',
requireAdminRoles(['admin']),
createJsonHandler((req) => listAdminFundSetupOptions(sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取备用金初始化选项失败',
scope: '[admin/fund/options]',
}),
)
router.get(
'/fund/accounts',
requireAdminPermission('fund.view'),
createJsonHandler((req) => listAdminFundAccounts(req.query as JsonObject, sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取备用金账户失败',
scope: '[admin/fund/accounts]',
}),
)
router.get(
'/fund/accounts/:accountId',
requireAdminPermission('fund.view'),
createJsonHandler(
(req) => getAdminFundAccountDetail(accountIdOf(req), req.query as JsonObject, sessionOf(req)),
{
successMessage: 'ok',
errorMessage: '读取备用金账户详情失败',
scope: '[admin/fund/accounts/:accountId]',
},
),
)
router.post(
'/fund/accounts',
requireAdminRoles(['admin']),
createJsonHandler((req) => createAdminFundAccount(req.body as JsonObject, sessionOf(req)), {
successMessage: '备用金账户已创建',
errorMessage: '创建备用金账户失败',
scope: '[admin/fund/accounts]',
audit: (req, data) => ({
action: 'fund_account_created',
targetType: 'fund_account',
targetId: String((data as { account?: { id?: number } })?.account?.id || ''),
data: asRecord(data),
}),
}),
)
router.post(
'/fund/accounts/ensure-agents',
requireAdminRoles(['admin']),
createJsonHandler((req) => ensureAdminFundAgentAccounts(req.body as JsonObject, sessionOf(req)), {
successMessage: '客服备用金账户已初始化',
errorMessage: '批量初始化客服备用金账户失败',
scope: '[admin/fund/accounts/ensure-agents]',
audit: (_req, data) => ({
action: 'fund_agent_accounts_ensured',
targetType: 'fund_account',
targetId: 'batch',
data: asRecord(data),
}),
}),
)
router.patch(
'/fund/accounts/:accountId',
requireAdminRoles(['admin']),
createJsonHandler(
(req) => updateAdminFundAccount(accountIdOf(req), req.body as JsonObject, sessionOf(req)),
{
successMessage: '备用金账户已更新',
errorMessage: '更新备用金账户失败',
scope: '[admin/fund/accounts/:accountId]',
audit: (req, data) => ({
action: 'fund_account_updated',
targetType: 'fund_account',
targetId: String(req.params.accountId || ''),
data: asRecord(data),
}),
},
),
)
router.post(
'/fund/accounts/:accountId/funding',
requireAdminRoles(['admin']),
createJsonHandler(
(req) => createAdminFundFunding(bodyWithAccountId(req, 'supervisorAccountId'), sessionOf(req)),
{
successMessage: '主管资金池已拨付',
errorMessage: '拨付主管资金池失败',
scope: '[admin/fund/accounts/:accountId/funding]',
audit: (req, data) => ({
action: 'fund_funding',
targetType: 'fund_account',
targetId: String(req.params.accountId || ''),
data: asRecord(data),
}),
},
),
)
router.post(
'/fund/accounts/:accountId/issue',
requireAdminPermission('fund.issue'),
createJsonHandler(
(req) => createAdminFundIssue(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)),
{
successMessage: '备用金已发放',
errorMessage: '发放备用金失败',
scope: '[admin/fund/accounts/:accountId/issue]',
audit: (req, data) => ({
action: 'fund_issue',
targetType: 'fund_account',
targetId: String(req.params.accountId || ''),
data: asRecord(data),
}),
},
),
)
router.post(
'/fund/accounts/:accountId/return',
requireAnyAdminPermission(['fund.issue', 'fund.expense']),
createJsonHandler(
(req) => createAdminFundReturn(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)),
{
successMessage: '备用金已缴回',
errorMessage: '缴回备用金失败',
scope: '[admin/fund/accounts/:accountId/return]',
audit: (req, data) => ({
action: 'fund_return',
targetType: 'fund_account',
targetId: String(req.params.accountId || ''),
data: asRecord(data),
}),
},
),
)
router.post(
'/fund/accounts/:accountId/expenses',
requireAdminPermission('fund.expense'),
createJsonHandler(
(req) => createAdminFundExpense(bodyWithAccountId(req, 'agentAccountId'), sessionOf(req)),
{
successMessage: '备用金支出已登记',
errorMessage: '登记备用金支出失败',
scope: '[admin/fund/accounts/:accountId/expenses]',
audit: (req, data) => ({
action: 'fund_expense_created',
targetType: 'fund_expense',
targetId: String((data as { expense?: { id?: number } })?.expense?.id || ''),
data: asRecord(data),
}),
},
),
)
router.get(
'/fund/expenses',
requireAdminPermission('fund.view'),
createJsonHandler((req) => listAdminFundExpenses(req.query as JsonObject, sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取备用金支出失败',
scope: '[admin/fund/expenses]',
}),
)
router.get(
'/fund/expenses/:expenseId',
requireAdminPermission('fund.view'),
createJsonHandler((req) => getAdminFundExpenseDetail(expenseIdOf(req), sessionOf(req)), {
successMessage: 'ok',
errorMessage: '读取备用金支出详情失败',
scope: '[admin/fund/expenses/:expenseId]',
}),
)
router.get(
'/fund/reconciliations',
requireAdminPermission('fund.view'),
createJsonHandler(
(req) => listAdminFundReconciliations(req.query as JsonObject, sessionOf(req)),
{
successMessage: 'ok',
errorMessage: '读取备用金对账失败',
scope: '[admin/fund/reconciliations]',
},
),
)
router.post(
'/fund/reconciliations',
requireAdminPermission('fund.reconcile'),
createJsonHandler(
(req) => createAdminFundReconciliation(req.body as JsonObject, sessionOf(req)),
{
successMessage: '对账已发起',
errorMessage: '发起备用金对账失败',
scope: '[admin/fund/reconciliations]',
audit: (_req, data) => ({
action: 'fund_reconciliation_created',
targetType: 'fund_reconciliation',
targetId: String((data as { reconciliation?: { id?: number } })?.reconciliation?.id || ''),
data: asRecord(data),
}),
},
),
)
router.post(
'/fund/reconciliations/:reconciliationId/confirm',
requireAdminPermission('fund.reconcile'),
createJsonHandler(
(req) =>
confirmAdminFundReconciliation(
reconciliationIdOf(req),
req.body as JsonObject,
sessionOf(req),
),
{
successMessage: '对账已确认',
errorMessage: '确认备用金对账失败',
scope: '[admin/fund/reconciliations/:reconciliationId/confirm]',
audit: (req, data) => ({
action: 'fund_reconciliation_confirmed',
targetType: 'fund_reconciliation',
targetId: String(req.params.reconciliationId || ''),
data: asRecord(data),
}),
},
),
)
function asRecord(data: unknown): Record<string, unknown> {
return data && typeof data === 'object' ? (data as Record<string, unknown>) : { a: data }
}
export default router
+26
View File
@@ -4,6 +4,7 @@ import {
createManagedAdminUser,
getAdminUserList,
resetManagedAdminUserPassword,
updateManagedAdminUserDisplayName,
updateManagedAdminUserRole,
updateManagedAdminUserStatus,
} from '../../services/admin/admin-auth-service.js'
@@ -13,6 +14,7 @@ type AdminUserMutationResult = {
user: {
userId: number
username: string
displayName: string
role: string
status?: string
}
@@ -82,6 +84,30 @@ router.post(
),
)
router.post(
'/users/:userId/display-name',
createJsonHandler(
(req) => updateManagedAdminUserDisplayName(String(req.params.userId || ''), req.body),
{
successMessage: '用户姓名已更新',
errorMessage: '更新用户姓名失败',
scope: '[admin/users/:userId/display-name]',
audit: (_req, data) => {
const result = data as AdminUserMutationResult
return {
action: 'admin_user_display_name_updated',
targetType: 'admin_user',
targetId: String(result.user.userId),
data: {
username: result.user.username,
displayName: result.user.displayName,
},
}
},
},
),
)
router.post(
'/users/:userId/status',
createJsonHandler(
@@ -21,7 +21,7 @@ router.post(
markAdminWorkOrderProblem(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '订单已标记为问题单',
@@ -45,7 +45,7 @@ router.post(
resolveAdminProblemWorkOrder(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '问题单已处置',
@@ -66,7 +66,10 @@ router.post(
requireAdminPermission('worker_order.accept'),
createJsonHandler(
(req) =>
acceptAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''),
acceptAdminWorkOrder(
String(req.params.workOrderId || ''),
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '订单已验收通过',
errorMessage: '验收接单订单失败',
@@ -89,7 +92,7 @@ router.post(
acceptAdminWorkOrderShare(
String(req.params.workOrderId || ''),
String(req.params.shareId || ''),
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '拼单份额已验收通过',
@@ -113,7 +116,7 @@ router.post(
cancelAdminWorkOrderSharing(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
req.adminSession?.permissions || [],
),
{
@@ -142,7 +145,7 @@ router.post(
String(req.params.workOrderId || ''),
String(req.params.shareId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '拼单参与者已撤单,份数已退回大厅',
@@ -166,7 +169,7 @@ router.post(
deductAdminWorkOrderPendingDeposit(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '待解冻押金已扣减',
@@ -39,7 +39,7 @@ router.post(
createAdminAfterSalesCase(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '售后问题单已创建',
@@ -63,7 +63,7 @@ router.post(
resolveAdminAfterSalesCase(
String(req.params.caseId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '售后问题单已处置',
@@ -87,7 +87,7 @@ router.post(
repayAdminAfterSalesCaseDebt(
String(req.params.caseId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '售后欠款已偿还',
@@ -167,7 +167,7 @@ router.post(
(req) =>
syncAdminWorkProductRulePrice(
String(req.params.ruleId || ''),
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '模板价格已同步',
@@ -68,7 +68,11 @@ router.post(
'/worker-platform/consignment/inventories',
requireAdminPermission('consignment.manage'),
createJsonHandler(
(req) => createAdminConsignmentInventory(req.body, req.adminSession?.username || 'admin'),
(req) =>
createAdminConsignmentInventory(
req.body,
req.adminSession?.displayName || req.adminSession?.username || 'admin',
),
{
successMessage: '寄卖物品已入库',
errorMessage: '创建寄卖物品失败',
@@ -85,7 +89,7 @@ router.put(
updateAdminConsignmentInventory(
String(req.params.id || ''),
req.body,
req.adminSession?.username || 'admin',
req.adminSession?.displayName || req.adminSession?.username || 'admin',
),
{
successMessage: '寄卖物品已更新',
@@ -127,7 +131,7 @@ router.post(
returnAdminConsignmentInventory(
String(req.params.id || ''),
req.body,
req.adminSession?.username || 'admin',
req.adminSession?.displayName || req.adminSession?.username || 'admin',
),
{
successMessage: '物品已退回复库',
@@ -167,7 +171,7 @@ router.post(
approveAdminConsignmentSubmission(
String(req.params.id || ''),
req.body,
req.adminSession?.username || 'admin',
req.adminSession?.displayName || req.adminSession?.username || 'admin',
),
{
successMessage: '审核通过,已正式入库',
@@ -185,7 +189,7 @@ router.post(
rejectAdminConsignmentSubmission(
String(req.params.id || ''),
req.body,
req.adminSession?.username || 'admin',
req.adminSession?.displayName || req.adminSession?.username || 'admin',
),
{
successMessage: '已驳回该提报',
@@ -247,7 +251,7 @@ router.post(
fulfillWorkOrderWithConsignment({
workOrderId: String(req.body?.workOrderId || ''),
inventoryId: String(req.body?.inventoryId || ''),
actorName: req.adminSession?.username || 'admin',
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
allowPartial: req.body?.allowPartial === true,
allowSpecMismatch: req.body?.allowSpecMismatch === true,
matchNote: String(req.body?.matchNote || ''),
@@ -269,7 +273,7 @@ router.post(
workOrderId: String(req.body?.workOrderId || ''),
inventoryId: String(req.body?.inventoryId || ''),
workerId: req.body?.workerId ? Number(req.body.workerId) : null,
actorName: req.adminSession?.username || 'admin',
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
allowPartial: req.body?.allowPartial === true,
allowSpecMismatch: req.body?.allowSpecMismatch === true,
matchNote: String(req.body?.matchNote || ''),
@@ -299,7 +303,7 @@ router.post(
// 押金处置由运营显式选择,非法值一律归一成"按规则自动"
workerDeposit: normalizeWorkerDepositHandling(req.body?.workerDeposit),
consignmentDeposit: normalizeConsignmentDepositHandling(req.body?.consignmentDeposit),
actorName: req.adminSession?.username || 'admin',
actorName: req.adminSession?.displayName || req.adminSession?.username || 'admin',
}),
{
successMessage: '寄售履约已撤销',
@@ -26,7 +26,7 @@ router.post(
reviewAdminWorkerFinanceRequest(
String(req.params.requestId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '资金申请已处理',
@@ -153,7 +153,7 @@ router.post(
submitAdminWorkOrderMaterial(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '订单资料已保存',
@@ -176,7 +176,7 @@ router.put(
(req) =>
updateAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}, {
canEditRequirementFields: req.adminSession?.role !== 'support',
actorName: req.adminSession?.username || '',
actorName: req.adminSession?.displayName || req.adminSession?.username || '',
}),
{
successMessage: '工单信息已保存',
@@ -200,7 +200,7 @@ router.put(
updateAdminWorkOrderNote(
String(req.params.workOrderId || ''),
req.body?.note,
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '工单备注已保存',
@@ -224,7 +224,7 @@ router.post(
assignAdminWorkOrderToWorker(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '已指派打手',
@@ -247,7 +247,7 @@ router.post(
(req) =>
unassignAdminWorkOrder(
String(req.params.workOrderId || ''),
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '已取消指派',
@@ -271,7 +271,7 @@ router.post(
cancelAdminWorkOrder(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
req.adminSession?.permissions || [],
),
{
@@ -314,7 +314,7 @@ router.post(
(req) =>
reopenAdminCancelledWorkOrder(
String(req.params.workOrderId || ''),
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '订单已恢复为未分配状态',
@@ -335,7 +335,10 @@ router.post(
requireAdminPermission('worker_order.publish'),
createJsonHandler(
(req) =>
publishAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''),
publishAdminWorkOrder(
String(req.params.workOrderId || ''),
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '订单已进入大厅队列',
errorMessage: '发布接单订单失败',
@@ -357,7 +360,7 @@ router.post(
(req) =>
unpublishAdminWorkOrder(
String(req.params.workOrderId || ''),
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '订单已撤回到未分配',
@@ -381,7 +384,7 @@ router.post(
pinAdminWorkOrder(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '置顶状态已更新',
@@ -404,7 +407,7 @@ router.post(
(req) =>
confirmAdminWorkOrderFriendAdded(
String(req.params.workOrderId || ''),
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '已确认加好友,赠送冷却开始',
@@ -428,7 +431,7 @@ router.post(
resetAdminWorkOrderGiftMaterial(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '已重置打手资料,等待打手重新提交',
@@ -452,7 +455,7 @@ router.post(
completeAdminWorkOrderGiftCooldown(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '已手动结束赠送冷却,打手现在可以提交验收',
@@ -496,7 +499,7 @@ router.post(
updateAdminWorkOrderSharing(
String(req.params.workOrderId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '拼单配置已保存',
@@ -516,7 +519,11 @@ router.post(
'/worker-platform/orders/accept-batch',
requireAdminPermission('worker_order.accept'),
createJsonHandler(
(req) => acceptAdminWorkOrders(req.body || {}, req.adminSession?.username || ''),
(req) =>
acceptAdminWorkOrders(
req.body || {},
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '选中订单已验收通过',
errorMessage: '批量验收接单订单失败',
@@ -38,7 +38,7 @@ router.post(
resolveAdminWorkerOrderFeedback(
String(req.params.feedbackId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '问题反馈已处理',
@@ -56,7 +56,7 @@ router.post(
reviewAdminWorkerCancelRequest(
String(req.params.requestId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '打手退单申请已审核',
@@ -18,6 +18,7 @@ function actorOf(req: Request) {
return {
userId: req.adminSession?.userId ?? null,
username: req.adminSession?.username || '',
displayName: req.adminSession?.displayName || req.adminSession?.username || '',
}
}
@@ -73,7 +73,7 @@ router.post(
reviewAdminWorkerUser(
String(req.params.workerId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '打手账号状态已更新',
@@ -97,7 +97,7 @@ router.post(
updateAdminWorkerLevelLock(
String(req.params.workerId || ''),
req.body || {},
req.adminSession?.username || '',
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '打手等级锁定状态已更新',
@@ -46,6 +46,7 @@ export async function getAdminAuditLogs(query: JsonObject = {}) {
logId: Number(item.id),
actorUserId: Number(item.actor_user_id || 0),
actorUsername: item.actor_username,
actorDisplayName: String(item.actor_display_name || item.actor_username || ''),
actorRole: item.actor_role,
action: item.action,
targetType: item.target_type,
@@ -28,6 +28,7 @@ export type AdminSession = {
sessionId: string
userId: number
username: string
displayName: string
role: AdminRole
expiresAt: string
sessionVersion: number
@@ -48,6 +49,7 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
const username = String(configuredUser?.username || '')
.trim()
.toLowerCase()
const displayName = normalizeDisplayName(configuredUser?.displayName, username) || username
const password = String(configuredUser?.password || '').trim()
const role = normalizeAdminRole(configuredUser?.role)
@@ -62,6 +64,7 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
const now = nowIso()
await createAdminUser({
username,
displayName,
passwordHash: hashAdminPassword(password),
role,
status: 'active',
@@ -229,6 +232,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
sessionId,
userId: Number(user.id),
username: String(user.username || ''),
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
expiresAt,
sessionVersion: currentSessionVersion,
@@ -251,6 +255,7 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
user: {
userId: session.userId,
username: session.username,
displayName: session.displayName,
role: session.role,
permissions: session.permissions,
},
@@ -277,7 +282,7 @@ export async function getAdminUserList(query: JsonObject = {}): Promise<JsonObje
const { items, total } = await listAdminUsers({
page,
pageSize,
username: String(query.username || '').trim(),
keyword: String(query.keyword || query.username || '').trim(),
role: normalizeRoleQuery(query.role),
status: normalizeStatusQuery(query.status),
})
@@ -290,6 +295,7 @@ export async function getAdminUserList(query: JsonObject = {}): Promise<JsonObje
export async function createManagedAdminUser(payload: JsonObject = {}): Promise<JsonObject> {
const username = normalizeUsername(payload.username)
const displayName = normalizeDisplayName(payload.displayName, username) || username
const password = normalizePassword(payload.password)
if (!username) {
@@ -319,6 +325,7 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
const now = nowIso()
const created = await createAdminUser({
username,
displayName,
passwordHash: hashAdminPassword(password),
role: normalizeAdminRole(payload.role),
status: normalizeAdminUserStatus(payload.status || 'active'),
@@ -401,6 +408,28 @@ export async function updateManagedAdminUserStatus(
}
}
export async function updateManagedAdminUserDisplayName(
userId: number | string,
payload: JsonObject = {},
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const displayName = normalizeDisplayName(payload.displayName, user.username) || user.username
const updated = await updateAdminUser(user.id, {
display_name: displayName,
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
}
}
export async function resetManagedAdminUserPassword(
userId: number | string,
payload: JsonObject = {},
@@ -472,6 +501,7 @@ function createAdminSession(user: AdminUserRow): JsonObject {
user: {
userId: Number(user.id),
username: String(user.username || ''),
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
},
}
@@ -559,6 +589,27 @@ function normalizeUsername(username: unknown): string {
.toLowerCase()
}
function normalizeDisplayName(displayName: unknown, fallback = ''): string {
const normalized = String(displayName ?? '')
.trim()
.replace(/\s+/g, ' ')
if (!normalized) {
return fallback
}
const characters = Array.from(normalized)
const hasControlCharacter = characters.some((character) => {
const codePoint = character.codePointAt(0) || 0
return codePoint <= 0x1f || codePoint === 0x7f
})
if (characters.length > 32 || hasControlCharacter) {
throw createHttpError('姓名格式无效,最多 32 个字符且不能包含控制字符', {
statusCode: 400,
errorCode: 'admin_user_display_name_invalid',
})
}
return normalized
}
function normalizePassword(password: unknown): string {
return String(password || '').trim()
}
@@ -634,6 +685,7 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
return {
userId: Number(user.id),
username: String(user.username || ''),
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
status: normalizeAdminUserStatus(user.status),
createdAt: user.created_at,
File diff suppressed because it is too large Load Diff
@@ -67,6 +67,7 @@ export async function getAdminLoginLogs(query: JsonObject = {}) {
logId: Number(item.id),
userId: Number(item.user_id || 0) || null,
username: String(item.username || '').trim(),
displayName: String(item.display_name || item.username || '').trim(),
role: String(item.role || '').trim(),
ip: String(item.ip || '').trim(),
location: String(item.location || '').trim(),
@@ -6,6 +6,12 @@ export const ADMIN_PERMISSION_KEYS = [
'finance.review',
'finance.config',
'finance.export',
'fund.view',
'fund.issue',
'fund.expense',
'fund.reconcile',
'fund.adjust',
'fund.export',
'worker_order.view',
'worker_order.edit',
'worker_order.configure_share',
@@ -5,6 +5,7 @@ import {
canViewerCloseTask,
createAdminViewerContext,
mapKuaishouCloudFulfillmentContext,
mapManualDispatchContext,
resolveAdminTaskScreenshotUrl,
resolveOrderItemSkuNick,
resolveOrderItemTitle,
@@ -68,6 +69,23 @@ test('createAdminViewerContext lets support rebind-assisted actions without full
assert.equal(operator.canManageTaskLifecycle, true)
})
test('mapManualDispatchContext prefers display name and falls back to username', () => {
const withDisplayName = mapManualDispatchContext({
completedBy: { userId: 3, username: 'kf_1', displayName: '客服-1', role: 'support' },
})
assert.deepEqual(withDisplayName?.completedBy, {
userId: 3,
username: 'kf_1',
displayName: '客服-1',
role: 'support',
})
const legacy = mapManualDispatchContext({
completedBy: { userId: 3, username: 'kf_1', role: 'support' },
})
assert.equal(legacy?.completedBy?.displayName, 'kf_1')
})
test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => {
const flow = mapKuaishouCloudFulfillmentContext(
{
@@ -64,6 +64,9 @@ export function mapManualDispatchContext(
? {
userId: Number(record.completedBy.userId || 0) || 0,
username: String(record.completedBy.username || '').trim(),
displayName: String(
record.completedBy.displayName || record.completedBy.username || '',
).trim(),
role: String(record.completedBy.role || '').trim(),
}
: null,
@@ -333,6 +333,7 @@ function buildAdminActionActor(session: AdminViewerSessionInput | null) {
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
displayName: String(session.displayName || session.username || '').trim(),
role: String(session.role || '').trim(),
}
: null
@@ -59,6 +59,12 @@ export async function updateAdminOrderClaimStatus(
claimStatus: updated.claim_status || payload.status,
claimStatusReason: updated.claim_status_reason || reason,
claimStatusUpdatedAt: updated.claim_status_updated_at || now,
operator: session ? { userId: session.userId, username: session.username } : null,
operator: session
? {
userId: session.userId,
username: session.username,
displayName: session.displayName || session.username,
}
: null,
}
}
@@ -96,6 +96,7 @@ export async function closeAdminTask(
? {
userId: session.userId,
username: session.username,
displayName: session.displayName || session.username,
role: session.role,
}
: null,
@@ -140,6 +141,7 @@ export async function markAdminTaskManualReview(
? {
userId: session.userId,
username: session.username,
displayName: session.displayName || session.username,
role: session.role,
}
: null,
@@ -215,6 +217,7 @@ export async function retryAdminTask(
? {
userId: session.userId,
username: session.username,
displayName: session.displayName || session.username,
role: session.role,
}
: null,
@@ -335,6 +338,7 @@ export async function regenerateAdminTaskClaimLink(
? {
userId: session.userId,
username: session.username,
displayName: session.displayName || session.username,
role: session.role,
}
: null,
@@ -394,6 +398,7 @@ export async function completeAdminTaskManualDispatch(
? {
userId: Number(session.userId || 0),
username: String(session.username || ''),
displayName: String(session.displayName || session.username || ''),
role: String(session.role || ''),
}
: null,
@@ -29,7 +29,11 @@ import { getWorkerRiskConfig, saveWorkerRiskConfig } from './worker-risk-config-
* - **留痕层**:worker_risk_events(只记显著事件)。持久、可追溯。
*/
export type WorkerRiskActor = { userId?: number | null; username?: string }
export type WorkerRiskActor = {
userId?: number | null
username?: string
displayName?: string
}
const OVERVIEW_SUMMARY_WINDOW_MS = 24 * 60 * 60 * 1000
@@ -123,7 +127,7 @@ export async function banAdminWorkerRisk(
reason,
action: 'manual',
actorUserId: actor.userId ?? null,
actorUsername: actor.username || '',
actorUsername: actor.displayName || actor.username || '',
})
return { workerId: id, status: 'frozen', reason }
@@ -162,7 +166,7 @@ export async function unbanAdminWorkerRisk(
reason,
action: 'manual',
actorUserId: actor.userId ?? null,
actorUsername: actor.username || '',
actorUsername: actor.displayName || actor.username || '',
})
return { workerId: id, status: 'active', reason }
@@ -182,7 +186,7 @@ export async function releaseAdminWorkerRiskTracker(
reason: '后台仅释放内存风控状态(账号状态未变更)',
action: 'manual',
actorUserId: actor.userId ?? null,
actorUsername: actor.username || '',
actorUsername: actor.displayName || actor.username || '',
})
return { workerId: id }
}
@@ -1,6 +1,7 @@
export type AdminViewerSessionInput = {
userId?: number | string
username?: string
displayName?: string
role?: string
}
+1
View File
@@ -1,5 +1,6 @@
export type AdminDefaultUser = {
username: string
displayName?: string
password: string
role: string
status?: string