POS 终端配对流程与原理
设备绑定 · 一次性配对码 · Placeholder Record 模式 · Device Token 认证
1. 概览
POS 终端(CodePay P5 等 Android 手持设备)通过一次性 6 位配对码完成与门店的绑定。配对成功后,设备获得一个持久化的 device_token(UUID),后续所有 API 调用和 WebSocket 连接都使用该 token 认证,无需 JWT。
核心设计理念:配对码是"入场券"(一次性、有时效),device_token 是"工牌"(持久、可撤销)。管理员在 Web 端生成配对码,员工在 POS 终端输入,两端通过后端完成握手。
参与方
| 角色 |
技术 |
职责 |
| Web Admin 管理员 |
Next.js 前端 |
生成配对码、管理已配对设备 |
| Backend 后端 API |
Express.js + PostgreSQL |
配对码校验、设备注册、token 管理 |
| POS App 终端应用 |
Flutter (Dart) |
输入配对码、持久化凭证、心跳上报 |
2. 完整配对时序图
sequenceDiagram
participant Admin as Web Admin
participant API as Backend API
participant DB as pos_devices 表
participant POS as POS App
Note over Admin,POS: Phase 1 — 管理员生成配对码
Admin->>API: POST /api/pos/pairing-code
{storeId}
API->>API: crypto.randomInt(100000, 999999)
生成 6 位随机码
API->>DB: INSERT pos_devices
(device_uid='pending_123456',
pairing_code='123456',
code_expires_at=NOW()+10min,
status='pending')
DB-->>API: placeholder record created
API-->>Admin: {pairingCode: '123456', expiresIn: 600}
Admin->>Admin: 显示配对码给员工
Note over Admin,POS: Phase 2 — 终端输入配对码
POS->>POS: 员工输入 6 位配对码
POS->>API: POST /api/pos/device/register
{pairingCode, deviceUid,
deviceName, deviceInfo}
Note over API,DB: Phase 3 — 后端注册(事务内)
API->>DB: BEGIN TRANSACTION
API->>DB: SELECT * FROM pos_devices
WHERE pairing_code='123456'
AND code_expires_at > NOW()
DB-->>API: placeholder record found
API->>DB: SELECT * FROM pos_devices
WHERE device_uid='actual-uid'
DB-->>API: no duplicate found
API->>API: generate UUID device_token
API->>DB: UPDATE pos_devices SET
device_uid='actual-uid',
device_token=uuid,
device_name='CodePay P5',
pairing_code=NULL,
status='active',
paired_at=NOW()
API->>DB: COMMIT
DB-->>API: device registered
API-->>POS: {deviceToken, storeId,
deviceId, tenantId, deviceName}
Note over Admin,POS: Phase 4 — 终端持久化凭证
POS->>POS: SecureStorage.savePairingData()
存储 token/storeId/deviceId
POS->>POS: 导航到 Payment Idle 页面
POS->>API: POST /api/pos/device/heartbeat
Header: X-Device-Token: uuid
3. Placeholder Record 模式详解
这是本配对系统最核心的设计模式。与"先验证配对码 → 再创建记录"的传统方式不同,我们采用先创建占位记录 → 后填充真实设备信息的方式。
为什么用 Placeholder?
| 问题 |
传统方式 |
Placeholder 方式 |
| 配对码存哪里? |
需要额外的 pairing_codes 表 |
直接存在 pos_devices 表中 |
| 并发竞态 |
两步操作(查码 → 建记录),有时间窗口 |
单条记录 UPDATE,原子操作 |
| 过期清理 |
需要清理两张表 |
只需清理一张表的 pending 记录 |
| 数据一致性 |
可能出现"有码无设备"或"有设备无码" |
始终是同一条记录的状态流转 |
数据库记录状态流转
stateDiagram-v2
[*] --> Pending: 管理员生成配对码
Pending --> Active: 终端输入正确配对码
Pending --> Expired: 10 分钟未使用
Active --> Inactive: 管理员手动解绑
Active --> Active: 心跳更新 last_seen_at
Expired --> [*]: 定时清理
Inactive --> [*]: 记录保留供审计
state Pending {
device_uid = "pending_123456"
pairing_code = "123456"
code_expires_at = NOW() + 10min
status = "pending"
}
state Active {
device_uid = "actual-hardware-uid"
device_token = "uuid-v4"
pairing_code = NULL
status = "active"
}
字段变化对照
| 字段 |
Pending 阶段(占位) |
Active 阶段(已配对) |
device_uid |
"pending_123456"(占位值) |
"actual-hardware-uid"(真实设备 ID) |
pairing_code |
"123456" |
NULL(已清除) |
device_token |
NULL |
"uuid-v4-string" |
device_name |
NULL |
"CodePay P5 #1" |
status |
"pending" |
"active" |
code_expires_at |
NOW() + 10min |
保留(历史记录) |
paired_at |
NULL |
NOW() |
4. 认证机制:Device Token
配对成功后,POS App 获得的 device_token(UUID v4)是其后续所有通信的唯一凭证。
为什么不用 JWT?
POS 终端是设备而非用户。它不需要用户登录、不需要角色权限、不需要 token 刷新。JWT 的"过期 + 刷新"机制反而会增加复杂度——手持终端可能长时间离线,JWT 过期后无法自动续期。
| 特性 |
JWT(用户认证) |
Device Token(设备认证) |
| 签发对象 |
人类用户 |
硬件设备 |
| 有效期 |
短期(如 1h),需要刷新 |
永久有效,直到管理员撤销 |
| 传输方式 |
Authorization: Bearer xxx |
X-Device-Token: uuid |
| 撤销方式 |
黑名单或等过期 |
管理员解绑即时生效 |
| 存储内容 |
用户信息、角色、权限 |
仅设备身份标识(UUID) |
| 离线场景 |
过期后需网络刷新 |
无过期问题 |
API 认证分层
flowchart LR
subgraph "POS API 端点"
A["POST /device/register"] -->|无需认证| B["配对码即凭证"]
C["POST /device/heartbeat"] -->|X-Device-Token| D["设备 Token 认证"]
E["WebSocket pos:store:*"] -->|X-Device-Token| F["设备 Token 认证"]
end
subgraph "Web Admin API 端点"
G["POST /pairing-code"] -->|JWT Bearer| H["员工 JWT 认证"]
I["GET /devices"] -->|JWT Bearer| J["员工 JWT 认证"]
K["DELETE /devices/:id"] -->|JWT Bearer| L["员工 JWT 认证"]
end
5. POS App 端实现
文件结构
pos_app/lib/
├── features/pairing/
│ ├── data/
│ │ └── pairing_repository.dart # API 调用 + 数据持久化
│ └── presentation/
│ ├── bloc/
│ │ ├── pairing_cubit.dart # 状态管理
│ │ └── pairing_state.dart # 状态定义
│ └── pages/
│ └── pairing_page.dart # UI(配对码输入 + 数字键盘)
├── core/
│ ├── storage/
│ │ └── secure_storage.dart # FlutterSecureStorage 封装
│ ├── api/
│ │ └── api_client.dart # HTTP 客户端(自动附加 X-Device-Token)
│ └── di/
│ └── injection.dart # GetIt 依赖注入
启动时检查流程
flowchart TD
A["App 启动"] --> B{"SecureStorage 中有 deviceToken?"}
B -->|有| C["emit PairingAlreadyPaired"]
B -->|无| D["emit PairingCodeInput"]
C --> E["自动导航到 Payment Idle"]
D --> F["显示配对码输入界面"]
F --> G["用户输入 6 位数字"]
G --> H{"自动提交"}
H --> I["调用 repository.registerDevice()"]
I -->|成功| J["emit PairingSuccess"]
I -->|失败| K["emit PairingFailure"]
J --> L["2 秒后导航到 Payment Idle"]
K --> M["显示错误 + Retry 按钮"]
关键代码路径
PairingCubit.checkExistingPairing()
App 启动时首先检查 SecureStorage 中是否存在有效的 deviceToken。如果有,说明设备已配对,跳过配对流程直接进入支付待机页面。
// pairing_cubit.dart
Future<void> checkExistingPairing() async {
final token = await _repository.getStoredDeviceToken();
if (token != null) {
final storeId = await _repository.getStoredStoreId();
emit(PairingAlreadyPaired(storeId: storeId ?? ''));
} else {
emit(PairingCodeInput());
}
}
PairingRepository.registerDevice()
发送注册请求到后端,成功后批量写入 5 个凭证字段到 SecureStorage。使用 Future.wait() 并行写入提升速度。
// pairing_repository.dart
Future<PairingResult> registerDevice({
required String pairingCode,
}) async {
final deviceUid = await _getDeviceUid(); // 获取硬件唯一 ID
final deviceName = await _getDeviceName(); // 获取设备名称
final response = await _apiClient.post('/api/pos/device/register', data: {
'pairingCode': pairingCode,
'deviceUid': deviceUid,
'deviceName': deviceName,
'deviceInfo': { 'platform': 'android', ... },
});
// 批量持久化凭证
await _storage.savePairingData(
deviceToken: response['deviceToken'],
storeId: response['storeId'],
deviceId: response['deviceId'],
tenantId: response['tenantId'],
deviceName: response['deviceName'],
);
return PairingResult.fromJson(response);
}
6. 后端实现
文件结构
backend/
├── api/
│ └── pos.js # 路由定义(6 个端点)
├── services/
│ └── pos-device-service.js # 业务逻辑(配对码生成、设备注册)
└── database/migrations/
└── ...create-pos-devices.js # 数据库迁移
API 端点一览
| 方法 |
路径 |
认证方式 |
调用方 |
说明 |
POST |
/api/pos/pairing-code |
JWT |
Web |
生成 6 位配对码(10 分钟有效) |
GET |
/api/pos/devices |
JWT |
Web |
列出门店所有 POS 设备 |
DELETE |
/api/pos/devices/:deviceId |
JWT |
Web |
解绑设备 |
POST |
/api/pos/device/register |
配对码 |
POS |
设备注册(用配对码换 token) |
POST |
/api/pos/device/heartbeat |
Device Token |
POS |
心跳上报(更新 last_seen_at) |
配对码生成逻辑
// pos-device-service.js — generatePairingCode()
async generatePairingCode(tenantId, storeId) {
// 1. 生成安全的 6 位随机数
const code = crypto.randomInt(100000, 999999).toString();
// 2. 创建 placeholder 记录
await tenantDb.query(req, `
INSERT INTO pos_devices (
store_id, tenant_id, device_uid,
pairing_code, code_expires_at, status
) VALUES ($1, $2, $3, $4, NOW() + INTERVAL '10 minutes', 'pending')
`, [storeId, tenantId, `pending_${code}`, code]);
return { pairingCode: code, expiresIn: 600 };
}
设备注册逻辑(事务保护)
// pos-device-service.js — registerDevice()
async registerDevice({ pairingCode, deviceUid, deviceName, deviceInfo }) {
return await tenantDb.transaction(req, async (client) => {
// Step 1: 查找匹配的 pending 记录(未过期)
const { rows } = await client.query(`
SELECT * FROM pos_devices
WHERE pairing_code = $1
AND code_expires_at > NOW()
AND status = 'pending'
`, [pairingCode]);
if (rows.length === 0) throw new Error('Invalid or expired pairing code');
// Step 2: 检查设备是否已绑定(防重复绑定)
const existing = await client.query(`
SELECT id FROM pos_devices
WHERE device_uid = $1 AND status = 'active'
`, [deviceUid]);
if (existing.rows.length > 0) throw new Error('Device already paired');
// Step 3: 生成 device token 并更新占位记录
const deviceToken = crypto.randomUUID();
const updated = await client.query(`
UPDATE pos_devices SET
device_uid = $1,
device_name = $2,
device_token = $3,
device_info = $4,
pairing_code = NULL,
status = 'active',
paired_at = NOW()
WHERE id = $5
RETURNING *
`, [deviceUid, deviceName, deviceToken, deviceInfo, rows[0].id]);
return {
deviceToken,
deviceId: updated.rows[0].id,
storeId: updated.rows[0].store_id,
tenantId: updated.rows[0].tenant_id,
deviceName: updated.rows[0].device_name,
};
});
}
事务保护的必要性:Step 1~3 在同一个数据库事务中执行。如果两台设备同时使用同一个配对码(理论上不会,但要防御),事务会确保只有一台设备能成功注册——另一台会因为配对码已被清除(pairing_code = NULL)而失败。
7. pos_devices 表结构
| 字段 |
类型 |
说明 |
id |
SERIAL PRIMARY KEY |
自增主键 |
store_id |
INTEGER NOT NULL |
关联门店 |
tenant_id |
VARCHAR NOT NULL |
租户标识 |
device_uid |
VARCHAR UNIQUE |
硬件唯一 ID(pending 阶段为占位值) |
device_name |
VARCHAR |
设备名称(如 "CodePay P5 #1") |
device_token |
UUID UNIQUE |
设备认证 token |
device_info |
JSONB |
设备元信息(OS 版本、SDK 版本等) |
pairing_code |
VARCHAR(6) |
一次性配对码(配对后清除) |
code_expires_at |
TIMESTAMP |
配对码过期时间 |
status |
VARCHAR |
pending / active / inactive |
paired_at |
TIMESTAMP |
配对成功时间 |
last_seen_at |
TIMESTAMP |
最后心跳时间 |
created_at |
TIMESTAMP |
记录创建时间 |
8. 配对后生命周期
flowchart TD
A["配对成功"] --> B["POS App 进入 Payment Idle"]
B --> C["加入 WebSocket Room: pos:store:{storeId}"]
B --> D["定期发送心跳 POST /device/heartbeat"]
C --> E["接收 pos:payment_request 事件"]
E --> F["显示订单详情"]
F --> G["客户确认 + 选小费"]
G --> H["Intent 唤起 CodePay 支付"]
H --> I["上报结果 POST /payment/pos-confirm"]
I --> J["后端通知 Web 前端支付完成"]
D --> K{"心跳超时?"}
K -->|是| L["Web 端显示设备离线"]
K -->|否| M["Web 端显示设备在线"]
style A fill:#22c55e,color:#fff
style L fill:#ef4444,color:#fff
style M fill:#3b82f6,color:#fff
心跳机制
1
POS App 每
30 秒发送一次
POST /api/pos/device/heartbeat
pos_app/lib/core/websocket/websocket_client.dart
2
后端更新
last_seen_at = NOW(),返回服务器时间和设备状态
backend/services/pos-device-service.js → processHeartbeat()
3
Web 管理端根据
last_seen_at 判断设备在线状态(超过 60 秒无心跳视为离线)
frontend/web_app/src/components/Payment/PosDeviceManager.tsx
解绑(Unpair)
2
后端 DELETE /api/pos/devices/:deviceId 将设备状态更新为 inactive
3
POS App 下次心跳时收到 401,自动清除本地凭证,回到配对页面
9. 安全考量
| 威胁 |
防御措施 |
| 暴力破解配对码 |
6 位数字 + 10 分钟过期 + rate limiting(/device/register 限制 5 次/分钟) |
| 配对码重放 |
使用后立即清除(pairing_code = NULL),一次性使用 |
| Device Token 泄露 |
FlutterSecureStorage 加密存储;管理员可随时解绑使 token 失效 |
| 设备冒充 |
device_uid UNIQUE 约束,同一硬件 ID 不能绑定两次 |
| 跨租户访问 |
token 验证时同时检查 tenant_id,确保设备只能访问所属租户数据 |
10. 错误处理映射
POS App 将后端返回的技术性错误信息转换为用户友好的提示:
| 后端错误 |
POS App 显示 |
Invalid or expired pairing code |
"Code expired. Please get a new one." |
Device already paired |
"Device already linked to a store." |
| 网络超时 / 连接失败 |
"Cannot reach server. Check WiFi." |
| 其他错误 |
"Pairing failed. Try again." |
实现位置:
pos_app/lib/features/pairing/presentation/bloc/pairing_cubit.dart 中的 _humanReadableError() 方法
更新于 2026-02-16 · 基于 067-pos-payment-intent 功能分支