|
|
|
@@ -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]}.*.*` : '***';
|
|
|
|
|
}
|