# 固定地点签到（Site Attendance）- 数据模型文档

> **module**: site-attendance
> **doc_type**: DataModel
> **status**: Draft
> **owner**: 待定
> **upstream_docs**: `01-prd.md`
> **last_verified**: 2026-04-17

---

## Schema 摘要

| 字段 | 内容 |
|------|------|
| Schema 名称 | `platform_site_attendance` |
| 业务域 | 签到点管理、签到/签退事件、当日汇总、共享签到分诊（v1.5） |
| 核心实体 | Checkpoint, AttendanceEvent, DailySummary, SharedCheckinPartner（v1.5）, SharedCheckinTicketUsage（v1.5） |

---

## 实体定义

### 1. Checkpoint（签到点）

管理员配置的固定签到地点，绑定一个永久二维码。

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| id | String (cuid) | ✅ | cuid() | 主键 |
| code | String | ✅ | cuid() | 二维码标识码，URL 中使用，不可变 |
| name | String | ✅ | — | 签到点名称（如"公司正门"） |
| description | String? | ❌ | — | 签到点描述 |
| address | String? | ❌ | — | 签到点地址（由地图搜索自动填充，展示给员工和管理员） |
| timezone | String | ✅ | — | IANA 时区（如 `America/Los_Angeles`），决定"今天"的边界 |
| latitude | Float | ✅ | — | 纬度 |
| longitude | Float | ✅ | — | 经度 |
| geoPolicy | Enum | ✅ | SKIP | 定位校验策略（见枚举 GeoPolicy） |
| geoRadius | Int | ✅ | 200 | 校验半径（米），最小 100 |
| geoAccuracyThreshold | Int | ✅ | 100 | 可接受的定位精度阈值（米） |
| allowUnauthenticatedCheckin | Boolean | ✅ | false | 是否允许免登录签到 |
| isActive | Boolean | ✅ | true | 是否启用 |
| accessMode | Enum (SiteCheckpointAccessMode) | ✅ | `PUBLIC`（存量迁移）/ `SIGNED`（新建） | 签到页准入模式（v1.5 新增） |
| qrRotationSeconds | Int? | ❌ | null | null = 永久 token；正整数 = 轮换周期秒（v1.5 新增） |
| qrGraceSeconds | Int | ✅ | 120 | 轮换宽限期秒（v1.5 新增） |
| sharedCheckinEnabled | Boolean | ✅ | false | 是否作为共享分诊入口（v1.5 新增） |
| sharedCompanyId | String? | 条件 | — | 启用共享时必填，URL-safe 短标识（如 "ff" / "aixc"）（v1.5 新增） |
| sharedCompanyLabel | String? | 条件 | — | 启用共享时必填，展示名（如 "Faraday Future"）（v1.5 新增） |
| createdBy | UUID | ✅ | — | 创建者 ID（→ platform_iam.users.id） |
| createdAt | DateTime | ✅ | now() | 创建时间 |
| updatedAt | DateTime | ✅ | @updatedAt | 更新时间 |

**约束**：
- `code` UNIQUE
- `geoRadius` 最小值 100（应用层校验）
- `qrRotationSeconds` 必须为 null 或 ≥ 60（应用层校验，防止高频轮换压垮服务）
- `qrGraceSeconds` 必须 ≥ 0 且 ≤ `qrRotationSeconds`（应用层校验）
- `sharedCheckinEnabled=true` 时 `sharedCompanyId` 和 `sharedCompanyLabel` 必须非空（应用层校验）
- `sharedCompanyId` 必须是 URL-safe 字符（`[a-z0-9_-]+`，应用层校验）

**索引**：
- `code` (unique)
- `isActive`
- `(sharedCheckinEnabled, isActive)` — 分诊选项查询

---

### 2. AttendanceEvent（签到事件）

主事实表。每次签到或签退操作生成一条事件记录，不可修改。

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| id | String (cuid) | ✅ | cuid() | 主键 |
| checkpointId | String | ✅ | — | 签到点 ID（→ Checkpoint.id） |
| userId | UUID | ✅ | — | 用户 ID（→ platform_iam.users.id） |
| eventType | Enum | ✅ | — | CHECK_IN / CHECK_OUT |
| timestamp | DateTime (@db.Timestamptz) | ✅ | now() | 事件发生时间（UTC） |
| localDate | String | ✅ | — | 签到点时区下的日期（YYYY-MM-DD），用于按日查询 |
| authMethod | Enum | ✅ | — | AUTHENTICATED / UNAUTHENTICATED |
| latitude | Float? | ❌ | — | 用户签到时的纬度 |
| longitude | Float? | ❌ | — | 用户签到时的经度 |
| accuracy | Float? | ❌ | — | GPS 精度（米） |
| geoStatus | Enum | ✅ | — | 定位校验结果（见枚举 GeoStatus） |
| distanceToCheckpoint | Float? | ❌ | — | 距签到点距离（米） |
| deviceId | String? | ❌ | — | 设备标识 |
| userAgent | String? | ❌ | — | 浏览器 UA |
| ipAddress | String? | ❌ | — | 客户端 IP |
| createdAt | DateTime | ✅ | now() | 记录创建时间 |

**约束**：
- 无唯一约束（同一用户同一天可多次签到/签退）
- 防重复提交在应用层实现（30 秒内同类型事件拒绝）

**索引**：
- `(checkpointId, userId, localDate)` — 按签到点+用户+日期查询
- `(checkpointId, localDate)` — 管理端按日查询
- `(userId, localDate)` — 用户查看自己某天的记录
- `timestamp` — 按时间排序

---

### 3. DailySummary（当日汇总）

衍生汇总表。由服务层在每次签到/签退事件写入时同步维护，不由定时任务生成。
首页展示"今天状态"和管理端当日概览直接读此表，避免每次聚合事件流。

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| id | String (cuid) | ✅ | cuid() | 主键 |
| checkpointId | String | ✅ | — | 签到点 ID（→ Checkpoint.id） |
| userId | UUID | ✅ | — | 用户 ID（→ platform_iam.users.id） |
| localDate | String | ✅ | — | 签到点时区下的日期（YYYY-MM-DD） |
| firstCheckInAt | DateTime? | ❌ | — | 首次签到时间（UTC） |
| lastCheckOutAt | DateTime? | ❌ | — | 末次签退时间（UTC） |
| checkInCount | Int | ✅ | 0 | 当天签到次数 |
| checkOutCount | Int | ✅ | 0 | 当天签退次数 |
| hasGeoAnomaly | Boolean | ✅ | false | 当天是否有定位异常事件 |
| lastEventId | String? | ❌ | — | 最近一条事件 ID（→ AttendanceEvent.id） |
| updatedAt | DateTime | ✅ | @updatedAt | 最近更新时间 |

**约束**：
- `(checkpointId, userId, localDate)` UNIQUE — 每个用户每天每个签到点一条汇总

**索引**：
- `(checkpointId, localDate)` — 管理端按日查询
- `(userId, localDate)` — 用户查看自己某天的状态

**维护规则**：
- 签到事件写入时，upsert DailySummary：
  - CHECK_IN: 更新 `firstCheckInAt`（仅首次）、`checkInCount++`
  - CHECK_OUT: 更新 `lastCheckOutAt`、`checkOutCount++`
  - 如果事件 `geoStatus` 非 VALID 且非 SKIPPED，设置 `hasGeoAnomaly = true`
  - 更新 `lastEventId`

**派生字段（不入库）**：
- `todayDurationSeconds`：今日累计在岗时长（秒）。**不存表**，由服务层在响应时基于当天 `AttendanceEvent` 时序流动态计算（按 timestamp 升序配对 CHECK_IN/CHECK_OUT 累加每段；若末段为 CHECK_IN 未配对，则按服务器当前时刻累计）。这样避免历史事件回填或修正时还要刷新汇总表。

---

## 工作日（workday）边界规则

`localDate` 字段语义不是「日历日」，而是「**工作日**」。工作日窗口为 `[当地 05:00, 次日 05:00)`：

- 当地 `00:00 – 04:59` 的事件归到 **前一天** 的 workday
- 解决晚班场景：保安 23:00 签到、次日 03:00 签退，两条事件 `localDate` 相同，可正常配对累计在岗时长
- 实现位置：`backend/src/modules/site-attendance/utils/geo.util.ts` 的 `getLocalDate()`，常量 `WORKDAY_START_HOUR = 5`

**已知限制**：
- 历史数据（本规则上线前）的 `localDate` 按午夜切分，跨此变更点的统计可能在凌晨段出现偏差。生产/UAT 数据迁移由部署时人工评估。
- 同一名员工夜里 04:30 签到、05:30 才签退的极端场景：两条事件分别落在前一天和后一天 workday，不会配对（视为业务上不应发生的情况）。

---

### 4. SharedCheckinPartner（共享签到合作伙伴，v1.5 新增）

每条 Partner 属于某个启用共享签到的 checkpoint，描述"扫本码后可跳转的其他公司签到点"。

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| id | String (cuid) | ✅ | cuid() | 主键 |
| checkpointId | String | ✅ | — | 归属的 checkpoint（→ Checkpoint.id, onDelete: Cascade） |
| companyId | String | ✅ | — | 公司英文短标识，URL-safe（如 `aixc`） |
| companyLabel | String | ✅ | — | 公司展示名（如 `AIxCrypto`） |
| displayLabel | String? | ❌ | — | 签到点级展示名（可选，如 `AIxCrypto - 上海总部`） |
| targetUrl | String | ✅ | — | 目标签到页完整 URL；**保存前强制校验 hostname 在 env 白名单内** |
| isActive | Boolean | ✅ | true | 是否启用 |
| sortOrder | Int | ✅ | 0 | 展示顺序（升序） |
| createdBy | UUID | ✅ | — | 创建者 ID（→ platform_iam.users.id） |
| updatedBy | UUID | ✅ | — | 最后修改者（每次 PATCH 同步刷新） |
| createdAt | DateTime | ✅ | now() | 创建时间 |
| updatedAt | DateTime | ✅ | @updatedAt | 更新时间 |

**约束**：
- `companyId` 必须匹配 `[a-z0-9_-]+`（应用层校验）
- `targetUrl` 必须是有效的 `http(s)://` URL（应用层校验）
- `targetUrl` 的 hostname 必须在 env `SHARED_CHECKIN_ALLOWED_HOSTS` 白名单内（应用层校验，保存前）
- 级联删除：checkpoint 删除时所有 partners 一并删除

**索引**：
- `(checkpointId, isActive, sortOrder)` — 分诊选项查询

---

### 5. SharedCheckinTicketUsage（Ticket 一次性使用记录，v1.5 新增）

防止 ticket 重放。每个 ticket 的 nonce 在被使用时写入本表；重复使用直接拒绝。

| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| nonce | String | ✅ | — | 主键，ticket 载荷中的 16 字节随机 hex（32 字符） |
| usedAt | DateTime | ✅ | now() | 首次使用时间 |
| expiresAt | DateTime | ✅ | — | 过期时间（= ticket.ts + TICKET_TTL_SECONDS） |

**约束**：
- `nonce` PRIMARY KEY

**索引**：
- `expiresAt` — 定期清理过期记录

**维护规则**：
- 每次 ticket 校验通过后立即写入 `nonce`（INSERT，冲突则拒绝，返回 `TICKET_ALREADY_USED`）
- 定时任务（每小时）清理 `expiresAt < now()` 的记录，防止表无限增长
- 定时清理可用 Prisma `deleteMany` + 简单 cron；v1.5 不引入 Temporal

---

## 枚举定义

### EventType

| 值 | 说明 |
|----|------|
| CHECK_IN | 签到 |
| CHECK_OUT | 签退 |

### AuthMethod

| 值 | 说明 |
|----|------|
| AUTHENTICATED | 已登录签到 |
| UNAUTHENTICATED | 免登录签到 |

### GeoPolicy

| 值 | 说明 |
|----|------|
| STRICT_BLOCK | 定位失败时阻止签到 |
| ALLOW_WITH_FLAG | 定位失败时允许签到但标记异常 |
| SKIP | 完全不校验定位 |

### GeoStatus

签到事件上记录的实际定位校验结果。

| 值 | 说明 |
|----|------|
| VALID | 定位校验通过 |
| OUT_OF_RANGE | 超出签到半径 |
| LOW_ACCURACY | GPS 精度不足（accuracy > radius） |
| PERMISSION_DENIED | 用户拒绝定位权限 |
| UNAVAILABLE | 设备不支持定位 |
| TIMEOUT | 定位超时 |
| SKIPPED | 签到点策略为 SKIP，未执行校验 |

### SiteCheckpointAccessMode（v1.5 新增）

签到页准入模式，决定是否强制 token/ticket 访问。

| 值 | 说明 |
|----|------|
| PUBLIC | 签到页任何人可访问；存量 checkpoint 默认此模式，兼容已贴静态二维码 |
| SIGNED | 签到页必须带 `?t=` 或 `?ticket=` 才能进入；新建 checkpoint 默认此模式 |

---

## 关系图

```
platform_iam.User (1) ──── (N) AttendanceEvent
platform_iam.User (1) ──── (N) DailySummary
Checkpoint (1) ──────────── (N) AttendanceEvent
Checkpoint (1) ──────────── (N) DailySummary
Checkpoint (1) ──────────── (N) SharedCheckinPartner        [v1.5]
AttendanceEvent (N) ─────── (1) DailySummary (通过 checkpointId+userId+localDate 关联)

SharedCheckinTicketUsage                                    [v1.5]
  └─ 独立表，不关联实体；以 nonce 为主键，仅用于 ticket 重放防护
```

---

## 跨 Schema 引用

| 本模块实体 | 引用字段 | 目标 Schema | 目标实体 | 说明 |
|-----------|---------|------------|---------|------|
| Checkpoint | createdBy | platform_iam | User.id | 创建者 |
| AttendanceEvent | userId | platform_iam | User.id | 签到用户 |
| DailySummary | userId | platform_iam | User.id | 汇总用户 |
| SharedCheckinPartner | createdBy | platform_iam | User.id | 创建者（v1.5） |
| SharedCheckinPartner | updatedBy | platform_iam | User.id | 最后修改者（v1.5，每次 PATCH 刷新） |

注：跨 schema 引用不建立外键约束（Prisma 多 schema 限制），在应用层保证引用完整性。

---

## 迁移策略（v1.5 增量）

### Schema 变更

1. `SiteCheckpoint` 增 6 字段：`accessMode` / `qrRotationSeconds` / `qrGraceSeconds` / `sharedCheckinEnabled` / `sharedCompanyId` / `sharedCompanyLabel`
2. 新增枚举：`SiteCheckpointAccessMode`
3. 新增表：`SharedCheckinPartner`、`SharedCheckinTicketUsage`

### 默认值策略（兼容存量）

- 存量 `SiteCheckpoint` 行：`accessMode = 'PUBLIC'`（保持现有 QR 可用）、`qrRotationSeconds = NULL`（不轮换）、`sharedCheckinEnabled = false`
- 新建 `SiteCheckpoint`：`accessMode = 'SIGNED'`（由 DTO 默认值决定，不进 DB default 以避免混淆）、`qrRotationSeconds = 3600`（1 小时轮换）

### 迁移文件约束

- **单个迁移文件**涵盖本次全部 schema 变更（遵循"每提交至多 1 个迁移文件"规则）
- 迁移必须包含数据回填：所有存量 checkpoint 行设置 `accessMode = 'PUBLIC'`
