# 固定地点签到（Site Attendance）- 架构设计

> **module**: site-attendance
> **doc_type**: Architecture
> **status**: Draft
> **owner**: 待定
> **upstream_docs**: `01-prd.md`, `06-data-model.md`
> **last_verified**: 2026-04-17

---

## 架构总览

```
┌────────────────────────────────────────────────────────────┐
│  大屏页（手机 / iPad / 前台屏幕）                              │
│  /siteattendance/display/:code                             │
│  整点对齐刷新 QR                                              │
└──────────┬──────────────────────────────────┬───────────────┘
           │ sharedCheckinEnabled=false       │ sharedCheckinEnabled=true
           │ QR → /c/:code?t=…               │ QR → /shared/:code?t=…
           │                                 │
           │                       ┌─────────▼──────────┐
           │                       │  分诊页              │
           │                       │  选公司（本地 checkpoint │
           │                       │  + 合作伙伴列表合并）   │
           │                       │  localStorage 记忆    │
           │                       │  POST /dispatch 签 ticket │
           │                       └──┬──────────────┬────┘
           │                          │              │
           │                     本地跳转         跨域跳转
           │                          │              │
           ▼                          ▼              ▼
  /c/:code?t=…           /c/:code?ticket=…    https://{other}/c/:code?ticket=…
           │                          │              │
           └──────────────────────────┴──────────────┘
                                │
                                ▼
          ┌────────────────────────────────────────────┐
          │ 签到页 `/siteattendance/c/:code`             │
          │ 准入分支：ticket / t / PUBLIC                │
          │ 登录 + guest 签到流程复用现有 v1.0 能力        │
          └────────────────────────────────────────────┘
```

---

## 核心组件

### 1. QR Token（QR 码载荷签名）

**用途**：防止伪造 QR URL，限制 QR 有效期。

**格式**：`<bucket>.<mac>` 或 `permanent.<mac>`

**签名规则**：

| 场景 | 载荷 |
|------|------|
| 独立签到（SIGNED，轮换） | `HMAC-SHA256(secret, "checkpoint:" + code + ":" + bucket)` |
| 独立签到（SIGNED，永久） | `HMAC-SHA256(secret, "checkpoint:" + code + ":permanent")` |
| 共享分诊（轮换） | `HMAC-SHA256(secret, "shared:" + code + ":" + bucket)` |
| 共享分诊（永久） | `HMAC-SHA256(secret, "shared:" + code + ":permanent")` |

其中：
- `secret` = 环境变量 `SHARED_CHECKIN_SECRET`（32 字节 hex 解码后的二进制串）
- `bucket = floor(now_ms / (qrRotationSeconds * 1000))`
- `code` = checkpoint 的 `code` 字段
- `mac` = HMAC 结果的 hex 编码

**校验流程**：

```
1. 解析 token → (bucketOrPermanent, mac)
2. 读 checkpoint 找到配置（code, qrRotationSeconds, qrGraceSeconds）
3. 如果 token 是 "permanent.<mac>":
   - 重算 HMAC(secret, "<scope>:" + code + ":permanent")
   - timingSafeEqual 比对 → 通过即放行
4. 如果 token 是 "<bucket>.<mac>":
   - 重算 HMAC(secret, "<scope>:" + code + ":" + bucket)
   - timingSafeEqual 比对失败 → QR_TOKEN_INVALID
   - 比对通过后验时效：
     - currentBucket = floor(now_ms / (rotationSeconds * 1000))
     - bucket == currentBucket → 通过
     - bucket == currentBucket - 1 且 now_ms % (rotationSeconds * 1000) < graceSeconds * 1000 → 通过
     - 其他 → QR_TOKEN_EXPIRED
```

**生成流程（大屏轮询拉取）**：

```
GET /api/v1/site-attendance/checkpoints/code/:code/qr-token

响应 {
  token: string,        // "<bucket>.<mac>" 或 "permanent.<mac>"
  expiresAt: number,    // 下一个 bucket 起始的毫秒时间戳，大屏据此对齐刷新
  scope: "checkpoint" | "shared",   // 决定 QR URL 的路径前缀
}
```

永久 token 时 `expiresAt` 返回 `null`（大屏不再定时刷新）。

---

### 2. Ticket（分诊 → 签到页的一次性令牌）

**用途**：分诊页把"用户选了哪家公司"这个决策安全传递给下游签到页；同时防止未经分诊直接访问。

**格式**：`<base64(payload)>.<mac>`

**载荷结构**：

```typescript
{
  companyId: string;              // "ff" / "aixc"
  targetMode: "local" | "external";
  targetCode: string | null;      // local 时是本地 checkpoint code
  targetUrl: string | null;       // external 时是完整 URL
  dispatchCheckpointId: string;   // 发起分诊的 checkpoint id
  dispatchOrigin: string;         // 原始分诊页 URL（"切换公司"按钮用）
  ts: number;                     // 签发毫秒时间戳
  nonce: string;                  // 16 字节随机的 hex（32 字符）
}
```

**签名**：`HMAC-SHA256(secret, base64(payload))` → hex

**校验规则（必须全部通过）**：

| 检查项 | 失败错误码 |
|--------|-----------|
| 签名正确 | `TICKET_INVALID` |
| `now - ts < TICKET_TTL_SECONDS * 1000`（默认 300 秒） | `TICKET_EXPIRED` |
| `nonce` 未在 `SharedCheckinTicketUsage` 表里 | `TICKET_ALREADY_USED` |
| 本地跳（targetMode=local）时 `targetCode` 匹配当前访问的 checkpoint code | `TICKET_TARGET_MISMATCH` |
| `dispatchOrigin` 的 hostname 在白名单内 | `TICKET_ORIGIN_NOT_ALLOWED` |

**校验通过后动作**：
1. 向 `SharedCheckinTicketUsage` INSERT `{nonce, usedAt=now, expiresAt=ts+TTL*1000}`
2. INSERT 冲突（nonce 主键） → 说明并发重放，返回 `TICKET_ALREADY_USED`
3. 成功后放行进入签到流程

**常量**：
- `TICKET_TTL_SECONDS = 300`（代码常量，不进 DB / UI）
- `TICKET_NONCE_BYTES = 16`

---

### 3. 分诊服务（SharedCheckinService）

**职责**：
- 组合本地 Partner + Checkpoint 自身生成分诊选项列表
- 签发 ticket
- 校验 ticket

**核心方法**：

```typescript
class SharedCheckinService {
  // 分诊页加载时调用
  async getDispatchOptions(code: string, qrToken: string): Promise<{
    self: { companyId, companyLabel, checkpointCode },
    partners: Array<{ companyId, companyLabel, displayLabel, targetUrl }>
  }>
  
  // 用户选择公司时调用
  async dispatch(
    code: string,
    qrToken: string,
    choice: { companyId: string; partnerId?: string }
  ): Promise<{
    redirectUrl: string,        // 带 ticket 的完整跳转 URL
    ticketExpiresAt: number,
  }>
  
  // 签到页入口校验（内部）
  async validateTicket(
    ticket: string,
    expectedTargetCode: string | null
  ): Promise<TicketPayload>   // 失败抛具体错误
  
  // Partner CRUD（权限 site-attendance:sharedCheckin:manage）
  async createPartner(checkpointId, dto): Promise<Partner>
  async updatePartner(id, dto): Promise<Partner>
  async deletePartner(id): Promise<void>
  async listPartners(checkpointId): Promise<Partner[]>
}
```

---

### 4. Hostname 白名单校验

**适用场景**：
- Partner.targetUrl 保存前
- Ticket.dispatchOrigin 校验时

**实现**：

```typescript
function isAllowedHost(url: string): boolean {
  const allowed = (process.env.SHARED_CHECKIN_ALLOWED_HOSTS || '')
    .split(',').map(s => s.trim()).filter(Boolean);
  const parsed = new URL(url);
  return allowed.includes(parsed.hostname);
}
```

**部署约定**：
- UAT：两侧 workspace 的 hostname 互加
- PROD：两侧 workspace 的 hostname 互加
- 本地开发：`localhost:3000,127.0.0.1:3000` 可加入便于联调

**失败响应**：
- Partner 保存：`PARTNER_TARGETURL_HOST_NOT_ALLOWED`（400）
- Ticket dispatchOrigin：`TICKET_ORIGIN_NOT_ALLOWED`（403）

---

## 数据流

### 流 1：独立签到（SIGNED + 轮换）

```
[大屏]
  轮询 GET /checkpoints/code/FF-LOBBY/qr-token
  渲染 QR 指向 https://ff.../siteattendance/c/FF-LOBBY?t=<bucket>.<mac>
  根据 expiresAt 对齐整点刷新

[员工手机]
  扫码 → 签到页
  页面读 ?t= → 调服务端校验 → 通过
  进入现有签到流程（登录 / guest、GPS 校验、事件写入）
```

### 流 2：共享分诊（跨域跳转）

```
[大屏]
  轮询 GET /checkpoints/code/SHARED-LOBBY/qr-token
  渲染 QR 指向 https://ff.../siteattendance/shared/SHARED-LOBBY?t=<bucket>.<mac>

[员工手机]
  扫码 → FF 域名分诊页
  验 QR token
  读 localStorage["sharedCheckin.lastChoice.SHARED-LOBBY"]
    有记忆 + 未点"切换" → 自动调 POST /shared-checkin/dispatch
    无记忆 → 展示 [Faraday Future] [AIxCrypto] 选项，用户点击后调 dispatch
  
  POST /shared-checkin/dispatch { checkpointCode: "SHARED-LOBBY", qrToken, choice }
    → 后端校验 qrToken，读 Partners，签发 ticket
    → 返回 { redirectUrl: "https://aixc.../siteattendance/c/AIXC-HQ?ticket=...&dispatchOrigin=...", ticketExpiresAt }
  
  window.location.replace(redirectUrl)

[AIxC 域名签到页]
  读 ?ticket= → 调本域名 POST /shared-checkin/validate-ticket
    后端 (AIxC workspace) 用同一个 secret 验签
    nonce 写入本域名的 SharedCheckinTicketUsage
    dispatchOrigin hostname 校验白名单
  通过 → 进入 AIxC checkpoint 的签到流程
```

### 流 3：切换公司

```
[AIxC 签到页]
  用户点"切换公司"按钮
  读 query 里的 dispatchOrigin
  window.location.href = dispatchOrigin + "?switch=1"

[跳回 FF 分诊页]
  读 switch=1 → 清除 localStorage → 重新拉 QR token → 展示选择列表
```

---

## 四种部署形态

配置驱动，同一套代码支持：

| 形态 | `accessMode` | `sharedCheckinEnabled` | 使用场景 |
|------|-------------|------------------------|---------|
| A. 存量兼容 | PUBLIC | false | 老二维码贴墙继续用，无任何新能力 |
| B. 独立 + 轮换 | SIGNED | false | 每家公司独立 QR + 大屏展示，防截图流传 |
| C. 共享分诊 | SIGNED | true | 一个 QR 统一入口，扫码选公司 |
| D. 独立 + 共享并存 | SIGNED | true | 同一 checkpoint 既有独立大屏也能被分诊路由命中 |

形态切换只改配置，不改代码，不影响历史数据。

---

## 安全设计

| 威胁 | 对策 |
|------|------|
| QR 截图流传 | HMAC 时间桶 + 默认 1h 轮换 + 120s 宽限期；截图最多 1h2min 可用 |
| Token 伪造 | HMAC-SHA256，不持 secret 无法构造；timingSafeEqual 防时序攻击 |
| Ticket 重放 | `SharedCheckinTicketUsage` 以 nonce 为主键一次性去重 |
| Ticket 跨 checkpoint 冒用 | 载荷含 `targetCode`，签到页校验匹配当前 code |
| Ticket 过期 | `ts + TTL` 校验 |
| 开放重定向（切换公司） | dispatchOrigin 必须在 hostname 白名单 |
| Partner URL 恶意篡改 | 保存前强制 hostname 白名单校验 |
| Secret 泄露 | 仅 env；泄露后两侧同步换 secret 即可作废所有存量 token/ticket |

---

## 兼容性与回滚

### 不破坏现有功能
- 存量 `SiteCheckpoint` 默认 `accessMode=PUBLIC` + 不轮换 → 现有 QR 继续工作
- 现有 API（`/public`, `/checkin`, `/guest-checkin` 等）零修改
- `@Public()` 签到页路由保留，只在 `SIGNED` 模式下新增准入校验分支

### 彻底回滚路径
- 所有 checkpoint 切回 `PUBLIC`、`sharedCheckinEnabled=false` → 完全退回 v1.0 行为
- 数据表保留，随时可再启用
- 回滚无需数据迁移

---

## 与 meeting-attendance 的区别

| 维度 | site-attendance | meeting-attendance |
|------|----------------|--------------------|
| 码生命周期 | 每签到点一个码，永久/轮换可配 | 每次会议一个码，会议结束即失效 |
| 签到约束 | 打开/打烊时间无限制 | 仅会议窗口内有效 |
| 共享签到 | v1.5 支持（本文档主题） | 不适用（每会议独立） |
| 代码位置 | `backend/src/modules/site-attendance/` | `backend/src/modules/meeting-attendance/`（规划） |

---

## 未来扩展（非 v1.5 范围）

- QR 按需手动作废（加 `qrRevokedAt` 字段）
- Secret 零停机轮换（双 secret + grace period）
- 跨域 localStorage 共享方案（postMessage 代理）
- Partner 分组 / 层级展示（当合作伙伴 > 5 个时）
