# 固定地点签到（Site Attendance）- API 文档

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

---

## 统一约定

- Base URL: `/api/v1/site-attendance`
- 认证: Bearer Token (JWT)，签到页面和免登录相关接口支持匿名访问
- 错误响应格式: `{ "statusCode": number, "message": string, "error": string }`
- 角色: Administrator（签到点管理）、普通用户（签到/签退）

## 通用状态码

| 状态码 | 说明 |
|-------|------|
| 200 | 成功 |
| 201 | 创建成功 |
| 204 | 成功无响应体（如 DELETE） |
| 400 | 参数错误/业务校验失败 |
| 401 | 未认证 / token 无效或过期（v1.5） |
| 403 | 无权限 / 禁用项 / 来源不允许（v1.5） |
| 404 | 资源不存在 / 未启用共享签到（v1.5） |
| 409 | Ticket 一次性冲突（v1.5） |
| 429 | 重复提交（30 秒内同类型事件） |
| 500 | 服务器错误 |

**错误响应体（v1.5）**：
```typescript
{
  statusCode: number;
  error: string;      // HTTP error name (e.g. "Unauthorized")
  message: string;    // 人类可读消息
  code?: string;      // 08-error-codes.md 中定义的具体 code（v1.5 新增）
}
```

---

## 接口清单

### 1) 签到点管理（5）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 001 | GET | /checkpoints | Administrator | 签到点列表 |
| 002 | POST | /checkpoints | Administrator | 创建签到点 |
| 003 | GET | /checkpoints/:id | Administrator | 签到点详情 |
| 004 | PATCH | /checkpoints/:id | Administrator | 更新签到点 |
| 005 | DELETE | /checkpoints/:id | Administrator | 删除签到点 |

### 2) 公开接口 — 签到页（3）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 006 | GET | /checkpoints/code/:code/public | 匿名 | 通过 code 获取签到点公开信息 |
| 007 | GET | /checkpoints/code/:code/user-search | 匿名 | 免登录用户搜索 |
| 008 | GET | /checkpoints/code/:code/today-events | 匿名/登录 | 获取指定用户当天签到事件 |

### 3) 签到/签退（2）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 009 | POST | /checkpoints/code/:code/checkin | 登录 | 已登录用户签到/签退 |
| 010 | POST | /checkpoints/code/:code/guest-checkin | 匿名 | 免登录签到/签退 |

### 4) 地理编码代理（2）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 013 | GET | /checkpoints/code/geocode/search | 匿名 | 地点搜索（后端代理 Nominatim，支持 lang 参数） |
| 014 | GET | /checkpoints/code/geocode/reverse | 匿名 | 反向地理编码（后端代理 Nominatim，带缓存+429重试） |

### 5) 管理端查询（3）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 011 | GET | /checkpoints/:id/events | Administrator | 签到事件列表（日期/用户/类型筛选，含反向编码地址） |
| 012 | GET | /checkpoints/:id/today-summary | Administrator | 签到点今日签到概览 |
| 015 | GET | /checkpoints/:id/events/export | Administrator | CSV 导出签到事件（含反向编码地址） |

### 6) QR Token 与分诊（v1.5 新增，5）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 016 | GET | /checkpoints/code/:code/qr-token | 匿名 | 大屏拉取当前 QR token（独立 / 共享 by checkpoint 配置） |
| 017 | GET | /checkpoints/code/:code/dispatch-options | 匿名（验 QR token） | 分诊页加载时拉选项列表 |
| 018 | POST | /shared-checkin/dispatch | 匿名（验 QR token） | 用户选公司后签发 ticket |
| 019 | POST | /shared-checkin/validate-ticket | 匿名（内部调用） | 签到页校验 ticket |
| 020 | POST | /internal/shared-checkin/cleanup-ticket-usage | 内部/cron | 清理过期 ticket nonce |

### 7) 合作伙伴管理（v1.5 新增，4）

| 序号 | 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|------|
| 021 | GET | /checkpoints/:id/partners | Administrator | 列出签到点的合作伙伴 |
| 022 | POST | /checkpoints/:id/partners | Administrator | 添加合作伙伴（含 hostname 白名单校验） |
| 023 | PATCH | /partners/:partnerId | Administrator | 更新合作伙伴 |
| 024 | DELETE | /partners/:partnerId | Administrator | 删除合作伙伴 |

---

## 接口详情

### [006] GET /checkpoints/code/:code/public

签到页首屏加载，获取签到点公开信息。

**参数**：
- `code` (path): 签到点 code

**响应 200**：
```typescript
{
  id: string;
  name: string;
  description?: string;
  address?: string;
  timezone: string;
  geoPolicy: 'STRICT_BLOCK' | 'ALLOW_WITH_FLAG' | 'SKIP';
  geoRadius?: number;          // 仅 geoPolicy !== 'SKIP' 时返回
  allowUnauthenticatedCheckin: boolean;
  accessMode: 'PUBLIC' | 'SIGNED';                 // v1.5 新增
  sharedCheckinEnabled: boolean;                    // v1.5 新增
  sharedCompanyId?: string;                         // v1.5 新增，sharedCheckinEnabled=true 时返回
  sharedCompanyLabel?: string;                      // v1.5 新增，sharedCheckinEnabled=true 时返回
}
```

**响应 404**: 签到点不存在或已停用

---

### [007] GET /checkpoints/code/:code/user-search

免登录场景下搜索系统用户。

**参数**：
- `code` (path): 签到点 code
- `q` (query, required): 搜索关键词，最少 3 个字符

**前置条件**：签到点 `allowUnauthenticatedCheckin === true`

**响应 200**：
```typescript
{
  users: Array<{
    id: string;
    displayName: string;
    email: string;
    department?: string;      // 所在部门名称
  }>;
}
```

**响应 400**: 搜索关键词少于 3 个字符
**响应 403**: 该签到点不允许免登录

---

### [008] GET /checkpoints/code/:code/today-events

获取指定用户在该签到点的当天签到事件。

**参数**：
- `code` (path): 签到点 code
- `userId` (query, required): 用户 ID

**响应 200**：
```typescript
{
  events: Array<{
    id: string;
    eventType: 'CHECK_IN' | 'CHECK_OUT';
    timestamp: string;          // ISO 8601 UTC
    geoStatus: GeoStatus;
    authMethod: 'AUTHENTICATED' | 'UNAUTHENTICATED';
  }>;
  summary: {
    firstCheckIn?: string;      // 首次签到时间
    lastCheckOut?: string;      // 末次签退时间
    totalEvents: number;
    todayDurationSeconds: number; // 今日累计在岗时长（秒）；按时序配对 CHECK_IN/CHECK_OUT 累加，末段未签退则计至当前时刻
  };
}
```

---

### [009] POST /checkpoints/code/:code/checkin

已登录用户签到或签退。

**参数**：
- `code` (path): 签到点 code

**请求体**：
```typescript
{
  eventType: 'CHECK_IN' | 'CHECK_OUT';
  latitude?: number;
  longitude?: number;
  accuracy?: number;            // GPS 精度（米）
  geoStatus: GeoStatus;        // 前端定位结果
  deviceId?: string;
}
```

**响应 201**：
```typescript
{
  event: {
    id: string;
    eventType: 'CHECK_IN' | 'CHECK_OUT';
    timestamp: string;
    geoStatus: GeoStatus;
    distanceToCheckpoint?: number;
  };
  todaySummary: {
    firstCheckIn?: string;
    lastCheckOut?: string;
    totalEvents: number;
    todayDurationSeconds: number; // 今日累计在岗时长（秒）；末段未签退按服务器当前时刻累计
  };
}
```

**响应 400**: 参数错误
**响应 403**: 定位校验失败且策略为 STRICT_BLOCK
**响应 429**: 30 秒内重复提交

---

### [010] POST /checkpoints/code/:code/guest-checkin

免登录用户签到或签退。

**参数**：
- `code` (path): 签到点 code

**请求体**：
```typescript
{
  userId: string;               // 用户在搜索结果中选择的用户 ID
  eventType: 'CHECK_IN' | 'CHECK_OUT';
  latitude?: number;
  longitude?: number;
  accuracy?: number;
  geoStatus: GeoStatus;
  deviceId?: string;
}
```

**响应 201**: 同 [009]
**响应 403**: 签到点不允许免登录 / 定位校验失败且策略为 STRICT_BLOCK / 用户非 ACTIVE 状态
**响应 429**: 30 秒内重复提交

---

### [001] GET /checkpoints

管理员获取签到点列表。

**响应 200**：
```typescript
{
  checkpoints: Array<{
    id: string;
    code: string;
    name: string;
    timezone: string;
    geoPolicy: 'STRICT_BLOCK' | 'ALLOW_WITH_FLAG' | 'SKIP';
    allowUnauthenticatedCheckin: boolean;
    isActive: boolean;
    createdAt: string;
  }>;
}
```

---

### [002] POST /checkpoints

创建签到点。

**请求体**：
```typescript
{
  name: string;
  description?: string;
  timezone: string;             // IANA 时区
  latitude: number;
  longitude: number;
  geoPolicy?: 'STRICT_BLOCK' | 'ALLOW_WITH_FLAG' | 'SKIP'; // 默认 SKIP
  geoRadius?: number;           // 默认 200，最小 100
  geoAccuracyThreshold?: number; // 默认 100
  allowUnauthenticatedCheckin?: boolean; // 默认 false
  // v1.5 新增
  accessMode?: 'PUBLIC' | 'SIGNED';      // 默认 SIGNED（新建）
  qrRotationSeconds?: number | null;     // 默认 3600，最小 60；null 表示永久
  qrGraceSeconds?: number;                // 默认 120
  sharedCheckinEnabled?: boolean;         // 默认 false
  sharedCompanyId?: string;               // sharedCheckinEnabled=true 时必填
  sharedCompanyLabel?: string;            // sharedCheckinEnabled=true 时必填
}
```

**响应 201**：完整 Checkpoint 对象（含自动生成的 `code`，v1.5 字段全部返回）

**错误（v1.5 新增）**：
- 400 `CHECKPOINT_QR_ROTATION_INVALID`
- 400 `CHECKPOINT_QR_GRACE_INVALID`
- 400 `CHECKPOINT_SHARED_COMPANY_MISSING`
- 400 `CHECKPOINT_SHARED_COMPANYID_INVALID`
- 400 `CHECKPOINT_ACCESSMODE_INVALID`

---

### [004] PATCH /checkpoints/:id

更新签到点配置。

**请求体**：Partial<CreateCheckpointDto>（不含 code，code 不可变）

**响应 200**：更新后的完整 Checkpoint 对象（含 v1.5 字段）

**注意**：
- 切换 `accessMode` PUBLIC → SIGNED 后，原有静态 QR 立即失效，需换大屏展示
- 修改 `qrRotationSeconds` 会导致当前大屏 token 下次拉取时重算

---

### [011] GET /checkpoints/:id/events

管理端查看签到事件，支持筛选和分页。后端会对每条事件的经纬度做反向地理编码（Nominatim 代理，带缓存），返回 `resolvedAddress`。

**参数**：
- `id` (path): 签到点 ID
- `date` (query, optional): 日期筛选（YYYY-MM-DD，签到点时区），默认今天
- `page` (query, optional): 页码，默认 1
- `pageSize` (query, optional): 每页数量，默认 20
- `userName` (query, optional): 按用户名模糊筛选
- `eventType` (query, optional): 按事件类型筛选（CHECK_IN / CHECK_OUT）
- `lang` (query, optional): 地址语言偏好（`zh` → 中文，`en` → 英文），影响 resolvedAddress

**响应 200**：
```typescript
{
  events: Array<{
    id: string;
    userId: string;
    userName: string;
    userEmail: string;
    eventType: 'CHECK_IN' | 'CHECK_OUT';
    timestamp: string;
    authMethod: 'AUTHENTICATED' | 'UNAUTHENTICATED';
    geoStatus: GeoStatus;
    latitude?: number;
    longitude?: number;
    accuracy?: number;
    distanceToCheckpoint?: number;
    resolvedAddress?: string;    // 反向编码后的地址，无坐标时为 null
  }>;
  pagination: { total: number; page: number; pageSize: number; };
}
```

---

### [015] GET /checkpoints/:id/events/export

CSV 导出签到事件，含反向编码地址。

**参数**：
- `id` (path): 签到点 ID
- `date` (query, optional): 日期（YYYY-MM-DD），默认今天
- `lang` (query, optional): 地址语言偏好

**响应**：`text/csv` 文件下载，含 BOM（Excel 兼容）
**列**：User, Email, Type, Time, Auth, Geo Status, Distance(m), Accuracy(m), Location

---

### [013] GET /checkpoints/code/geocode/search

地点搜索代理（后端转发 Nominatim，避免国内直连问题）。

**参数**：
- `q` (query, required): 搜索关键词
- `lang` (query, optional): 语言偏好，默认 `zh-CN,zh,en`

**响应 200**：
```typescript
{
  places: Array<{
    latitude: number;
    longitude: number;
    displayName: string;
    title: string;
  }>;
}
```

---

### [014] GET /checkpoints/code/geocode/reverse

反向地理编码代理（后端转发 Nominatim，带内存缓存 + 429 重试）。

**参数**：
- `lat` (query, required): 纬度
- `lon` (query, required): 经度
- `lang` (query, optional): 语言偏好

**响应 200**：
```typescript
{
  displayName: string;    // 精简地址（道路+城市+州），无数据时返回坐标字符串
}
```

---

### [012] GET /checkpoints/:id/today-summary

签到点今日概览。

**响应 200**：
```typescript
{
  date: string;                 // YYYY-MM-DD（签到点时区，工作日维度，05:00 切换）
  totalCheckedIn: number;       // 今日签到人数（去重）
  totalCheckedOut: number;      // 今日签退人数（去重）
  notCheckedOut: number;        // 已签到未签退人数
  totalDurationSeconds: number; // 今日所有签到用户累计在岗总秒数（未签退算到 now）
  avgDurationSeconds: number;   // 人均在岗秒数（totalDuration / 签到人数；零人则为 0）
  recentEvents: Array<{         // 最近 10 条事件
    userId: string;
    userName: string;
    eventType: string;
    timestamp: string;
  }>;
}
```

---

## v1.5 新增端点详情

---

### [016] GET /checkpoints/code/:code/qr-token

大屏轮询拉取当前 QR token。匿名访问。

**参数**：
- `code` (path): 签到点 code

**响应 200**：
```typescript
{
  token: string;              // "<bucket>.<mac>" 或 "permanent.<mac>"
  expiresAt: number | null;   // 下一个 bucket 起始毫秒时间戳；永久 token 时为 null
  scope: "checkpoint" | "shared"; // 决定前端拼 QR URL 时走 /c/:code 还是 /shared/:code
  rotationSeconds: number | null; // null 表示永久
  graceSeconds: number;
}
```

**响应 404**: `CHECKPOINT_NOT_FOUND` / checkpoint 已停用

**备注**：
- `accessMode=PUBLIC` 且 `sharedCheckinEnabled=false` 的 checkpoint 仍会返回 200，但 `scope=checkpoint`、`token=''`；前端可据此降级为直接拼 `/c/:code` 静态 URL
- 大屏应按 `expiresAt` 对齐整点刷新（`expiresAt + 1000` ms 保险）

---

### [017] GET /checkpoints/code/:code/dispatch-options

分诊页加载时获取公司选项列表。

**参数**：
- `code` (path): 分诊入口 checkpoint code
- `t` (query, required): QR token

**前置条件**：
- 目标 checkpoint `sharedCheckinEnabled=true`
- QR token 有效

**响应 200**：
```typescript
{
  self: {
    companyId: string;
    companyLabel: string;
    checkpointCode: string;
  };
  partners: Array<{
    partnerId: string;
    companyId: string;
    companyLabel: string;
    displayLabel?: string;
    isExternal: boolean;     // true = 跨域跳转
  }>;
}
```

**响应 400**: `QR_TOKEN_INVALID` / `QR_TOKEN_EXPIRED` / `QR_TOKEN_MALFORMED`
**响应 404**: `DISPATCH_NOT_ENABLED` / `DISPATCH_CHECKPOINT_NOT_FOUND` / `DISPATCH_CHECKPOINT_INACTIVE`

---

### [018] POST /shared-checkin/dispatch

用户选公司后签发 ticket 并返回跳转 URL。

**请求体**：
```typescript
{
  checkpointCode: string;         // 分诊入口 checkpoint code
  qrToken: string;                 // QR token 再次校验
  choice: {
    companyId: string;             // self 或 partner 的 companyId
    partnerId?: string;            // 选中 partner 时必填（同 companyId 下可能多个 partner）
  };
}
```

**响应 201**：
```typescript
{
  redirectUrl: string;            // 完整跳转 URL，含 ticket + dispatchOrigin
  ticketExpiresAt: number;        // ticket 到期毫秒时间戳
  targetMode: "local" | "external";
}
```

**响应错误**：
- 400 `DISPATCH_CHOICE_INVALID` / `DISPATCH_PARTNER_INACTIVE`
- 401 `QR_TOKEN_INVALID` / `QR_TOKEN_EXPIRED`
- 404 `DISPATCH_NOT_ENABLED` / `DISPATCH_CHECKPOINT_NOT_FOUND`

---

### [019] POST /shared-checkin/validate-ticket

签到页入口校验 ticket（内部调用，前端用）。

**请求体**：
```typescript
{
  ticket: string;
  targetCheckpointCode: string;   // 当前签到页所属 checkpoint code（服务端校验匹配）
}
```

**响应 200**：
```typescript
{
  valid: true;
  payload: {
    companyId: string;
    companyLabel: string;
    dispatchOrigin: string;
    expiresAt: number;
  };
}
```

**响应错误**：
- 400 `TICKET_MALFORMED` / `TICKET_TARGET_MISMATCH`
- 401 `TICKET_INVALID` / `TICKET_EXPIRED`
- 403 `TICKET_ORIGIN_NOT_ALLOWED`
- 409 `TICKET_ALREADY_USED`

**副作用**：
- 校验通过后立即 INSERT `SharedCheckinTicketUsage` 记录 nonce（一次性去重）

---

### [020] POST /internal/shared-checkin/cleanup-ticket-usage

清理过期 ticket nonce。内部接口（由 cron 调用），需特殊 IAM 或 x-internal header。

**请求体**：无

**响应 200**：
```typescript
{
  deletedCount: number;
}
```

---

### [021] GET /checkpoints/:id/partners

列出指定 checkpoint 的合作伙伴。

**参数**：
- `id` (path): checkpoint ID

**响应 200**：
```typescript
{
  partners: Array<{
    id: string;
    companyId: string;
    companyLabel: string;
    displayLabel?: string;
    targetUrl: string;
    isActive: boolean;
    sortOrder: number;
    createdBy: string;
    updatedBy: string;
    updatedAt: string;
  }>;
}
```

---

### [022] POST /checkpoints/:id/partners

添加合作伙伴。**保存前强制校验 targetUrl hostname 在 env 白名单内**。

**请求体**：
```typescript
{
  companyId: string;       // [a-z0-9_-]+
  companyLabel: string;
  displayLabel?: string;
  targetUrl: string;       // http(s):// URL，hostname 必须在白名单内
  isActive?: boolean;      // 默认 true
  sortOrder?: number;      // 默认 0
}
```

**响应 201**：完整 Partner 对象

**响应错误**：
- 400 `PARTNER_TARGETURL_INVALID`
- 400 `PARTNER_TARGETURL_HOST_NOT_ALLOWED`
- 400 `PARTNER_COMPANYID_INVALID`
- 400 `PARTNER_CHECKPOINT_NOT_SHARED`（checkpoint 未开启共享）
- 404 `CHECKPOINT_NOT_FOUND`

---

### [023] PATCH /partners/:partnerId

更新合作伙伴。`updatedBy` 自动设为当前用户。

**请求体**：`Partial<CreatePartnerDto>`

**响应 200**：更新后的完整 Partner 对象

**响应错误**：同 [022] + 404 `PARTNER_NOT_FOUND`

---

### [024] DELETE /partners/:partnerId

删除合作伙伴。

**响应 204**: 成功无响应体

**响应 404**: `PARTNER_NOT_FOUND`

---

## 变更影响矩阵

| 端点 | v1.5 变更 |
|------|---------|
| [006] /public | 响应新增 `accessMode` / `sharedCheckinEnabled` / `sharedCompanyId` / `sharedCompanyLabel` |
| [002] POST /checkpoints | 请求体新增 6 字段；响应返回完整对象（含 v1.5 字段） |
| [004] PATCH /checkpoints/:id | 同 [002] |
| [009] POST /checkin | 入口处新增 ticket/t 校验分支；其他逻辑不变 |
| [010] POST /guest-checkin | 同 [009] |

其他端点（[001]/[003]/[005]/[007]/[008]/[011]/[012]/[013]/[014]/[015]）**无变更**。
