Files
order_site/apps/backend/src/services/admin/admin-auth-service.ts
T

1032 lines
29 KiB
TypeScript

import crypto from 'node:crypto'
import type { JsonObject } from '../../types/json.js'
import { runtimeConfig } from '../../config/runtime.js'
import {
countActiveAdminUsers,
createAdminUser,
getAdminUserById,
getAdminUserByPhone,
getAdminUserByUsername,
listAdminUsers,
updateAdminUser,
} from '../../repositories/admin-user-repo.js'
import { resetAdminUserDevice as resetAdminUserDeviceRecord } from '../../repositories/admin-device-repo.js'
import {
createAdminSession as createAdminSessionRecord,
getActiveAdminSession,
revokeAdminSession,
touchAdminSession,
} from '../../repositories/admin-session-repo.js'
import { nowIso } from '../../utils/time.js'
import {
resolveSessionExpiryWindow,
resolveSlidingSessionExpiresAt,
} from '../../utils/session-window.js'
import { setSessionExpiryHeaders } from '../../utils/session-headers.js'
import { createHttpError } from '../../utils/http.js'
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
import { recordAdminLoginLog } from './admin-login-log-service.js'
import { listAdminPermissionKeysForRole } from './admin-rbac-service.js'
import {
consumeAdminDeviceLogin,
createAdminLoginDeviceChallenge,
ensureAdminDeviceBinding,
} from './admin-device-auth-service.js'
import { maskPhone, normalizeAdminPhone, verifyAdminLoginSmsCode } from './admin-sms-service.js'
type AdminUserRow = NonNullable<Awaited<ReturnType<typeof getAdminUserById>>>
export type AdminSession = {
sessionId: string
userId: number
username: string
displayName: string
role: AdminRole
expiresAt: string
absoluteExpiresAt: string
sessionVersion: number
deviceId: string
permissions: string[]
}
type AdminRole = 'admin' | 'operator' | 'support' | 'finance'
type AdminUserStatus = 'active' | 'disabled'
export async function ensureAdminUsersBootstrapped(): Promise<void> {
ensureAdminAuthConfigured()
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers)
? runtimeConfig.admin.defaultUsers
: []
for (const configuredUser of configuredUsers) {
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)
if (!username || !password) {
continue
}
if (await getAdminUserByUsername(username)) {
continue
}
const now = nowIso()
await createAdminUser({
username,
displayName,
passwordHash: hashAdminPassword(password),
role,
status: 'active',
createdAt: now,
updatedAt: now,
})
}
}
export async function loginAdmin(
username: unknown,
password: unknown,
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const normalizedUsername = String(username || '')
.trim()
.toLowerCase()
const normalizedPassword = String(password || '').trim()
if (!normalizedUsername || !normalizedPassword) {
await recordAdminLoginLog({
username: normalizedUsername,
success: false,
failureReason: 'missing_credentials',
...pickLoginMeta(meta),
})
throw createHttpError('缺少后台账号或密码', {
statusCode: 400,
errorCode: 'admin_credentials_required',
})
}
const user = await getAdminUserByUsername(normalizedUsername)
if (
!user ||
user.status !== 'active' ||
!verifyAdminPassword(normalizedPassword, user.password_hash)
) {
await recordAdminLoginLog({
userId: user ? Number(user.id) : null,
username: normalizedUsername,
role: user ? normalizeAdminRole(user.role) : '',
success: false,
failureReason: 'invalid_credentials',
...pickLoginMeta(meta),
})
throw createHttpError('账号或密码错误', {
statusCode: 401,
errorCode: 'admin_login_failed',
})
}
if (String(user.phone || '').trim() && user.test_account !== true) {
throw createHttpError('该账号已启用安全登录,请使用账号密码 + 短信验证码 + 本机验证', {
statusCode: 403,
errorCode: 'admin_device_auth_required',
})
}
const session = createAdminSession(user)
const createdSession = await createAdminSessionRecord({
sessionId: String(session.sessionId || ''),
userId: Number(user.id),
deviceId: '',
issuedAt: String(session.issuedAt),
expiresAt: String(session.expiresAt),
now: nowIso(),
})
if (!createdSession) {
throw createHttpError('后台登录会话创建失败,请重试', {
statusCode: 503,
errorCode: 'admin_session_create_failed',
})
}
await recordAdminLoginLog({
userId: Number(user.id),
username: String(user.username || normalizedUsername),
role: normalizeAdminRole(user.role),
success: true,
loginMethod: user.test_account === true ? 'password_test' : 'password',
...pickLoginMeta(meta),
})
return session
}
export async function prepareAdminLogin(
usernameValue: unknown,
passwordValue: unknown,
smsCodeValue: unknown,
meta: {
ip?: string
userAgent?: string
location?: string
apiBase: string
},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const normalizedUsername = normalizeUsername(usernameValue)
const normalizedPassword = String(passwordValue || '').trim()
if (!normalizedUsername || !normalizedPassword) {
throw createHttpError('缺少后台账号或密码', {
statusCode: 400,
errorCode: 'admin_credentials_required',
})
}
const user = await getAdminUserByUsername(normalizedUsername)
if (
!user ||
user.status !== 'active' ||
!verifyAdminPassword(normalizedPassword, user.password_hash)
) {
await recordAdminLoginLog({
userId: user ? Number(user.id) : null,
username: normalizedUsername,
role: user ? normalizeAdminRole(user.role) : '',
success: false,
failureReason: 'invalid_credentials',
...pickLoginMeta(meta),
})
throw createHttpError('账号或密码错误', {
statusCode: 401,
errorCode: 'admin_login_failed',
})
}
if (!String(user.phone || '').trim()) {
throw createHttpError('该账号尚未绑定手机号,请联系管理员', {
statusCode: 409,
errorCode: 'admin_phone_unbound',
})
}
await verifyAdminLoginSmsCode(Number(user.id), smsCodeValue)
const challenge = await createAdminLoginDeviceChallenge(Number(user.id), meta.apiBase)
return {
...challenge,
phoneMasked: maskPhone(user.phone),
app: 'order_site',
}
}
export async function completeAdminLogin(
payload: JsonObject = {},
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const challengeId = String(payload.challengeId || '').trim()
const pollToken = String(payload.pollToken || '').trim()
const deviceTicket = String(payload.deviceTicket || '').trim()
if (!challengeId || !pollToken || !deviceTicket) {
throw createHttpError('缺少设备认证票据', {
statusCode: 400,
errorCode: 'admin_device_ticket_required',
})
}
const challenge = await getAdminDeviceChallengeForLogin(challengeId)
const user = await getAdminUserById(Number(challenge.user_id))
if (!user || user.status !== 'active') {
throw createHttpError('后台账号已不可用,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_user_invalid',
})
}
const consumed = await consumeAdminDeviceLogin(Number(user.id), {
challengeId,
pollToken,
deviceTicket,
})
await ensureAdminDeviceBinding(Number(user.id), consumed)
return createAdminLoginSession(user, consumed.device_id, meta)
}
async function createAdminLoginSession(
user: AdminUserRow,
deviceId: string,
meta: {
ip?: string
userAgent?: string
location?: string
},
): Promise<JsonObject> {
const session = createAdminSession(user, deviceId)
const createdSession = await createAdminSessionRecord({
sessionId: String(session.sessionId || ''),
userId: Number(user.id),
deviceId,
issuedAt: String(session.issuedAt),
expiresAt: String(session.expiresAt),
now: nowIso(),
})
if (!createdSession) {
throw createHttpError('后台登录会话创建失败,请重试', {
statusCode: 503,
errorCode: 'admin_session_create_failed',
})
}
await recordAdminLoginLog({
userId: Number(user.id),
username: String(user.username || ''),
role: normalizeAdminRole(user.role),
success: true,
loginMethod: 'sms_device',
...pickLoginMeta(meta),
})
return session
}
async function getAdminDeviceChallengeForLogin(challengeId: string) {
const { getAdminDeviceChallenge } = await import('../../repositories/admin-device-repo.js')
const challenge = await getAdminDeviceChallenge(challengeId)
if (!challenge || !challenge.user_id) {
throw createHttpError('设备认证会话不存在,请重新登录', {
statusCode: 404,
errorCode: 'admin_device_challenge_not_found',
})
}
return challenge
}
export async function verifyAdminSessionToken(token: unknown): Promise<AdminSession> {
ensureAdminAuthConfigured()
const normalizedToken = String(token || '').trim()
if (!normalizedToken) {
throw createHttpError('未登录或登录已失效', {
statusCode: 401,
errorCode: 'admin_auth_required',
})
}
const parts = normalizedToken.split('.')
const encodedPayload = parts[0]
const signature = parts[1]
if (parts.length !== 2 || !encodedPayload || !signature) {
throw createHttpError('后台登录态无效', {
statusCode: 401,
errorCode: 'admin_auth_invalid',
})
}
const expectedSignature = signPayload(encodedPayload)
if (!safeCompare(signature, expectedSignature)) {
throw createHttpError('后台登录态无效', {
statusCode: 401,
errorCode: 'admin_auth_invalid',
})
}
let payload: JsonObject | null
try {
payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'))
} catch {
throw createHttpError('后台登录态无效', {
statusCode: 401,
errorCode: 'admin_auth_invalid',
})
}
const absoluteExpiresAt = String(payload?.exp || '').trim()
if (!absoluteExpiresAt || Date.parse(absoluteExpiresAt) <= Date.now()) {
throw createHttpError('后台登录已过期,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_expired',
})
}
const userId = Number(payload?.uid || 0)
const sessionId = String(payload?.sid || '').trim()
if (!sessionId) {
throw createHttpError('后台登录态无效', {
statusCode: 401,
errorCode: 'admin_auth_invalid',
})
}
const now = nowIso()
const activeSession = await getActiveAdminSession(sessionId, now)
if (!activeSession || Number(activeSession.user_id) !== userId) {
throw createHttpError('后台登录态已失效,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_revoked',
})
}
const touchedExpiresAt = await touchAdminSession({
sessionId,
now,
idleExpiresAt: resolveSlidingSessionExpiresAt({
now,
idleHours: runtimeConfig.admin.sessionIdleTtlHours,
absoluteExpiresAt,
}),
absoluteExpiresAt,
})
if (!touchedExpiresAt) {
throw createHttpError('后台登录态已失效,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_revoked',
})
}
const user = await getAdminUserById(userId)
if (!user || user.status !== 'active') {
throw createHttpError('后台账号已不可用,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_user_invalid',
})
}
const tokenSessionVersion = Number(payload?.ver || 0)
const currentSessionVersion = normalizeAdminSessionVersion(user.session_version)
if (tokenSessionVersion !== currentSessionVersion) {
throw createHttpError('后台登录态已失效,请重新登录', {
statusCode: 401,
errorCode: 'admin_auth_stale',
})
}
return {
sessionId,
userId: Number(user.id),
username: String(user.username || ''),
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
expiresAt: touchedExpiresAt,
absoluteExpiresAt,
sessionVersion: currentSessionVersion,
deviceId: String(activeSession.device_id || ''),
permissions: await listAdminPermissionKeysForRole(normalizeAdminRole(user.role)),
}
}
export async function logoutAdmin(token: unknown): Promise<{ success: boolean; revoked: boolean }> {
const session = await verifyAdminSessionToken(token)
const revoked = await revokeAdminSession(session.sessionId, nowIso())
return { success: true, revoked }
}
export async function getAdminSessionSummary(
token: unknown,
res?: { setHeader(name: string, value: string): unknown },
): Promise<JsonObject> {
const session = await verifyAdminSessionToken(token)
if (res) setSessionExpiryHeaders(res, session)
return {
authenticated: true,
expiresAt: session.expiresAt,
absoluteExpiresAt: session.absoluteExpiresAt,
user: {
userId: session.userId,
username: session.username,
displayName: session.displayName,
role: session.role,
deviceId: session.deviceId,
permissions: session.permissions,
},
}
}
export function requireAdminRole(
session: { role?: string } | null | undefined,
allowedRoles: string[],
): void {
if (session && allowedRoles.includes(session.role || '')) {
return
}
throw createHttpError('当前账号没有此操作权限', {
statusCode: 403,
errorCode: 'admin_permission_denied',
})
}
export async function getAdminUserList(query: JsonObject = {}): Promise<JsonObject> {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const { items, total } = await listAdminUsers({
page,
pageSize,
keyword: String(query.keyword || query.username || '').trim(),
role: normalizeRoleQuery(query.role),
status: normalizeStatusQuery(query.status),
})
return {
items: items.map(mapAdminUser),
pagination: { page, pageSize, total },
}
}
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)
const phone = normalizeAdminPhone(payload.phone)
if (!username) {
throw createHttpError('缺少后台账号', {
statusCode: 400,
errorCode: 'admin_user_username_required',
})
}
if (!password) {
throw createHttpError('缺少后台密码', {
statusCode: 400,
errorCode: 'admin_user_password_required',
})
}
validateUsername(username)
validatePassword(password)
if (await getAdminUserByUsername(username)) {
throw createHttpError('后台账号已存在', {
statusCode: 409,
errorCode: 'admin_user_exists',
})
}
if (phone && (await getAdminUserByPhone(phone))) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const created = await createAdminUser({
username,
displayName,
passwordHash: hashAdminPassword(password),
role: normalizeAdminRole(payload.role),
status: normalizeAdminUserStatus(payload.status || 'active'),
phone,
phoneBoundAt: phone ? now : null,
phoneBoundBy: null,
testAccount: payload.testAccount === true,
createdAt: now,
updatedAt: now,
})
if (!created) {
throw createHttpError('后台用户创建失败', {
statusCode: 500,
errorCode: 'admin_user_create_failed',
})
}
return {
user: mapAdminUser(created),
}
}
export async function updateManagedAdminUserRole(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const role = normalizeAdminRole(payload.role)
if (user.role === role) {
return {
user: mapAdminUser(user),
}
}
await ensureAdminUserChangeAllowed(user, { nextRole: role }, session)
const updated = await updateAdminUser(user.id, {
role,
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
}
}
export async function updateManagedAdminUserStatus(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const status = normalizeAdminUserStatus(payload.status)
if (user.status === status) {
return {
user: mapAdminUser(user),
}
}
await ensureAdminUserChangeAllowed(user, { nextStatus: status }, session)
const updated = await updateAdminUser(user.id, {
status,
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
}
}
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 updateManagedAdminUserPhone(
userId: number | string,
payload: JsonObject = {},
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const phone = normalizeAdminPhone(payload.phone)
if (!phone) {
throw createHttpError('请输入手机号', {
statusCode: 400,
errorCode: 'admin_phone_required',
})
}
if (phone === String(user.phone || '').trim()) {
return { user: mapAdminUser(user) }
}
const existing = await getAdminUserByPhone(phone)
if (existing && Number(existing.id) !== Number(user.id)) {
throw createHttpError('该手机号已绑定其他后台账号', {
statusCode: 409,
errorCode: 'admin_phone_exists',
})
}
const now = nowIso()
const updated = await updateAdminUser(user.id, {
phone,
phone_bound_at: now,
phone_bound_by: session.userId,
session_version: nextAdminSessionVersion(user),
updated_at: now,
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return { user: mapAdminUser(updated) }
}
export async function updateManagedAdminUserTestAccount(
userId: number | string,
payload: JsonObject = {},
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const testAccount = payload.testAccount === true
const updated = await updateAdminUser(user.id, {
test_account: testAccount,
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
testAccount,
}
}
export async function resetManagedAdminUserDevice(
userId: number | string,
session: AdminSession,
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const reset = await resetAdminUserDeviceRecord(user.id, session.userId, nowIso())
const updated = await updateAdminUser(user.id, {
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
reset,
}
}
export async function resetManagedAdminUserPassword(
userId: number | string,
payload: JsonObject = {},
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const password = normalizePassword(payload.password)
if (!password) {
throw createHttpError('缺少新密码', {
statusCode: 400,
errorCode: 'admin_user_password_required',
})
}
validatePassword(password)
const updated = await updateAdminUser(user.id, {
password_hash: hashAdminPassword(password),
session_version: nextAdminSessionVersion(user),
updated_at: nowIso(),
})
if (!updated) {
throw createHttpError('后台用户更新失败', {
statusCode: 500,
errorCode: 'admin_user_update_failed',
})
}
return {
user: mapAdminUser(updated),
}
}
export function ensureAdminAuthConfigured(): void {
const sessionSecret = String(runtimeConfig.admin?.sessionSecret || '').trim()
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers)
? runtimeConfig.admin.defaultUsers
: []
if (sessionSecret && configuredUsers.length > 0) {
return
}
throw createHttpError('后台鉴权未配置,请先设置 admin.sessionSecret 和 admin.defaultUsers', {
statusCode: 503,
errorCode: 'admin_auth_not_configured',
})
}
function createAdminSession(user: AdminUserRow, deviceId = ''): JsonObject {
const issuedAt = nowIso()
// Token 的 exp 是绝对上限;DB 会话的 expires_at 是空闲窗口,会在每次访问时滑动。
const { idleExpiresAt, absoluteExpiresAt } = resolveSessionExpiryWindow({
now: issuedAt,
idleHours: Number(runtimeConfig.admin.sessionIdleTtlHours || 24),
absoluteHours: Number(runtimeConfig.admin.sessionAbsoluteTtlHours || 168),
})
const payload = {
sid: crypto.randomBytes(12).toString('hex'),
uid: Number(user.id),
usr: String(user.username || ''),
role: normalizeAdminRole(user.role),
dev: String(deviceId || ''),
ver: normalizeAdminSessionVersion(user.session_version),
iat: issuedAt,
exp: absoluteExpiresAt,
}
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
const signature = signPayload(encodedPayload)
return {
sessionId: payload.sid,
issuedAt,
token: `${encodedPayload}.${signature}`,
expiresAt: idleExpiresAt,
absoluteExpiresAt,
user: {
userId: Number(user.id),
username: String(user.username || ''),
displayName: String(user.display_name || user.username || ''),
role: normalizeAdminRole(user.role),
},
}
}
export function hashAdminPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex')
const derived = crypto.scryptSync(password, salt, 64).toString('hex')
return `scrypt$${salt}$${derived}`
}
export function verifyAdminPassword(password: string, storedHash: string): boolean {
const [algorithm, salt, expectedHash] = String(storedHash || '').split('$')
if (algorithm !== 'scrypt' || !salt || !expectedHash) {
return false
}
const actualHash = crypto.scryptSync(password, salt, 64).toString('hex')
return safeCompare(actualHash, expectedHash)
}
function signPayload(encodedPayload: string): string {
return crypto
.createHmac('sha256', String(runtimeConfig.admin.sessionSecret || ''))
.update(encodedPayload)
.digest('base64url')
}
export function normalizeAdminRole(role: unknown): AdminRole {
const normalized = String(role || '')
.trim()
.toLowerCase()
if (normalized === 'admin') {
return 'admin'
}
if (normalized === 'support') {
return 'support'
}
if (normalized === 'finance') {
return 'finance'
}
return 'operator'
}
export function normalizeAdminUserStatus(status: unknown): AdminUserStatus {
return String(status || '')
.trim()
.toLowerCase() === 'disabled'
? 'disabled'
: 'active'
}
function safeCompare(input: unknown, expected: unknown): boolean {
const left = Buffer.from(String(input || ''), 'utf8')
const right = Buffer.from(String(expected || ''), 'utf8')
if (left.length !== right.length) {
return false
}
return crypto.timingSafeEqual(left, right)
}
function normalizeRoleQuery(role: unknown): string {
const normalized = String(role || '')
.trim()
.toLowerCase()
return ['admin', 'operator', 'support', 'finance'].includes(normalized) ? normalized : ''
}
function normalizeStatusQuery(status: unknown): string {
const normalized = String(status || '')
.trim()
.toLowerCase()
return ['active', 'disabled'].includes(normalized) ? normalized : ''
}
function normalizeUsername(username: unknown): string {
return String(username || '')
.trim()
.normalize('NFKC')
.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()
}
function validateUsername(username: string): void {
if (!/^[\p{L}\p{N}._-]{2,32}$/u.test(username)) {
throw createHttpError('后台账号格式无效,需为 2-32 位中文、字母、数字或 ._-', {
statusCode: 400,
errorCode: 'admin_user_username_invalid',
})
}
}
function validatePassword(password: string): void {
if (password.length < 8) {
throw createHttpError('后台密码至少 8 位', {
statusCode: 400,
errorCode: 'admin_user_password_invalid',
})
}
}
function normalizeAdminSessionVersion(value: unknown): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1
}
function nextAdminSessionVersion(user: AdminUserRow): number {
return normalizeAdminSessionVersion(user.session_version) + 1
}
async function getRequiredAdminUser(userId: number | string): Promise<AdminUserRow> {
const user = await getAdminUserById(Number(userId))
if (!user) {
throw createHttpError('后台用户不存在', {
statusCode: 404,
errorCode: 'admin_user_not_found',
})
}
return user
}
async function ensureAdminUserChangeAllowed(
user: AdminUserRow,
options: { nextRole?: AdminRole; nextStatus?: AdminUserStatus } = {},
session: AdminSession,
): Promise<void> {
const nextRole = options.nextRole || user.role
const nextStatus = options.nextStatus || user.status
if (session?.userId === Number(user.id) && (nextRole !== 'admin' || nextStatus !== 'active')) {
throw createHttpError('不能停用或降级当前登录账号', {
statusCode: 409,
errorCode: 'admin_user_self_change_not_allowed',
})
}
if (
user.role === 'admin' &&
(nextRole !== 'admin' || nextStatus !== 'active') &&
(await countActiveAdminUsers()) <= 1
) {
throw createHttpError('至少保留一个启用中的管理员账号', {
statusCode: 409,
errorCode: 'admin_user_last_admin_not_allowed',
})
}
}
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),
phone: String(user.phone || ''),
phoneMasked: maskPhone(user.phone),
phoneBoundAt: user.phone_bound_at || null,
testAccount: user.test_account === true,
deviceBound: Boolean(user.device_id),
deviceId: String(user.device_id || ''),
deviceIdMasked: maskDeviceId(user.device_id),
deviceBoundAt: user.device_bound_at || null,
deviceLastUsedAt: user.device_last_used_at || null,
createdAt: user.created_at,
updatedAt: user.updated_at,
}
}
function maskDeviceId(value: unknown): string {
const deviceId = String(value || '')
.trim()
.toUpperCase()
if (deviceId.length <= 8) return deviceId
return `${deviceId.slice(0, 6)}-****-${deviceId.slice(-4)}`
}
function pickLoginMeta(
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
) {
const result: {
ip?: string
userAgent?: string
location?: string
} = {}
if (typeof meta.ip === 'string' && meta.ip.trim()) {
result.ip = meta.ip.trim()
}
if (typeof meta.userAgent === 'string' && meta.userAgent.trim()) {
result.userAgent = meta.userAgent.trim()
}
if (typeof meta.location === 'string' && meta.location.trim()) {
result.location = meta.location.trim()
}
return result
}