feat(M02-D): 完成用户与员工权限管理

This commit is contained in:
Codex
2026-06-18 14:45:01 +08:00
parent 46acb8422a
commit 5a300a82f6
21 changed files with 894 additions and 40 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs" "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+8
View File
@@ -9,11 +9,16 @@ import {
type PlatformConfigResolver type PlatformConfigResolver
} from './routes/platform-bootstrap.js'; } from './routes/platform-bootstrap.js';
import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js'; import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js';
import {
registerUserManagementRoutes,
type UserManagementRouteOptions
} from './routes/user-management.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
platformConfigRepository?: PlatformConfigResolver; platformConfigRepository?: PlatformConfigResolver;
auth?: AuthRouteOptions; auth?: AuthRouteOptions;
userManagement?: UserManagementRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -62,6 +67,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.auth) { if (options.auth) {
await registerAuthRoutes(app, options.auth); await registerAuthRoutes(app, options.auth);
} }
if (options.userManagement) {
await registerUserManagementRoutes(app, options.userManagement);
}
return app; return app;
} }
+8 -2
View File
@@ -138,8 +138,14 @@ export class AuthRepository {
await connection.execute( await connection.execute(
`INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id) `INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT ?, r.id, p.id FROM qipai_roles r SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON p.code IN ('profile.read', 'order.self.read') INNER JOIN qipai_permissions p ON
WHERE r.tenant_id = ? AND r.code = 'CUSTOMER'`, (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'))
WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId] [input.context.tenantId, input.context.tenantId]
); );
await connection.execute( await connection.execute(
+25
View File
@@ -0,0 +1,25 @@
import type { AuthRepository, AuthSession } from './auth-repository.js';
import { verifyAccessToken } from './jwt.js';
export interface AuthenticatedRequest {
sessionId: string;
session: AuthSession;
}
export async function authenticateAccessToken(
authorization: string | undefined,
repository: Pick<AuthRepository, 'validateSession'>,
jwtSecret: string
): Promise<AuthenticatedRequest | null> {
if (!authorization?.startsWith('Bearer ')) return null;
try {
const claims = verifyAccessToken(authorization.slice(7), jwtSecret);
const session = await repository.validateSession(claims.sid, claims.tid, claims.sub);
if (!session || session.platformAppId !== claims.aid || session.user.roleVersion !== claims.rv) {
return null;
}
return { sessionId: claims.sid, session };
} catch {
return null;
}
}
+9 -4
View File
@@ -33,10 +33,15 @@ export class RbacRepository {
); );
await this.pool.execute( await this.pool.execute(
`INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id) `INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT ?, r.id, p.id SELECT ?, r.id, p.id FROM qipai_roles r
FROM qipai_roles r INNER JOIN qipai_permissions p ON
INNER JOIN qipai_permissions p ON p.code IN ('profile.read', 'order.self.read') (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
WHERE r.tenant_id = ? AND r.code = 'CUSTOMER'`, OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'))
WHERE r.tenant_id = ?`,
[tenantId, tenantId] [tenantId, tenantId]
); );
} }
@@ -0,0 +1,347 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { AccessProfile } from './rbac-repository.js';
const assignableRoles = ['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN'] as const;
export type AssignableRole = typeof assignableRoles[number];
export interface ManagedUser {
id: string;
userType: string;
status: string;
nickname: string;
maskedPhone: string;
maskedLastIp: string;
note: string;
roles: string[];
storeIds: string[];
registeredAt: Date;
lastLoginAt: Date | null;
}
export interface ManagementActor {
tenantId: string;
userId: string;
access: AccessProfile;
traceId: string;
ip: string;
userAgent: string;
}
export interface UserMutation {
nickname?: string;
phone?: string;
note?: string;
status?: 'ACTIVE' | 'DISABLED';
roles?: AssignableRole[];
storeIds?: string[];
}
interface UserRow extends RowDataPacket {
id: string;
userType: string;
status: string;
nickname: string;
phone: string;
lastIp: string | null;
note: string;
registeredAt: Date;
lastLoginAt: Date | null;
}
interface CountRow extends RowDataPacket { total: number }
interface CodeRow extends RowDataPacket { code: string }
interface IdRow extends RowDataPacket { id: string }
export class UserManagementError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export class UserManagementRepository {
constructor(private readonly pool: MySqlPool) {}
async listUsers(input: {
actor: ManagementActor;
page: number;
pageSize: number;
status?: 'ACTIVE' | 'DISABLED';
search?: string;
}): Promise<{ items: ManagedUser[]; total: number }> {
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
const params: Array<string | number> = [input.actor.tenantId];
if (input.status) {
filters.push('u.status = ?');
params.push(input.status);
}
if (input.search) {
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
const like = `%${input.search}%`;
params.push(like, like, input.search);
}
const scope = this.scopeClause(input.actor, 'u.id');
filters.push(scope.sql);
params.push(...scope.params);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT u.id) AS total FROM qipai_users u
WHERE ${filters.join(' AND ')}`,
params
);
const [rows] = await this.pool.execute<UserRow[]>(
`SELECT u.id, u.user_type AS userType, u.status, u.nickname, u.phone,
u.created_at AS registeredAt, u.last_login_at AS lastLoginAt,
COALESCE(p.note, '') AS note,
(SELECT s.ip FROM qipai_auth_sessions s
WHERE s.tenant_id = u.tenant_id AND s.user_id = u.id
ORDER BY s.created_at DESC LIMIT 1) AS lastIp
FROM qipai_users u
LEFT JOIN qipai_user_admin_profiles p
ON p.tenant_id = u.tenant_id AND p.user_id = u.id
WHERE ${filters.join(' AND ')}
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
[...params, input.pageSize, (input.page - 1) * input.pageSize]
);
const items = await Promise.all(rows.map(async (row) => ({
id: String(row.id),
userType: row.userType,
status: row.status,
nickname: row.nickname,
maskedPhone: maskPhone(row.phone),
maskedLastIp: maskIp(row.lastIp ?? ''),
note: row.note,
roles: await this.getCodes('role', input.actor.tenantId, String(row.id)),
storeIds: await this.getCodes('store', input.actor.tenantId, String(row.id)),
registeredAt: row.registeredAt,
lastLoginAt: row.lastLoginAt
})));
return { items, total: Number(counts[0]?.total ?? 0) };
}
async createStaff(actor: ManagementActor, input: Required<Pick<UserMutation, 'nickname' | 'phone'>> & UserMutation) {
return this.inTransaction(async (connection) => {
this.assertMutationAllowed(actor, input.roles ?? ['STAFF'], input.storeIds ?? []);
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_users (tenant_id, user_type, status, nickname, phone)
VALUES (?, 'STAFF', 'ACTIVE', ?, ?)`,
[actor.tenantId, input.nickname, input.phone]
);
const userId = String(created.insertId);
await this.applyMutation(connection, actor, userId, {
...input,
roles: input.roles ?? ['STAFF'],
storeIds: input.storeIds ?? []
}, 'STAFF_CREATED');
return { userId };
});
}
async updateUser(actor: ManagementActor, userId: string, input: UserMutation) {
return this.inTransaction(async (connection) => {
await this.lockManageableUser(connection, actor, userId);
this.assertMutationAllowed(actor, input.roles ?? [], input.storeIds ?? []);
await this.applyMutation(connection, actor, userId, input, 'USER_UPDATED');
return { userId };
});
}
async resetSessions(actor: ManagementActor, userId: string) {
return this.inTransaction(async (connection) => {
await this.lockManageableUser(connection, actor, userId);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'ADMIN_RESET'
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, userId]
);
await connection.execute(
`UPDATE qipai_users SET role_version = role_version + 1
WHERE tenant_id = ? AND id = ?`,
[actor.tenantId, userId]
);
await this.audit(connection, actor, 'USER_SESSIONS_RESET', userId, {
revokedSessions: result.affectedRows
});
return { userId, revokedSessions: result.affectedRows };
});
}
private async applyMutation(
connection: PoolConnection,
actor: ManagementActor,
userId: string,
input: UserMutation,
action: string
) {
if (input.nickname !== undefined || input.phone !== undefined || input.status !== undefined) {
await connection.execute(
`UPDATE qipai_users SET
nickname = COALESCE(?, nickname),
phone = COALESCE(?, phone),
status = COALESCE(?, status),
role_version = role_version + 1
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.nickname ?? null, input.phone ?? null, input.status ?? null, actor.tenantId, userId]
);
}
if (input.note !== undefined) {
await connection.execute(
`INSERT INTO qipai_user_admin_profiles (tenant_id, user_id, note, updated_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE note = VALUES(note), updated_by = VALUES(updated_by)`,
[actor.tenantId, userId, input.note, actor.userId]
);
}
if (input.roles !== undefined) {
await connection.execute(
'DELETE FROM qipai_user_roles WHERE tenant_id = ? AND user_id = ?',
[actor.tenantId, userId]
);
for (const role of input.roles) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_user_roles (tenant_id, user_id, role_id)
SELECT ?, ?, id FROM qipai_roles
WHERE tenant_id = ? AND code = ? AND status = 'ACTIVE' AND deleted_at IS NULL`,
[actor.tenantId, userId, actor.tenantId, role]
);
if (result.affectedRows !== 1) throw new UserManagementError('ROLE_NOT_FOUND');
}
await connection.execute(
`UPDATE qipai_users SET role_version = role_version + 1
WHERE tenant_id = ? AND id = ?`,
[actor.tenantId, userId]
);
}
if (input.storeIds !== undefined) {
await connection.execute(
'DELETE FROM qipai_user_store_scopes WHERE tenant_id = ? AND user_id = ?',
[actor.tenantId, userId]
);
for (const storeId of input.storeIds) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_user_store_scopes (tenant_id, user_id, store_id, scope_type)
SELECT ?, ?, id, 'STAFF' FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[actor.tenantId, userId, actor.tenantId, storeId]
);
if (result.affectedRows !== 1) throw new UserManagementError('STORE_NOT_FOUND');
}
}
if (input.status === 'DISABLED' || input.roles !== undefined || input.storeIds !== undefined) {
await connection.execute(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'ACCESS_CHANGED'
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, userId]
);
}
await this.audit(connection, actor, action, userId, {
status: input.status,
roles: input.roles,
storeIds: input.storeIds,
noteChanged: input.note !== undefined
});
}
private assertMutationAllowed(actor: ManagementActor, roles: readonly string[], storeIds: readonly string[]) {
const isTenantAdmin = actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN');
if (!isTenantAdmin && roles.some((role) => role === 'TENANT_ADMIN' || role === 'PLATFORM_ADMIN')) {
throw new UserManagementError('ROLE_ASSIGNMENT_FORBIDDEN');
}
if (!isTenantAdmin && storeIds.some((storeId) => !actor.access.storeIds.includes(storeId))) {
throw new UserManagementError('STORE_SCOPE_FORBIDDEN');
}
if (roles.some((role) => !assignableRoles.includes(role as AssignableRole))) {
throw new UserManagementError('ROLE_ASSIGNMENT_FORBIDDEN');
}
}
private async lockManageableUser(connection: PoolConnection, actor: ManagementActor, userId: string) {
const scope = this.scopeClause(actor, 'u.id');
const [rows] = await connection.execute<IdRow[]>(
`SELECT u.id FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.deleted_at IS NULL
AND ${scope.sql} FOR UPDATE`,
[actor.tenantId, userId, ...scope.params]
);
if (!rows[0]) throw new UserManagementError('USER_NOT_MANAGEABLE');
if (userId === actor.userId) throw new UserManagementError('SELF_ACCESS_CHANGE_FORBIDDEN');
}
private scopeClause(actor: ManagementActor, userExpression: string) {
if (actor.access.capabilities.includes('tenant.manage') || actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (!actor.access.capabilities.includes('staff.manage')) {
return { sql: '1 = 0', params: [] as string[] };
}
if (actor.access.storeIds.length === 0) return { sql: '1 = 0', params: [] as string[] };
return {
sql: `EXISTS (
SELECT 1 FROM qipai_user_store_scopes ms
WHERE ms.tenant_id = u.tenant_id AND ms.user_id = ${userExpression}
AND ms.store_id IN (${actor.access.storeIds.map(() => '?').join(',')})
)`,
params: actor.access.storeIds
};
}
private async getCodes(type: 'role' | 'store', tenantId: string, userId: string): Promise<string[]> {
const sql = type === 'role'
? `SELECT r.code FROM qipai_user_roles ur
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY r.code`
: `SELECT CAST(store_id AS CHAR) AS code FROM qipai_user_store_scopes
WHERE tenant_id = ? AND user_id = ? ORDER BY store_id`;
const [rows] = await this.pool.execute<CodeRow[]>(sql, [tenantId, userId]);
return rows.map((row) => row.code);
}
private async audit(
connection: PoolConnection,
actor: ManagementActor,
action: string,
userId: string,
metadata: object
) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, ?, 'USER', ?, ?, ?, ?, ?)`,
[
actor.tenantId, actor.userId, action, userId, actor.traceId,
actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)
]
);
}
private async inTransaction<T>(work: (connection: PoolConnection) => Promise<T>): Promise<T> {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
export function maskPhone(phone: string): string {
if (!phone) return '';
if (phone.length < 7) return `${phone.slice(0, 2)}***`;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export function maskIp(ip: string): string {
if (!ip) return '';
if (ip.includes(':')) return `${ip.split(':').slice(0, 2).join(':')}:****`;
const parts = ip.split('.');
return parts.length === 4 ? `${parts[0]}.${parts[1]}.*.*` : '***';
}
+13 -3
View File
@@ -25,16 +25,19 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026061802_m01c_async_tasks.up.sql', 'database/migrations/2026061802_m01c_async_tasks.up.sql',
'database/migrations/2026061803_m02a_tenant_apps.up.sql', 'database/migrations/2026061803_m02a_tenant_apps.up.sql',
'database/migrations/2026061804_m02b_wechat_auth.up.sql', 'database/migrations/2026061804_m02b_wechat_auth.up.sql',
'database/migrations/2026061805_m02c_rbac.up.sql' 'database/migrations/2026061805_m02c_rbac.up.sql',
'database/migrations/2026061806_m02d_user_management.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
'database/migrations/2026061802_m01c_async_tasks.verify.sql', 'database/migrations/2026061802_m01c_async_tasks.verify.sql',
'database/migrations/2026061803_m02a_tenant_apps.verify.sql', 'database/migrations/2026061803_m02a_tenant_apps.verify.sql',
'database/migrations/2026061804_m02b_wechat_auth.verify.sql', 'database/migrations/2026061804_m02b_wechat_auth.verify.sql',
'database/migrations/2026061805_m02c_rbac.verify.sql' 'database/migrations/2026061805_m02c_rbac.verify.sql',
'database/migrations/2026061806_m02d_user_management.verify.sql'
], ],
down: [ down: [
'database/migrations/2026061806_m02d_user_management.down.sql',
'database/migrations/2026061805_m02c_rbac.down.sql', 'database/migrations/2026061805_m02c_rbac.down.sql',
'database/migrations/2026061804_m02b_wechat_auth.down.sql', 'database/migrations/2026061804_m02b_wechat_auth.down.sql',
'database/migrations/2026061803_m02a_tenant_apps.down.sql', 'database/migrations/2026061803_m02a_tenant_apps.down.sql',
@@ -155,7 +158,14 @@ export async function executeMigrationPlan(
for (const [index, statement] of plan.statements.entries()) { for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement); const [result] = await pool.query(statement);
if (plan.direction === 'verify') { if (plan.direction === 'verify') {
const minimumRows = [10, 26, 1, 2, 5, 1, 3, 5, 1, 3, 7, 1, 5, 3, 7, 1][index] ?? 1; const minimumRows = [
10, 26, 1,
2, 5, 1,
3, 5, 1,
3, 7, 1,
5, 3, 7, 1,
1, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.` `Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
+10 -19
View File
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js'; import type { AuthRepository } from '../auth/auth-repository.js';
import { signAccessToken, verifyAccessToken } from '../auth/jwt.js'; import { authenticateAccessToken } from '../auth/authenticate.js';
import { signAccessToken } from '../auth/jwt.js';
import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js'; import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js';
import type { AccessProfile } from '../auth/rbac-repository.js'; import type { AccessProfile } from '../auth/rbac-repository.js';
@@ -100,40 +101,30 @@ export async function registerAuthRoutes(app: FastifyInstance, options: AuthRout
}); });
app.get('/app-api/auth/me', async (request, reply) => { app.get('/app-api/auth/me', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options); const auth = await authenticateAccessToken(
request.headers.authorization, options.repository, options.jwtSecret
);
if (!auth) return unauthorized(reply, request.traceId); if (!auth) return unauthorized(reply, request.traceId);
const access = options.accessControl const access = options.accessControl
? await options.accessControl.getAccessProfile(auth.user.tenantId, auth.user.id) ? await options.accessControl.getAccessProfile(auth.session.user.tenantId, auth.session.user.id)
: { roles: [], capabilities: [], storeIds: [] }; : { roles: [], capabilities: [], storeIds: [] };
return { return {
code: 0, code: 0,
data: { user: publicUser(auth.user), access }, data: { user: publicUser(auth.session.user), access },
traceId: request.traceId traceId: request.traceId
}; };
}); });
app.post('/app-api/auth/logout', async (request, reply) => { app.post('/app-api/auth/logout', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options); const auth = await authenticateAccessToken(
request.headers.authorization, options.repository, options.jwtSecret
);
if (!auth) return unauthorized(reply, request.traceId); if (!auth) return unauthorized(reply, request.traceId);
await options.repository.revokeSession(auth.sessionId); await options.repository.revokeSession(auth.sessionId);
return { code: 0, data: { revoked: true }, traceId: request.traceId }; return { code: 0, data: { revoked: true }, traceId: request.traceId };
}); });
} }
async function authenticate(authorization: string | undefined, options: AuthRouteOptions) {
if (!authorization?.startsWith('Bearer ')) return null;
try {
const claims = verifyAccessToken(authorization.slice(7), options.jwtSecret);
const session = await options.repository.validateSession(claims.sid, claims.tid, claims.sub);
if (!session || session.platformAppId !== claims.aid || session.user.roleVersion !== claims.rv) {
return null;
}
return { sessionId: claims.sid, user: session.user };
} catch {
return null;
}
}
function unauthorized(reply: { status(code: number): { send(payload: unknown): unknown } }, traceId: string) { function unauthorized(reply: { status(code: number): { send(payload: unknown): unknown } }, traceId: string) {
return reply.status(401).send({ return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', code: 'AUTH_SESSION_INVALID',
+159
View File
@@ -0,0 +1,159 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
UserManagementError,
type AssignableRole,
type ManagementActor,
type UserManagementRepository
} from '../auth/user-management-repository.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const rolesSchema = z.array(z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN'])).max(4);
const storesSchema = z.array(z.string().regex(/^[1-9]\d{0,19}$/)).max(100);
const createSchema = z.object({
nickname: z.string().trim().min(1).max(128),
phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/),
note: z.string().trim().max(1000).default(''),
roles: rolesSchema.default(['STAFF']),
storeIds: storesSchema.default([])
});
const updateSchema = z.object({
nickname: z.string().trim().min(1).max(128).optional(),
phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/).optional(),
note: z.string().trim().max(1000).optional(),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
roles: rolesSchema.optional(),
storeIds: storesSchema.optional()
}).refine((value) => Object.keys(value).length > 0);
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
search: z.string().trim().max(128).optional()
});
export interface UserManagementRouteOptions {
repository: Pick<UserManagementRepository, 'listUsers' | 'createStaff' | 'updateUser' | 'resetSessions'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerUserManagementRoutes(
app: FastifyInstance,
options: UserManagementRouteOptions
): Promise<void> {
app.get('/admin-api/users', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
const data = await options.repository.listUsers({ actor, ...query.data });
return { code: 0, data, traceId: request.traceId };
});
app.post('/admin-api/staff', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const body = createSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
return handleMutation(reply, request.traceId, async () => {
const data = await options.repository.createStaff(actor, {
...body.data,
roles: body.data.roles as AssignableRole[]
});
return reply.status(201).send({ code: 0, data, traceId: request.traceId });
});
});
app.patch('/admin-api/users/:id', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
const body = updateSchema.safeParse(request.body);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handleMutation(reply, request.traceId, async () => {
const data = await options.repository.updateUser(actor, params.data.id, {
...body.data,
roles: body.data.roles as AssignableRole[] | undefined
});
return { code: 0, data, traceId: request.traceId };
});
});
app.post('/admin-api/users/:id/reset-sessions', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handleMutation(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.resetSessions(actor, params.data.id),
traceId: request.traceId
}));
});
}
async function requireManager(
request: FastifyRequest,
reply: FastifyReply,
options: UserManagementRouteOptions
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'The access token or session is invalid.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
if (!access.capabilities.some((code) => code === 'staff.manage' || code === 'tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'STAFF_MANAGEMENT_FORBIDDEN', message: 'Staff management permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function handleMutation(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof UserManagementError)) throw error;
const forbidden = error.code.endsWith('_FORBIDDEN') || error.code === 'USER_NOT_MANAGEABLE';
return reply.status(forbidden ? 403 : 404).send({
code: error.code,
message: 'The requested user, role or store assignment is not allowed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_USER_MANAGEMENT_REQUEST',
message: 'The user management request is invalid.',
traceId
});
}
+11 -2
View File
@@ -5,19 +5,28 @@ import { PlatformConfigRepository } from './tenancy/platform-config-repository.j
import { AuthRepository } from './auth/auth-repository.js'; import { AuthRepository } from './auth/auth-repository.js';
import { RbacRepository } from './auth/rbac-repository.js'; import { RbacRepository } from './auth/rbac-repository.js';
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js'; import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
import { UserManagementRepository } from './auth/user-management-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
const authRepository = new AuthRepository(pool);
const accessControl = new RbacRepository(pool);
const app = await buildApp({ const app = await buildApp({
config, config,
platformConfigRepository: new PlatformConfigRepository(pool), platformConfigRepository: new PlatformConfigRepository(pool),
auth: { auth: {
repository: new AuthRepository(pool), repository: authRepository,
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)), wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
jwtSecret: config.auth.jwtSecret, jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds, accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds, sessionTtlSeconds: config.auth.sessionTtlSeconds,
accessControl: new RbacRepository(pool) accessControl
},
userManagement: {
repository: new UserManagementRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
} }
}); });
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+10 -1
View File
@@ -24,6 +24,9 @@ const authVerifySql = read('database/migrations/2026061804_m02b_wechat_auth.veri
const rbacUpSql = read('database/migrations/2026061805_m02c_rbac.up.sql'); const rbacUpSql = read('database/migrations/2026061805_m02c_rbac.up.sql');
const rbacDownSql = read('database/migrations/2026061805_m02c_rbac.down.sql'); const rbacDownSql = read('database/migrations/2026061805_m02c_rbac.down.sql');
const rbacVerifySql = read('database/migrations/2026061805_m02c_rbac.verify.sql'); const rbacVerifySql = read('database/migrations/2026061805_m02c_rbac.verify.sql');
const userManagementUpSql = read('database/migrations/2026061806_m02d_user_management.up.sql');
const userManagementDownSql = read('database/migrations/2026061806_m02d_user_management.down.sql');
const userManagementVerifySql = read('database/migrations/2026061806_m02d_user_management.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -124,5 +127,11 @@ for (const table of [
assert.match(rbacVerifySql, new RegExp(`'${table}'`)); assert.match(rbacVerifySql, new RegExp(`'${table}'`));
} }
assert.match(rbacUpSql, /PRIMARY KEY \(tenant_id, user_id, store_id, scope_type\)/); assert.match(rbacUpSql, /PRIMARY KEY \(tenant_id, user_id, store_id, scope_type\)/);
assert.match(userManagementUpSql, /CREATE TABLE IF NOT EXISTS qipai_user_admin_profiles/);
assert.match(userManagementDownSql, /DROP TABLE IF EXISTS qipai_user_admin_profiles/);
assert.match(userManagementVerifySql, /'qipai_user_admin_profiles'/);
for (const permission of ['user.read', 'staff.manage', 'session.reset']) {
assert.match(userManagementUpSql, new RegExp(permission.replace('.', '\\.')));
}
console.log('PASS: M01-B through M02-C migration contracts are present.'); console.log('PASS: M01-B through M02-D migration contracts are present.');
+2 -1
View File
@@ -16,7 +16,8 @@ assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/);
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/); assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/);
assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/); assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/);
assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/); assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/);
assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql$/); assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql/);
assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -12,6 +12,7 @@ import {
} from '../dist/tenancy/platform-config-repository.js'; } from '../dist/tenancy/platform-config-repository.js';
import { AuthRepository } from '../dist/auth/auth-repository.js'; import { AuthRepository } from '../dist/auth/auth-repository.js';
import { RbacRepository } from '../dist/auth/rbac-repository.js'; import { RbacRepository } from '../dist/auth/rbac-repository.js';
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -38,6 +39,7 @@ const expectedTables = [
'qipai_tenant_apps', 'qipai_tenant_apps',
'qipai_tenant_configs', 'qipai_tenant_configs',
'qipai_tenants', 'qipai_tenants',
'qipai_user_admin_profiles',
'qipai_user_identities', 'qipai_user_identities',
'qipai_user_roles', 'qipai_user_roles',
'qipai_user_store_scopes', 'qipai_user_store_scopes',
@@ -62,9 +64,9 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', '2026061805'] ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805', '2026061806']
); );
return rows; return rows;
} }
@@ -250,6 +252,69 @@ async function assertRevocableAuthSession(pool, context) {
assert.equal(await repository.validateSession(roleSessionId, context.tenantId, session.user.id), null); assert.equal(await repository.validateSession(roleSessionId, context.tenantId, session.user.id), null);
} }
async function assertUserManagement(pool, context) {
const [adminResult] = await pool.query(
`INSERT INTO qipai_users (tenant_id, user_type, nickname, phone)
VALUES (?, 'STAFF', 'Tenant Admin', '13800000001')`,
[context.tenantId]
);
const adminId = String(adminResult.insertId);
await pool.query(
`INSERT INTO qipai_user_roles (tenant_id, user_id, role_id)
SELECT ?, ?, id FROM qipai_roles WHERE tenant_id = ? AND code = 'TENANT_ADMIN'`,
[context.tenantId, adminId, context.tenantId]
);
const rbac = new RbacRepository(pool);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
assert.ok(access.capabilities.includes('tenant.manage'));
assert.ok(access.capabilities.includes('staff.manage'));
const [storeResult] = await pool.query(
`INSERT INTO qipai_stores (tenant_id, name) VALUES (?, 'M02D Staff Store')`,
[context.tenantId]
);
const repository = new UserManagementRepository(pool);
const actor = {
tenantId: context.tenantId,
userId: adminId,
access,
traceId: 'm02d-live-test',
ip: '127.0.0.1',
userAgent: 'M02-D live test'
};
const created = await repository.createStaff(actor, {
nickname: 'Live Staff',
phone: '13800000002',
note: 'sanitized live test',
roles: ['STAFF'],
storeIds: [String(storeResult.insertId)]
});
const sessionId = '7197e528-f727-4c85-a490-f5ec1721594c';
await pool.query(
`INSERT INTO qipai_auth_sessions
(id, tenant_id, platform_app_id, user_id, role_version, expires_at)
SELECT ?, ?, ?, id, role_version, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 1 HOUR)
FROM qipai_users WHERE tenant_id = ? AND id = ?`,
[sessionId, context.tenantId, context.platformAppId, context.tenantId, created.userId]
);
await repository.updateUser(actor, created.userId, {
status: 'DISABLED',
roles: ['STAFF'],
storeIds: [String(storeResult.insertId)]
});
const [sessionRows] = await pool.query(
'SELECT status, revoke_reason AS revokeReason FROM qipai_auth_sessions WHERE id = ?',
[sessionId]
);
assert.deepEqual(sessionRows, [{ status: 'REVOKED', revokeReason: 'ACCESS_CHANGED' }]);
const [auditRows] = await pool.query(
`SELECT action FROM qipai_audit_logs
WHERE tenant_id = ? AND resource_type = 'USER' AND resource_id = ?
ORDER BY id`,
[context.tenantId, created.userId]
);
assert.deepEqual(auditRows.map((row) => row.action), ['STAFF_CREATED', 'USER_UPDATED']);
}
const config = loadConfig(); const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match( assert.match(
@@ -278,11 +343,13 @@ try {
{ version: '2026061802', name: 'm01c_async_tasks' }, { version: '2026061802', name: 'm01c_async_tasks' },
{ version: '2026061803', name: 'm02a_tenant_apps' }, { version: '2026061803', name: 'm02a_tenant_apps' },
{ version: '2026061804', name: 'm02b_wechat_auth' }, { version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' } { version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' }
]); ]);
await assertTaskDurability(pool); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
await assertRevocableAuthSession(pool, loginContext); await assertRevocableAuthSession(pool, loginContext);
await assertUserManagement(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -299,7 +366,8 @@ try {
{ version: '2026061802', name: 'm01c_async_tasks' }, { version: '2026061802', name: 'm01c_async_tasks' },
{ version: '2026061803', name: 'm02a_tenant_apps' }, { version: '2026061803', name: 'm02a_tenant_apps' },
{ version: '2026061804', name: 'm02b_wechat_auth' }, { version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' } { version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' }
]); ]);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -332,7 +400,10 @@ try {
'session revocation', 'session revocation',
'role-version invalidation', 'role-version invalidation',
'customer capabilities', 'customer capabilities',
'cross-tenant store grant rejection' 'cross-tenant store grant rejection',
'staff creation and store assignment',
'access-change session revocation',
'user-management audit log'
] ]
}, null, 2)); }, null, 2));
} finally { } finally {
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import { maskIp, maskPhone, UserManagementRepository } from '../dist/auth/user-management-repository.js';
assert.equal(maskPhone('13800138000'), '138****8000');
assert.equal(maskIp('192.168.10.22'), '192.168.*.*');
const calls = [];
const connection = {
async beginTransaction() { calls.push('begin'); },
async commit() { calls.push('commit'); },
async rollback() { calls.push('rollback'); },
release() { calls.push('release'); },
async execute(sql) {
calls.push(sql);
if (sql.includes('SELECT u.id')) return [[{ id: '22' }], []];
return [{ affectedRows: 1, insertId: 22 }, []];
}
};
const repository = new UserManagementRepository({
async getConnection() { return connection; },
async execute() { return [[], []]; }
});
const actor = {
tenantId: '7',
userId: '21',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId: 'trace-test',
ip: '127.0.0.1',
userAgent: 'test'
};
await repository.updateUser(actor, '22', {
status: 'DISABLED',
roles: ['STAFF'],
storeIds: ['11'],
note: '离职'
});
assert.ok(calls.some((sql) => typeof sql === 'string' && sql.includes('qipai_audit_logs')));
assert.ok(calls.some((sql) => typeof sql === 'string' && sql.includes("revoke_reason = 'ACCESS_CHANGED'")));
assert.ok(calls.includes('commit'));
const secret = 'test-only-jwt-secret-with-at-least-32-characters';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 1
}, secret, 900);
let createdInput;
const app = await buildApp({
userManagement: {
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60_000),
user: {
id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
roleVersion: 1, nickname: '管理员', avatarUrl: '', phone: '13800138000'
}
};
}
},
accessControl: {
async getAccessProfile() {
return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
}
},
repository: {
async listUsers() { return { items: [], total: 0 }; },
async createStaff(_actor, input) {
createdInput = input;
return { userId: '22' };
},
async updateUser() { return { userId: '22' }; },
async resetSessions() { return { userId: '22', revokedSessions: 2 }; }
}
}
});
const created = await app.inject({
method: 'POST',
url: '/admin-api/staff',
headers: { authorization: `Bearer ${token}` },
payload: {
nickname: '测试员工',
phone: '13800138001',
roles: ['STAFF'],
storeIds: ['11']
}
});
assert.equal(created.statusCode, 201);
assert.equal(created.json().data.userId, '22');
assert.deepEqual(createdInput.storeIds, ['11']);
const unauthenticated = await app.inject({ method: 'GET', url: '/admin-api/users' });
assert.equal(unauthenticated.statusCode, 401);
await app.close();
console.log('PASS: M02-D staff management, session revocation, audit and masked fields are present.');
@@ -0,0 +1,6 @@
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('user.read', 'staff.manage', 'session.reset');
DELETE FROM qipai_permissions WHERE code IN ('user.read', 'staff.manage', 'session.reset');
DELETE FROM qipai_schema_migrations WHERE version = '2026061806';
DROP TABLE IF EXISTS qipai_user_admin_profiles;
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS qipai_user_admin_profiles (
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
note VARCHAR(1000) NOT NULL DEFAULT '',
updated_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id, user_id),
CONSTRAINT fk_qipai_user_admin_profiles_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_user_admin_profiles_user FOREIGN KEY (user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_user_admin_profiles_updater FOREIGN KEY (updated_by) REFERENCES qipai_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('user.read', '查看用户', 'account'),
('staff.manage', '管理员工', 'account'),
('session.reset', '重置登录会话', 'account');
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT r.tenant_id, r.id, p.id
FROM qipai_roles r
INNER JOIN qipai_permissions p
ON (r.code = 'STORE_ADMIN' AND p.code IN ('user.read', 'staff.manage', 'session.reset'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'));
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026061806', 'm02d_user_management');
@@ -0,0 +1,8 @@
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'qipai_user_admin_profiles';
SELECT code FROM qipai_permissions
WHERE code IN ('user.read', 'staff.manage', 'session.reset')
ORDER BY code;
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061806';
@@ -0,0 +1,21 @@
# M02-D 用户与员工管理 API
## 新增接口
- `GET /admin-api/users`:分页查询当前租户用户,支持状态和关键词筛选;手机号与最近登录 IP 脱敏。
- `POST /admin-api/staff`:创建员工并分配角色、门店和备注。
- `PATCH /admin-api/users/:id`:更新资料、状态、角色、门店范围和备注。
- `POST /admin-api/users/:id/reset-sessions`:撤销目标用户全部活动会话。
## 权限与数据范围
- 需要 `staff.manage``tenant.manage` 或平台管理员角色。
- 门店管理员只能查看和管理自己授权门店内的员工,不能授予租户级或平台级角色。
- 租户管理员可管理本租户员工,但所有 SQL 仍强制包含 `tenant_id`
- 禁止管理员修改自己的角色、状态和门店范围,避免自锁或提权绕过。
## 会话与审计
- 禁用、角色变化、门店范围变化会提升 `role_version` 并撤销活动会话。
- 重置登录会话会提升 `role_version`,旧 JWT 立即失效。
- 创建、更新和重置会话均写入 `qipai_audit_logs`,记录操作者、资源、traceId 和非敏感变更摘要。
@@ -0,0 +1,16 @@
# M02-D 用户与员工管理数据库变更
- 迁移版本:`2026061806`
- 新表:`qipai_user_admin_profiles`
- 新权限:`user.read``staff.manage``session.reset`
`qipai_user_admin_profiles` 保存租户内用户备注及最后更新人。角色权限初始化同时接入租户首次登录路径,确保角色晚于迁移创建时仍能获得 M02-D capability。
## 回滚
1. 删除三项权限对应的角色绑定。
2. 删除三项权限目录。
3. 删除迁移版本记录。
4. 删除 `qipai_user_admin_profiles`
回滚不会删除 `qipai_users`、角色、门店授权或审计日志。
@@ -0,0 +1,30 @@
# M02-D 用户与员工管理
- 日期:2026-06-18
- 起始 commit`46acb84`
- 工程 commit:本阶段工程提交
- ENGINEERING_DELTA=YES
- 子阶段状态:待 push 与远端校验
## 工程增量
- 新增后台用户列表、员工创建、用户更新和会话重置 API。
- 新增租户/门店两级管理边界,禁止跨租户、跨授权门店和越级角色分配。
- 手机号与最近登录 IP 仅返回脱敏值。
- 禁用、角色或门店范围变化立即撤销活动会话并提升 `role_version`
- 所有员工权限变更写入既有审计日志。
- 新增 `2026061806` 迁移、回滚和验证 SQL。
## 已执行验证
- Windows `npm test`:退出码 0,M01-A 至 M02-D 全量后端测试通过。
- WSL MySQL `8.4.9` 临时库:`up → verify → down → up → verify`,退出码 0。
- 实测员工创建、角色/门店分配、禁用后会话撤销和两条审计日志。
- 迁移语句数:up 33、verify 19、down 31。
## 影响
- 数据库迁移:`2026061806_m02d_user_management`
- API:新增四个 `/admin-api` 用户与员工管理接口。
- 配置:无新增秘密或环境变量。
- 部署:生产更新前必须备份,由 Ubuntu 菜单执行迁移;不得从 Windows 直连生产数据库。
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M02-C migration roundtrip in a temporary database." echo "INFO: MySQL ${mysql_version}; running M01-B through M02-D migration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M02-C live MySQL migration roundtrip completed." echo "PASS: M01-B through M02-D live MySQL migration roundtrip completed."