diff --git a/backend/package.json b/backend/package.json index ee6d700..0613eee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "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", "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": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 0a42a7d..3fd1973 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,11 +9,16 @@ import { type PlatformConfigResolver } from './routes/platform-bootstrap.js'; import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js'; +import { + registerUserManagementRoutes, + type UserManagementRouteOptions +} from './routes/user-management.js'; export interface BuildAppOptions { config?: AppConfig; platformConfigRepository?: PlatformConfigResolver; auth?: AuthRouteOptions; + userManagement?: UserManagementRouteOptions; } declare module 'fastify' { @@ -62,6 +67,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, + jwtSecret: string +): Promise { + 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; + } +} diff --git a/backend/src/auth/rbac-repository.ts b/backend/src/auth/rbac-repository.ts index 82c6048..38dc734 100644 --- a/backend/src/auth/rbac-repository.ts +++ b/backend/src/auth/rbac-repository.ts @@ -33,10 +33,15 @@ export class RbacRepository { ); await this.pool.execute( `INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id) - SELECT ?, r.id, p.id - FROM qipai_roles r - INNER JOIN qipai_permissions p ON p.code IN ('profile.read', 'order.self.read') - WHERE r.tenant_id = ? AND r.code = 'CUSTOMER'`, + SELECT ?, r.id, p.id FROM qipai_roles r + INNER JOIN qipai_permissions p ON + (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 = ?`, [tenantId, tenantId] ); } diff --git a/backend/src/auth/user-management-repository.ts b/backend/src/auth/user-management-repository.ts new file mode 100644 index 0000000..685e170 --- /dev/null +++ b/backend/src/auth/user-management-repository.ts @@ -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 = [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( + `SELECT COUNT(DISTINCT u.id) AS total FROM qipai_users u + WHERE ${filters.join(' AND ')}`, + params + ); + const [rows] = await this.pool.execute( + `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> & UserMutation) { + return this.inTransaction(async (connection) => { + this.assertMutationAllowed(actor, input.roles ?? ['STAFF'], input.storeIds ?? []); + const [created] = await connection.execute( + `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( + `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( + `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( + `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( + `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 { + 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(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(work: (connection: PoolConnection) => Promise): Promise { + 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]}.*.*` : '***'; +} diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 5c66ba7..46b7f37 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -25,16 +25,19 @@ const migrationFiles: Record = { 'database/migrations/2026061802_m01c_async_tasks.up.sql', 'database/migrations/2026061803_m02a_tenant_apps.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: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061802_m01c_async_tasks.verify.sql', 'database/migrations/2026061803_m02a_tenant_apps.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: [ + 'database/migrations/2026061806_m02d_user_management.down.sql', 'database/migrations/2026061805_m02c_rbac.down.sql', 'database/migrations/2026061804_m02b_wechat_auth.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()) { const [result] = await pool.query(statement); 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) { throw new Error( `Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.` diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 4b98a3c..77271c6 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; 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 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) => { - 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); 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: [] }; return { code: 0, - data: { user: publicUser(auth.user), access }, + data: { user: publicUser(auth.session.user), access }, traceId: request.traceId }; }); 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); await options.repository.revokeSession(auth.sessionId); 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) { return reply.status(401).send({ code: 'AUTH_SESSION_INVALID', diff --git a/backend/src/routes/user-management.ts b/backend/src/routes/user-management.ts new file mode 100644 index 0000000..9fe1acb --- /dev/null +++ b/backend/src/routes/user-management.ts @@ -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; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerUserManagementRoutes( + app: FastifyInstance, + options: UserManagementRouteOptions +): Promise { + 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 { + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index c2b91c2..2f4f047 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,19 +5,28 @@ import { PlatformConfigRepository } from './tenancy/platform-config-repository.j import { AuthRepository } from './auth/auth-repository.js'; import { RbacRepository } from './auth/rbac-repository.js'; import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js'; +import { UserManagementRepository } from './auth/user-management-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); +const authRepository = new AuthRepository(pool); +const accessControl = new RbacRepository(pool); const app = await buildApp({ config, platformConfigRepository: new PlatformConfigRepository(pool), auth: { - repository: new AuthRepository(pool), + repository: authRepository, wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)), jwtSecret: config.auth.jwtSecret, accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds, sessionTtlSeconds: config.auth.sessionTtlSeconds, - accessControl: new RbacRepository(pool) + accessControl + }, + userManagement: { + repository: new UserManagementRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index fe8d0a3..92aa081 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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 rbacDownSql = read('database/migrations/2026061805_m02c_rbac.down.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 = [ 'qipai_schema_migrations', @@ -124,5 +127,11 @@ for (const table of [ assert.match(rbacVerifySql, new RegExp(`'${table}'`)); } 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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index d4cef6f..2345f15 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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, /2026061803_m02a_tenant_apps\.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.ok(plan.statements.length >= 11); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index acf77d9..fe2e8c3 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -12,6 +12,7 @@ import { } from '../dist/tenancy/platform-config-repository.js'; import { AuthRepository } from '../dist/auth/auth-repository.js'; import { RbacRepository } from '../dist/auth/rbac-repository.js'; +import { UserManagementRepository } from '../dist/auth/user-management-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -38,6 +39,7 @@ const expectedTables = [ 'qipai_tenant_apps', 'qipai_tenant_configs', 'qipai_tenants', + 'qipai_user_admin_profiles', 'qipai_user_identities', 'qipai_user_roles', 'qipai_user_store_scopes', @@ -62,9 +64,9 @@ async function readMigrationVersions(pool) { const [rows] = await pool.query( `SELECT version, name FROM qipai_schema_migrations - WHERE version IN (?, ?, ?, ?, ?) + WHERE version IN (?, ?, ?, ?, ?, ?) ORDER BY version`, - ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805'] + ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805', '2026061806'] ); return rows; } @@ -250,6 +252,69 @@ async function assertRevocableAuthSession(pool, context) { 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(); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.match( @@ -278,11 +343,13 @@ try { { version: '2026061802', name: 'm01c_async_tasks' }, { version: '2026061803', name: 'm02a_tenant_apps' }, { 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); const loginContext = await assertPlatformTenantIsolation(pool); await assertRevocableAuthSession(pool, loginContext); + await assertUserManagement(pool, loginContext); await assertLegacyCompatibility(pool); 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: '2026061803', name: 'm02a_tenant_apps' }, { 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); console.log('PASS: second up and verify restored the schema.'); @@ -332,7 +400,10 @@ try { 'session revocation', 'role-version invalidation', '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)); } finally { diff --git a/backend/tests/user-management.test.mjs b/backend/tests/user-management.test.mjs new file mode 100644 index 0000000..31128f4 --- /dev/null +++ b/backend/tests/user-management.test.mjs @@ -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.'); diff --git a/database/migrations/2026061806_m02d_user_management.down.sql b/database/migrations/2026061806_m02d_user_management.down.sql new file mode 100644 index 0000000..8c17d29 --- /dev/null +++ b/database/migrations/2026061806_m02d_user_management.down.sql @@ -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; diff --git a/database/migrations/2026061806_m02d_user_management.up.sql b/database/migrations/2026061806_m02d_user_management.up.sql new file mode 100644 index 0000000..15de42a --- /dev/null +++ b/database/migrations/2026061806_m02d_user_management.up.sql @@ -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'); diff --git a/database/migrations/2026061806_m02d_user_management.verify.sql b/database/migrations/2026061806_m02d_user_management.verify.sql new file mode 100644 index 0000000..54f891c --- /dev/null +++ b/database/migrations/2026061806_m02d_user_management.verify.sql @@ -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'; diff --git a/docs/api-changelog/2026-06-18-M02-D-user-management.md b/docs/api-changelog/2026-06-18-M02-D-user-management.md new file mode 100644 index 0000000..6686542 --- /dev/null +++ b/docs/api-changelog/2026-06-18-M02-D-user-management.md @@ -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 和非敏感变更摘要。 diff --git a/docs/db-changelog/2026-06-18-M02-D-user-management.md b/docs/db-changelog/2026-06-18-M02-D-user-management.md new file mode 100644 index 0000000..9b2ab76 --- /dev/null +++ b/docs/db-changelog/2026-06-18-M02-D-user-management.md @@ -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`、角色、门店授权或审计日志。 diff --git a/docs/devlogs/2026-06-18-M02-D-用户与员工管理.md b/docs/devlogs/2026-06-18-M02-D-用户与员工管理.md new file mode 100644 index 0000000..8fd53e8 --- /dev/null +++ b/docs/devlogs/2026-06-18-M02-D-用户与员工管理.md @@ -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 直连生产数据库。 diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 203d9d4..a3bfa90 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" 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 -echo "PASS: M01-B through M02-C live MySQL migration roundtrip completed." +echo "PASS: M01-B through M02-D live MySQL migration roundtrip completed."