feat(M06-B): 建立设备资产与拓扑管理
This commit is contained in:
@@ -41,6 +41,7 @@ import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/paymen
|
||||
import {
|
||||
registerThirdPartyRoutes, type ThirdPartyRouteOptions
|
||||
} from './routes/third-party.js';
|
||||
import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -58,6 +59,7 @@ export interface BuildAppOptions {
|
||||
payment?: PaymentRouteOptions;
|
||||
thirdParty?: ThirdPartyRouteOptions;
|
||||
mqtt?: MqttHealthProvider;
|
||||
devices?: DeviceRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -141,6 +143,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.thirdParty) {
|
||||
await registerThirdPartyRoutes(app, options.thirdParty);
|
||||
}
|
||||
if (options.devices) {
|
||||
await registerDeviceRoutes(app, options.devices);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ export class AuthRepository {
|
||||
(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'))
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'))
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -38,9 +38,11 @@ export class RbacRepository {
|
||||
(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'))
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'))
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[tenantId, tenantId]
|
||||
);
|
||||
|
||||
@@ -38,7 +38,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062015_m05a_payment_domain.up.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.up.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.up.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.up.sql'
|
||||
'database/migrations/2026062218_m05d_profit_sharing.up.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -58,9 +59,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062015_m05a_payment_domain.verify.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.verify.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.verify.sql'
|
||||
'database/migrations/2026062218_m05d_profit_sharing.verify.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062219_m06b_device_topology.down.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.down.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
||||
@@ -212,7 +215,8 @@ export async function executeMigrationPlan(
|
||||
5, 8, 4, 1,
|
||||
1, 5, 3, 1,
|
||||
5, 6, 1,
|
||||
3, 7, 5, 1
|
||||
3, 7, 5, 1,
|
||||
5, 8, 5, 2, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export type DeviceType = 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
|
||||
|
||||
export interface DeviceAssetInput {
|
||||
storeId: string;
|
||||
roomId?: string | null;
|
||||
deviceId: string;
|
||||
imei?: string;
|
||||
iccid?: string | null;
|
||||
deviceType: DeviceType;
|
||||
model: string;
|
||||
firmwareVersion: string;
|
||||
signalStrength?: number | null;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface DeviceRow extends RowDataPacket {
|
||||
id: string; storeId: string; roomId: string | null; deviceId: string; imei: string;
|
||||
iccid: string | null; deviceType: DeviceType; model: string; firmwareVersion: string;
|
||||
status: string; signalStrength: number | null; capabilities: string | string[] | null;
|
||||
stateSnapshot: string | Record<string, unknown> | null; lastSeenAt: Date | null;
|
||||
lastHeartbeatAt: Date | null; maintenanceStatus: string;
|
||||
}
|
||||
|
||||
export class DeviceError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class DeviceRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async createAsset(actor: ManagementActor, input: DeviceAssetInput) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId ?? null);
|
||||
try {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_devices
|
||||
(tenant_id, store_id, room_id, device_id, imei, iccid, device_type, model,
|
||||
firmware_version, signal_strength, capabilities)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.storeId, input.roomId ?? null, input.deviceId,
|
||||
input.imei ?? '', input.iccid || null, input.deviceType, input.model,
|
||||
input.firmwareVersion, input.signalStrength ?? null,
|
||||
JSON.stringify([...new Set(input.capabilities)].sort())]
|
||||
);
|
||||
const assetId = String(result.insertId);
|
||||
await this.audit(connection, actor, 'DEVICE_ASSET_CREATED', assetId, {
|
||||
deviceType: input.deviceType, storeId: input.storeId, roomId: input.roomId ?? null
|
||||
});
|
||||
return { assetId };
|
||||
} catch (error) {
|
||||
if (isDuplicate(error)) throw new DeviceError('DEVICE_IDENTITY_CONFLICT');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listAssets(actor: ManagementActor, storeId?: string) {
|
||||
if (storeId) this.assertStoreScope(actor, storeId, false);
|
||||
const scope = this.scope(actor, 'd.store_id');
|
||||
const params: Array<string> = [actor.tenantId, ...scope.params];
|
||||
const storeFilter = storeId ? ' AND d.store_id = ?' : '';
|
||||
if (storeId) params.push(storeId);
|
||||
const [rows] = await this.pool.execute<DeviceRow[]>(
|
||||
`SELECT d.id, d.store_id AS storeId, d.room_id AS roomId, d.device_id AS deviceId,
|
||||
d.imei, d.iccid, d.device_type AS deviceType, d.model,
|
||||
d.firmware_version AS firmwareVersion, d.status, d.signal_strength AS signalStrength,
|
||||
d.capabilities, d.state_snapshot AS stateSnapshot,
|
||||
d.last_seen_at AS lastSeenAt, d.last_heartbeat_at AS lastHeartbeatAt,
|
||||
d.maintenance_status AS maintenanceStatus
|
||||
FROM qipai_devices d
|
||||
WHERE d.tenant_id = ? AND d.deleted_at IS NULL AND ${scope.sql}${storeFilter}
|
||||
ORDER BY d.store_id, d.room_id, d.id`,
|
||||
params
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
storeId: String(row.storeId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId),
|
||||
capabilities: parseJson<string[]>(row.capabilities, []),
|
||||
stateSnapshot: parseJson<Record<string, unknown>>(row.stateSnapshot, {})
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopology(actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId, false);
|
||||
const [assets, channelsResult, linksResult, alertsResult, maintenanceResult] = await Promise.all([
|
||||
this.listAssets(actor, storeId),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, device_id AS assetId, room_id AS roomId, channel_code AS channelCode,
|
||||
purpose, target_key AS targetKey, enabled
|
||||
FROM qipai_device_channels
|
||||
WHERE tenant_id = ? AND store_id = ? ORDER BY device_id, channel_code`,
|
||||
[actor.tenantId, storeId]
|
||||
),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, parent_device_id AS parentAssetId, child_device_id AS childAssetId,
|
||||
room_id AS roomId, link_type AS linkType, sub_id AS subId, subtype, status
|
||||
FROM qipai_device_links
|
||||
WHERE tenant_id = ? AND store_id = ? ORDER BY parent_device_id, child_device_id`,
|
||||
[actor.tenantId, storeId]
|
||||
),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, device_id AS assetId, room_id AS roomId, alert_type AS alertType,
|
||||
severity, status, summary, first_seen_at AS firstSeenAt,
|
||||
last_seen_at AS lastSeenAt
|
||||
FROM qipai_device_alerts
|
||||
WHERE tenant_id = ? AND store_id = ? AND status = 'OPEN'
|
||||
ORDER BY severity DESC, last_seen_at DESC`,
|
||||
[actor.tenantId, storeId]
|
||||
),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, device_id AS assetId, room_id AS roomId, record_type AS recordType,
|
||||
status, description, created_at AS createdAt, resolved_at AS resolvedAt
|
||||
FROM qipai_device_maintenance_records
|
||||
WHERE tenant_id = ? AND store_id = ?
|
||||
ORDER BY created_at DESC LIMIT 200`,
|
||||
[actor.tenantId, storeId]
|
||||
)
|
||||
]);
|
||||
return {
|
||||
assets,
|
||||
channels: normalizeIds(channelsResult[0]),
|
||||
links: normalizeIds(linksResult[0]),
|
||||
openAlerts: normalizeIds(alertsResult[0]),
|
||||
maintenance: normalizeIds(maintenanceResult[0])
|
||||
};
|
||||
}
|
||||
|
||||
async bindChannel(actor: ManagementActor, input: {
|
||||
assetId: string; storeId: string; roomId: string; channelCode: string; purpose: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
const device = await this.lockDevice(connection, actor, input.assetId, input.storeId);
|
||||
if (!['CONTROL_BOX', 'SMART_SOCKET'].includes(device.deviceType)) {
|
||||
throw new DeviceError('DEVICE_CHANNEL_UNSUPPORTED');
|
||||
}
|
||||
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId);
|
||||
const targetKey = `room:${input.roomId}:target:${input.purpose}`;
|
||||
try {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_device_channels
|
||||
(tenant_id, device_id, store_id, room_id, channel_code, purpose, target_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.assetId, input.storeId, input.roomId,
|
||||
input.channelCode, input.purpose, targetKey]
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDuplicate(error)) throw new DeviceError('DEVICE_CONTROL_TARGET_CONFLICT');
|
||||
throw error;
|
||||
}
|
||||
await connection.execute(
|
||||
'UPDATE qipai_devices SET room_id = ? WHERE tenant_id = ? AND id = ?',
|
||||
[input.roomId, actor.tenantId, input.assetId]
|
||||
);
|
||||
await this.audit(connection, actor, 'DEVICE_CHANNEL_BOUND', input.assetId, {
|
||||
roomId: input.roomId, channelCode: input.channelCode, purpose: input.purpose
|
||||
});
|
||||
return { assetId: input.assetId, targetKey };
|
||||
});
|
||||
}
|
||||
|
||||
async bindSubDevice(actor: ManagementActor, input: {
|
||||
parentAssetId: string; childAssetId: string; storeId: string; roomId: string;
|
||||
subId: string; subtype: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
const parent = await this.lockDevice(connection, actor, input.parentAssetId, input.storeId);
|
||||
const child = await this.lockDevice(connection, actor, input.childAssetId, input.storeId);
|
||||
if (parent.deviceType !== 'CONTROL_BOX' || child.deviceType !== 'SUB_LOCK') {
|
||||
throw new DeviceError('DEVICE_LINK_TYPE_INVALID');
|
||||
}
|
||||
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId);
|
||||
try {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_device_links
|
||||
(tenant_id, parent_device_id, child_device_id, store_id, room_id,
|
||||
link_type, sub_id, subtype)
|
||||
VALUES (?, ?, ?, ?, ?, 'SUB_1G', ?, ?)`,
|
||||
[actor.tenantId, input.parentAssetId, input.childAssetId, input.storeId,
|
||||
input.roomId, input.subId, input.subtype]
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDuplicate(error)) throw new DeviceError('DEVICE_LINK_CONFLICT');
|
||||
throw error;
|
||||
}
|
||||
await connection.execute(
|
||||
'UPDATE qipai_devices SET room_id = ? WHERE tenant_id = ? AND id = ?',
|
||||
[input.roomId, actor.tenantId, input.childAssetId]
|
||||
);
|
||||
await this.audit(connection, actor, 'SUB_DEVICE_BOUND', input.childAssetId, {
|
||||
parentAssetId: input.parentAssetId, roomId: input.roomId,
|
||||
subId: maskIdentifier(input.subId), subtype: input.subtype
|
||||
});
|
||||
return { childAssetId: input.childAssetId, parentAssetId: input.parentAssetId };
|
||||
});
|
||||
}
|
||||
|
||||
async recordStatus(actor: ManagementActor, input: {
|
||||
assetId: string; storeId: string; onlineStatus: 'ONLINE' | 'OFFLINE' | 'FAULT';
|
||||
signalStrength?: number | null; firmwareVersion?: string; snapshot: Record<string, unknown>;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockDevice(connection, actor, input.assetId, input.storeId);
|
||||
const capturedAt = new Date();
|
||||
await connection.execute(
|
||||
`UPDATE qipai_devices SET status = ?, signal_strength = ?,
|
||||
firmware_version = COALESCE(NULLIF(?, ''), firmware_version),
|
||||
state_snapshot = ?, last_seen_at = ?, last_heartbeat_at = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.onlineStatus, input.signalStrength ?? null, input.firmwareVersion ?? '',
|
||||
JSON.stringify(input.snapshot), capturedAt, capturedAt, actor.tenantId, input.assetId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_device_status_snapshots
|
||||
(tenant_id, device_id, online_status, signal_strength, firmware_version, snapshot,
|
||||
captured_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.assetId, input.onlineStatus, input.signalStrength ?? null,
|
||||
input.firmwareVersion ?? '', JSON.stringify(input.snapshot), capturedAt]
|
||||
);
|
||||
return { assetId: input.assetId, capturedAt: capturedAt.toISOString() };
|
||||
});
|
||||
}
|
||||
|
||||
async addMaintenance(actor: ManagementActor, input: {
|
||||
assetId: string; storeId: string; roomId?: string | null;
|
||||
recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
|
||||
status: 'OPEN' | 'RESOLVED'; description: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockDevice(connection, actor, input.assetId, input.storeId);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_device_maintenance_records
|
||||
(tenant_id, device_id, store_id, room_id, record_type, status, description,
|
||||
created_by, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.assetId, input.storeId, input.roomId ?? null,
|
||||
input.recordType, input.status, input.description, actor.userId,
|
||||
input.status === 'RESOLVED' ? new Date() : null]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_devices SET maintenance_status = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.status === 'OPEN' ? 'MAINTENANCE' : 'NORMAL', actor.tenantId, input.assetId]
|
||||
);
|
||||
await this.audit(connection, actor, 'DEVICE_MAINTENANCE_RECORDED', input.assetId, {
|
||||
recordType: input.recordType, status: input.status
|
||||
});
|
||||
return { maintenanceId: String(result.insertId) };
|
||||
});
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string, write: boolean) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
const capability = write ? 'device.write' : 'device.read';
|
||||
if (!actor.access.capabilities.includes(capability)
|
||||
|| !actor.access.storeIds.includes(storeId)) {
|
||||
throw new DeviceError('DEVICE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private scope(actor: ManagementActor, expression: string) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return { sql: '1 = 1', params: [] as string[] };
|
||||
if (actor.access.storeIds.length === 0) return { sql: '1 = 0', params: [] as string[] };
|
||||
return {
|
||||
sql: `${expression} IN (${actor.access.storeIds.map(() => '?').join(',')})`,
|
||||
params: actor.access.storeIds
|
||||
};
|
||||
}
|
||||
|
||||
private async assertStoreAndRoom(
|
||||
connection: PoolConnection, actor: ManagementActor, storeId: string, roomId: string | null
|
||||
) {
|
||||
const [stores] = await connection.execute<IdRow[]>(
|
||||
'SELECT id FROM qipai_stores WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL',
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
if (!stores[0]) throw new DeviceError('DEVICE_STORE_NOT_FOUND');
|
||||
if (!roomId) return;
|
||||
const [rooms] = await connection.execute<IdRow[]>(
|
||||
`SELECT id FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[actor.tenantId, storeId, roomId]
|
||||
);
|
||||
if (!rooms[0]) throw new DeviceError('DEVICE_ROOM_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async lockDevice(
|
||||
connection: PoolConnection, actor: ManagementActor, assetId: string, storeId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { id: string; deviceType: DeviceType }>>(
|
||||
`SELECT id, device_type AS deviceType FROM qipai_devices
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||
[actor.tenantId, storeId, assetId]
|
||||
);
|
||||
if (!rows[0]) throw new DeviceError('DEVICE_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async audit(
|
||||
connection: PoolConnection, actor: ManagementActor,
|
||||
action: string, resourceId: string, metadata: Record<string, unknown>
|
||||
) {
|
||||
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', ?, ?, 'DEVICE', ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, actor.userId, action, resourceId, actor.traceId,
|
||||
actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDuplicate(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null
|
||||
&& 'code' in error && error.code === 'ER_DUP_ENTRY';
|
||||
}
|
||||
|
||||
function parseJson<T>(value: unknown, fallback: T): T {
|
||||
if (value && typeof value === 'object') return value as T;
|
||||
if (typeof value !== 'string' || value.length === 0) return fallback;
|
||||
try { return JSON.parse(value) as T; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function maskIdentifier(value: string): string {
|
||||
if (value.length <= 4) return '*'.repeat(value.length);
|
||||
return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function normalizeIds(rows: RowDataPacket[]) {
|
||||
return rows.map((row) => Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
(key === 'id' || key.endsWith('Id')) && value !== null ? String(value) : value
|
||||
])
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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 type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { DeviceError, type DeviceRepository } from '../devices/device-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const optionalId = id.nullable().optional();
|
||||
const deviceIdentity = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
||||
const assetSchema = z.object({
|
||||
storeId: id,
|
||||
roomId: optionalId,
|
||||
deviceId: deviceIdentity,
|
||||
imei: z.string().trim().max(64).default(''),
|
||||
iccid: z.string().trim().max(32).nullable().optional(),
|
||||
deviceType: z.enum(['CONTROL_BOX', 'SUB_LOCK', 'SMART_SOCKET']),
|
||||
model: z.string().trim().min(1).max(64),
|
||||
firmwareVersion: z.string().trim().max(64).default(''),
|
||||
signalStrength: z.number().int().min(-200).max(200).nullable().optional(),
|
||||
capabilities: z.array(z.string().trim().regex(/^[A-Z0-9_]{1,64}$/)).max(64).default([])
|
||||
});
|
||||
const channelSchema = z.object({
|
||||
assetId: id,
|
||||
storeId: id,
|
||||
roomId: id,
|
||||
channelCode: z.enum(['SLOT1', 'SLOT2', 'SLOT3', 'MAIN', 'LOCK', 'LED', 'TTS']),
|
||||
purpose: z.enum([
|
||||
'ROOM_POWER', 'AIR_CONDITIONER', 'LIGHTING', 'DOOR_MAGNET',
|
||||
'STORE_DOOR', 'ROOM_DOOR', 'TTS', 'LED'
|
||||
])
|
||||
});
|
||||
const linkSchema = z.object({
|
||||
parentAssetId: id,
|
||||
childAssetId: id,
|
||||
storeId: id,
|
||||
roomId: id,
|
||||
subId: z.string().trim().min(1).max(64),
|
||||
subtype: z.string().trim().min(1).max(32)
|
||||
});
|
||||
const statusSchema = z.object({
|
||||
storeId: id,
|
||||
onlineStatus: z.enum(['ONLINE', 'OFFLINE', 'FAULT']),
|
||||
signalStrength: z.number().int().min(-200).max(200).nullable().optional(),
|
||||
firmwareVersion: z.string().trim().max(64).optional(),
|
||||
snapshot: z.record(z.unknown()).default({})
|
||||
});
|
||||
const maintenanceSchema = z.object({
|
||||
storeId: id,
|
||||
roomId: optionalId,
|
||||
recordType: z.enum(['INSPECTION', 'REPAIR', 'REPLACEMENT']),
|
||||
status: z.enum(['OPEN', 'RESOLVED']),
|
||||
description: z.string().trim().max(500).default('')
|
||||
});
|
||||
|
||||
export interface DeviceRouteOptions {
|
||||
repository: Pick<DeviceRepository,
|
||||
'createAsset' | 'listAssets' | 'getTopology' | 'bindChannel' | 'bindSubDevice'
|
||||
| 'recordStatus' | 'addMaintenance'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
|
||||
app.get('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = assetSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.createAsset(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/admin-api/device-topology', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/device-channels', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = channelSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.bindChannel(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/device-links', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = linkSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.bindSubDevice(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/devices/:id/status', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = statusSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.recordStatus(actor, {
|
||||
assetId: params.data.id, ...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/devices/:id/maintenance', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = maintenanceSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.addMaintenance(actor, {
|
||||
assetId: params.data.id, ...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireDeviceOperator(
|
||||
request: FastifyRequest, reply: FastifyReply, options: DeviceRouteOptions
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.some((code) =>
|
||||
code === 'device.read' || code === 'device.write' || code === 'tenant.manage'
|
||||
) && !access.roles.includes('PLATFORM_ADMIN')) {
|
||||
reply.status(403).send({
|
||||
code: 'DEVICE_OPERATION_FORBIDDEN',
|
||||
message: 'Device 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 mutate(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof DeviceError)) throw error;
|
||||
const forbidden = error.code.endsWith('_FORBIDDEN');
|
||||
const conflict = error.code.endsWith('_CONFLICT');
|
||||
return reply.status(forbidden ? 403 : conflict ? 409 : 400).send({
|
||||
code: error.code, message: 'The device operation is not allowed.', traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_DEVICE_REQUEST', message: 'The device request is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './third-party/third-party-client.js';
|
||||
import { ThirdPartyService } from './third-party/third-party-service.js';
|
||||
import { MqttService } from './mqtt/mqtt-service.js';
|
||||
import { DeviceRepository } from './devices/device-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -133,6 +134,12 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
devices: {
|
||||
repository: new DeviceRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
Reference in New Issue
Block a user