diff --git a/backend/package.json b/backend/package.json index 5c53f2c..ee6d700 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" + "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" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/auth/auth-repository.ts b/backend/src/auth/auth-repository.ts index 240a03e..5844e14 100644 --- a/backend/src/auth/auth-repository.ts +++ b/backend/src/auth/auth-repository.ts @@ -119,6 +119,29 @@ export class AuthRepository { ); } if (!user || user.status !== 'ACTIVE') throw new Error('USER_DISABLED'); + await connection.execute( + `INSERT IGNORE INTO qipai_roles (tenant_id, code, name) VALUES + (?, 'CUSTOMER', '顾客'), + (?, 'CLEANER', '保洁员'), + (?, 'STAFF', '门店员工'), + (?, 'STORE_ADMIN', '门店管理员'), + (?, 'TENANT_ADMIN', '租户管理员'), + (?, 'PLATFORM_ADMIN', '平台管理员')`, + Array(6).fill(input.context.tenantId) + ); + await connection.execute( + `INSERT IGNORE INTO qipai_user_roles (tenant_id, user_id, role_id) + SELECT ?, ?, id FROM qipai_roles + WHERE tenant_id = ? AND code = 'CUSTOMER' AND status = 'ACTIVE'`, + [input.context.tenantId, user.id, input.context.tenantId] + ); + await connection.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'`, + [input.context.tenantId, input.context.tenantId] + ); await connection.execute( `INSERT INTO qipai_auth_sessions (id, tenant_id, platform_app_id, user_id, role_version, expires_at, ip, user_agent) diff --git a/backend/src/auth/rbac-repository.ts b/backend/src/auth/rbac-repository.ts new file mode 100644 index 0000000..82c6048 --- /dev/null +++ b/backend/src/auth/rbac-repository.ts @@ -0,0 +1,93 @@ +import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; + +export const roleCodes = [ + 'CUSTOMER', 'CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN' +] as const; + +export interface AccessProfile { + roles: string[]; + capabilities: string[]; + storeIds: string[]; +} + +interface CodeRow extends RowDataPacket { code: string } +interface StoreRow extends RowDataPacket { storeId: string } + +export class RbacRepository { + constructor(private readonly pool: MySqlPool) {} + + async ensureCustomerRole(tenantId: string, userId: string): Promise { + await this.pool.execute( + `INSERT IGNORE INTO qipai_roles (tenant_id, code, name) VALUES + (?, 'CUSTOMER', '顾客'), (?, 'CLEANER', '保洁员'), (?, 'STAFF', '门店员工'), + (?, 'STORE_ADMIN', '门店管理员'), (?, 'TENANT_ADMIN', '租户管理员'), + (?, 'PLATFORM_ADMIN', '平台管理员')`, + Array(6).fill(tenantId) + ); + await this.pool.execute( + `INSERT IGNORE INTO qipai_user_roles (tenant_id, user_id, role_id) + SELECT ?, ?, id FROM qipai_roles + WHERE tenant_id = ? AND code = 'CUSTOMER' AND status = 'ACTIVE'`, + [tenantId, userId, tenantId] + ); + 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'`, + [tenantId, tenantId] + ); + } + + async getAccessProfile(tenantId: string, userId: string): Promise { + const [roles] = await this.pool.execute( + `SELECT DISTINCT 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 + AND r.status = 'ACTIVE' AND r.deleted_at IS NULL + WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY r.code`, + [tenantId, userId] + ); + const [permissions] = await this.pool.execute( + `SELECT DISTINCT p.code FROM qipai_user_roles ur + INNER JOIN qipai_roles r + ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id + AND r.status = 'ACTIVE' AND r.deleted_at IS NULL + INNER JOIN qipai_role_permissions rp + ON rp.tenant_id = ur.tenant_id AND rp.role_id = ur.role_id + INNER JOIN qipai_permissions p ON p.id = rp.permission_id + WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY p.code`, + [tenantId, userId] + ); + const [stores] = await this.pool.execute( + `SELECT DISTINCT store_id AS storeId FROM qipai_user_store_scopes + WHERE tenant_id = ? AND user_id = ? ORDER BY store_id`, + [tenantId, userId] + ); + return { + roles: roles.map((row) => row.code), + capabilities: permissions.map((row) => row.code), + storeIds: stores.map((row) => String(row.storeId)) + }; + } + + async grantStore(input: { + tenantId: string; userId: string; storeId: string; scopeType: 'STAFF' | 'CLEANER'; + }): Promise { + const [result] = await this.pool.execute( + `INSERT IGNORE INTO qipai_user_store_scopes + (tenant_id, user_id, store_id, scope_type) + SELECT ?, ?, s.id, ? + FROM qipai_stores s + INNER JOIN qipai_users u ON u.id = ? AND u.tenant_id = ? + WHERE s.id = ? AND s.tenant_id = ? AND s.deleted_at IS NULL`, + [ + input.tenantId, input.userId, input.scopeType, + input.userId, input.tenantId, input.storeId, input.tenantId + ] + ); + return result.affectedRows === 1; + } +} diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 9a5fd37..5c66ba7 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -24,15 +24,18 @@ const migrationFiles: Record = { 'database/migrations/2026061601_m01b_core_schema.up.sql', '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/2026061804_m02b_wechat_auth.up.sql', + 'database/migrations/2026061805_m02c_rbac.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/2026061804_m02b_wechat_auth.verify.sql', + 'database/migrations/2026061805_m02c_rbac.verify.sql' ], down: [ + 'database/migrations/2026061805_m02c_rbac.down.sql', 'database/migrations/2026061804_m02b_wechat_auth.down.sql', 'database/migrations/2026061803_m02a_tenant_apps.down.sql', 'database/migrations/2026061802_m01c_async_tasks.down.sql', @@ -152,7 +155,7 @@ 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][index] ?? 1; + const minimumRows = [10, 26, 1, 2, 5, 1, 3, 5, 1, 3, 7, 1, 5, 3, 7, 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 0d8ce8a..4b98a3c 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import type { AuthRepository } from '../auth/auth-repository.js'; import { signAccessToken, verifyAccessToken } from '../auth/jwt.js'; import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js'; +import type { AccessProfile } from '../auth/rbac-repository.js'; const headersSchema = z.object({ 'x-wechat-appid': z.string().trim().min(6).max(64), @@ -17,6 +18,9 @@ export interface AuthRouteOptions { jwtSecret: string; accessTokenTtlSeconds: number; sessionTtlSeconds: number; + accessControl?: { + getAccessProfile(tenantId: string, userId: string): Promise; + }; } export async function registerAuthRoutes(app: FastifyInstance, options: AuthRouteOptions): Promise { @@ -98,7 +102,14 @@ 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); if (!auth) return unauthorized(reply, request.traceId); - return { code: 0, data: { user: publicUser(auth.user) }, traceId: request.traceId }; + const access = options.accessControl + ? await options.accessControl.getAccessProfile(auth.user.tenantId, auth.user.id) + : { roles: [], capabilities: [], storeIds: [] }; + return { + code: 0, + data: { user: publicUser(auth.user), access }, + traceId: request.traceId + }; }); app.post('/app-api/auth/logout', async (request, reply) => { diff --git a/backend/src/server.ts b/backend/src/server.ts index a9580b3..c2b91c2 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,6 +3,7 @@ import { loadConfig } from './config.js'; import { closeMySqlPool, createMySqlPool } from './db/mysql.js'; import { PlatformConfigRepository } from './tenancy/platform-config-repository.js'; import { AuthRepository } from './auth/auth-repository.js'; +import { RbacRepository } from './auth/rbac-repository.js'; import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js'; const config = loadConfig(); @@ -15,7 +16,8 @@ const app = await buildApp({ wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)), jwtSecret: config.auth.jwtSecret, accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds, - sessionTtlSeconds: config.auth.sessionTtlSeconds + sessionTtlSeconds: config.auth.sessionTtlSeconds, + accessControl: new RbacRepository(pool) } }); app.addHook('onClose', async () => { diff --git a/backend/tests/auth.test.mjs b/backend/tests/auth.test.mjs index 667be54..8821add 100644 --- a/backend/tests/auth.test.mjs +++ b/backend/tests/auth.test.mjs @@ -90,7 +90,16 @@ const auth = { }, jwtSecret: secret, accessTokenTtlSeconds: 900, - sessionTtlSeconds: 604800 + sessionTtlSeconds: 604800, + accessControl: { + async getAccessProfile() { + return { + roles: ['CUSTOMER'], + capabilities: ['order.self.read', 'profile.read'], + storeIds: [] + }; + } + } }; const app = await buildApp({ auth }); @@ -114,6 +123,7 @@ const me = await app.inject({ }); assert.equal(me.statusCode, 200); assert.equal(me.json().data.user.id, '21'); +assert.deepEqual(me.json().data.access.roles, ['CUSTOMER']); const logout = await app.inject({ method: 'POST', diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 0143699..fe8d0a3 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -21,6 +21,9 @@ const tenantAppsVerifySql = read('database/migrations/2026061803_m02a_tenant_app const authUpSql = read('database/migrations/2026061804_m02b_wechat_auth.up.sql'); const authDownSql = read('database/migrations/2026061804_m02b_wechat_auth.down.sql'); const authVerifySql = read('database/migrations/2026061804_m02b_wechat_auth.verify.sql'); +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 coreTables = [ 'qipai_schema_migrations', @@ -112,4 +115,14 @@ assert.match(authUpSql, /UNIQUE KEY uq_qipai_user_identities_tenant_app_openid/) assert.match(authUpSql, /revoked_at DATETIME\(3\)/); assert.match(authUpSql, /expires_at DATETIME\(3\)/); -console.log('PASS: M01-B through M02-B migration contracts are present.'); +for (const table of [ + 'qipai_permissions', 'qipai_roles', 'qipai_role_permissions', + 'qipai_user_roles', 'qipai_user_store_scopes' +]) { + assert.match(rbacUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`)); + assert.match(rbacDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`)); + assert.match(rbacVerifySql, new RegExp(`'${table}'`)); +} +assert.match(rbacUpSql, /PRIMARY KEY \(tenant_id, user_id, store_id, scope_type\)/); + +console.log('PASS: M01-B through M02-C migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 06c614e..d4cef6f 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -15,7 +15,8 @@ assert.equal(plan.direction, 'up'); 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, /2026061804_m02b_wechat_auth\.up\.sql/); +assert.match(plan.file, /2026061805_m02c_rbac\.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 6d33ab6..acf77d9 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -11,6 +11,7 @@ import { PlatformConfigRepository } from '../dist/tenancy/platform-config-repository.js'; import { AuthRepository } from '../dist/auth/auth-repository.js'; +import { RbacRepository } from '../dist/auth/rbac-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -27,7 +28,10 @@ const expectedTables = [ 'qipai_orders', 'qipai_outbox_events', 'qipai_payments', + 'qipai_permissions', 'qipai_platform_apps', + 'qipai_role_permissions', + 'qipai_roles', 'qipai_rooms', 'qipai_schema_migrations', 'qipai_stores', @@ -35,6 +39,8 @@ const expectedTables = [ 'qipai_tenant_configs', 'qipai_tenants', 'qipai_user_identities', + 'qipai_user_roles', + 'qipai_user_store_scopes', 'qipai_users' ]; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); @@ -56,9 +62,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'] + ['2026061601', '2026061802', '2026061803', '2026061804', '2026061805'] ); return rows; } @@ -193,6 +199,28 @@ async function assertRevocableAuthSession(pool, context) { userAgent: 'M02-B test' }); assert.equal(session.user.userType, 'CUSTOMER'); + const rbac = new RbacRepository(pool); + assert.deepEqual(await rbac.getAccessProfile(context.tenantId, session.user.id), { + roles: ['CUSTOMER'], + capabilities: ['order.self.read', 'profile.read'], + storeIds: [] + }); + const [storeResult] = await pool.query( + `INSERT INTO qipai_stores (tenant_id, name) VALUES (?, 'M02C Store')`, + [context.tenantId] + ); + assert.equal(await rbac.grantStore({ + tenantId: context.tenantId, + userId: session.user.id, + storeId: String(storeResult.insertId), + scopeType: 'STAFF' + }), true); + assert.equal(await rbac.grantStore({ + tenantId: String(Number(context.tenantId) + 1), + userId: session.user.id, + storeId: String(storeResult.insertId), + scopeType: 'STAFF' + }), false); assert.equal((await repository.loginWithWechat({ context, openid: 'm02b-openid-a', @@ -249,7 +277,8 @@ try { { version: '2026061601', name: 'm01b_core_schema' }, { version: '2026061802', name: 'm01c_async_tasks' }, { version: '2026061803', name: 'm02a_tenant_apps' }, - { version: '2026061804', name: 'm02b_wechat_auth' } + { version: '2026061804', name: 'm02b_wechat_auth' }, + { version: '2026061805', name: 'm02c_rbac' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -269,7 +298,8 @@ try { { version: '2026061601', name: 'm01b_core_schema' }, { version: '2026061802', name: 'm01c_async_tasks' }, { version: '2026061803', name: 'm02a_tenant_apps' }, - { version: '2026061804', name: 'm02b_wechat_auth' } + { version: '2026061804', name: 'm02b_wechat_auth' }, + { version: '2026061805', name: 'm02c_rbac' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -300,7 +330,9 @@ try { 'cross-tenant bootstrap rejection', 'openid identity reuse', 'session revocation', - 'role-version invalidation' + 'role-version invalidation', + 'customer capabilities', + 'cross-tenant store grant rejection' ] }, null, 2)); } finally { diff --git a/backend/tests/rbac.test.mjs b/backend/tests/rbac.test.mjs new file mode 100644 index 0000000..414cd36 --- /dev/null +++ b/backend/tests/rbac.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { RbacRepository, roleCodes } from '../dist/auth/rbac-repository.js'; + +assert.deepEqual(roleCodes, [ + 'CUSTOMER', 'CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN' +]); + +const calls = []; +const repository = new RbacRepository({ + async execute(sql, params) { + calls.push([sql, params]); + if (sql.includes('SELECT DISTINCT r.code')) return [[{ code: 'CUSTOMER' }], []]; + if (sql.includes('SELECT DISTINCT p.code')) { + return [[{ code: 'order.self.read' }, { code: 'profile.read' }], []]; + } + if (sql.includes('SELECT DISTINCT store_id')) return [[{ storeId: 11 }], []]; + return [{ affectedRows: 1 }, []]; + } +}); +assert.deepEqual(await repository.getAccessProfile('7', '21'), { + roles: ['CUSTOMER'], + capabilities: ['order.self.read', 'profile.read'], + storeIds: ['11'] +}); +assert.equal(await repository.grantStore({ + tenantId: '7', userId: '21', storeId: '11', scopeType: 'STAFF' +}), true); +assert.match(calls.at(-1)[0], /s\.tenant_id = \?/); +assert.match(calls.at(-1)[0], /u\.tenant_id = \?/); + +console.log('PASS: M02-C roles, capabilities and tenant-scoped store grants are present.'); diff --git a/database/migrations/2026061805_m02c_rbac.down.sql b/database/migrations/2026061805_m02c_rbac.down.sql new file mode 100644 index 0000000..467e8f2 --- /dev/null +++ b/database/migrations/2026061805_m02c_rbac.down.sql @@ -0,0 +1,6 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026061805'; +DROP TABLE IF EXISTS qipai_user_store_scopes; +DROP TABLE IF EXISTS qipai_user_roles; +DROP TABLE IF EXISTS qipai_role_permissions; +DROP TABLE IF EXISTS qipai_roles; +DROP TABLE IF EXISTS qipai_permissions; diff --git a/database/migrations/2026061805_m02c_rbac.up.sql b/database/migrations/2026061805_m02c_rbac.up.sql new file mode 100644 index 0000000..6f1dd6e --- /dev/null +++ b/database/migrations/2026061805_m02c_rbac.up.sql @@ -0,0 +1,69 @@ +CREATE TABLE IF NOT EXISTS qipai_permissions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(128) NOT NULL, + name VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uq_qipai_permissions_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + code VARCHAR(32) NOT NULL, + name VARCHAR(64) NOT NULL, + role_version INT UNSIGNED NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + 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), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_roles_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + UNIQUE KEY uq_qipai_roles_tenant_code (tenant_id, code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_role_permissions ( + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id, role_id, permission_id), + CONSTRAINT fk_qipai_role_permissions_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_role_permissions_role FOREIGN KEY (role_id) REFERENCES qipai_roles(id), + CONSTRAINT fk_qipai_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES qipai_permissions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_user_roles ( + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id, user_id, role_id), + CONSTRAINT fk_qipai_user_roles_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_user_roles_user FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_user_roles_role FOREIGN KEY (role_id) REFERENCES qipai_roles(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_user_store_scopes ( + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + store_id BIGINT UNSIGNED NOT NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'STAFF', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id, user_id, store_id, scope_type), + CONSTRAINT fk_qipai_user_store_scopes_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_user_store_scopes_user FOREIGN KEY (user_id) REFERENCES qipai_users(id), + CONSTRAINT fk_qipai_user_store_scopes_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + KEY idx_qipai_user_store_scopes_store (tenant_id, store_id, scope_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES + ('profile.read', '查看个人资料', 'account'), + ('order.self.read', '查看本人订单', 'order'), + ('cleaning.task.read', '查看保洁任务', 'cleaning'), + ('store.operation.read', '查看门店运营', 'store'), + ('store.operation.write', '管理门店运营', 'store'), + ('tenant.manage', '管理租户', 'tenant'), + ('platform.manage', '管理平台', 'platform'); + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026061805', 'm02c_rbac'); diff --git a/database/migrations/2026061805_m02c_rbac.verify.sql b/database/migrations/2026061805_m02c_rbac.verify.sql new file mode 100644 index 0000000..6647da3 --- /dev/null +++ b/database/migrations/2026061805_m02c_rbac.verify.sql @@ -0,0 +1,19 @@ +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ('qipai_permissions', 'qipai_roles', 'qipai_role_permissions', + 'qipai_user_roles', 'qipai_user_store_scopes') +ORDER BY table_name; + +SELECT table_name, index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_permissions' AND index_name = 'uq_qipai_permissions_code') + OR (table_name = 'qipai_roles' AND index_name = 'uq_qipai_roles_tenant_code') + OR (table_name = 'qipai_user_store_scopes' AND index_name = 'idx_qipai_user_store_scopes_store')) +GROUP BY table_name, index_name ORDER BY table_name, index_name; + +SELECT code FROM qipai_permissions +WHERE code IN ('profile.read', 'order.self.read', 'cleaning.task.read', + 'store.operation.read', 'store.operation.write', 'tenant.manage', 'platform.manage') +ORDER BY code; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061805'; diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index 1ecafcd..726737c 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -8,6 +8,7 @@ $requiredFiles = @( "backend/src/app.ts", "backend/src/auth/auth-repository.ts", "backend/src/auth/jwt.ts", + "backend/src/auth/rbac-repository.ts", "backend/src/auth/wechat-client.ts", "backend/src/config.ts", "backend/src/db/mysql.ts", @@ -31,6 +32,7 @@ $requiredFiles = @( "backend/tests/task-repository.test.mjs", "backend/tests/platform-config-repository.test.mjs", "backend/tests/auth.test.mjs", + "backend/tests/rbac.test.mjs", "scripts/dev/wsl/mysql-migration-roundtrip.sh", "database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.down.sql", @@ -44,6 +46,9 @@ $requiredFiles = @( "database/migrations/2026061804_m02b_wechat_auth.up.sql", "database/migrations/2026061804_m02b_wechat_auth.down.sql", "database/migrations/2026061804_m02b_wechat_auth.verify.sql", + "database/migrations/2026061805_m02c_rbac.up.sql", + "database/migrations/2026061805_m02c_rbac.down.sql", + "database/migrations/2026061805_m02c_rbac.verify.sql", "database/seeds/2026061601_m01b_minimal_seed.sql", "deploy/pm2/ecosystem.config.cjs" ) diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index b6e93a0..203d9d4 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-B migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M02-C migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M02-B live MySQL migration roundtrip completed." +echo "PASS: M01-B through M02-C live MySQL migration roundtrip completed."