# 固定地点签到（Site Attendance）- 产品需求文档

> **module**: site-attendance
> **doc_type**: PRD
> **status**: Active
> **owner**: 待定
> **upstream_docs**: 无（新模块）
> **last_verified**: 2026-04-17

---

## 目标与问题

### 问题

公司需要在固定地点（如公司入口）实现日常考勤签到，要求：
- 一个永久不变的二维码，员工每天扫同一个码
- 支持签到和签退
- 记录签到位置，支持定位校验
- 默认登录，但提供免登录兜底
- 场景在海外，需考虑国际化和 GPS 定位问题

### 目标

提供轻量、可靠的固定地点签到能力，复用平台已有的登录、用户体系、权限和 i18n。

---

## 功能边界

### In Scope

- 签到点（Checkpoint）管理：CRUD、地址、地图选点、QR 码生成、定位配置
- 员工签到/签退：已登录直接操作，未登录走兜底流程
- 免登录兜底：搜索系统用户 → 选择 → 操作
- 定位校验：三级策略配置（SKIP / ALLOW_WITH_FLAG / STRICT_BLOCK）
- 签到/签退严格交替流程（签到 → 签退 → 签到 → 签退）
- 当日签到状态展示（签到页 + 管理端）
- 管理端：签到点配置、签到记录查询（日期/用户/类型筛选）、分页、CSV 导出
- 签到页：当前时间显示、地点信息、中英文切换
- 签到事件即审计记录（每条事件记录完整上下文）
- **共享签到（v1.5）**：同办公地多公司共用一个分诊二维码，扫码后选公司再进入各自签到页
- **QR 轮换（v1.5）**：每个签到点独立配置永久 / 定时轮换（默认 1 小时），防截图流传
- **签到页准入模式（v1.5）**：PUBLIC（任何人可访问）/ SIGNED（必须带 QR token 或分诊 ticket）

### Out of Scope

- 考勤报表与统计分析（v2）
- 请假/补签流程（v2）
- 生物识别验证（v2）
- 离线签到（已知限制）
- 通知与告警规则（v2）
- 共享设备 Kiosk 模式（暂不考虑）
- 多签到点同时使用（v1 仅单签到点场景）
- 接口限流（v1 不加，用前端防抖 + 后端 30 秒去重）

---

## 术语表

| 术语 | 定义 |
|------|------|
| 签到点 (Checkpoint) | 固定物理位置，绑定一个二维码，员工在此签到/签退。包含名称、地址、经纬度、时区、定位策略等配置 |
| 签到事件 (Attendance Event) | 一次签到或签退的记录，包含时间、位置、精度、验证方式等（主事实表，不可修改） |
| 当日汇总 (Daily Summary) | 每用户每签到点每天的衍生汇总记录，由事件写入时同步 upsert 维护 |
| 定位校验 (Geo Validation) | 校验用户签到时的 GPS 位置是否在签到点半径内 |
| 共享签到 (Shared Checkin) | 多家公司在同一办公地共用一个二维码，扫码后选公司再进入各自签到页 |
| 分诊页 (Dispatch Page) | 共享签到的中间页，承载"选公司"+"记住选择"交互 |
| QR Token | 签到点或分诊页二维码 URL 中携带的时限签名串（HMAC-SHA256） |
| Ticket | 分诊页签发的一次性跳转令牌，携带目标公司/checkpoint 信息 |
| 合作伙伴 (Partner) | 某签到点启用共享签到后，可跳转的其他公司签到点记录 |
| 访问模式 (AccessMode) | PUBLIC / SIGNED；SIGNED 要求签到页必须通过 token 或 ticket 访问 |
| 桶 (Bucket) | QR Token 轮换周期的时间片编号，`bucket = floor(now / rotationSeconds)` |
| 公司标识 (companyId) | 共享签到分诊用的公司英文短标识（如 `ff` / `aixc`）；**只用于分诊展示与 ticket 路由，不改变租户/组织模型** |

---

## 版本口径

| 版本 | 范围 |
|------|------|
| v1.0 | 签到点 CRUD + 地图选点、QR 码、签到/签退交替流程、免登录兜底、定位校验、管理端记录查询/筛选/导出、签到页 i18n |
| v1.5 | 共享签到分诊入口 + QR 轮换（per-checkpoint）+ 签到页 SIGNED 准入模式 + 合作伙伴配置 + 动态大屏页 + dispatchOrigin 白名单 |
| v2.0 | 报表分析、请假/补签、通知告警、Kiosk 模式、多签到点协同 |

---

## 功能清单

| 功能 | 优先级 | 说明 |
|------|--------|------|
| 签到点管理 | P0 | 创建/编辑/删除签到点，配置名称、地址、位置（地图选点）、定位规则 |
| QR 码生成 | P0 | 为签到点生成二维码（永久 / 轮换可配） |
| 已登录签到/签退 | P0 | 登录用户扫码后直接签到或签退，严格交替 |
| 免登录签到/签退 | P0 | 未登录用户搜索选择系统用户后操作 |
| 定位校验 | P0 | GPS 定位校验，三级策略可配 |
| 当日状态展示 | P0 | 签到页显示当天签到/签退记录、当前时间、地点信息 |
| 管理端记录查询 | P0 | 按日期/用户/事件类型筛选，分页浏览 |
| CSV 导出 | P0 | 管理端按日期导出签到记录 |
| 签到页 i18n | P0 | 支持中英文无刷新切换 |
| **共享签到开关（per-checkpoint）** | P0 (v1.5) | 每个签到点独立开关是否作为分诊入口 |
| **合作伙伴配置** | P0 (v1.5) | 列出其他公司签到点 URL + label，支持跨域跳转 |
| **域名白名单校验** | P0 (v1.5) | 伙伴 targetUrl 的 hostname 必须在后端 env 白名单内，否则拒绝保存 |
| **分诊页** | P0 (v1.5) | 扫码后选公司，localStorage 记住选择（按 checkpoint code 分区），切换公司入口 |
| **Ticket 签发与校验** | P0 (v1.5) | 一次性、时效 5min、nonce 去重、targetCode 匹配、dispatchOrigin 白名单 |
| **QR 轮换（per-checkpoint）** | P0 (v1.5) | 永久 / N 秒轮换 + 宽限期（默认 120s） |
| **签到页准入模式** | P0 (v1.5) | PUBLIC（存量默认）/ SIGNED（新建默认） |
| **动态大屏页** | P0 (v1.5) | `/display/:code` 全屏展示当前 QR，整点对齐刷新 |

---

## 角色与权限矩阵

| 角色 | 签到/签退 | 查看自己记录 | 管理签到点 | 管理共享签到配置 | 查看所有签到记录 |
|------|-----------|-------------|-----------|----------------|----------------|
| 普通员工 | ✅ | ✅ | ❌ | ❌ | ❌ |
| 管理员 (Administrator) | ✅ | ✅ | ✅ | ✅ | ✅ |

权限码：
- `site-attendance:checkin` — 签到/签退操作
- `site-attendance:checkpoint:manage` — 签到点 CRUD（含 accessMode / qrRotationSeconds / sharedCheckin fields）
- `site-attendance:sharedCheckin:manage` — 合作伙伴（Partner）CRUD
- `site-attendance:records:read` — 查看所有签到记录

---

## 核心业务规则

### 签到/签退规则

1. 签到和签退是独立事件，每次操作生成一条事件记录
2. **严格交替流程**：签到 → 签退 → 签到 → 签退。签到后只能签退，签退后只能签到；每天第一次操作必须是签到
3. 同一用户同一天可以多次签到/签退循环（支持午休外出等场景）
4. 防重复提交：同一用户 + 签到点，30 秒内拒绝相同类型的重复事件（v1 不加接口限流，仅用此机制防双击）
5. "今天"的边界由签到点的时区决定，不是 UTC 也不是用户设备时区
6. 签到时必须校验用户 `status === ACTIVE`，已停用/离职用户不可签到
7. 每次签到/签退事件写入时，同步维护 DailySummary 汇总表（upsert）
8. v1 仅支持单签到点场景

### 免登录规则

1. 免登录入口不在首屏显示，仅在用户点击签到/签退后检测到未登录时弹出
2. 弹出二级选择："登录后继续" / "免登录继续"
3. 免登录流程：输入关键词（≥3 字符）→ 模糊搜索系统用户（姓名/邮箱/工号）→ 选择用户 → 显示当天状态 → 执行签到/签退
4. 搜索 API 返回字段：显示名、邮箱（不脱敏，内部系统）、部门
5. 签到点可配置是否允许免登录（`allowUnauthenticatedCheckin`，默认关闭）
6. 免登录签到事件标记 `authMethod: 'UNAUTHENTICATED'`，与登录签到区分

### 定位校验规则

1. 签到点通过 `geoPolicy` 配置定位策略（三级）：
   - `STRICT_BLOCK` — 定位失败时阻止签到
   - `ALLOW_WITH_FLAG` — 定位失败时允许签到但标记异常
   - `SKIP` — 完全不校验定位（默认）
2. 可配置参数（`geoPolicy` 非 SKIP 时生效）：校验半径（默认 200 米，最小 100 米）、可接受精度阈值（默认 100 米）
3. 定位校验结果枚举：`VALID` | `OUT_OF_RANGE` | `LOW_ACCURACY` | `PERMISSION_DENIED` | `UNAVAILABLE` | `TIMEOUT` | `SKIPPED`
4. 当 GPS 精度 > 签到半径时，结果为 `LOW_ACCURACY` 而非 `OUT_OF_RANGE`
5. 页面加载时即请求定位权限（预热 GPS），不等到点击签到时才请求
6. 前端必须通过 HTTPS 提供（Geolocation API 要求）；本地开发可通过 Chrome DevTools Sensors 模拟定位

### 签退缺席处理

1. v1 不实现自动关闭/补签退
2. 管理端可通过筛选查看未签退记录（签到后无签退事件的用户）

### 共享签到规则（v1.5）

1. 共享签到是 per-checkpoint 开关（`sharedCheckinEnabled`），每个签到点独立决定是否成为"分诊入口"
2. 签到点启用共享签到后，**其二维码指向** `/siteattendance/shared/:code`；分诊页展示本签到点 + 所有合作伙伴
3. 合作伙伴记录（`SharedCheckinPartner`）归属于具体 checkpoint，支持多个
4. 合作伙伴可指向：
   - 跨域 URL（外部 workspace 的签到页完整 URL，如 `https://aixc.../siteattendance/c/AIXC-HQ`）
   - 同 workspace 其他 checkpoint（罕见场景，v1.5 复用 targetUrl 字段填本地 URL）
5. **跨域跳转强制白名单校验**：
   - 目标 URL 的 hostname 必须在后端 env `SHARED_CHECKIN_ALLOWED_HOSTS`（逗号分隔）内
   - 管理员在后台配置 Partner 时，保存前服务端必须校验 hostname；不在白名单的 URL 直接拒绝（返回错误码 `PARTNER_TARGETURL_HOST_NOT_ALLOWED`）
   - 白名单管理：UAT 两侧互加、PROD 两侧互加
6. 分诊页签发 **Ticket** 给签到页，ticket 载荷含：
   - `companyId` / `targetCode`（本地跳） / `targetUrl`（跨域跳）
   - `dispatchCheckpointId`（发起分诊的 checkpoint）
   - `dispatchOrigin`（原始分诊页 URL，用于"切换公司"回跳）
   - `ts`（签发时间戳）
   - `nonce`（16 字节随机，一次性标识）
7. Ticket 校验规则（全部必须通过）：
   - HMAC 签名正确
   - `ts` 在 TTL 内（`TICKET_TTL_SECONDS = 300`，代码常量）
   - `nonce` 未被使用过（写入 `SharedCheckinTicketUsage` 一次性去重）
   - 本地跳时 `targetCode` 必须匹配当前访问的 checkpoint code
   - `dispatchOrigin` 的 hostname 必须在白名单（防开放重定向）
8. **切换公司交互**：
   - 签到页右上角"切换公司"按钮携带 `dispatchOrigin` 跳回原分诊页 `+ ?switch=1`
   - 分诊页收到 `switch=1` → 清除 localStorage → 重新拉 QR token → 展示选择列表
   - 跨 workspace 场景下，localStorage 按 dispatch checkpoint 所在域名独立维护，回原域名清除
9. localStorage key：`sharedCheckin.lastChoice.{dispatchCheckpointCode}`（按 dispatch checkpoint 分区，同域名多个分诊入口互不干扰）

### QR 轮换与访问模式规则（v1.5）

1. 每个 checkpoint 独立配置 `accessMode`：
   - `PUBLIC` — 签到页任何人可访问（兼容现状）；**存量 checkpoint 默认此模式，不破坏已贴二维码**
   - `SIGNED` — 签到页必须带 `?t=` 或 `?ticket=`；**新建 checkpoint 默认此模式**
2. SIGNED 模式下 `qrRotationSeconds` 决定轮换周期：
   - `null` → 永久 token（只验签，不验时效；本质等同加签的静态 URL，防伪造不防截图）
   - 正整数（如 3600）→ 每 N 秒轮换
3. QR token 载荷签名：
   - 独立签到：`HMAC(secret, "checkpoint:" + code + ":" + bucket)` 或 `"checkpoint:" + code + ":permanent"`
   - 共享分诊：`HMAC(secret, "shared:" + code + ":" + bucket)`
   - `bucket = floor(now / qrRotationSeconds)`
4. 校验时允许 `bucket == currentBucket` 或 `bucket == currentBucket - 1` 且处于 `qrGraceSeconds`（默认 120 秒）宽限期内
5. 大屏页 JS 根据后端返回的 `expiresAt` 对齐整点刷新，+1 秒保险
6. 签到页准入分支：
   - 带 `?ticket=xxx` → 调用 ticket 校验
   - 带 `?t=xxx` → 调用 QR token 校验
   - 都没带 → 看 `accessMode`：PUBLIC 放行；SIGNED 拒绝，显示"请通过前台二维码签到"

---

## 签到点配置项

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| name | String | ✅ | — | 签到点名称 |
| description | String | ❌ | — | 签到点描述 |
| address | String | ❌ | — | 签到点地址（显示给员工和管理员） |
| timezone | String | ✅ | — | IANA 时区，决定"今天"边界 |
| latitude / longitude | Float | ✅ | — | 经纬度，支持地图选点和"获取当前位置" |
| geoPolicy | Enum | ✅ | SKIP | 三级定位策略 |
| geoRadius | Int | ✅ | 200 | 校验半径（米），最小 100 |
| geoAccuracyThreshold | Int | ✅ | 100 | 可接受 GPS 精度阈值（米） |
| allowUnauthenticatedCheckin | Boolean | ✅ | false | 是否允许免登录签到 |
| isActive | Boolean | ✅ | true | 是否启用（编辑页可停用） |
| accessMode | Enum | ✅ | PUBLIC（存量）/ SIGNED（新建） | 签到页准入模式（v1.5 新增） |
| qrRotationSeconds | Int? | ❌ | null（永久） | `null` = 不轮换；正整数 = 轮换周期秒（v1.5 新增） |
| qrGraceSeconds | Int | ✅ | 120 | 轮换宽限期秒（v1.5 新增） |
| sharedCheckinEnabled | Boolean | ✅ | false | 是否作为共享分诊入口（v1.5 新增） |
| sharedCompanyId | String? | 条件 | — | 启用共享时必填，英文短标识（如 "ff" / "aixc"）（v1.5 新增） |
| sharedCompanyLabel | String? | 条件 | — | 启用共享时必填，显示名（如 "Faraday Future"）（v1.5 新增） |

### 合作伙伴配置项（SharedCheckinPartner，v1.5 新增）

每个启用共享签到的 checkpoint 可挂 N 个合作伙伴。

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| companyId | String | ✅ | 公司英文短标识，URL 安全（如 `aixc`） |
| companyLabel | String | ✅ | 公司显示名（如 `AIxCrypto`） |
| displayLabel | String? | ❌ | 签到点级显示名（如 `AIxCrypto - 上海总部`） |
| targetUrl | String | ✅ | 目标签到页完整 URL；**保存时强制校验 hostname 在白名单内** |
| isActive | Boolean | ✅ | 是否启用（默认 true） |
| sortOrder | Int | ✅ | 展示顺序（默认 0） |

---

## 边界规则

### Always（始终遵守）

- 签到事件必须记录完整上下文：时间、位置、精度、设备、验证方式
- 签到事件记录即审计记录，不可修改
- 签到点时区决定"今天"的边界
- 签到/签退必须严格交替
- SIGNED 模式下签到页必须带合法 token 或 ticket，否则拒绝
- Ticket 使用前必须验 nonce 未使用过、ts 在 TTL 内、targetCode 匹配（本地跳时）、dispatchOrigin 在白名单
- Partner targetUrl 保存前必须通过 hostname 白名单校验

### Ask（需确认才能变更）

- 签到点经纬度和半径的修改（影响所有后续定位校验）
- 删除签到点（是否需要保留历史签到数据）
- 切换 accessMode（PUBLIC ↔ SIGNED）会影响已贴二维码的有效性
- 修改 `sharedCompanyId` / Partner 的 `targetUrl`（影响分诊路由）
- 调大/调小 `qrRotationSeconds`（影响大屏刷新节奏与旧 token 失效时机）

### Never（禁止）

- 禁止修改已生成的签到事件记录
- 禁止在免登录场景下跳过用户搜索选择流程
- 禁止向非 ACTIVE 状态的用户记录签到
- 禁止连续两次相同类型操作（签到→签到 或 签退→签退）
- **禁止把 `SHARED_CHECKIN_SECRET` 写进 DB、UI 或 Git**
- **禁止保存 hostname 不在白名单的 Partner targetUrl**
- **禁止跳过 ticket nonce 去重**（防重放）
- **禁止在 SIGNED 模式下放行无 token/ticket 的请求**

---

## 风险与假设

### 假设

- 签到场景在海外，员工使用个人手机扫码
- 公司网络环境支持 HTTPS
- GPS 在室外精度可达 10-30 米，室内可能退化到 50 米以上
- 共享签到下：两侧 workspace 共享同一个 `SHARED_CHECKIN_SECRET`，UAT 值与 PROD 值分别同步
- 运维在部署 env 注入 secret；secret 由 `openssl rand -hex 32` 生成（32 字节 hex）
- 两侧 hostname 白名单互加（UAT 两侧互加，PROD 两侧互加）

### 风险

| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| GPS 室内精度差 | 签到频繁失败 | 默认半径 200 米 + ALLOW_WITH_FLAG 策略 |
| 免登录被滥用 | 代签到 | 定位校验 + 审计日志 + 管理员可关闭免登录 |
| 无网络 | 无法签到 | v1 已知限制，提示用户检查网络 |
| HTTP 下定位受限 | 精度极低或被浏览器阻止 | 生产环境必须 HTTPS；开发环境用 Chrome DevTools Sensors 模拟 |
| **QR 截图流传** | 旧截图被长期用于代签到 | HMAC 时间桶 + 默认 1 小时轮换 + 宽限期 120s；截图最多 1h2min 内有效 |
| **Secret 泄露** | token/ticket 可被伪造 | 仅放环境变量；UAT/PROD 隔离；泄露后生成新 secret 并同步两侧 |
| **Ticket 重放** | 一 ticket 被多次使用 | nonce 写入 `SharedCheckinTicketUsage` 一次性去重 |
| **开放重定向（切换公司）** | dispatchOrigin 被恶意篡改指向外部站 | 强制 hostname 白名单校验 `dispatchOrigin` |
| **跨域 localStorage 隔离** | 用户在外部 workspace 签到页无法读本地记忆 | 切换公司按钮带 `dispatchOrigin` 回跳原分诊页，在该域名清除 localStorage |
| **Partner URL 被配错** | 员工被路由到错误的签到页 | Partner 保存前 hostname 白名单校验；targetUrl 变更在审计日志记录 updatedBy |

---

## 关键依赖

| 依赖 | 模块/服务 | 说明 |
|------|----------|------|
| 用户体系 | platform_iam | 用户搜索、状态校验、登录认证 |
| JWT 认证 | organization/auth | 登录态和 @Public() 装饰器 |
| 权限系统 | organization/permissions | 签到点管理权限 |
| i18n | frontend/locales | 多语言支持 |
| Leaflet 地图 | npm leaflet | 管理端地图选点 |
| HMAC / crypto | Node.js 内置 `crypto` | QR token / ticket 的签名与校验（SHA-256） |
| qrcode 渲染 | npm `qrcode` | 前端大屏 / 详情页 QR 展示 |

---

## 环境变量（v1.5 新增）

| 变量 | 必需性 | 说明 |
|------|--------|------|
| `SHARED_CHECKIN_SECRET` | 启用 SIGNED 模式或共享签到时必需 | 32 字节 hex；**生成方式**：`openssl rand -hex 32`；**部署约定**：UAT 两侧 workspace 同值、PROD 两侧同值、UAT ≠ PROD；**存储**：仅运维在环境变量中注入，绝不进 DB / UI / Git |
| `SHARED_CHECKIN_ALLOWED_HOSTS` | 配置跨域 Partner 时必需 | 逗号分隔的 hostname 白名单（如 `ff-staging.example.com,aixc-staging.example.com`）；用于校验 Partner `targetUrl` 和 ticket `dispatchOrigin` 的 hostname |

**运维部署动作**：
1. 生成 secret：`openssl rand -hex 32`（UAT 和 PROD 分别生成，不可复用）
2. 注入到两侧 workspace 的 `.env`（或 CI/CD 密钥管理系统）
3. 填好白名单后重启服务生效
