Browse Source

V2.4.1版本

liuq 6 days ago
parent
commit
1cae795125

+ 100 - 1
frontend/package-lock.json

@@ -11,6 +11,7 @@
         "@element-plus/icons-vue": "^2.1.0",
         "axios": "^1.4.0",
         "element-plus": "^2.3.0",
+        "jszip": "^3.10.1",
         "pinia": "^2.1.0",
         "qrcode": "^1.5.4",
         "vue": "^3.3.0",
@@ -837,7 +838,6 @@
       "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "undici-types": "~7.18.0"
       }
@@ -1235,6 +1235,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/csstype": {
       "version": "3.2.3",
       "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -1632,6 +1638,12 @@
         "he": "bin/he"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.4",
       "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz",
@@ -1639,6 +1651,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -1684,6 +1702,33 @@
         "node": ">=0.12.0"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/locate-path": {
       "version": "5.0.0",
       "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
@@ -1872,6 +1917,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/path-browserify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1967,6 +2018,12 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -1990,6 +2047,21 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "4.1.2",
       "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
@@ -2036,6 +2108,12 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/sass": {
       "version": "1.97.2",
       "resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.2.tgz",
@@ -2077,6 +2155,12 @@
       "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
       "license": "ISC"
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2086,6 +2170,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "4.2.3",
       "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
@@ -2148,6 +2241,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/vite": {
       "version": "4.5.14",
       "resolved": "https://registry.npmmirror.com/vite/-/vite-4.5.14.tgz",

+ 1 - 0
frontend/package.json

@@ -13,6 +13,7 @@
     "@element-plus/icons-vue": "^2.1.0",
     "axios": "^1.4.0",
     "element-plus": "^2.3.0",
+    "jszip": "^3.10.1",
     "pinia": "^2.1.0",
     "qrcode": "^1.5.4",
     "vue": "^3.3.0",

+ 1009 - 0
frontend/public/skills/uap-api/LLM_UAP_INTEGRATION.md

@@ -0,0 +1,1009 @@
+# UAP 系统对接说明(面向自动化阅读)
+
+本文档**独立成篇**,描述如何在业务系统中对接 **Unified Authentication Platform(UAP,统一认证平台)**。阅读者应能仅凭本文完成:配置项填写、登录、SSO、**用户查询与映射同步**、**消息发送**(系统通知与广播)。
+
+> **鉴权模型范围**:本文只覆盖外部应用服务端对接所需的**三项凭据** —— `APP_ID`、`APP_SECRET`、`APP_ACCESS_TOKEN`。不涉及需要"终端用户 JWT"或"用户手机号+密码"的平台侧接口。
+
+### 示例约定
+
+下文示例中:
+
+- `https://api.hnyunzhu.com/api/v1` 表示 `UAP_API_BASE`(生产环境统一认证 API);
+- `app_demo_001`、`your_app_secret`、`eyJhbG...` 等为占位符,需替换为真实值;
+- `sign` 为按第 4 节算法计算得到的 **64 位小写十六进制**字符串,示例里用 `deadbeef...` 形式表示,**不能**直接复制使用;
+- 时间戳 `1710000000` 仅为示例,调用时需使用当前 Unix 秒。
+
+---
+
+## 1. 两类基础地址
+
+| 变量名 | 含义 | 示例 |
+|--------|------|------|
+| `UAP_API_BASE` | **HTTP(S) API 根路径**,必须包含版本前缀 `/api/v1` | `https://api.hnyunzhu.com/api/v1` |
+| `UAP_WEB_BASE` | **用户浏览器访问的前端站点根**(用于跳转登录页;本部署与 API 同主机) | `https://api.hnyunzhu.com` |
+
+下文所有接口路径均相对于 `UAP_API_BASE` 拼接,例如:`UAP_API_BASE` + `/simple/login` → `https://api.hnyunzhu.com/api/v1/simple/login`。
+
+跳转登录页使用 `UAP_WEB_BASE`:
+
+- PC:`{UAP_WEB_BASE}/login?app_id=<APP_ID>`
+- 移动端 H5:`{UAP_WEB_BASE}/mobile/login?app_id=<APP_ID>`
+
+---
+
+## 2. 对接前必填配置
+
+在 UAP 管理后台创建应用后,请收集并安全保存以下信息:
+
+| 配置项 | 说明 |
+|--------|------|
+| `APP_ID` | 应用对外标识(字符串,如 `app_xxxxxxxx`) |
+| `APP_SECRET` | 用于 **HMAC-SHA256 签名**,**仅允许在业务服务端使用**,禁止写入前端或客户端 |
+| `APP_ACCESS_TOKEN` | 应用访问令牌,用于 **M2M** 接口(HTTP Header:`X-App-Access-Token`) |
+| `REDIRECT_URIS` | 在应用中配置的回调地址列表;Redirect SSO、`exchange` 等流程依赖合法回调 |
+
+可选:若使用标准 OIDC 协议,还需 OIDC 客户端凭据(本文不展开 OIDC 全流程)。
+
+### 2.1 对接方填写(凭据留空)
+
+实施对接时,请将贵司从 UAP 管理后台获取的值填入下表(可打印或复制后替换正文中的占位符)。**App Secret 仅保存在服务端,勿提交到代码仓库或前端。** 下表中 **API / 前端根地址** 已按当前生产环境填写;若贵司使用独立域名部署,请改为实际地址。
+
+| 项目 | 变量名(本文档) | 填写 |
+|------|------------------|------|
+| API 根地址 | `UAP_API_BASE` | `https://api.hnyunzhu.com/api/v1` |
+| 前端站点根地址 | `UAP_WEB_BASE` | `https://api.hnyunzhu.com` |
+| App ID | `APP_ID` | (向 UAP 管理后台索取后填写) |
+| App Secret | `APP_SECRET` | (向 UAP 管理后台索取后填写) |
+| Access Token(应用访问令牌) | `APP_ACCESS_TOKEN` | (向 UAP 管理后台索取后填写) |
+
+**说明:**
+
+- **App ID**、**App Secret**:用于 Simple Auth 签名、消息接口 `X-App-Id` / `X-Sign` 等。
+- **Access Token**:用于 HTTP Header `X-App-Access-Token`,对应 M2M(用户搜索、映射同步等);**不是** App Secret。
+- **`UAP_API_BASE`** 必须包含版本前缀 **`/api/v1`**;**`UAP_WEB_BASE`** 为浏览器访问登录页所用站点根(本部署与 API 同主机时为 `https://api.hnyunzhu.com`)。
+
+---
+
+## 3. 认证方式总览
+
+对接时会遇到**三类**身份校验,请勿混用:
+
+| 方式 | 典型场景 | 请求形式 |
+|------|----------|----------|
+| **A. JSON Body 签名(Simple Auth)** | `/simple/login`、`/simple/validate`、`/simple/exchange` 等 | Body 内含 `timestamp`、`sign`,用 `APP_SECRET` 对**除 `sign` 外**的参数做 HMAC-SHA256(见第 4 节) |
+| **B. HTTP Header 签名(应用调消息接口)** | 服务端代应用调用 `POST /messages/` | Header:`X-App-Id`、`X-Timestamp`、`X-Sign`(签名字符串仅含 `app_id` 与 `timestamp`,见第 4.2 节) |
+| **C. M2M 访问令牌** | 用户搜索、映射同步、全量用户拉取 | `X-App-Access-Token: <APP_ACCESS_TOKEN>` |
+
+---
+
+## 4. 签名算法(HMAC-SHA256)
+
+平台校验逻辑与实现一致:**参数键名按 ASCII 升序排列**,拼接为 `key1=value1&key2=value2`(不含 `sign`),再对拼接串做 **HMAC-SHA256**,密钥为 UTF-8 编码的 `APP_SECRET`,结果为小写 **十六进制**字符串。
+
+**时间戳**:`timestamp` 为 Unix 秒级整数;服务端对时间偏差有约 **300 秒**容差。
+
+### 4.1 Simple Auth(JSON Body)
+
+参与签名的键为请求 JSON 中**除 `sign` 以外**的所有键(`None` 值一般应排除,与常见实现一致);**仅包含实际发送的字段**。各接口以下文「参与签名的字段」为准。
+
+**Python 示例:**
+
+```python
+import hmac
+import hashlib
+
+def sign_simple_auth(secret: str, params: dict) -> str:
+    data = {k: v for k, v in params.items() if k != "sign" and v is not None}
+    query_string = "&".join(f"{k}={data[k]}" for k in sorted(data.keys()))
+    return hmac.new(
+        secret.encode("utf-8"),
+        query_string.encode("utf-8"),
+        hashlib.sha256,
+    ).hexdigest()
+```
+
+### 4.2 消息接口(Header 签名)
+
+仅对两个键签名:`app_id`(即 `X-App-Id` 的值)、`timestamp`(即 `X-Timestamp` 的字符串值,与 Header 中一致)。拼接串为:
+
+`app_id=<APP_ID>&timestamp=<TIMESTAMP>`
+
+对该字符串做 HMAC-SHA256,结果放入 `X-Sign`。
+
+**消息 Header 签名示例(伪代码):**
+
+- 待签名字符串:`app_id=app_demo_001&timestamp=1710000000`
+- `X-Timestamp` 取值与字符串中的 `timestamp` **完全一致**(通常为数字的十进制字符串,如 `"1710000000"`)。
+
+```http
+X-App-Id: app_demo_001
+X-Timestamp: 1710000000
+X-Sign: 1a2b3c4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcd
+```
+
+---
+
+## 5. 登录相关接口
+
+### 5.1 `POST /simple/login`(密码登录,应用 SSO)
+
+- **Content-Type**:`application/json`
+- **说明**:服务端代应用调用,用 `APP_SECRET` 对 Body 做签名换取目标应用的 `ticket`。`app_id` / `sign` / `timestamp` **必须全部提供**。
+
+#### 请求体字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `app_id` | string | 是 | 当前应用 ID,决定票据归属与签名 Secret |
+| `identifier` | string | 是 | 用户标识:手机号、映射 `mapped_key` 或映射邮箱等 |
+| `password` | string | 是 | 登录密码 |
+| `timestamp` | int | 是 | Unix 秒,参与签名 |
+| `sign` | string | 是 | HMAC-SHA256 十六进制 |
+
+**参与签名的字段为**:`app_id`、`identifier`、`password`、`timestamp`(不含 `sign`)。
+
+#### 响应 `200`:`PasswordLoginResponse`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `ticket` | string | 临时票据,供 `POST /simple/validate` 使用 |
+
+#### 调用与返回示例
+
+```http
+POST https://api.hnyunzhu.com/api/v1/simple/login HTTP/1.1
+Content-Type: application/json
+
+{
+  "app_id": "app_demo_001",
+  "identifier": "13800138000",
+  "password": "UserPassword123",
+  "timestamp": 1710000000,
+  "sign": "1a2b3c4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcd"
+}
+```
+
+`sign` 由 `APP_SECRET` 对以下键签名(不含 `sign`):`app_id`、`identifier`、`password`、`timestamp`。
+
+**响应 `200`:**
+
+```json
+{
+  "ticket": "TICKET-7f8e9d0a-1234-5678-abcd-ef0123456789"
+}
+```
+
+**错误示例(密码错误,`401`):**
+
+```json
+{
+  "detail": "密码错误"
+}
+```
+
+#### 常见错误 HTTP 状态与 `detail`(节选)
+
+| 状态码 | 含义(示例) |
+|--------|----------------|
+| 400 | 用户已禁用、签名无效 |
+| 401 | 密码错误 |
+| 404 | 用户未找到、应用未找到 |
+
+---
+
+### 5.2 `POST /simple/sms-login`(短信验证码登录,应用 SSO)
+
+- **Content-Type**:`application/json`
+- **说明**:与 5.1 对称,用短信验证码换取目标应用的 `ticket`。`app_id` / `sign` / `timestamp` **必须全部提供**。短信功能需在平台开启(否则可能 `403`)。
+
+#### 请求体字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `app_id` | string | 是 | 当前应用 ID,决定票据归属与签名 Secret |
+| `mobile` | string | 是 | 手机号 |
+| `code` | string | 是 | 短信验证码 |
+| `timestamp` | int | 是 | Unix 秒,参与签名 |
+| `sign` | string | 是 | HMAC-SHA256 十六进制 |
+
+**参与签名的字段为**:`app_id`、`mobile`、`code`、`timestamp`(不含 `sign`)。
+
+#### 响应 `200`
+
+与 **5.1** 相同,使用 `PasswordLoginResponse`(仅返回 `ticket`)。
+
+#### 调用与返回示例
+
+```http
+POST https://api.hnyunzhu.com/api/v1/simple/sms-login HTTP/1.1
+Content-Type: application/json
+
+{
+  "app_id": "app_demo_001",
+  "mobile": "13800138000",
+  "code": "123456",
+  "timestamp": 1710000000,
+  "sign": "2b3c4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcde"
+}
+```
+
+参与签名的键:`app_id`、`mobile`、`code`、`timestamp`。
+
+**响应 `200`:**
+
+```json
+{
+  "ticket": "TICKET-8a9b0c1d-2345-6789-bcde-f01234567890"
+}
+```
+
+#### 常见错误(节选)
+
+| 状态码 | 含义(示例) |
+|--------|----------------|
+| 400 | 验证码错误或已过期、用户已禁用、签名无效 |
+| 403 | 短信登录功能未开启 |
+| 404 | 用户未找到、应用未找到 |
+
+---
+
+### 5.3 `POST /simple/validate`(验证票据)
+
+- **Content-Type**:`application/json`
+- **认证**:必须使用 **目标应用**的 `APP_SECRET` 对 Body 签名(`sign` / `timestamp` **必填**)。
+
+#### 请求体字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `ticket` | string | 是 | 来自登录跳转、`exchange` 等流程的票据 |
+| `app_id` | string | 是 | **消费票据的应用** ID(与为该应用签名的 Secret 一致) |
+| `timestamp` | int | 是 | Unix 秒 |
+| `sign` | string | 是 | 参与签名的字段为:`ticket`、`app_id`、`timestamp` |
+
+#### 响应 `200`:`TicketValidateResponse`
+
+**票据有效时:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `valid` | bool | `true` |
+| `user_id` | int | 平台用户主键 |
+| `mobile` | string | 用户手机号 |
+| `mapped_key` | string \| null | 该用户在**当前应用**下的映射账号(无映射时为 `null`) |
+| `mapped_email` | string \| null | 该用户在**当前应用**下的映射邮箱 |
+
+**票据无效或已消费:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `valid` | bool | `false` |
+| `user_id`、`mobile` 等 | — | 一般为 `null` 或省略(以实际 JSON 为准) |
+
+#### 调用与返回示例
+
+参与签名的键:`ticket`、`app_id`、`timestamp`(不含 `sign`)。
+
+```http
+POST https://api.hnyunzhu.com/api/v1/simple/validate HTTP/1.1
+Content-Type: application/json
+
+{
+  "app_id": "app_demo_001",
+  "ticket": "TICKET-7f8e9d0a-1234-5678-abcd-ef0123456789",
+  "timestamp": 1710000001,
+  "sign": "3c4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcdef"
+}
+```
+
+**响应 `200`(成功):**
+
+```json
+{
+  "valid": true,
+  "user_id": 1001,
+  "mobile": "13800138000",
+  "mapped_key": "ext_user_001",
+  "mapped_email": "zhangsan@example.com"
+}
+```
+
+**响应 `200`(票据无效或已使用):**
+
+```json
+{
+  "valid": false
+}
+```
+
+**错误示例(签名错误,`400`):**
+
+```json
+{
+  "detail": "签名无效"
+}
+```
+
+#### 常见错误
+
+| 状态码 | 含义(示例) |
+|--------|----------------|
+| 400 | 签名无效 |
+| 404 | 应用未找到 |
+
+---
+
+## 6. SSO 相关接口
+
+### 6.1 浏览器跳转登录(Redirect SSO)
+
+**非 JSON 接口**,步骤如下:
+
+1. 浏览器访问:`{UAP_WEB_BASE}/login?app_id=<APP_ID>` 或 `{UAP_WEB_BASE}/mobile/login?app_id=<APP_ID>`。
+2. 用户在 UAP 登录成功后,浏览器重定向到应用配置的**回调 URL**,查询参数携带 `ticket=<票据>`。
+3. 应用**服务端**调用 **`POST /simple/validate`**(见 5.3)换用户信息并建立会话。
+
+**前提**:回调地址必须在应用 `REDIRECT_URIS` 中配置。
+
+#### 调用与返回示例
+
+**浏览器打开登录页(仅说明 URL,无 JSON Body):**
+
+```
+https://api.hnyunzhu.com/login?app_id=app_demo_001
+```
+
+**登录成功后,浏览器跳转到应用回调(示例):**
+
+```
+https://biz.example.com/oauth/callback?ticket=TICKET-7f8e9d0a-1234-5678-abcd-ef0123456789
+```
+
+业务后端再使用 **5.3** 的 `POST /simple/validate` 换用户信息。
+
+---
+
+### 6.2 `POST /simple/exchange`(源应用用户免登进目标应用)
+
+- **Content-Type**:`application/json`
+- **签名**:使用**源应用** `APP_SECRET`;`sign` / `timestamp` **必填**。
+
+#### 请求体字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `app_id` | string | 是 | **源应用** ID |
+| `target_app_id` | string | 是 | **目标应用** ID |
+| `user_mobile` | string | 是 | 用户在 UAP 的手机号(须已存在) |
+| `timestamp` | int | 是 | Unix 秒 |
+| `sign` | string | 是 | 参与签名:`app_id`、`target_app_id`、`user_mobile`、`timestamp` |
+
+#### 响应 `200`:`TicketExchangeResponse`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `ticket` | string | 目标应用可用的票据 |
+| `redirect_url` | string | 目标应用首个 `redirect_uri` 拼接 `?ticket=<ticket>` 的完整 URL(若未配置合法 URI,实现中可能回退占位,生产环境应保证配置正确) |
+
+#### 常见错误
+
+| 状态码 | 含义(示例) |
+|--------|----------------|
+| 400 | 签名无效 |
+| 404 | 源应用 / 目标应用 / 用户未找到 |
+
+目标应用收到 `ticket` 后,用**目标应用**的 `app_id` + `APP_SECRET` 调用 **`POST /simple/validate`**。
+
+#### 调用与返回示例
+
+参与签名的键:`app_id`、`target_app_id`、`user_mobile`、`timestamp`(使用**源应用** Secret)。
+
+```http
+POST https://api.hnyunzhu.com/api/v1/simple/exchange HTTP/1.1
+Content-Type: application/json
+
+{
+  "app_id": "source_app_001",
+  "target_app_id": "target_app_002",
+  "user_mobile": "13800138000",
+  "timestamp": 1710000002,
+  "sign": "4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcdef01"
+}
+```
+
+**响应 `200`:**
+
+```json
+{
+  "ticket": "TICKET-bbbbcccc-dddd-eeee-ffff-000011112222",
+  "redirect_url": "https://target.example.com/callback?ticket=TICKET-bbbbcccc-dddd-eeee-ffff-000011112222"
+}
+```
+
+---
+
+### 6.3 `GET /simple/sso/jump`(通知内嵌 SSO 跳转)
+
+用于消息中配置的跳转:用户点击后,若已在 UAP 登录则带上 Ticket 重定向到应用回调。
+
+#### 查询参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `app_id` | string | 是 | 目标应用 ID |
+| `redirect_to` | string | 是 | 登录成功后最终要到达的业务页 URL(经 URL 编码传入) |
+
+#### 响应行为(非 JSON)
+
+| 场景 | HTTP | 说明 |
+|------|------|------|
+| 当前**未**登录 UAP | `307`/`302` 等 | `RedirectResponse` 到相对路径 `/login?redirect=<当前完整 jump URL>`(依赖网关/前端将用户导向登录页) |
+| 当前**已**登录 | `307`/`302` 等 | 重定向到应用 `redirect_uris` 中首个 URI,查询参数包含 `ticket`、`next`(`next` 为 `redirect_to`) |
+| 应用不存在 | 404 | `detail` 说明 |
+| 未配置回调 | 400 | `detail` 说明 |
+
+#### 调用与返回示例
+
+**请求 URL(`redirect_to` 需 URL 编码):**
+
+```
+GET https://api.hnyunzhu.com/api/v1/simple/sso/jump?app_id=app_demo_001&redirect_to=https%3A%2F%2Foa.example.com%2Fapprove%2F123
+```
+
+**已登录时:** HTTP 重定向(`302`/`307`),`Location` 类似:
+
+```
+https://biz.example.com/callback?ticket=TICKET-xxx&next=https%3A%2F%2Foa.example.com%2Fapprove%2F123
+```
+
+**未登录时:** 重定向到登录页,`Location` 可能类似:
+
+```
+/login?redirect=https%3A%2F%2Fapi.hnyunzhu.com%2Fapi%2Fv1%2Fsimple%2Fsso%2Fjump%3Fapp_id%3D...
+```
+
+---
+
+## 7. 用户接口(M2M)
+
+以下接口使用 **`X-App-Access-Token: <APP_ACCESS_TOKEN>`**(或实现所支持的应用 JWT),**不使用** Simple Auth 的 Body 签名。
+
+### 7.1 推荐业务流程(查询 → 建用户 / 推映射)
+
+对接方在「把业务账号与平台用户绑定」时,建议按下面顺序操作:
+
+1. **先查询用户**  
+   调用 **`GET /users/search`**(见 **7.2**),用手机号、姓名等关键词确认平台是否**已存在**该用户,并记录返回中的 **`id`(平台用户 ID)**、`mobile`、`name` 等。  
+   - **能查到**:说明平台已有账号,只需为本应用维护**映射**(`mapped_key`、`mapped_email` 等)。  
+   - **查不到**:需先在平台**创建用户**,再推映射。创建方式包括:  
+     - 在 **UAP 管理后台**人工新增;或  
+     - 若贵司具备超级管理员能力,调用管理端 **`POST /users/`** 创建(需管理员权限);或  
+     - 直接使用 **`POST /apps/mapping/sync`** 的 **`UPSERT`**:当手机号在平台不存在时,可在同一请求中携带 **`name`** 等,由平台**新建用户并建立映射**(见 **7.3**)。
+
+2. **填充本应用账号信息并同步**  
+   使用查询到的 **`mobile`**(及必要时 **`name`**)与业务侧 **`mapped_key` / `mapped_email`**,调用 **`POST /apps/mapping/sync`**:  
+   - **新增绑定**:`sync_action` 为 **`UPSERT`**(默认),平台会插入或更新**当前应用**下的映射;若本次同时新建了平台用户,响应中 **`new_user_created`** 为 `true`。  
+   - **仅改映射**:对已存在用户再次 **`UPSERT`**,可更新 `mapped_key`、`mapped_email`、`is_active` 等;**不可**通过该接口修改已存在用户的姓名、手机号等基础档案(与接口校验一致)。  
+   - **解除映射**:`sync_action` 为 **`DELETE`**(仅删映射,不删平台用户)。
+
+3. **全量对账(可选)**  
+   需要批量拉取平台用户基础信息时,使用 **`GET /apps/mapping/users`**(见 **7.4**)。
+
+---
+
+### 7.2 `GET /users/search`(用户查询)
+
+- **说明**:按关键词搜索**已激活、未删除**的平台用户,用于在推映射前确认人选。
+- **路径**:`GET /users/search`
+- **认证**:`X-App-Access-Token: <APP_ACCESS_TOKEN>`
+
+#### 查询参数
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| `q` | string | 否 | — | 关键词;对**手机号、姓名、英文名**模糊匹配(`ilike`)。不传时返回一批活跃用户(受 `limit` 限制) |
+| `limit` | int | 否 | 20 | 最大返回条数 |
+
+#### 响应 `200`
+
+返回 **JSON 数组**,元素为平台用户对象,主要字段如下:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | int | 平台用户 ID(可用于发消息时的 `receiver_id` 等) |
+| `mobile` | string | 手机号 |
+| `name` | string \| null | 姓名 |
+| `english_name` | string \| null | 英文名 |
+| `organization_id` | int \| null | 用户所属组织 ID;未归属任何组织时为 `null` |
+| `organization_name` | string \| null | 用户所属组织名称(由 UAP 从组织表 join 后扁平化返回,免二次请求);未归属时为 `null` |
+| `status` | string | 如 `ACTIVE` |
+| `role` | string \| null | 角色 |
+| `created_at` | string | 创建时间 ISO8601 |
+| `updated_at` | string | 更新时间 ISO8601 |
+| `is_deleted` | int | 是否删除标记 |
+
+#### 调用与返回示例
+
+```http
+GET https://api.hnyunzhu.com/api/v1/users/search?q=13800138000&limit=20 HTTP/1.1
+X-App-Access-Token: pA9s8d7f6g5h4j3k2l1m0n9o8p7q6r5s4t3u2v1w0
+```
+
+**响应 `200` 示例:**
+
+```json
+[
+  {
+    "id": 1001,
+    "mobile": "13800138000",
+    "name": "张三",
+    "english_name": "zhangsan",
+    "organization_id": 7,
+    "organization_name": "研发中心",
+    "status": "ACTIVE",
+    "role": "ORDINARY_USER",
+    "created_at": "2025-01-10T08:00:00",
+    "updated_at": "2025-06-01T12:00:00",
+    "is_deleted": 0
+  }
+]
+```
+
+---
+
+### 7.3 `POST /apps/mapping/sync`
+
+- **Content-Type**:`application/json`
+
+#### 请求体字段 `UserSyncRequest`
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `mobile` | string | 是 | 中国大陆手机号,正则 `^1[3-9]\d{9}$`(与平台校验一致) |
+| `name` | string \| null | 条件 | **UPSERT 时必填**(非空姓名);已存在用户时**不可**通过此接口改姓名等基础信息;**DELETE** 可不依赖姓名逻辑(以 `sync_action` 分支为准) |
+| `english_name` | string \| null | 否 | 已废弃于 M2M;英文名由平台按规则生成 |
+| `password` | string \| null | 否 | 按平台实现(若有) |
+| `status` | string \| null | 否 | 按平台实现 |
+| `mapped_key` | string | 是 | 外部系统用户 ID,长度 1~100 |
+| `mapped_email` | string \| null | 否 | 外部邮箱 |
+| `is_active` | boolean \| null | 否 | 映射是否启用;`null` 表示不修改 |
+| `sync_action` | string \| null | 否 | `UPSERT`(默认)或 `DELETE` |
+
+#### 响应 `200`:`MappingResponse`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | int | 映射记录 ID |
+| `app_id` | int | 应用**数据库主键**(整数) |
+| `user_id` | int | 平台用户 ID |
+| `mapped_key` | string \| null | 映射账号 |
+| `mapped_email` | string \| null | 映射邮箱 |
+| `user_mobile` | string | 用户手机号 |
+| `user_status` | string | 用户统一状态描述 |
+| `is_active` | bool | 映射是否有效;DELETE 后可能为 `false` |
+| `new_user_created` | bool | 是否本次新建了平台用户 |
+| `generated_password` | string \| null | 若平台为新用户生成了初始密码,可能返回 |
+
+#### 常见错误
+
+| 状态码 | 含义(节选) |
+|--------|----------------|
+| 400 | 参数非法、与业务规则冲突(如重复映射、禁止修改字段) |
+| 403 | Token 无效 |
+
+#### 调用与返回示例
+
+**UPSERT**
+
+```http
+POST https://api.hnyunzhu.com/api/v1/apps/mapping/sync HTTP/1.1
+Content-Type: application/json
+X-App-Access-Token: pA9s8d7f6g5h4j3k2l1m0n9o8p7q6r5s4t3u2v1w0
+
+{
+  "mobile": "13800138000",
+  "name": "张三",
+  "mapped_key": "ext_user_1001",
+  "mapped_email": "zhangsan@partner.com",
+  "is_active": true,
+  "sync_action": "UPSERT"
+}
+```
+
+**响应 `200`:**
+
+```json
+{
+  "id": 501,
+  "app_id": 12,
+  "user_id": 1001,
+  "mapped_key": "ext_user_1001",
+  "mapped_email": "zhangsan@partner.com",
+  "user_mobile": "13800138000",
+  "user_status": "ACTIVE",
+  "is_active": true,
+  "new_user_created": true,
+  "generated_password": null
+}
+```
+
+**DELETE(仅删除映射)**
+
+```http
+POST https://api.hnyunzhu.com/api/v1/apps/mapping/sync HTTP/1.1
+Content-Type: application/json
+X-App-Access-Token: pA9s8d7f6g5h4j3k2l1m0n9o8p7q6r5s4t3u2v1w0
+
+{
+  "mobile": "13800138000",
+  "mapped_key": "ext_user_1001",
+  "sync_action": "DELETE"
+}
+```
+
+**响应 `200`:** 结构同 `MappingResponse`,`is_active` 可能为 `false`(见字段表)。
+
+---
+
+### 7.4 `GET /apps/mapping/users`
+
+- **认证**:`X-App-Access-Token`
+
+#### 查询参数
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| `skip` | int | 否 | 0 | 跳过条数 |
+| `limit` | int | 否 | 100 | 每页条数 |
+
+#### 响应 `200`:`UserSyncList`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `total` | int | 符合条件的用户总数 |
+| `items` | array | 用户列表 |
+
+**`items[]` 元素 `UserSyncSimple`:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `mobile` | string | 手机号 |
+| `name` | string \| null | 姓名 |
+| `english_name` | string \| null | 英文名 |
+
+#### 调用与返回示例
+
+```http
+GET https://api.hnyunzhu.com/api/v1/apps/mapping/users?skip=0&limit=100 HTTP/1.1
+X-App-Access-Token: pA9s8d7f6g5h4j3k2l1m0n9o8p7q6r5s4t3u2v1w0
+```
+
+**响应 `200`:**
+
+```json
+{
+  "total": 1250,
+  "items": [
+    {
+      "mobile": "13800138000",
+      "name": "张三",
+      "english_name": "zhangsan"
+    },
+    {
+      "mobile": "13900139000",
+      "name": "李四",
+      "english_name": "lisi"
+    }
+  ]
+}
+```
+
+---
+
+## 8. 消息发送
+
+本节说明由**应用服务端**调用、使用 **Header 签名**(第 3 节方式 B、第 4.2 节)发送 **`NOTIFICATION`** 的两种形态:
+
+1. **单用户系统通知**(指定一名接收者)
+2. **广播**(向全体活跃用户各发一条)
+
+不涉及用户间私信(`type: MESSAGE`)的**发送**。
+
+### 发送接口
+
+- **路径**:`POST /messages/`
+- **Content-Type**:`application/json`
+
+> **⚠️ 注意:两种同名 `app_id` 不要混用**
+>
+> - 请求侧 `app_id` / `X-App-Id`:**字符串** App ID(如 `app_xxxxxxxx`),用于签名、鉴权、按应用解析接收人。下文 8.2 请求体中的 `app_id` 即此字段。
+> - 响应体 `MessageResponse.app_id`(以及第 7 节 `MappingResponse.app_id`):**整数**,为应用在 UAP 数据库中的主键,仅作为记录标识;**不能**用它回填请求。
+
+### 8.1 请求头(应用签名)
+
+| Header | 说明 |
+|--------|------|
+| `Content-Type` | `application/json` |
+| `X-App-Id` | 应用字符串 ID,与 `APP_ID` 一致 |
+| `X-Timestamp` | Unix 秒(与签名、Body 中逻辑一致) |
+| `X-Sign` | 对 `app_id=<X-App-Id>&timestamp=<X-Timestamp>` 的 HMAC-SHA256 十六进制 |
+
+#### `content_type` 取值说明
+
+与后端枚举一致,发送时传**字符串**(大写)。未传时默认 **`TEXT`**。
+
+| 取值 | 含义 | `content` 建议形态 |
+|------|------|-------------------|
+| `TEXT` | 普通文本通知 | 字符串 |
+| `IMAGE` | 图片 | 对象存储中的 **Object Key** 或经平台处理的资源标识;若误传完整 URL,服务端可能尝试抽取 Key |
+| `VIDEO` | 视频 | 同上 |
+| `FILE` | 文件 | 同上 |
+| `USER_NOTIFICATION` | **业务/申请类通知**(会话列表中突出标题、正文可结构化) | **字符串**或 **JSON 对象**;传对象时服务端会序列化为 JSON 字符串存储,客户端常对 `content` 做 `JSON.parse` 解析 |
+
+**与 `auto_sso` 的关系**:当 `auto_sso=true` 且已提供 `target_url`、应用 `app_id` 时,只要 **`type` 为 `NOTIFICATION`** 或 **`content_type` 为 `USER_NOTIFICATION`**,平台都会按同一规则生成 **SSO jump** 形式的 `action_url`(见下文示例)。
+
+**选用建议**:常规系统通知用 `TEXT`;需要「申请单样式」、富文本结构或前端自定义渲染时,可改用 `USER_NOTIFICATION` 并在 `content` 中传结构化数据。
+
+---
+
+### 8.2 单用户系统通知(`type: NOTIFICATION`,非广播)
+
+向**一名**接收者推送。接收人二选一指定:
+
+- **`receiver_id`**:平台用户 ID(整数);或
+- **`app_id` + `app_user_id`**:应用字符串 ID + 该用户在本应用映射中的外部账号。
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `type` | string | 是 | 固定 `NOTIFICATION` |
+| `title` | string | 是 | 标题,最大 255 字 |
+| `content` | string \| object | 是 | 正文;`content_type` 为 `USER_NOTIFICATION` 时可传对象,见 **8.1 `content_type` 取值说明** |
+| `content_type` | string | 否 | 默认 `TEXT`;可选:`TEXT`、`IMAGE`、`VIDEO`、`FILE`、`USER_NOTIFICATION`(见 **8.1**) |
+| `receiver_id` | int | 条件 | 与 `app_id`+`app_user_id` 二选一 |
+| `app_id` | string | 条件 | 与 `app_user_id` 同时出现时用于解析接收人 |
+| `app_user_id` | string | 条件 | 接收方在本应用映射中的 ID |
+| `action_url` | string \| null | 否 | 自定义跳转;若 `auto_sso` 为 true 可能被平台改写 |
+| `action_text` | string \| null | 否 | 按钮文案 |
+| `target_url` | string \| null | 否 | `auto_sso=true` 时作为 SSO 目标页,用于生成 jump 链接 |
+| `auto_sso` | boolean | 否 | 默认 `false`;为 true 时可生成 `/api/v1/simple/sso/jump?...` 形式的 `action_url` |
+| `sender_app_user_id` | string \| null | 否 | 可选,标识应用侧「发起人」映射,用于审计展示 |
+
+**约束**:`is_broadcast` 为 `false` 或不传;须提供 `receiver_id` **或** (`app_id` + `app_user_id`)。
+
+**请求 / 响应示例(按 `app_user_id` 指定接收人,含 SSO 跳转)**
+
+```http
+POST https://api.hnyunzhu.com/api/v1/messages/ HTTP/1.1
+Content-Type: application/json
+X-App-Id: app_demo_001
+X-Timestamp: 1710000000
+X-Sign: 1a2b3c4d5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcd
+
+{
+  "app_id": "app_demo_001",
+  "app_user_id": "ext_user_1001",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "审批提醒",
+  "content": "您有一条待办审批",
+  "auto_sso": true,
+  "target_url": "https://oa.example.com/approve/123",
+  "action_text": "去处理"
+}
+```
+
+```json
+{
+  "id": 90001,
+  "sender_id": null,
+  "receiver_id": 1001,
+  "app_id": 12,
+  "app_name": "演示应用",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "审批提醒",
+  "content": "您有一条待办审批",
+  "action_url": "/api/v1/simple/sso/jump?app_id=app_demo_001&redirect_to=https%3A%2F%2Foa.example.com%2Fapprove%2F123",
+  "action_text": "去处理",
+  "is_read": false,
+  "created_at": "2026-04-07T10:00:00",
+  "read_at": null
+}
+```
+
+**补充示例(用平台用户 ID 指定接收人:`receiver_id`)**
+
+```http
+POST https://api.hnyunzhu.com/api/v1/messages/ HTTP/1.1
+Content-Type: application/json
+X-App-Id: app_demo_001
+X-Timestamp: 1710000001
+X-Sign: <按 X-App-Id 与 X-Timestamp 计算的签名>
+
+{
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "到账提醒",
+  "content": "您的订单已支付成功",
+  "receiver_id": 1001
+}
+```
+
+响应体结构与上一示例相同,字段值随请求变化(如 `receiver_id` 为 `1001`)。
+
+**补充示例(`content_type: USER_NOTIFICATION`,结构化 `content` + SSO)**
+
+```http
+POST https://api.hnyunzhu.com/api/v1/messages/ HTTP/1.1
+Content-Type: application/json
+X-App-Id: app_demo_001
+X-Timestamp: 1710000002
+X-Sign: <按 X-App-Id 与 X-Timestamp 计算的签名>
+
+{
+  "app_id": "app_demo_001",
+  "app_user_id": "ext_user_1001",
+  "type": "NOTIFICATION",
+  "content_type": "USER_NOTIFICATION",
+  "title": "请假申请待审批",
+  "content": {
+    "applicant": "张三",
+    "days": 3,
+    "reason": "年假"
+  },
+  "auto_sso": true,
+  "target_url": "https://oa.example.com/hr/leave/999",
+  "action_text": "去审批"
+}
+```
+
+**响应 `200`(节选):** `content` 存为 JSON 字符串;`content_type` 为 `USER_NOTIFICATION`;`action_url` 在 `auto_sso` 为 true 时同样可生成 jump 链接。
+
+```json
+{
+  "type": "NOTIFICATION",
+  "content_type": "USER_NOTIFICATION",
+  "title": "请假申请待审批",
+  "content": "{\"applicant\":\"张三\",\"days\":3,\"reason\":\"年假\"}",
+  "action_url": "/api/v1/simple/sso/jump?app_id=app_demo_001&redirect_to=..."
+}
+```
+
+### 8.3 广播(全员系统通知)
+
+仅应用可调;向平台内**全部活跃用户**各投递一条通知。
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `is_broadcast` | boolean | 是 | 必须为 `true` |
+| `type` | string | 是 | 必须为 `NOTIFICATION` |
+| `title` | string | 是 | 标题 |
+| `content` | string \| object | 是 | 正文 |
+| `content_type` | string | 否 | 默认 `TEXT`;可选值见 **8.1**(`USER_NOTIFICATION` 等) |
+| `auto_sso` | boolean | 否 | 是否对跳转链接做 SSO 封装 |
+| `target_url` | string \| null | 否 | 与 `auto_sso` 配合使用 |
+| `action_url` | string \| null | 否 | 自定义跳转(不走 SSO 时) |
+| `action_text` | string \| null | 否 | 按钮文案 |
+
+**约束**:不要传 `receiver_id`、`app_user_id`;广播仅支持通知类型。
+
+**请求示例**
+
+```http
+POST https://api.hnyunzhu.com/api/v1/messages/ HTTP/1.1
+Content-Type: application/json
+X-App-Id: app_demo_001
+X-Timestamp: 1710000003
+X-Sign: 5e6f708192a3b4c5d6e7f8090a1b2c3d4e5f678901234567890abcdef0123
+
+{
+  "is_broadcast": true,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护",
+  "content": "今晚 22:00-24:00 维护"
+}
+```
+
+**响应 `200`:** 返回 **`MessageResponse`**,与「为全体用户各创建一条通知」中的**第一条**记录对应(便于对接方拿到一条结构化回包);其余用户的消息结构相同,仅 `id`、`receiver_id` 等不同。若平台无活跃用户则返回 `400`,`detail` 为「没有可发送的活跃用户」。
+
+```json
+{
+  "id": 90010,
+  "sender_id": null,
+  "receiver_id": 1001,
+  "app_id": 12,
+  "app_name": "演示应用",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护",
+  "content": "今晚 22:00-24:00 维护",
+  "action_url": null,
+  "action_text": null,
+  "is_read": false,
+  "created_at": "2026-04-07T10:00:00",
+  "read_at": null
+}
+```
+
+---
+
+### 8.4 成功响应 `MessageResponse`(发送接口共性)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | int | 消息 ID |
+| `sender_id` | int \| null | 发送方用户 ID;应用系统通知可能为 `null` |
+| `receiver_id` | int | 接收方用户 ID |
+| `app_id` | int \| null | 应用**数据库主键**(整数) |
+| `app_name` | string \| null | 应用名称 |
+| `type` | string | 本文档场景下为 `NOTIFICATION`(平台尚存在其他类型,见 OpenAPI) |
+| `content_type` | string | 内容类型:`TEXT`、`IMAGE`、`VIDEO`、`FILE`、`USER_NOTIFICATION` |
+| `title` | string | 标题 |
+| `content` | string | 正文 |
+| `action_url` | string \| null | 跳转链接(系统通知可能存在) |
+| `action_text` | string \| null | 按钮文案 |
+| `is_read` | bool | 是否已读 |
+| `created_at` | datetime | 创建时间 ISO8601 |
+| `read_at` | datetime \| null | 已读时间 |
+
+**返回示例(`200 OK`,单条系统通知,与上文发送示例对应):**
+
+```json
+{
+  "id": 90001,
+  "sender_id": null,
+  "receiver_id": 1001,
+  "app_id": 12,
+  "app_name": "演示应用",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "审批提醒",
+  "content": "您有一条待办审批",
+  "action_url": "/api/v1/simple/sso/jump?app_id=app_demo_001&redirect_to=https%3A%2F%2Foa.example.com%2Fapprove%2F123",
+  "action_text": "去处理",
+  "is_read": false,
+  "created_at": "2026-04-07T10:00:00",
+  "read_at": null
+}
+```
+
+(广播成功时结构相同,通常 `receiver_id` 为本次作为「代表返回」的那条消息对应的用户 ID;`action_url`、`app_id` 等以实际响应为准。)
+
+---
+
+## 9. 调试与规范来源
+
+- **交互式 API 文档**:`https://api.hnyunzhu.com/api/v1/docs`(Swagger)。
+- 若本文与线上部署不一致,以**实际 OpenAPI 与接口返回**为准。
+
+---
+
+## 10. 安全要点(摘要)
+
+1. `APP_SECRET` 仅留在服务端;任何含 Secret 的签名不得在浏览器完成。  
+2. 区分 `UAP_API_BASE` 与 `UAP_WEB_BASE`。  
+3. `APP_ACCESS_TOKEN` 与 JWT 同样属于敏感凭据。  
+4. SSO 与 Ticket 流程务必使用 HTTPS 与合法回调域。
+
+---
+
+## 11. 附录:应用侧账号与映射(与平台字段的关系)
+
+平台字段定义见上文 **`GET /users/search`**(含 `english_name`、`mapped_key` 等)与 **`POST /apps/mapping/sync`**(`mapped_key` 长度等约束)。
+
+业务后端在「本系统账号字段」与「发往 UAP 的 `mapped_key`」上的**常见策略**(可按需采纳,非强制):
+
+1. 优先使用 `english_name`(若存在)——人类可读、跨系统稳定;
+2. 其次使用行内已有 `mapped_key`——表示之前已建立过映射;
+3. 再回退为本库用户 id 的字符串形式,保证唯一性。
+
+注意点:
+
+- `mapped_key` 长度须满足本文档 §7.3 的约束(1~100),超过时按约定截断。
+- 若 `POST /simple/validate` 响应中含 `english_name`,可与上述优先级对齐后再写入本系统用户。
+- 合并本系统与 UAP 用户的「选人 / 导入」由应用自行设计:建议由服务端使用 M2M 令牌调用 UAP,避免将 `APP_ACCESS_TOKEN` 暴露给前端;导入与合并查询的权限可按业务需要分层(例如仅管理员可触发 UAP 搜索与导入)。
+
+---
+
+*文档版本:与当前仓库后端 Schema/路由一致时,API 前缀为 `/api/v1`。*

+ 374 - 0
frontend/public/skills/uap-api/SKILL.md

@@ -0,0 +1,374 @@
+---
+name: uap-api
+description: 在业务系统中对接统一认证平台(UAP / Unified Authentication Platform):含 UAP_API_BASE / UAP_WEB_BASE 配置、三类认证(Simple Auth Body 签名 / 消息 Header 签名 / M2M Access Token)、HMAC-SHA256 签名规则、登录、SSO、用户查询与映射同步、系统通知与广播。当需要实现 UAP 登录或 SSO、服务端调用 UAP 接口、用户映射同步、或给平台用户推送通知时使用。详细字段与示例以同目录 LLM_UAP_INTEGRATION.md 为准。
+---
+
+# UAP 对接
+
+## 何时使用
+
+业务系统需要对接 **UAP(统一认证平台)**:实现登录/SSO、服务端调用 M2M 接口、用户同步或消息通知。
+
+## 向用户询问(必须)
+
+同目录 `LLM_UAP_INTEGRATION.md` §2 中的 **App ID、App Secret、Access Token** 等均为占位,**不得**臆造或把文档示例当真实值。在编写或修改对接代码、配置 `.env` 前:
+
+1. **若仓库内尚无有效配置**(如无 `.env`、或缺少关键变量):在对话中**向用户询问**,请其从 **UAP 管理后台** 提供至少:
+   - `APP_ID`
+   - `APP_SECRET`(仅用于服务端;提醒用户勿贴到公开渠道)
+   - `APP_ACCESS_TOKEN`(M2M 用,与 App Secret 不同)
+2. **若使用独立域名或非文档默认环境**:询问或确认 `UAP_API_BASE`、`UAP_WEB_BASE`(须含 `/api/v1` 等约定见长文 §1)。
+3. **涉及登录 / SSO / 回调**:询问或确认已在 UAP 后台配置的 **Redirect URI** 是否与实际访问地址一致(本地开发常见为 `http://localhost:端口/callback` 等)。
+4. 用户已在本机填好 `.env` 且仅调试验证时,可读 `.env`(勿把内容写回仓库);**仍缺项时继续询问用户**,不要留空提交或填假值。
+
+## 权威文档(必须先读)
+
+**完整接口、字段、流程、HTTP 示例与错误处理以项目内下文为准,不要凭通用 OAuth 经验臆测:**
+
+| 路径 | 说明 |
+|------|------|
+| `./LLM_UAP_INTEGRATION.md`(与本 `SKILL.md` 同目录) | 独立成篇的对接说明,面向自动化阅读 |
+
+实现具体接口前,用 Read 工具打开该文件对应章节,按文档中的路径与「参与签名的字段」编写代码。若该文件不存在,请向用户询问获取方式(通常由 UAP 运维方提供下载链接)。
+
+## 两类基础地址
+
+| 变量 | 含义 |
+|------|------|
+| `UAP_API_BASE` | HTTP(S) **API 根路径**,必须包含 **`/api/v1`** |
+| `UAP_WEB_BASE` | 浏览器访问的**前端站点根**(跳转登录页等) |
+
+接口路径均相对 `UAP_API_BASE` 拼接。登录页跳转示例(详见长文):PC `{UAP_WEB_BASE}/login?app_id=...`,H5 `{UAP_WEB_BASE}/mobile/login?app_id=...`。
+
+## 对接前配置(来自 UAP 管理后台)
+
+| 变量 | 用途 |
+|------|------|
+| `APP_ID` | 应用标识 |
+| `APP_SECRET` | **仅服务端** HMAC 签名用,**禁止**写入前端或提交仓库 |
+| `APP_ACCESS_TOKEN` | M2M:`X-App-Access-Token`,**不是** App Secret |
+| `REDIRECT_URIS` | 后台配置的回调列表;SSO / exchange 等依赖合法回调 |
+
+环境变量放在 `.env`,示例在 `.env.example`;**绝不**把真实凭据写入 Skill、README 或提交的源码。
+
+## 三类认证(勿混用)
+
+| 方式 | 场景 | 要点 |
+|------|------|------|
+| **A. Simple Auth** | `/simple/login`、`/simple/validate`、`/simple/exchange` 等 | JSON Body 含 `timestamp`、`sign`;对**除 `sign` 外**参与字段按 ASCII 键名排序拼接后 HMAC |
+| **B. 消息 Header 签名** | `POST /messages/` 等 | `X-App-Id`、`X-Timestamp`、`X-Sign`;待签串仅为 `app_id=<APP_ID>&timestamp=<TIMESTAMP>` |
+| **C. M2M Access Token** | 用户搜索、映射同步、全量拉取等 | `X-App-Access-Token: <APP_ACCESS_TOKEN>` |
+
+## 签名算法要点(细节与每接口字段见长文第 4 节)
+
+- 算法:**HMAC-SHA256**,密钥为 UTF-8 的 `APP_SECRET`,输出 **小写十六进制**(64 位)。
+- **Simple Auth**:键名 **ASCII 升序**,`key1=value1&key2=value2`,**不含** `sign`;`None` 值通常不参与。
+- **消息接口**:只对 `app_id` 与 `timestamp` 拼接签名;`X-Timestamp` 与参与签名的字符串一致。
+- **时间戳**:Unix **秒**;服务端对时钟偏差约有 **300 秒**容差。
+
+## 两种同名 `app_id` 的区分(易错点)
+
+- 请求侧 `app_id` / `X-App-Id`:**字符串** App ID(如 `app_xxxxxxxx`),用于签名与鉴权。
+- 响应体里 `MessageResponse.app_id`、`MappingResponse.app_id`:**整数**(应用数据库主键)。
+- 两者**不可混用**。对接方通常只关心字符串 App ID;整数 `app_id` 仅作为消息/映射记录的内部标识读取。
+
+## 工作流程建议
+
+1. **识别意图**:用户说"对接一键登录 / SSO / 登录跳转"→ 走 [§A 一键登录](#a-一键登录--sso-redirect-sso);说"推送 / 站内通知 / 广播"→ 走 [§B 消息通知](#b-消息通知系统通知--广播);说"账号同步 / 用户拉取 / 建映射"→ 走 [§C 用户同步](#c-用户同步--映射绑定)。
+2. **凭据与地址**:按上文「向用户询问」收集或确认;缺什么问什么,不要臆造。
+3. **查对应章节**:按下文意图清单里的「长文锚点」打开 `LLM_UAP_INTEGRATION.md` 对应节,严格按「参与签名的字段」实现,再发请求。
+4. **区分认证**:开始写签名前,先确认属于三类认证中的哪一种(Simple Auth Body / 消息 Header / M2M Token)。
+
+## 按意图的最短操作清单(命中 skill 后必读)
+
+> 只有把**前置确认**清单全部勾上,才能开始写代码;缺项先问用户。
+>
+> **三意图的依赖关系(务必先看)**:
+>
+> - §A 一键登录要**真正跳进本系统**,前提是目标用户手机号**在该 `APP_ID` 下已建立映射**——`POST /simple/validate` 响应里 `mapped_key` 必须非 `null`,业务侧才能据此定位本系统账号。
+> - 也就是说:**§C 用户同步是 §A 一键登录的前置**。上线 SSO 之前,通常要先把本系统用户通过 `POST /apps/mapping/sync` 批量或按需推到 UAP 该 `APP_ID` 下。没有映射的手机号即使能在 UAP 登录,到 callback 侧也会拿到 `mapped_key=null`,无法一键进入本系统。
+> - §B 若按 `app_user_id` 投递消息,同样依赖映射存在;仅按平台 `user_id` 或广播时不依赖。
+
+### A. 一键登录 / SSO(Redirect SSO)
+
+**前置确认**(缺任一项不写代码,先向用户问):
+
+- [ ] 已拿到 `APP_ID` + `APP_SECRET`(Secret 仅用于后端签名)。
+- [ ] 业务侧回调 URL(如 `https://biz.example.com/uap/callback`)已登记到 UAP 后台 `REDIRECT_URIS`。
+- [ ] `UAP_API_BASE`(含 `/api/v1`)与 `UAP_WEB_BASE` 已确认。
+- [ ] **映射前置**:目标用户手机号已在该 `APP_ID` 下建立映射(否则 `validate` 响应 `mapped_key=null`,无法落地到本系统账号)。若尚未同步,先走 §C 补齐;SSO 上线前建议批量同步一次全量。
+
+**代码产出(对照长文 §6.1 流程 + §5.3 字段)**:
+
+1. **前端跳登录页**:把浏览器导向 `{UAP_WEB_BASE}/login?app_id=<APP_ID>`(H5 用 `/mobile/login?app_id=<APP_ID>`)。
+2. **后端 callback 路由**(如 `GET /uap/callback`):从 query 读 `ticket`;为空直接 400。
+3. **后端调用 `POST {UAP_API_BASE}/simple/validate`**:
+   - Body 字段:`{ ticket, app_id, timestamp }`,`timestamp` 为当前 Unix **秒**。
+   - **签名**:对 `app_id=<APP_ID>&ticket=<TICKET>&timestamp=<TS>`(键名 ASCII 升序,**不含** `sign`、**不含** `None`)做 **HMAC-SHA256**,密钥为 UTF-8 的 `APP_SECRET`,输出 **小写 64 位 hex**,写入 Body 的 `sign` 字段。
+   - `Content-Type: application/json`,POST 最终 Body `{ ticket, app_id, timestamp, sign }`。
+4. **处理响应 `TicketValidateResponse`**:
+   - `valid=false` → 提示票据失效并跳回登录;
+   - `valid=true` 且 `mapped_key` **非空** → 用 `mapped_key` 定位本系统账号、建立会话(cookie / JWT 由业务自定);可取的辅助字段有 `user_id`(平台 ID)、`mobile`、`mapped_email`、`english_name`。
+   - `valid=true` 但 `mapped_key=null`(该手机号在本 `APP_ID` 下**无映射**)→ **不可**直接放行。按业务选择:(a) 拒绝并提示联系管理员同步账号;(b) 若允许自助绑定,引导用户绑定本系统账号后调 §C 同步;(c) 管理员控制台触发批量 / 按手机号即时同步后再让用户重试。
+   - 账号绑定策略见下文「账号与 `mapped_key`」。
+
+**常见坑**:
+
+- 用错 Secret:`sign` 必须用**消费票据的应用**(即当前 callback 所属应用)的 `APP_SECRET`。
+- 时间戳非 Unix 秒,或偏差超 **300s** 容差。
+- 签名时把 `sign` 自身也加进去、或没按 ASCII 升序。
+- 回调 URL 未登记到 `REDIRECT_URIS`,导致跳转失败。
+
+### B. 消息通知(三类场景)
+
+> 消息通知按**业务语义**分三类,字段组合与前端会话样式**不同**,不要一把梭;识别意图后对号入座。
+>
+> | 场景 | 何时用 | 前端会话样式 |
+> |------|-------|-------------|
+> | **B-1 平台自动 → 用户** | 平台定时任务 / 触发器自动发:告警、工单未审核提醒、任务超时提醒、备份完成回执等 | 系统通知 |
+> | **B-2 用户 → 用户**(业务流) | 人类用户在应用里操作触发,平台代发给另一人:OA 请假 / 报销 / 审批通过 / 任务下发 / 评论 @ 等 | 发起人的私信(会话聚合到 A) |
+> | **B-3 平台 → 全员广播** | 仅平台级公告:系统维护、版本上线、全员通知 | 系统通知(全量分发) |
+
+**前置确认(三类都要)**:
+
+- [ ] 已拿到 `APP_ID` + `APP_SECRET`(服务端签名用)。
+- [ ] 已识别场景属于 B-1 / B-2 / B-3 中哪一类。
+- [ ] 非广播时接收者定位方式:平台 `user_id`(整数)或本应用 `app_user_id`(即 `mapped_key`,字符串)。
+- [ ] 若是 **B-2**:**发起人本人**在该 `APP_ID` 下已有映射(否则 `sender_app_user_id` 会 404)。
+
+**共用 Header 签名**:
+
+- `X-App-Id: <APP_ID>` / `X-Timestamp: <TS>` / `X-Sign = HMAC-SHA256(APP_SECRET, "app_id=<APP_ID>&timestamp=<TS>")` → 小写 hex。
+- 签名字段**只有** `app_id` + `timestamp` 两个键,不要套 Simple Auth 规则。
+
+**共用端点**:`POST {UAP_API_BASE}/messages/`,`Content-Type: application/json`。
+
+---
+
+#### B-1 平台自动 → 用户(系统通知)
+
+**特征**:由本系统定时任务 / 触发器自动生成,没有"人类发起者";前端会话显示为**系统通知**样式。
+
+**字段组合**:
+
+- `type: "NOTIFICATION"`;
+- `content_type: "TEXT"`(普通)或 `"USER_NOTIFICATION"`(需结构化 / 富文本时);
+- **不传** `sender_app_user_id` → `sender_id` 为空 → 显示系统通知;
+- 接收者:`app_id + app_user_id` 或 `receiver_id`;
+- 需点击跳转时配 `auto_sso:true + target_url`,平台会把 `action_url` 改写成 `/api/v1/simple/sso/jump?...`。
+
+**Body 模板**:
+
+```json
+{
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "有 5 单待审核",
+  "content": "您有 5 条工单超过 24 小时未处理",
+  "app_id": "<APP_ID>",
+  "app_user_id": "<接收人 mapped_key>",
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/tickets/pending",
+  "action_text": "去审核"
+}
+```
+
+**典型场景**:告警阈值触发、累计待办聚合提醒、定时汇总、系统事件回执(备份完成、任务完成)。
+
+---
+
+#### B-2 用户 → 用户(业务流通知)
+
+**特征**:用户 A 在应用内触发动作(提交申请 / 指派任务 / 审批通过),平台代 A 给用户 B 发通知;前端会话**聚合到 A 的私信**,B 能回溯到谁发起的。
+
+**字段组合(关键差异)**:
+
+- `type: "NOTIFICATION"`(应用侧**仍用 NOTIFICATION**,**不要**改 `MESSAGE`——`MESSAGE` 是用户登录态自己发的私信,应用身份调用会被拒);
+- `content_type: "USER_NOTIFICATION"`(推荐:申请单样式,`content` 可传**对象**,服务端自动 JSON 序列化存储,前端可 `JSON.parse` 结构化渲染);
+- **必传** `sender_app_user_id`:发起人 A 在本 `APP_ID` 下的 `mapped_key`,平台解析成 `sender_id` 写入消息(无映射 → 404);
+- 接收者:`app_id + app_user_id`(B 的 `mapped_key`)或 `receiver_id`;
+- 强烈建议配 `auto_sso:true + target_url`:点击消息 SSO 跳到业务详情页。
+
+**Body 模板(OA 请假申请)**:
+
+```json
+{
+  "type": "NOTIFICATION",
+  "content_type": "USER_NOTIFICATION",
+  "title": "请假申请待审批",
+  "content": {
+    "applicant": "张三",
+    "days": 3,
+    "reason": "年假",
+    "request_id": "LEAVE-2026-000123"
+  },
+  "app_id": "<APP_ID>",
+  "app_user_id": "<审批人 mapped_key>",
+  "sender_app_user_id": "<申请人 mapped_key>",
+  "auto_sso": true,
+  "target_url": "https://oa.example.com/hr/leave/LEAVE-2026-000123",
+  "action_text": "去审批"
+}
+```
+
+**典型场景**:
+
+- OA:请假 / 报销 / 加班 / 审批通过回执;
+- 工单 / 任务:任务下发、完成反馈;
+- 物料 / 采购:物料申请、到货提醒(业务人员触发);
+- 协作:@提及、评论回复。
+
+---
+
+#### B-3 平台 → 全员广播(独立功能)
+
+**特征**:一次调用向平台**全部活跃用户**各建一条通知;**仅应用身份可调**。
+
+**约束(后端强制)**:
+
+- `is_broadcast: true`;
+- `type` **必须** `"NOTIFICATION"`(否则 403 "广播模式仅支持系统通知");
+- **不**传 `receiver_id` / `app_user_id`;
+- **不**传 `sender_app_user_id`(广播通常是平台级公告,显示系统通知样式);
+- 平台无活跃用户时 400。
+
+**Body 模板**:
+
+```json
+{
+  "is_broadcast": true,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护通知",
+  "content": "今晚 22:00-24:00 进行系统维护,期间服务可能中断。"
+}
+```
+
+**最佳实践:独立成一条"发广播"入口**:
+
+1. 广播影响面极大,应在管理后台**单独设页面**(权限控制 + 二次确认弹窗),**不要**把它和 B-1 / B-2 的发消息接口混进同一个前端按钮或后端函数。
+2. 留操作审计:记录发起管理员、广播时间、内容快照、影响用户数。
+3. 节流 / 防重:前端禁用重复点击,后端按操作者+内容哈希做短时间内防重。
+
+---
+
+**三类速查表**:
+
+| 维度 | B-1 平台→用户 | B-2 用户→用户 | B-3 广播 |
+|------|--------------|--------------|----------|
+| `type` | `NOTIFICATION` | `NOTIFICATION` | `NOTIFICATION`(强制) |
+| `content_type` | `TEXT` 为主 | **`USER_NOTIFICATION`** | `TEXT` |
+| `sender_app_user_id` | **不传** | **必传**(发起人 mapped_key) | **不传** |
+| 接收者字段 | `app_user_id` / `receiver_id` | 同左 | `is_broadcast:true`,其他都不传 |
+| 前端会话归类 | 系统通知 | 发起人的私信会话 | 系统通知 |
+| 触发方 | 定时任务 / 事件监听 | 用户在应用内操作 | 管理员手动(独立入口) |
+
+**常见坑**:
+
+- 把 Simple Auth 的「所有字段 ASCII 升序」规则套到消息接口(消息接口**仅** `app_id` + `timestamp` 参与签名)。
+- 把响应里**整数** `app_id`(数据库主键)当请求参数回填。
+- B-2 忘传 `sender_app_user_id` → 退化成 B-1 样式,OA 审批变"系统通知"、对话回溯不到谁发起;反之 B-1 / B-3 误传 `sender_app_user_id` → 变"用户私信"样式,归类错乱。
+- B-2 发起人在 UAP 该 `APP_ID` 下**没有映射** → 404(先走 §C 建映射)。
+- 应用身份想用 `type:"MESSAGE"` 代用户发私信——平台只允许 `NOTIFICATION`,应改走 B-2 的 `USER_NOTIFICATION`。
+- 广播时误传 `receiver_id` / `app_user_id`,或把 `type` 写成 `MESSAGE` → 403 / 400。
+- 把广播入口和 B-1 / B-2 合并 → 误点击一次全员炸。
+
+### C. 用户同步 / 映射绑定
+
+> **用途与地位**:把本系统用户(以手机号定位)在 UAP 该 `APP_ID` 下建立 / 维护**映射**(`mobile ↔ mapped_key`)。这是 §A 一键登录能落地到本系统账号的**硬前置**——`/simple/validate` 响应里的 `mapped_key` 就来自这里的同步结果;同时也是 §B 按 `app_user_id` 投递消息的前提。**没有同步过的手机号,即便能在 UAP 登录,也拿不到本系统的身份。**
+
+**前置确认**:
+
+- [ ] 已拿到 `APP_ACCESS_TOKEN`(注意**不是** `APP_SECRET`,两者不可互换)。
+- [ ] 本系统 `mapped_key` 取值策略已确定(见下文「账号与 `mapped_key`」)。
+
+**代码产出(对照长文 §7)**:
+
+1. **先查**:`GET {UAP_API_BASE}/users/search?q=<mobile|name>&limit=20`,Header `X-App-Access-Token: <APP_ACCESS_TOKEN>`。
+2. **再同步**:`POST {UAP_API_BASE}/apps/mapping/sync`,Header `X-App-Access-Token`,`Content-Type: application/json`:
+   - **UPSERT**(新增 / 更新映射;若平台无此手机号会连同建用户):`{ mobile, name, mapped_key, mapped_email?, is_active?, sync_action:"UPSERT" }`;
+   - **DELETE**(仅删映射,不删平台用户):`{ mobile, mapped_key, sync_action:"DELETE" }`。
+3. **读响应**:关注 `new_user_created`(是否新建平台用户)、`generated_password`(新用户初始密码,可能存在)、`is_active`。
+
+#### C.1 最佳实践:本地用户 CRUD 与 UAP 映射保持一致
+
+> **目标**:本系统的「新建 / 编辑 / 删除用户」每一个动作,都**同步**更新 UAP 该 `APP_ID` 下的映射,让 SSO、消息投递、对账三者始终可用。
+
+**① 新建本地用户(Create)— UAP 预填 + UPSERT**
+
+1. 管理员在「新建用户」表单里输入**手机号**(或姓名 / 英文名关键词)。
+2. 前端点「从 UAP 查询」→ 后端以 `X-App-Access-Token` 调 `GET /users/search?q=<mobile>&limit=1`。
+3. 取返回首条的 `name` / `english_name` / `mobile` / `organization_name` 等**回填表单**,管理员确认或补录本系统专属字段(角色、权限、业务档案)。
+4. 提交保存时(**同一工作单元内**):
+   - 先在本系统落库产生本地 `id`;
+   - 随即调 `POST /apps/mapping/sync`(`UPSERT`):`{ mobile, name, mapped_key: <本系统账号/id>, mapped_email?, is_active: true, sync_action: "UPSERT" }`;
+   - 若本次**同时**新建了 UAP 平台用户(响应 `new_user_created: true`),把响应里的 `generated_password` 通过安全通道告知用户或提示管理员(切勿写日志 / 明文显示)。
+5. **失败处理**:UAP 同步失败时二选一——回滚本地创建 / 把本地用户标记为 `pending_sync` 并入队异步补偿,避免两侧漂移。
+
+**② 编辑本地用户(Update)— 仅同步映射层字段**
+
+1. 允许通过本接口同步的仅限**映射字段**:`mapped_key`、`mapped_email`、`is_active`(以及本地业务字段不发 UAP)。
+2. 保存时调 `POST /apps/mapping/sync`(`UPSERT`):`mobile` 作为用户定位键保持不变,只把变更字段发过去。
+3. **禁止**通过此接口改 UAP 侧的 `name` / `mobile` 等基础档案(平台会拒绝)——如确需变更,指引管理员到 UAP 后台处理,或走管理端 `PUT /users/{id}`(需超级管理员)。
+4. 本地「停用」操作 → 对应把 `is_active: false` UPSERT 到 UAP,保证 SSO 侧与本地状态一致(停用用户登不进)。
+
+**③ 删除本地用户(Delete)— DELETE 映射 或 软停用**
+
+1. **解绑映射(推荐硬删除场景)**:`POST /apps/mapping/sync` → `{ mobile, mapped_key, sync_action: "DELETE" }`。只删**本 `APP_ID` 下**的映射,不影响 UAP 平台用户本身与其他应用。
+2. **软停用**(保留映射便于恢复):用 `UPSERT` 把 `is_active` 设为 `false`。
+3. **顺序要正确**:先调 UAP DELETE 成功再删本地用户,避免本地已删、UAP 侧留下孤儿映射(再用此 `mapped_key` 投消息会发到已不存在的本地用户上)。
+
+**④ 批量对账(可选 / 兜底)**
+
+- 定时拉 `GET /apps/mapping/users?skip=&limit=` 与本地用户表做 diff:
+  - 本地有 / UAP 无 → 补 `UPSERT`;
+  - UAP 有 / 本地无 → 按策略选择 `DELETE` UAP 映射或在本地回填。
+- 用于覆盖同步失败未补偿、UAP 后台手工改动等漂移场景。
+
+**设计要点**:
+
+- 本地 CRUD 与 UAP 同步**同一事务边界**;无法强一致时用「本地成功 → MQ → 异步 UAP 同步 + 重试」的最终一致方案,并留可观察的失败表。
+- `mapped_key` 一旦对外使用(SSO / 消息)就**不要随意改**;需换键时:先 UPSERT 新 key → 业务引用切换 → 再 DELETE 旧 key。
+- 所有 M2M 调用只在**服务端**完成,`APP_ACCESS_TOKEN` 不下发前端。
+
+**常见坑**:
+
+- 用 `APP_SECRET` 去发 M2M 请求(M2M 用 `X-App-Access-Token`,不签名)。
+- 想用 `/apps/mapping/sync` 改已存在用户的 `name` / `mobile`(平台禁止,应到后台改)。
+- `mapped_key` 超过 100 字符未截断。
+- 本地删了用户但**忘了调** `DELETE` 映射 → UAP 侧残留孤儿映射,给已不存在的本地账号发消息 / 让 SSO 跳到失效账号。
+- 本地 CRUD 成功但 UAP 同步失败**无回滚 / 无补偿** → 两侧漂移,下次 SSO 出 `mapped_key=null`。
+
+## 账号与 `mapped_key`(可选策略)
+
+从 **`GET /users/search`** 返回的用户行中,业务侧常见做法是取一个稳定、可读的字段作为本系统「账号」字段和 **`POST /apps/mapping/sync`** 里的 **`mapped_key`**。可选策略(按需采纳,不强制):
+
+- 优先用 **`english_name`**(若存在):人类可读、跨系统稳定;
+- 其次用行内已有 **`mapped_key`**(表示之前已建立过映射);
+- 再回退为本库用户 id 的字符串形式,保证唯一性。
+
+**约束:**
+
+- **`mapped_key`** 发往 UAP 时须满足平台文档中的长度上限(见长文 §7.3)。
+- 若 `POST /simple/validate` 响应中包含 `english_name`,可与上述优先级对齐后再写入本系统用户。
+
+## 组织信息(用户附带返回)
+
+- `GET /users/search` 的每条用户行带 **`organization_id`** 与 **`organization_name`**(无归属为 `null`),可直接用于对接方展示部门归属,**不必**再二次请求组织接口。
+
+## 自检
+
+- [ ] 已识别用户意图并走对应清单(§A 一键登录 / §B 消息通知 / §C 用户同步),而非泛泛串代码。
+- [ ] 清单里的**前置确认**项全部已向用户确认,未自行填假值。
+- [ ] 凭据来自用户或已验证的本地 `.env`,未使用文档中的占位符当真实值。
+- [ ] `UAP_API_BASE` 含 `/api/v1`。
+- [ ] `APP_SECRET` 仅出现在服务端;前端只用公开信息(如 `APP_ID`、跳转 URL)。
+- [ ] 未将 Simple Auth 与消息 Header 两种签名规则混用;M2M 接口未误用 `APP_SECRET`。
+- [ ] 消息场景已按 B-1 / B-2 / B-3 分类:B-2 必传 `sender_app_user_id` 且优先 `USER_NOTIFICATION`;B-1 / B-3 不传 `sender_app_user_id`;广播仅 `NOTIFICATION` 且不传接收者。
+- [ ] 广播功能有独立入口、权限控制、二次确认与操作审计,未与 B-1 / B-2 的发送逻辑混用。
+- [ ] 未把字符串 App ID 与响应中的整数 `app_id` 主键混用。
+- [ ] 一键登录:callback 路由已处理 `ticket` 缺失 / `valid=false` / **`mapped_key=null`**(无映射)三种分支;`validate` 签名字段为 `app_id + ticket + timestamp`,使用**消费票据应用**的 Secret。
+- [ ] 上线 SSO 前:本系统用户到 UAP 该 `APP_ID` 下的**映射已同步**(或已设计好 `mapped_key=null` 的回退策略)。
+- [ ] 若本系统有用户 CRUD:**创建 / 编辑 / 删除**均对应调用 `POST /apps/mapping/sync`(UPSERT / UPSERT / DELETE),创建时先 `GET /users/search` 回填表单;同步失败有回滚或补偿策略(见 §C.1)。
+- [ ] 若涉及账号、`mapped_key` 或映射同步:已对照长文 `/users/search`、`POST /apps/mapping/sync` 章节。
+- [ ] 若展示组织/部门信息:优先用 `/users/search` 返回的 `organization_name`。

+ 5 - 0
frontend/src/router/index.ts

@@ -167,6 +167,11 @@ const routes: Array<RouteRecordRaw> = [
         name: 'Help',
         component: () => import('../views/Help.vue')
       },
+      {
+        path: 'api-skill',
+        name: 'ApiSkill',
+        component: () => import('../views/ApiSkill.vue')
+      },
       {
         path: 'messages',
         name: 'Messages',

+ 262 - 0
frontend/src/views/ApiSkill.vue

@@ -0,0 +1,262 @@
+<template>
+  <div class="api-skill-page">
+    <div class="api-skill-header">
+      <h1>接口 skill</h1>
+      <p class="lead">
+        在 Cursor 等编辑器中为本仓库或业务项目提供 <strong>UAP 接口对接</strong> 相关的助手能力:下载下方 Skill 文件,按说明放入技能目录即可。
+      </p>
+    </div>
+
+    <el-card shadow="never" class="block-card">
+      <template #header>
+        <span>这是什么</span>
+      </template>
+      <p>
+        Cursor <strong>Skill</strong> 是一段给 AI 读的说明(Markdown),用于在对话里约束「如何调用统一认证平台 API、签名规则、安全注意点」等。
+        它与站内「接口开发文档」一致互补:文档偏完整图文,Skill 偏给自动化助手用的精简规则。
+      </p>
+    </el-card>
+
+    <el-card shadow="never" class="block-card">
+      <template #header>
+        <span>安装步骤(Cursor)</span>
+      </template>
+      <ol class="steps">
+        <li>点击下方按钮下载 <code>uap-api.zip</code>(内含 <code>SKILL.md</code> 与 <code>LLM_UAP_INTEGRATION.md</code>,两份缺一则 AI 无法读到接口细节)。</li>
+        <li>
+          选择一个技能目录(二选一):
+          <ul class="sub-list">
+            <li><strong>项目级</strong>:<code>&lt;仓库根&gt;/.cursor/skills/</code> —— 仅当前项目可用,会随仓库共享。</li>
+            <li><strong>用户级</strong>:<code>~/.cursor/skills/</code>(Windows 为 <code>%USERPROFILE%\.cursor\skills\</code>)—— 本机所有项目都可用。</li>
+          </ul>
+        </li>
+        <li>把 <code>uap-api.zip</code> 解压到上一步选择的目录,最终路径形如 <code>.../.cursor/skills/uap-api/SKILL.md</code> 与 <code>.../.cursor/skills/uap-api/LLM_UAP_INTEGRATION.md</code>。</li>
+        <li>重启 Cursor 或重新打开项目后,在对话中说明「按 UAP skill 对接」即可触发相关约束。</li>
+      </ol>
+      <p class="hint">
+        具体目录名以 Cursor 当前版本的 <a href="https://cursor.com/docs" target="_blank" rel="noopener noreferrer">官方文档</a> 为准;不同版本可能使用 <code>skills</code> 或规则目录,请与本地设置对照。
+      </p>
+    </el-card>
+
+    <el-card shadow="never" class="block-card">
+      <template #header>
+        <span>下载</span>
+      </template>
+      <div class="download-row">
+        <el-button type="primary" :loading="zipping" @click="downloadZip">
+          <el-icon class="btn-icon"><Download /></el-icon>
+          {{ zipping ? '正在打包…' : '下载 uap-api.zip(含两份文件)' }}
+        </el-button>
+      </div>
+      <el-alert
+        v-if="zipError"
+        type="error"
+        :closable="false"
+        show-icon
+        class="zip-error"
+        :title="zipError"
+      />
+      <p class="hint">
+        zip 解压后得到 <code>uap-api/</code> 目录,直接放入 <code>.cursor/skills/</code> 即可。<code>SKILL.md</code> 对 <code>LLM_UAP_INTEGRATION.md</code> 的引用为相对路径。
+      </p>
+      <details class="fallback">
+        <summary>无法打包?分开下载</summary>
+        <div class="download-row fallback-row">
+          <a :href="skillUrl" download="SKILL.md" class="download-anchor">
+            <el-button size="small">下载 SKILL.md</el-button>
+          </a>
+          <a :href="referenceUrl" download="LLM_UAP_INTEGRATION.md" class="download-anchor">
+            <el-button size="small">下载 LLM_UAP_INTEGRATION.md</el-button>
+          </a>
+        </div>
+        <div class="path-list">
+          <div class="file-path">静态路径:{{ skillUrl }}</div>
+          <div class="file-path">静态路径:{{ referenceUrl }}</div>
+        </div>
+        <p class="hint">两份文件必须放在同一目录(目录名 <code>uap-api</code>)。</p>
+      </details>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import JSZip from 'jszip'
+import { Download } from '@element-plus/icons-vue'
+
+const skillUrl = '/skills/uap-api/SKILL.md'
+const referenceUrl = '/skills/uap-api/LLM_UAP_INTEGRATION.md'
+
+const zipping = ref(false)
+const zipError = ref('')
+
+async function fetchText(url: string): Promise<string> {
+  const resp = await fetch(url, { cache: 'no-cache' })
+  if (!resp.ok) {
+    throw new Error(`下载 ${url} 失败:HTTP ${resp.status}`)
+  }
+  return await resp.text()
+}
+
+async function downloadZip() {
+  if (zipping.value) return
+  zipping.value = true
+  zipError.value = ''
+  try {
+    const [skillText, referenceText] = await Promise.all([
+      fetchText(skillUrl),
+      fetchText(referenceUrl),
+    ])
+    const zip = new JSZip()
+    const folder = zip.folder('uap-api')
+    if (!folder) throw new Error('创建 zip 目录失败')
+    folder.file('SKILL.md', skillText)
+    folder.file('LLM_UAP_INTEGRATION.md', referenceText)
+    const blob = await zip.generateAsync({ type: 'blob' })
+    const href = URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = href
+    a.download = 'uap-api.zip'
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    URL.revokeObjectURL(href)
+  } catch (err) {
+    zipError.value = err instanceof Error ? err.message : '打包失败,请稍后重试或使用下方分开下载。'
+  } finally {
+    zipping.value = false
+  }
+}
+</script>
+
+<style scoped>
+.api-skill-page {
+  padding: 30px;
+  max-width: 900px;
+  margin: 0 auto;
+  background-color: #fff;
+  border-radius: 8px;
+  min-height: 80vh;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+.api-skill-header {
+  border-bottom: 1px solid #eaecef;
+  margin-bottom: 24px;
+  padding-bottom: 16px;
+}
+
+h1 {
+  font-size: 28px;
+  color: #1f2f3d;
+  font-weight: 600;
+  margin: 0 0 12px;
+}
+
+.lead {
+  margin: 0;
+  color: #606266;
+  line-height: 1.6;
+  font-size: 15px;
+}
+
+.block-card {
+  margin-bottom: 16px;
+}
+
+.block-card :deep(.el-card__body) {
+  color: #606266;
+  line-height: 1.7;
+  font-size: 14px;
+}
+
+.steps {
+  margin: 0 0 12px;
+  padding-left: 20px;
+}
+
+.steps li {
+  margin-bottom: 8px;
+}
+
+.hint {
+  margin: 0;
+  font-size: 13px;
+  color: #909399;
+}
+
+.download-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+}
+
+.btn-icon {
+  margin-right: 6px;
+  vertical-align: middle;
+}
+
+.download-anchor {
+  text-decoration: none;
+  display: inline-flex;
+}
+
+.path-list {
+  margin-top: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.file-path {
+  font-size: 13px;
+  color: #909399;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  word-break: break-all;
+}
+
+.sub-list {
+  margin: 6px 0 0;
+  padding-left: 20px;
+}
+
+.sub-list li {
+  margin-bottom: 4px;
+}
+
+.zip-error {
+  margin: 12px 0;
+}
+
+.fallback {
+  margin-top: 16px;
+  padding: 10px 12px;
+  border: 1px dashed #e4e7ed;
+  border-radius: 4px;
+  background: #fafafa;
+}
+
+.fallback > summary {
+  cursor: pointer;
+  color: #606266;
+  font-size: 13px;
+  user-select: none;
+}
+
+.fallback[open] > summary {
+  margin-bottom: 8px;
+}
+
+.fallback-row {
+  margin-top: 4px;
+}
+
+code {
+  font-size: 13px;
+  background: #f5f7fa;
+  padding: 2px 6px;
+  border-radius: 4px;
+}
+</style>

+ 16 - 6
frontend/src/views/Dashboard.vue

@@ -88,13 +88,23 @@
             </el-menu-item>
           </el-sub-menu>
 
-          <el-menu-item 
+          <el-sub-menu
             v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')"
-            index="/dashboard/help"
+            index="help-docs"
           >
-            <el-icon><QuestionFilled /></el-icon>
-            <span>使用帮助</span>
-          </el-menu-item>
+            <template #title>
+              <el-icon><QuestionFilled /></el-icon>
+              <span>帮助文档</span>
+            </template>
+            <el-menu-item index="/dashboard/help">
+              <el-icon><Document /></el-icon>
+              <span>接口开发文档</span>
+            </el-menu-item>
+            <el-menu-item index="/dashboard/api-skill">
+              <el-icon><MagicStick /></el-icon>
+              <span>接口 skill</span>
+            </el-menu-item>
+          </el-sub-menu>
         </el-menu>
       </el-aside>
       
@@ -184,7 +194,7 @@
 import { computed, onMounted, onUnmounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../store/auth'
-import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound, Folder, Menu, Upload, Postcard } from '@element-plus/icons-vue'
+import { Grid, List, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound, Folder, Menu, Upload, Postcard, MagicStick, QuestionFilled } from '@element-plus/icons-vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import QRCode from 'qrcode'
 import api from '../utils/request'

+ 2 - 2
frontend/src/views/Help.vue

@@ -4,7 +4,7 @@
       <div class="header-left">
         <el-select 
           v-model="selectedTabValue" 
-          placeholder="请选择帮助主题"
+          placeholder="请选择文档主题"
           size="default"
           style="width: 250px; margin-right: 20px;"
           @change="handleTabSelect"
@@ -16,7 +16,7 @@
             :value="tab.value"
           />
         </el-select>
-        <h1>使用帮助</h1>
+        <h1>接口开发文档</h1>
       </div>
       <el-button type="info" plain @click="openSwagger">
         <el-icon style="margin-right: 5px"><Link /></el-icon>