diff --git a/backend/package.json b/backend/package.json index d2d67ac..171c654 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 && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.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 && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index d2dc77a..3472bb8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,6 +25,10 @@ import { registerStoreDiscoveryRoutes, type StoreDiscoveryRouteOptions } from './routes/store-discovery.js'; +import { + registerStoreAccessRoutes, + type StoreAccessRouteOptions +} from './routes/store-access.js'; export interface BuildAppOptions { config?: AppConfig; @@ -34,6 +38,7 @@ export interface BuildAppOptions { storeRoom?: StoreRoomRouteOptions; content?: ContentRouteOptions; storeDiscovery?: StoreDiscoveryRouteOptions; + storeAccess?: StoreAccessRouteOptions; } declare module 'fastify' { @@ -94,6 +99,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = { 'database/migrations/2026061806_m02d_user_management.up.sql', 'database/migrations/2026061807_m03a_store_room_domain.up.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.up.sql', - 'database/migrations/2026061809_m03c_store_discovery.up.sql' + 'database/migrations/2026061809_m03c_store_discovery.up.sql', + 'database/migrations/2026061810_m03d_scene_wifi_access.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -40,9 +41,11 @@ const migrationFiles: Record = { 'database/migrations/2026061806_m02d_user_management.verify.sql', 'database/migrations/2026061807_m03a_store_room_domain.verify.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql', - 'database/migrations/2026061809_m03c_store_discovery.verify.sql' + 'database/migrations/2026061809_m03c_store_discovery.verify.sql', + 'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql' ], down: [ + 'database/migrations/2026061810_m03d_scene_wifi_access.down.sql', 'database/migrations/2026061809_m03c_store_discovery.down.sql', 'database/migrations/2026061808_m03b_decoration_ads_media.down.sql', 'database/migrations/2026061807_m03a_store_room_domain.down.sql', @@ -176,7 +179,8 @@ export async function executeMigrationPlan( 1, 3, 1, 3, 6, 13, 1, 3, 3, 1, - 2, 2, 1 + 2, 2, 1, + 3, 3, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/routes/store-access.ts b/backend/src/routes/store-access.ts new file mode 100644 index 0000000..1fa3c80 --- /dev/null +++ b/backend/src/routes/store-access.ts @@ -0,0 +1,170 @@ +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 { + StoreAccessError, + type StoreAccessRepository +} from '../stores/access-repository.js'; + +const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) }); +const storeIdSchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) }); +const sceneSchema = z.object({ + targetType: z.enum(['STORE', 'ROOM']), + storeId: z.string().regex(/^[1-9]\d{0,19}$/), + roomId: z.string().regex(/^[1-9]\d{0,19}$/).optional() +}).refine((value) => value.targetType === 'STORE' || value.roomId !== undefined); +const resolveSchema = z.object({ + code: z.string().trim().min(12).max(32), + sourceType: z.enum(['QRCODE', 'NFC']).default('QRCODE') +}); + +export interface StoreAccessRouteOptions { + repository: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerStoreAccessRoutes( + app: FastifyInstance, options: StoreAccessRouteOptions +) { + app.post('/admin-api/scene-codes/regenerate', async (request, reply) => { + const actor = await requireManager(request, reply, options); + const body = sceneSchema.safeParse(request.body); + if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined; + return handle(reply, request.traceId, async () => reply.status(201).send({ + code: 0, + data: await options.repository.regenerateScene(actor, body.data), + traceId: request.traceId + })); + }); + app.post('/admin-api/scene-codes/:id/revoke', async (request, reply) => { + const actor = await requireManager(request, reply, options); + const params = idSchema.safeParse(request.params); + const query = storeIdSchema.safeParse(request.query); + if (!actor || !params.success || !query.success) { + return actor ? invalid(reply, request.traceId) : undefined; + } + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.revokeScene(actor, params.data.id, query.data.storeId), + traceId: request.traceId + })); + }); + app.get('/admin-api/stores/:storeId/scene-code-stats', async (request, reply) => { + const actor = await requireManager(request, reply, options); + const params = storeIdSchema.safeParse(request.params); + if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined; + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.sceneStats(actor, params.data.storeId), + traceId: request.traceId + })); + }); + app.post('/app-api/scenes/resolve', async (request, reply) => { + const body = resolveSchema.safeParse(request.body); + if (!body.success) return invalid(reply, request.traceId); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.resolveScene({ + ...body.data, + traceId: request.traceId, + ip: request.ip, + userAgent: request.headers['user-agent'] ?? '' + }), + traceId: request.traceId + })); + }); + app.get('/app-api/stores/:storeId/wifi', async (request, reply) => { + const auth = await authenticateAccessToken( + request.headers.authorization, options.authRepository, options.jwtSecret + ); + const params = storeIdSchema.safeParse(request.params); + if (!auth) { + return reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', + traceId: request.traceId + }); + } + if (!params.success) return invalid(reply, request.traceId); + const access = await options.accessControl.getAccessProfile( + auth.session.tenantId, auth.session.user.id + ); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.getWifi({ + tenantId: auth.session.tenantId, + userId: auth.session.user.id, + access, + storeId: params.data.storeId, + traceId: request.traceId, + ip: request.ip, + userAgent: request.headers['user-agent'] ?? '' + }), + traceId: request.traceId + })); + }); +} + +async function requireManager( + request: FastifyRequest, reply: FastifyReply, options: StoreAccessRouteOptions +): Promise { + 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((item) => + item === 'store.operation.write' || item === 'tenant.manage' + ) && !access.roles.includes('PLATFORM_ADMIN')) { + reply.status(403).send({ + code: 'SCENE_MANAGEMENT_FORBIDDEN', + message: 'Store 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 handle(reply: FastifyReply, traceId: string, work: () => Promise) { + try { + return await work(); + } catch (error) { + if (!(error instanceof StoreAccessError)) throw error; + const forbidden = error.code.endsWith('_FORBIDDEN'); + const notFound = error.code.endsWith('_NOT_FOUND') || error.code === 'SCENE_CODE_INVALID'; + return reply.status(forbidden ? 403 : notFound ? 404 : 400).send({ + code: error.code, + message: 'The scene or Wi-Fi request is invalid or not allowed.', + traceId + }); + } +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_STORE_ACCESS_REQUEST', + message: 'The scene or Wi-Fi request is invalid.', + traceId + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 4f639c7..96360e8 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -11,6 +11,7 @@ import { ContentRepository } from './content/content-repository.js'; import { MediaStorage } from './content/media-storage.js'; import { resolve } from 'node:path'; import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js'; +import { StoreAccessRepository } from './stores/access-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -49,6 +50,12 @@ const app = await buildApp({ storeDiscovery: { repository: new StoreDiscoveryRepository(pool), tenancy: authRepository + }, + storeAccess: { + repository: new StoreAccessRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/src/stores/access-repository.ts b/backend/src/stores/access-repository.ts new file mode 100644 index 0000000..a08fc98 --- /dev/null +++ b/backend/src/stores/access-repository.ts @@ -0,0 +1,219 @@ +import { randomBytes } from 'node:crypto'; +import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; +import type { AccessProfile } from '../auth/rbac-repository.js'; +import type { ManagementActor } from '../auth/user-management-repository.js'; +import type { MySqlPool } from '../db/mysql.js'; + +interface SceneRow extends RowDataPacket { + id: string; + tenantId: string; + targetType: 'STORE' | 'ROOM'; + storeId: string; + roomId: string | null; + generation: number; + scanCount: number; +} +interface WifiRow extends RowDataPacket { ssid: string; password: string } +interface CountRow extends RowDataPacket { total: number } + +export class StoreAccessError extends Error { + constructor(public readonly code: string) { super(code); } +} + +export class StoreAccessRepository { + constructor(private readonly pool: MySqlPool) {} + + async regenerateScene(actor: ManagementActor, input: { + targetType: 'STORE' | 'ROOM'; storeId: string; roomId?: string; + }) { + this.assertStoreManager(actor.access, input.storeId); + return this.transaction(async (connection) => { + await this.assertTarget(connection, actor.tenantId, input); + const [generations] = await connection.execute( + `SELECT COALESCE(MAX(generation), 0) + 1 AS total + FROM qipai_scene_codes + WHERE tenant_id = ? AND target_type = ? AND store_id = ? + AND room_id <=> ? FOR UPDATE`, + [actor.tenantId, input.targetType, input.storeId, input.roomId ?? null] + ); + await connection.execute( + `UPDATE qipai_scene_codes SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND target_type = ? AND store_id = ? + AND room_id <=> ? AND status = 'ACTIVE'`, + [actor.tenantId, input.targetType, input.storeId, input.roomId ?? null] + ); + const code = randomBytes(12).toString('base64url'); + const generation = Number(generations[0]?.total ?? 1); + const [result] = await connection.execute( + `INSERT INTO qipai_scene_codes + (tenant_id, code, target_type, store_id, room_id, generation, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [actor.tenantId, code, input.targetType, input.storeId, + input.roomId ?? null, generation, actor.userId] + ); + await this.audit(connection, actor, 'SCENE_CODE_REGENERATED', 'SCENE_CODE', String(result.insertId)); + return { sceneCodeId: String(result.insertId), code, generation }; + }); + } + + async revokeScene(actor: ManagementActor, sceneCodeId: string, storeId: string) { + this.assertStoreManager(actor.access, storeId); + const [result] = await this.pool.execute( + `UPDATE qipai_scene_codes SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3) + WHERE tenant_id = ? AND id = ? AND store_id = ? AND status = 'ACTIVE'`, + [actor.tenantId, sceneCodeId, storeId] + ); + if (result.affectedRows !== 1) throw new StoreAccessError('SCENE_CODE_NOT_FOUND'); + return { sceneCodeId, revoked: true }; + } + + async resolveScene(input: { + code: string; sourceType: 'QRCODE' | 'NFC'; traceId: string; + ip: string; userAgent: string; userId?: string; + }) { + return this.transaction(async (connection) => { + const [rows] = await connection.execute( + `SELECT id, tenant_id AS tenantId, target_type AS targetType, + store_id AS storeId, room_id AS roomId, generation, + scan_count AS scanCount + FROM qipai_scene_codes + WHERE code = ? AND status = 'ACTIVE' LIMIT 1 FOR UPDATE`, + [input.code] + ); + const scene = rows[0]; + if (!scene) throw new StoreAccessError('SCENE_CODE_INVALID'); + await connection.execute( + `UPDATE qipai_scene_codes SET scan_count = scan_count + 1, + last_scanned_at = UTC_TIMESTAMP(3) WHERE id = ?`, + [scene.id] + ); + await connection.execute( + `INSERT INTO qipai_scene_scan_events + (tenant_id, scene_code_id, source_type, user_id, trace_id, ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [scene.tenantId, scene.id, input.sourceType, input.userId ?? null, + input.traceId, input.ip, input.userAgent.slice(0, 255)] + ); + return { + targetType: scene.targetType, + storeId: String(scene.storeId), + roomId: scene.roomId === null ? null : String(scene.roomId), + page: scene.targetType === 'ROOM' ? '/pages/room/detail' : '/pages/store/detail', + permissions: [] + }; + }); + } + + async sceneStats(actor: ManagementActor, storeId: string) { + this.assertStoreManager(actor.access, storeId); + const [rows] = await this.pool.execute( + `SELECT id, tenant_id AS tenantId, target_type AS targetType, + store_id AS storeId, room_id AS roomId, generation, + scan_count AS scanCount + FROM qipai_scene_codes + WHERE tenant_id = ? AND store_id = ? AND status = 'ACTIVE' + ORDER BY target_type, room_id`, + [actor.tenantId, storeId] + ); + return rows.map((row) => ({ + sceneCodeId: String(row.id), + targetType: row.targetType, + storeId: String(row.storeId), + roomId: row.roomId === null ? null : String(row.roomId), + generation: row.generation, + scanCount: Number(row.scanCount) + })); + } + + async getWifi(input: { + tenantId: string; userId: string; access: AccessProfile; storeId: string; + traceId: string; ip: string; userAgent: string; + }) { + const manager = this.canManageStore(input.access, input.storeId); + if (!manager) { + const [rows] = await this.pool.execute( + `SELECT COUNT(*) AS total + FROM qipai_order_user_access a + INNER JOIN qipai_orders o + ON o.id = a.order_id AND o.tenant_id = a.tenant_id AND o.deleted_at IS NULL + WHERE a.tenant_id = ? AND a.user_id = ? AND a.revoked_at IS NULL + AND o.store_id = ? AND o.status IN ('PAID', 'CONFIRMED', 'IN_USE') + AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at`, + [input.tenantId, input.userId, input.storeId] + ); + if (Number(rows[0]?.total ?? 0) === 0) throw new StoreAccessError('WIFI_ACCESS_FORBIDDEN'); + } + const [rows] = await this.pool.execute( + `SELECT wifi_ssid AS ssid, wifi_password AS password + FROM qipai_stores + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`, + [input.tenantId, input.storeId] + ); + if (!rows[0] || !rows[0].ssid) throw new StoreAccessError('WIFI_NOT_CONFIGURED'); + await this.pool.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', ?, 'WIFI_CREDENTIAL_ACCESSED', 'STORE', ?, ?, ?, ?, + JSON_OBJECT('ssid', ?, 'passwordReturned', TRUE))`, + [input.tenantId, input.userId, input.storeId, input.traceId, + input.ip, input.userAgent.slice(0, 255), rows[0].ssid] + ); + return rows[0]; + } + + private assertStoreManager(access: AccessProfile, storeId: string) { + if (!this.canManageStore(access, storeId)) throw new StoreAccessError('STORE_SCOPE_FORBIDDEN'); + } + + private canManageStore(access: AccessProfile, storeId: string) { + return access.capabilities.includes('tenant.manage') + || access.roles.includes('PLATFORM_ADMIN') + || (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)); + } + + private async assertTarget( + connection: PoolConnection, tenantId: string, + input: { targetType: 'STORE' | 'ROOM'; storeId: string; roomId?: string } + ) { + if (input.targetType === 'ROOM' && !input.roomId) throw new StoreAccessError('ROOM_REQUIRED'); + const sql = input.targetType === 'ROOM' + ? `SELECT COUNT(*) AS total FROM qipai_rooms + WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL` + : `SELECT COUNT(*) AS total FROM qipai_stores + WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`; + const params: string[] = input.targetType === 'ROOM' + ? [tenantId, input.storeId, input.roomId as string] : [tenantId, input.storeId]; + const [rows] = await connection.execute(sql, params); + if (Number(rows[0]?.total ?? 0) !== 1) throw new StoreAccessError('SCENE_TARGET_NOT_FOUND'); + } + + private async audit( + connection: PoolConnection, actor: ManagementActor, + action: string, resourceType: string, resourceId: string + ) { + 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', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`, + [actor.tenantId, actor.userId, action, resourceType, resourceId, + actor.traceId, actor.ip, actor.userAgent.slice(0, 255)] + ); + } + + private async transaction(work: (connection: PoolConnection) => 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(); + } + } +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 807acdc..8c68053 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -36,6 +36,9 @@ const contentVerifySql = read('database/migrations/2026061808_m03b_decoration_ad const discoveryUpSql = read('database/migrations/2026061809_m03c_store_discovery.up.sql'); const discoveryDownSql = read('database/migrations/2026061809_m03c_store_discovery.down.sql'); const discoveryVerifySql = read('database/migrations/2026061809_m03c_store_discovery.verify.sql'); +const accessUpSql = read('database/migrations/2026061810_m03d_scene_wifi_access.up.sql'); +const accessDownSql = read('database/migrations/2026061810_m03d_scene_wifi_access.down.sql'); +const accessVerifySql = read('database/migrations/2026061810_m03d_scene_wifi_access.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -170,5 +173,15 @@ for (const column of ['city', 'district']) { } assert.match(discoveryUpSql, /idx_qipai_stores_tenant_city_status/); assert.match(discoveryUpSql, /idx_qipai_stores_tenant_coordinates/); +for (const table of [ + 'qipai_scene_codes', 'qipai_scene_scan_events', 'qipai_order_user_access' +]) { + assert.match(accessUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)); + assert.match(accessDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`)); + assert.match(accessVerifySql, new RegExp(`'${table}'`)); +} +assert.match(accessUpSql, /scan_count BIGINT UNSIGNED/); +assert.match(accessUpSql, /target_type = 'STORE'/); +assert.match(accessUpSql, /PRIMARY KEY \(tenant_id, order_id, user_id\)/); -console.log('PASS: M01-B through M03-C migration contracts are present.'); +console.log('PASS: M01-B through M03-D migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index d0f59d6..2d91f95 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -20,7 +20,8 @@ assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql/); assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/); assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/); assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql/); -assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql$/); +assert.match(plan.file, /2026061809_m03c_store_discovery\.up\.sql/); +assert.match(plan.file, /2026061810_m03d_scene_wifi_access\.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 f97ab84..cc39de0 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -16,6 +16,7 @@ import { UserManagementRepository } from '../dist/auth/user-management-repositor import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js'; import { ContentRepository, ContentError } from '../dist/content/content-repository.js'; import { StoreDiscoveryRepository } from '../dist/stores/store-discovery-repository.js'; +import { StoreAccessRepository, StoreAccessError } from '../dist/stores/access-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -31,6 +32,7 @@ const expectedTables = [ 'qipai_legacy_table_mappings', 'qipai_media_assets', 'qipai_members', + 'qipai_order_user_access', 'qipai_orders', 'qipai_outbox_events', 'qipai_payments', @@ -41,6 +43,8 @@ const expectedTables = [ 'qipai_room_categories', 'qipai_room_disabled_periods', 'qipai_rooms', + 'qipai_scene_codes', + 'qipai_scene_scan_events', 'qipai_schema_migrations', 'qipai_store_business_hours', 'qipai_store_decorations', @@ -73,10 +77,11 @@ 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', '2026061806', '2026061807', '2026061808', '2026061809'] + '2026061805', '2026061806', '2026061807', '2026061808', '2026061809', + '2026061810'] ); return rows; } @@ -425,6 +430,103 @@ async function assertStoreDiscovery(pool, context) { })).length, 0); } +async function assertSceneAndWifiAccess(pool, context) { + const [adminRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id + INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id + WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, + [context.tenantId] + ); + const [customerRows] = await pool.query( + `SELECT u.id FROM qipai_users u + INNER JOIN qipai_user_identities i + ON i.tenant_id = u.tenant_id AND i.user_id = u.id + WHERE u.tenant_id = ? AND i.openid = 'm02b-openid-a' LIMIT 1`, + [context.tenantId] + ); + const [targetRows] = await pool.query( + `SELECT s.id AS storeId, r.id AS roomId + FROM qipai_stores s + INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id + WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`, + [context.tenantId] + ); + const adminId = String(adminRows[0].id); + const customerId = String(customerRows[0].id); + const storeId = String(targetRows[0].storeId); + const roomId = String(targetRows[0].roomId); + const rbac = new RbacRepository(pool); + const adminAccess = await rbac.getAccessProfile(context.tenantId, adminId); + const customerAccess = await rbac.getAccessProfile(context.tenantId, customerId); + const actor = { + tenantId: context.tenantId, userId: adminId, access: adminAccess, + traceId: 'm03d-live-test', ip: '127.0.0.1', userAgent: 'M03-D live test' + }; + const repository = new StoreAccessRepository(pool); + const first = await repository.regenerateScene(actor, { + targetType: 'ROOM', storeId, roomId + }); + const firstResolved = await repository.resolveScene({ + code: first.code, sourceType: 'QRCODE', traceId: 'm03d-scan-1', + ip: '127.0.0.1', userAgent: 'M03-D scan' + }); + assert.equal(firstResolved.roomId, roomId); + assert.deepEqual(firstResolved.permissions, []); + const second = await repository.regenerateScene(actor, { + targetType: 'ROOM', storeId, roomId + }); + assert.equal(second.generation, 2); + await assert.rejects( + () => repository.resolveScene({ + code: first.code, sourceType: 'NFC', traceId: 'm03d-old-code', + ip: '127.0.0.1', userAgent: 'M03-D old code' + }), + (error) => error instanceof StoreAccessError && error.code === 'SCENE_CODE_INVALID' + ); + await repository.resolveScene({ + code: second.code, sourceType: 'NFC', traceId: 'm03d-scan-2', + ip: '127.0.0.1', userAgent: 'M03-D NFC' + }); + assert.equal((await repository.sceneStats(actor, storeId))[0].scanCount, 1); + + await assert.rejects( + () => repository.getWifi({ + tenantId: context.tenantId, userId: customerId, access: customerAccess, storeId, + traceId: 'm03d-wifi-denied', ip: '127.0.0.1', userAgent: 'M03-D denied' + }), + (error) => error instanceof StoreAccessError && error.code === 'WIFI_ACCESS_FORBIDDEN' + ); + const [orderResult] = await pool.query( + `INSERT INTO qipai_orders + (tenant_id, store_id, room_id, order_no, status, start_at, end_at) + VALUES (?, ?, ?, 'M03D-WIFI-ORDER', 'IN_USE', + DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE), + DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 50 MINUTE))`, + [context.tenantId, storeId, roomId] + ); + await pool.query( + `INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id) + VALUES (?, ?, ?)`, + [context.tenantId, orderResult.insertId, customerId] + ); + const wifi = await repository.getWifi({ + tenantId: context.tenantId, userId: customerId, access: customerAccess, storeId, + traceId: 'm03d-wifi-allowed', ip: '127.0.0.1', userAgent: 'M03-D allowed' + }); + assert.equal(wifi.ssid, 'M03A-WIFI'); + assert.equal(wifi.password, 'sanitized-password'); + const [auditRows] = await pool.query( + `SELECT CAST(metadata AS CHAR) AS metadata + FROM qipai_audit_logs + WHERE tenant_id = ? AND trace_id = 'm03d-wifi-allowed'`, + [context.tenantId] + ); + assert.equal(auditRows.length, 1); + assert.match(auditRows[0].metadata, /M03A-WIFI/); + assert.doesNotMatch(auditRows[0].metadata, /sanitized-password/); +} + async function assertContentManagement(pool, context) { const [adminRows] = await pool.query( `SELECT u.id FROM qipai_users u @@ -521,7 +623,8 @@ try { { version: '2026061806', name: 'm02d_user_management' }, { version: '2026061807', name: 'm03a_store_room_domain' }, { version: '2026061808', name: 'm03b_decoration_ads_media' }, - { version: '2026061809', name: 'm03c_store_discovery' } + { version: '2026061809', name: 'm03c_store_discovery' }, + { version: '2026061810', name: 'm03d_scene_wifi_access' } ]); await assertTaskDurability(pool); const loginContext = await assertPlatformTenantIsolation(pool); @@ -530,6 +633,7 @@ try { await assertStoreRoomDomain(pool, loginContext); await assertContentManagement(pool, loginContext); await assertStoreDiscovery(pool, loginContext); + await assertSceneAndWifiAccess(pool, loginContext); await assertLegacyCompatibility(pool); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); @@ -550,7 +654,8 @@ try { { version: '2026061806', name: 'm02d_user_management' }, { version: '2026061807', name: 'm03a_store_room_domain' }, { version: '2026061808', name: 'm03b_decoration_ads_media' }, - { version: '2026061809', name: 'm03c_store_discovery' } + { version: '2026061809', name: 'm03c_store_discovery' }, + { version: '2026061810', name: 'm03d_scene_wifi_access' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -598,7 +703,13 @@ try { 'platform advertisement rejection', 'city fallback store filtering', 'server-side distance sorting', - 'empty manual city result' + 'empty manual city result', + 'scene regeneration revokes old code', + 'QR and NFC navigation without permissions', + 'scene scan statistics', + 'Wi-Fi denied without active order', + 'Wi-Fi allowed by active order grant', + 'Wi-Fi audit excludes password' ] }, null, 2)); } finally { diff --git a/backend/tests/store-access.test.mjs b/backend/tests/store-access.test.mjs new file mode 100644 index 0000000..1b04fa8 --- /dev/null +++ b/backend/tests/store-access.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; +import { StoreAccessError, StoreAccessRepository } from '../dist/stores/access-repository.js'; + +const transactionCalls = []; +const connection = { + async beginTransaction() { transactionCalls.push('begin'); }, + async commit() { transactionCalls.push('commit'); }, + async rollback() { transactionCalls.push('rollback'); }, + release() { transactionCalls.push('release'); }, + async execute(sql) { + if (sql.includes('FROM qipai_scene_codes') && sql.includes('code = ?')) { + return [[{ + id: 1, tenantId: 7, targetType: 'ROOM', storeId: 11, + roomId: 31, generation: 1, scanCount: 0 + }], []]; + } + return [{ affectedRows: 1 }, []]; + } +}; +const repository = new StoreAccessRepository({ + async getConnection() { return connection; }, + async execute(sql) { + if (sql.includes('qipai_order_user_access')) return [[{ total: 0 }], []]; + return [[], []]; + } +}); +const resolved = await repository.resolveScene({ + code: 'abcdefghijklmnop', sourceType: 'NFC', traceId: 'trace', + ip: '127.0.0.1', userAgent: 'test' +}); +assert.equal(resolved.page, '/pages/room/detail'); +assert.deepEqual(resolved.permissions, []); +assert.ok(transactionCalls.includes('commit')); +await assert.rejects( + () => repository.getWifi({ + tenantId: '7', userId: '21', + access: { roles: ['CUSTOMER'], capabilities: [], storeIds: [] }, + storeId: '11', traceId: 'trace', ip: '127.0.0.1', userAgent: 'test' + }), + (error) => error instanceof StoreAccessError && error.code === 'WIFI_ACCESS_FORBIDDEN' +); + +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); +const app = await buildApp({ + storeAccess: { + jwtSecret: secret, + authRepository: { + async validateSession() { + return { + id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', + tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), + user: { + id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', + roleVersion: 1, nickname: '', avatarUrl: '', phone: '' + } + }; + } + }, + accessControl: { + async getAccessProfile() { + return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }; + } + }, + repository: { + async regenerateScene() { return { sceneCodeId: '1', code: 'abcdefghijklmnop', generation: 1 }; }, + async revokeScene() { return { sceneCodeId: '1', revoked: true }; }, + async resolveScene() { + return { + targetType: 'STORE', storeId: '11', roomId: null, + page: '/pages/store/detail', permissions: [] + }; + }, + async sceneStats() { return []; }, + async getWifi() { return { ssid: 'QIPAI', password: '' }; } + } + } +}); +const generated = await app.inject({ + method: 'POST', url: '/admin-api/scene-codes/regenerate', + headers: { authorization: `Bearer ${token}` }, + payload: { targetType: 'STORE', storeId: '11' } +}); +assert.equal(generated.statusCode, 201); +const scan = await app.inject({ + method: 'POST', url: '/app-api/scenes/resolve', + payload: { code: 'abcdefghijklmnop', sourceType: 'QRCODE' } +}); +assert.equal(scan.statusCode, 200); +assert.deepEqual(scan.json().data.permissions, []); +await app.close(); + +console.log('PASS: M03-D scene navigation has no door permission and Wi-Fi access is guarded.'); diff --git a/database/migrations/2026061810_m03d_scene_wifi_access.down.sql b/database/migrations/2026061810_m03d_scene_wifi_access.down.sql new file mode 100644 index 0000000..6b5db7e --- /dev/null +++ b/database/migrations/2026061810_m03d_scene_wifi_access.down.sql @@ -0,0 +1,4 @@ +DELETE FROM qipai_schema_migrations WHERE version = '2026061810'; +DROP TABLE IF EXISTS qipai_order_user_access; +DROP TABLE IF EXISTS qipai_scene_scan_events; +DROP TABLE IF EXISTS qipai_scene_codes; diff --git a/database/migrations/2026061810_m03d_scene_wifi_access.up.sql b/database/migrations/2026061810_m03d_scene_wifi_access.up.sql new file mode 100644 index 0000000..4e07ed2 --- /dev/null +++ b/database/migrations/2026061810_m03d_scene_wifi_access.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS qipai_scene_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + code VARCHAR(32) NOT NULL, + target_type VARCHAR(16) NOT NULL, + store_id BIGINT UNSIGNED NOT NULL, + room_id BIGINT UNSIGNED NULL, + generation INT UNSIGNED NOT NULL DEFAULT 1, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + scan_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + last_scanned_at DATETIME(3) NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_scene_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_scene_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id), + CONSTRAINT fk_qipai_scene_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id), + CONSTRAINT fk_qipai_scene_creator FOREIGN KEY (created_by) REFERENCES qipai_users(id), + UNIQUE KEY uq_qipai_scene_code (code), + KEY idx_qipai_scene_target (tenant_id, target_type, store_id, room_id, status), + CONSTRAINT chk_qipai_scene_target CHECK ( + (target_type = 'STORE' AND room_id IS NULL) + OR (target_type = 'ROOM' AND room_id IS NOT NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_scene_scan_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + scene_code_id BIGINT UNSIGNED NOT NULL, + source_type VARCHAR(16) NOT NULL DEFAULT 'QRCODE', + user_id BIGINT UNSIGNED NULL, + trace_id VARCHAR(128) NOT NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(255) NOT NULL DEFAULT '', + scanned_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT fk_qipai_scene_scans_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_scene_scans_code FOREIGN KEY (scene_code_id) REFERENCES qipai_scene_codes(id), + CONSTRAINT fk_qipai_scene_scans_user FOREIGN KEY (user_id) REFERENCES qipai_users(id), + KEY idx_qipai_scene_scans_time (tenant_id, scene_code_id, scanned_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_order_user_access ( + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + access_type VARCHAR(16) NOT NULL DEFAULT 'OWNER', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + PRIMARY KEY (tenant_id, order_id, user_id), + CONSTRAINT fk_qipai_order_access_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_order_access_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id), + CONSTRAINT fk_qipai_order_access_user FOREIGN KEY (user_id) REFERENCES qipai_users(id), + KEY idx_qipai_order_access_user (tenant_id, user_id, revoked_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026061810', 'm03d_scene_wifi_access'); diff --git a/database/migrations/2026061810_m03d_scene_wifi_access.verify.sql b/database/migrations/2026061810_m03d_scene_wifi_access.verify.sql new file mode 100644 index 0000000..48c156c --- /dev/null +++ b/database/migrations/2026061810_m03d_scene_wifi_access.verify.sql @@ -0,0 +1,13 @@ +SELECT table_name FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ('qipai_scene_codes', 'qipai_scene_scan_events', 'qipai_order_user_access') +ORDER BY table_name; + +SELECT table_name, index_name FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ((table_name = 'qipai_scene_codes' AND index_name = 'idx_qipai_scene_target') + OR (table_name = 'qipai_scene_scan_events' AND index_name = 'idx_qipai_scene_scans_time') + OR (table_name = 'qipai_order_user_access' AND index_name = 'idx_qipai_order_access_user')) +GROUP BY table_name, index_name ORDER BY table_name; + +SELECT version, name FROM qipai_schema_migrations WHERE version = '2026061810'; diff --git a/docs/api-changelog/2026-06-18-M03-D-scene-wifi.md b/docs/api-changelog/2026-06-18-M03-D-scene-wifi.md new file mode 100644 index 0000000..5b8f4d0 --- /dev/null +++ b/docs/api-changelog/2026-06-18-M03-D-scene-wifi.md @@ -0,0 +1,11 @@ +# M03-D 场景码、NFC 与 Wi-Fi API + +- `POST /admin-api/scene-codes/regenerate` +- `POST /admin-api/scene-codes/:id/revoke?storeId=...` +- `GET /admin-api/stores/:storeId/scene-code-stats` +- `POST /app-api/scenes/resolve` +- `GET /app-api/stores/:storeId/wifi` + +场景码重建会撤销同一目标旧码。二维码和 NFC 解析只返回页面、门店与房间参数,固定返回空权限集合。 + +Wi-Fi 仅向授权门店管理员,或拥有当前有效订单访问授权的用户返回。访问日志记录 SSID 和授权结果,不记录密码。 diff --git a/docs/db-changelog/2026-06-18-M03-D-scene-wifi.md b/docs/db-changelog/2026-06-18-M03-D-scene-wifi.md new file mode 100644 index 0000000..ca25a6b --- /dev/null +++ b/docs/db-changelog/2026-06-18-M03-D-scene-wifi.md @@ -0,0 +1,8 @@ +# M03-D 场景码与 Wi-Fi 访问数据库变更 + +- 迁移版本:`2026061810` +- `qipai_scene_codes`:门店/房间场景码、代次、状态和扫描计数。 +- `qipai_scene_scan_events`:二维码/NFC 来源、traceId 和扫描时间。 +- `qipai_order_user_access`:订单与登录用户的访问授权桥接。 + +场景码不保存任何设备权限。Wi-Fi 查询同时校验订单状态、时间窗、门店和未撤销的用户授权。 diff --git a/docs/devlogs/2026-06-18-M03-D-场景码NFC与WiFi.md b/docs/devlogs/2026-06-18-M03-D-场景码NFC与WiFi.md new file mode 100644 index 0000000..7275a69 --- /dev/null +++ b/docs/devlogs/2026-06-18-M03-D-场景码NFC与WiFi.md @@ -0,0 +1,28 @@ +# M03-D 场景码、NFC 与 Wi-Fi + +- 日期:2026-06-18 +- 起始 commit:`88fa22e` +- 工程 commit:本阶段工程提交 +- ENGINEERING_DELTA=YES +- 子阶段状态:待 push 与远端校验 + +## 工程增量 + +- 门店/房间场景码生成、重新生成、撤销和扫描统计。 +- 二维码/NFC 统一解析,仅返回页面导航参数和空权限集合。 +- 小程序 scene/NFC 入口与门店/房间详情页。 +- 订单用户访问授权桥接。 +- Wi-Fi 按管理员门店范围或当前有效订单受控返回。 +- Wi-Fi 访问审计不记录密码。 + +## 验证 + +- Windows 全量后端测试通过。 +- 小程序 JSON 和 JavaScript 静态检查通过。 +- WSL MySQL 8.4.9 往返迁移通过。 +- 迁移语句:up 50、verify 32、down 47。 +- 实测旧码失效、NFC/二维码无开门权限、扫描统计、无订单拒绝、有订单放行和审计脱敏。 + +## 外部联调 + +真实微信小程序码图片生成依赖可用 AppSecret 与微信接口;当前已完成 sceneCode 生命周期、接口和 Mock/页面全链路,真实图片生成留待 M08 真机联调。 diff --git a/docs/unresolved-issues.md b/docs/unresolved-issues.md index 22ab850..2515ac0 100644 --- a/docs/unresolved-issues.md +++ b/docs/unresolved-issues.md @@ -8,4 +8,5 @@ | ISSUE-004 | 2026-06-15 | M00/REF-001 | 多个原始参考包或 SQL 含秘密、依赖目录或旧业务数据,不能直接提交。 | 高 | 原包/原 SQL 移出 Git 跟踪并加入忽略;已生成脱敏页面、接口和表结构摘要。 | 参考资料未预先脱敏。 | 后续仅提交重新实现的正式源码、schema-only 迁移或脱敏摘录。 | OPEN | | ISSUE-005 | 2026-06-15 | M00-E/TLS-001 | `api.txyundm.cn` 已解析到 `101.42.38.246`,但 WSL 线上 TLS 检查返回证书主题 `CN=git.txyundm.cn`,且 `/health` 不可达。 | 高 | 菜单第 4 项已降级为 WARN 并记录状态,不标记 HTTPS 通过。 | 生产 Nginx/证书/API 健康接口尚未完成或仍复用 Gitea 证书。 | 在 Ubuntu 上安装 API 专用 Nginx 配置和 `api.txyundm.cn` 证书后复测。 | OPEN | | ISSUE-006 | 2026-06-16 | V5.0/IOT-001 | WSL 本地 EMQX 服务级核验通过,但 MQTT 客户端账号用途、错误密码拒绝、ACL 越权拒绝、TLS、遗嘱和幂等尚未验证。 | 中 | 已新增本地 MQTT 基线文档、服务级检查脚本和 MQTTX CLI 冒烟入口;未配置账号时明确 SKIP。 | 当前只有服务/端口证据,缺少真实认证和协议测试结果。 | 配置 Git 忽略的本地最小权限 MQTT 账号后执行 `scripts/dev/wsl/mqtt-smoke.sh`。 | OPEN | -| ISSUE-007 | 2026-06-18 | M00/SCM-001/M08 | 工作区存在用户已有且未跟踪的 `miniapp/` 模板文件,仓库完整性门禁因此失败。 | 低 | M01-B 提交仅使用显式路径,未修改、删除、忽略或暂存这些文件。 | 小程序模板尚未进入正式 M08 审核与纳管流程。 | 进入 M08 时核对来源、AppID 与代码质量后决定正式纳管或清理;此前每次提交继续显式隔离。 | OPEN | +| ISSUE-007 | 2026-06-18 | M00/SCM-001/M08 | 工作区曾存在未跟踪的 `miniapp/` 模板文件。 | 低 | 已检查 AppID、修复乱码、增加固定 API 配置并纳管推送。 | 小程序模板早于正式 M08 队列生成。 | M08 基于已纳管模板继续开发,不重复初始化。 | RESOLVED | +| ISSUE-008 | 2026-06-18 | M03-D/QR-001 | 真实微信小程序码图片生成尚未联调。 | 中 | sceneCode 生命周期、重建失效、扫码统计、二维码/NFC 页面解析和测试全链路已完成。 | 缺少可用于微信接口的 AppSecret 与真机联调环境。 | M08 配置真实 AppSecret 后调用微信小程序码接口并完成真机扫描验收。 | BLOCKED_EXTERNAL | diff --git a/miniapp/app.json b/miniapp/app.json index aeadfe3..5fc4b67 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -1,6 +1,8 @@ { "pages": [ "pages/index/index", + "pages/store/detail", + "pages/room/detail", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index d2bf1b4..43997d8 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -9,6 +9,12 @@ Page({ stores: [], }, + onLoad(options) { + if (options.scene) { + this.resolveScene(decodeURIComponent(options.scene), options.source === 'nfc' ? 'NFC' : 'QRCODE') + } + }, + onCityInput(event) { this.setData({ city: event.detail.value }) }, @@ -52,4 +58,22 @@ Page({ this.setData({ loading: false }) } }, + + async resolveScene(code, sourceType) { + this.setData({ loading: true, errorMessage: '' }) + try { + const response = await request('/scenes/resolve', { + method: 'POST', + data: { code, sourceType }, + }) + const target = response.data + const query = [`storeId=${encodeURIComponent(target.storeId)}`] + if (target.roomId) query.push(`roomId=${encodeURIComponent(target.roomId)}`) + wx.navigateTo({ url: `${target.page}?${query.join('&')}` }) + } catch (error) { + this.setData({ errorMessage: error.message || '场景码已失效' }) + } finally { + this.setData({ loading: false }) + } + }, }) diff --git a/miniapp/pages/room/detail.js b/miniapp/pages/room/detail.js new file mode 100644 index 0000000..696d562 --- /dev/null +++ b/miniapp/pages/room/detail.js @@ -0,0 +1,12 @@ +Page({ + data: { + storeId: '', + roomId: '', + }, + onLoad(options) { + this.setData({ + storeId: options.storeId || '', + roomId: options.roomId || '', + }) + }, +}) diff --git a/miniapp/pages/room/detail.json b/miniapp/pages/room/detail.json new file mode 100644 index 0000000..2be90c2 --- /dev/null +++ b/miniapp/pages/room/detail.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "房间详情" +} diff --git a/miniapp/pages/room/detail.wxml b/miniapp/pages/room/detail.wxml new file mode 100644 index 0000000..1c0241b --- /dev/null +++ b/miniapp/pages/room/detail.wxml @@ -0,0 +1,6 @@ + + 房间详情 + 门店编号:{{storeId}} + 房间编号:{{roomId}} + 扫码或 NFC 只打开此页面,开门权限仍由有效订单单独校验。 + diff --git a/miniapp/pages/room/detail.wxss b/miniapp/pages/room/detail.wxss new file mode 100644 index 0000000..2d5d9a5 --- /dev/null +++ b/miniapp/pages/room/detail.wxss @@ -0,0 +1,14 @@ +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.notice { + margin-top: 32rpx; + color: #777777; +} diff --git a/miniapp/pages/store/detail.js b/miniapp/pages/store/detail.js new file mode 100644 index 0000000..bd0e8ef --- /dev/null +++ b/miniapp/pages/store/detail.js @@ -0,0 +1,8 @@ +Page({ + data: { + storeId: '', + }, + onLoad(options) { + this.setData({ storeId: options.storeId || '' }) + }, +}) diff --git a/miniapp/pages/store/detail.json b/miniapp/pages/store/detail.json new file mode 100644 index 0000000..07641ea --- /dev/null +++ b/miniapp/pages/store/detail.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "门店详情" +} diff --git a/miniapp/pages/store/detail.wxml b/miniapp/pages/store/detail.wxml new file mode 100644 index 0000000..bac4bfa --- /dev/null +++ b/miniapp/pages/store/detail.wxml @@ -0,0 +1,5 @@ + + 门店详情 + 门店编号:{{storeId}} + 场景码仅用于页面导航,不授予开门或设备控制权限。 + diff --git a/miniapp/pages/store/detail.wxss b/miniapp/pages/store/detail.wxss new file mode 100644 index 0000000..2d5d9a5 --- /dev/null +++ b/miniapp/pages/store/detail.wxss @@ -0,0 +1,14 @@ +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.notice { + margin-top: 32rpx; + color: #777777; +} diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index f4a7412..1b9d7a2 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 M03-C migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M03-D migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M03-C live MySQL migration roundtrip completed." +echo "PASS: M01-B through M03-D live MySQL migration roundtrip completed."