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)

1
管理员在 Web 端点击"解绑设备"
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 功能分支