feat(M02-C): 建立RBAC与门店数据范围

This commit is contained in:
Codex
2026-06-18 10:50:43 +08:00
parent a8c2a3b3e1
commit caacc78545
16 changed files with 334 additions and 16 deletions
+23
View File
@@ -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)
+93
View File
@@ -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<void> {
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<AccessProfile> {
const [roles] = await this.pool.execute<CodeRow[]>(
`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<CodeRow[]>(
`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<StoreRow[]>(
`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<boolean> {
const [result] = await this.pool.execute<ResultSetHeader>(
`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;
}
}
+6 -3
View File
@@ -24,15 +24,18 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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.`
+12 -1
View File
@@ -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<AccessProfile>;
};
}
export async function registerAuthRoutes(app: FastifyInstance, options: AuthRouteOptions): Promise<void> {
@@ -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) => {
+3 -1
View File
@@ -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 () => {