From f5137a2b0547a7f23e50384460c6491fb567ead5 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 7 Jul 2026 11:05:41 +0800 Subject: [PATCH] feat(aihr): add open org snapshot sync endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/aihr/org/sync,从开放组织系统 /open/v1/sync/snapshot 拉取公司/部门/员工快照刷新 aihr_org_snapshot;支持 dryRun 预检、 Bearer/client-credentials 鉴权与可选 HMAC 签名;外部未配置时保留 SQL seed。附接口契约设计 v1 与 API/DEV_SETUP 配置说明。 --- .../controller/AihrOrgSyncController.java | 24 + .../dromara/aihr/domain/AihrOrgSyncDto.java | 33 + .../aihr/service/AihrOrgSyncService.java | 470 +++++ docs/API_INTEGRATION.md | 26 +- docs/DEV_SETUP.md | 9 +- docs/open-org-sync-api-design-v1.md | 1700 +++++++++++++++++ 6 files changed, 2259 insertions(+), 3 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java create mode 100644 docs/open-org-sync-api-design-v1.md diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java new file mode 100644 index 00000000..9af193bb --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrOrgSyncController.java @@ -0,0 +1,24 @@ +package org.dromara.aihr.controller; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; +import org.dromara.aihr.service.AihrOrgSyncService; +import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/aihr/org") +public class AihrOrgSyncController { + + private final AihrOrgSyncService orgSyncService; + + @PostMapping("/sync") + public R sync(@RequestBody(required = false) SyncRequest request) { + return R.ok(orgSyncService.sync(request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java new file mode 100644 index 00000000..1716a75e --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java @@ -0,0 +1,33 @@ +package org.dromara.aihr.domain; + +import java.util.List; + +public final class AihrOrgSyncDto { + + private AihrOrgSyncDto() { + } + + public record SyncRequest( + Boolean dryRun, + Boolean replaceExisting, + Integer pageSize, + Integer maxPages, + String groupId, + String companyId, + String departmentId + ) { + } + + public record SyncResponse( + boolean dryRun, + boolean replaceExisting, + String source, + int companyCount, + int departmentCount, + int employeeCount, + int syncedCount, + int skippedCount, + List warnings + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java new file mode 100644 index 00000000..020d20f8 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -0,0 +1,470 @@ +package org.dromara.aihr.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest; +import org.dromara.aihr.domain.AihrOrgSyncDto.SyncResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.LocalDate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrOrgSyncService { + + private static final String TENANT_ID = "000000"; + private static final int DEFAULT_PAGE_SIZE = 200; + private static final int MAX_PAGE_SIZE = 500; + private static final int DEFAULT_MAX_PAGES = 200; + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30); + + private final ObjectMapper objectMapper; + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + + @Value("${aihr.org-sync.base-url:${AIHR_ORG_SYNC_BASE_URL:}}") + private String configuredBaseUrl; + + @Value("${aihr.org-sync.access-token:${AIHR_ORG_SYNC_ACCESS_TOKEN:}}") + private String configuredAccessToken; + + @Value("${aihr.org-sync.client-id:${AIHR_ORG_SYNC_CLIENT_ID:}}") + private String configuredClientId; + + @Value("${aihr.org-sync.client-secret:${AIHR_ORG_SYNC_CLIENT_SECRET:}}") + private String configuredClientSecret; + + @Value("${aihr.org-sync.signing-secret:${AIHR_ORG_SYNC_SIGNING_SECRET:}}") + private String configuredSigningSecret; + + public SyncResponse sync(SyncRequest request) { + SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null) : request; + String baseUrl = normalizeBaseUrl(configuredBaseUrl); + if (baseUrl.isBlank()) { + throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /open/v1 前缀"); + } + requireSnapshotTable(); + + int pageSize = clamp(req.pageSize(), DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE); + int maxPages = clamp(req.maxPages(), DEFAULT_MAX_PAGES, 1, DEFAULT_MAX_PAGES); + boolean dryRun = Boolean.TRUE.equals(req.dryRun()); + boolean replaceExisting = req.replaceExisting() == null || Boolean.TRUE.equals(req.replaceExisting()); + String token = accessToken(baseUrl); + List warnings = new ArrayList<>(); + + List companyItems = fetchOptional(baseUrl, token, req, "company", pageSize, maxPages, warnings); + List departmentItems = fetchOptional(baseUrl, token, req, "department", pageSize, maxPages, warnings); + List employeeItems = fetchSnapshot(baseUrl, token, req, "employee", pageSize, maxPages); + Map companies = companyMap(companyItems); + Map departments = departmentMap(departmentItems); + + List rows = new ArrayList<>(); + int skipped = 0; + for (JsonNode employee : employeeItems) { + OrgRow row = orgRow(employee, companies, departments); + if (row == null) { + skipped++; + } else { + rows.add(row); + } + } + if (!dryRun && replaceExisting && rows.isEmpty()) { + throw new IllegalArgumentException("外部员工快照为空,已阻止覆盖本地组织人员快照"); + } + if (!dryRun && !rows.isEmpty()) { + saveRows(rows, replaceExisting); + } + + return new SyncResponse( + dryRun, + replaceExisting, + baseUrl, + companyItems.size(), + departmentItems.size(), + employeeItems.size(), + dryRun ? 0 : rows.size(), + skipped, + List.copyOf(warnings) + ); + } + + private List fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType, + int pageSize, int maxPages, List warnings) { + try { + return fetchSnapshot(baseUrl, token, req, resourceType, pageSize, maxPages); + } catch (Exception e) { + String warning = "外部 " + resourceType + " 快照拉取失败,已用员工字段兜底: " + e.getMessage(); + warnings.add(warning); + log.warn(warning); + return List.of(); + } + } + + private List fetchSnapshot(String baseUrl, String token, SyncRequest req, String resourceType, + int pageSize, int maxPages) { + List items = new ArrayList<>(); + for (int page = 1; page <= maxPages; page++) { + Map query = new TreeMap<>(); + query.put("resource_type", resourceType); + query.put("page", String.valueOf(page)); + query.put("page_size", String.valueOf(pageSize)); + putIfNotBlank(query, "group_id", req.groupId()); + putIfNotBlank(query, "company_id", req.companyId()); + putIfNotBlank(query, "department_id", req.departmentId()); + + JsonNode data = requestJson(baseUrl, "GET", "/sync/snapshot", query, "", token).path("data"); + JsonNode pageItems = data.path("items"); + if (pageItems.isMissingNode() && data.isArray()) { + pageItems = data; + } + if (!pageItems.isArray()) { + throw new IllegalStateException("外部 " + resourceType + " 快照响应缺少 data.items"); + } + for (JsonNode item : pageItems) { + items.add(item); + } + int total = data.path("total").asInt(-1); + boolean hasMore = data.path("has_more").asBoolean(data.path("hasMore").asBoolean(false)); + if (!hasMore && (total >= 0 ? page * pageSize >= total : pageItems.size() < pageSize)) { + return items; + } + } + throw new IllegalStateException("外部 " + resourceType + " 快照超过最大分页 " + maxPages); + } + + private String accessToken(String baseUrl) { + String token = clean(configuredAccessToken); + if (!token.isBlank()) { + return token; + } + String clientId = clean(configuredClientId); + String clientSecret = clean(configuredClientSecret); + if (clientId.isBlank() || clientSecret.isBlank()) { + return ""; + } + try { + String body = objectMapper.writeValueAsString(Map.of( + "grant_type", "client_credentials", + "client_id", clientId, + "client_secret", clientSecret + )); + JsonNode root = requestJson(baseUrl, "POST", "/auth/token", Map.of(), body, ""); + String fetchedToken = clean(root.path("data").path("access_token").asText(root.path("access_token").asText(""))); + if (fetchedToken.isBlank()) { + throw new IllegalStateException("外部令牌接口未返回 access_token"); + } + return fetchedToken; + } catch (Exception e) { + throw new IllegalStateException("外部组织同步令牌获取失败: " + e.getMessage(), e); + } + } + + private JsonNode requestJson(String baseUrl, String method, String path, Map query, String body, String token) { + try { + String queryString = queryString(query); + URI uri = URI.create(baseUrl + path + (queryString.isBlank() ? "" : "?" + queryString)); + HttpRequest.Builder builder = HttpRequest.newBuilder(uri) + .timeout(HTTP_TIMEOUT) + .header("Accept", "application/json"); + if (!clean(token).isBlank()) { + builder.header("Authorization", bearer(token)); + } + String requestBody = body == null ? "" : body; + if ("POST".equals(method)) { + builder.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(requestBody)); + } else { + builder.GET(); + } + sign(builder, method, uri.getRawPath(), queryString, requestBody); + HttpResponse response = HttpClient.newBuilder() + .connectTimeout(HTTP_TIMEOUT) + .build() + .send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("HTTP " + response.statusCode() + ": " + truncate(response.body(), 200)); + } + JsonNode root = objectMapper.readTree(response.body()); + if (root.has("success") && !root.path("success").asBoolean()) { + throw new IllegalStateException(root.path("message").asText("外部接口返回失败")); + } + return root; + } catch (Exception e) { + throw new IllegalStateException(e.getMessage(), e); + } + } + + private void sign(HttpRequest.Builder builder, String method, String path, String queryString, String body) throws Exception { + String signingSecret = clean(configuredSigningSecret); + String clientId = clean(configuredClientId); + if (signingSecret.isBlank() || clientId.isBlank()) { + return; + } + String timestamp = Instant.now().toString(); + String nonce = UUID.randomUUID().toString(); + String plain = method + "\n" + path + "\n" + queryString + "\n" + sha256(body) + "\n" + timestamp + "\n" + nonce; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + builder.header("X-Client-Id", clientId) + .header("X-Timestamp", timestamp) + .header("X-Nonce", nonce) + .header("X-Signature", Base64.getEncoder().encodeToString(mac.doFinal(plain.getBytes(StandardCharsets.UTF_8)))); + } + + private void saveRows(List rows, boolean replaceExisting) { + transactionTemplate.executeWithoutResult(status -> { + if (replaceExisting) { + jdbcTemplate.update("delete from aihr_org_snapshot where tenant_id = ?", TENANT_ID); + } + jdbcTemplate.batchUpdate(""" + insert into aihr_org_snapshot + (tenant_id, project_code, project_name, dept_name, ext_party_id, person_name, + position_name, position_level, employment_status, snapshot_date, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + on duplicate key update + project_code = values(project_code), + project_name = values(project_name), + dept_name = values(dept_name), + person_name = values(person_name), + position_name = values(position_name), + position_level = values(position_level), + employment_status = values(employment_status), + snapshot_date = values(snapshot_date), + create_time = now() + """, rows.stream().map(OrgRow::args).toList()); + }); + } + + private OrgRow orgRow(JsonNode employee, Map companies, Map departments) { + String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id")); + if (extPartyId.isBlank()) { + return null; + } + String departmentId = firstNonBlank(text(employee, "department_id", "departmentId", "dept_id", "deptId")); + DepartmentInfo dept = departments.get(departmentId); + String companyId = firstNonBlank(text(employee, "company_id", "companyId"), dept == null ? "" : dept.companyId()); + CompanyInfo company = companies.get(companyId); + String deptName = firstNonBlank(text(employee, "department_name", "departmentName", "dept_name", "deptName"), dept == null ? "" : dept.name()); + String projectCode = firstNonBlank( + text(employee, "project_code", "projectCode", "company_code", "companyCode"), + company == null ? "" : company.code(), + companyId, + dept == null ? "" : dept.code(), + "ORG" + ); + String projectName = firstNonBlank( + text(employee, "project_name", "projectName", "company_name", "companyName"), + company == null ? "" : company.name(), + deptName, + "组织架构" + ); + String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", "title"), "员工"); + String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName)); + return new OrgRow( + projectCode, + projectName, + deptName, + extPartyId, + firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"), + positionName, + positionLevel, + status(text(employee, "status", "employment_status", "employmentStatus")), + LocalDate.now() + ); + } + + private Map companyMap(List items) { + Map map = new TreeMap<>(); + for (JsonNode item : items) { + String id = firstNonBlank(text(item, "id", "company_id", "companyId")); + if (!id.isBlank()) { + map.put(id, new CompanyInfo( + firstNonBlank(text(item, "code", "company_code", "companyCode"), id), + firstNonBlank(text(item, "name", "company_name", "companyName"), id) + )); + } + } + return map; + } + + private Map departmentMap(List items) { + Map map = new TreeMap<>(); + for (JsonNode item : items) { + String id = firstNonBlank(text(item, "id", "department_id", "departmentId", "dept_id", "deptId")); + if (!id.isBlank()) { + map.put(id, new DepartmentInfo( + firstNonBlank(text(item, "code", "department_code", "departmentCode", "dept_code", "deptCode"), id), + firstNonBlank(text(item, "name", "department_name", "departmentName", "dept_name", "deptName"), id), + firstNonBlank(text(item, "company_id", "companyId"), "") + )); + } + } + return map; + } + + private void requireSnapshotTable() { + try { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' + """, Integer.class); + if (count == null || count == 0) { + throw new IllegalArgumentException("请先执行 aihr_org_snapshot_mysql8.sql 初始化组织人员快照表"); + } + } catch (DataAccessException e) { + throw new IllegalStateException("组织人员快照表检查失败: " + e.getMessage(), e); + } + } + + private static String status(String value) { + String text = clean(value).toLowerCase(); + if (text.isBlank() || text.equals("1") || text.equals("active") || text.equals("enabled") + || text.equals("normal") || text.equals("在职") || text.equals("正常")) { + return "active"; + } + if (text.equals("0") || text.equals("departed") || text.equals("left") || text.equals("inactive") + || text.equals("disabled") || text.equals("离职")) { + return "departed"; + } + return text.length() > 20 ? text.substring(0, 20) : text; + } + + private static String level(String positionName) { + String text = clean(positionName); + if (text.contains("项目经理")) { + return "项目经理"; + } + if (text.contains("主管") || text.contains("经理") || text.contains("负责人") || text.contains("组长")) { + return "主管"; + } + return "一线"; + } + + private static String text(JsonNode node, String... fields) { + for (String field : fields) { + JsonNode child = node.path(field); + if (child.isValueNode()) { + String value = clean(child.asText()); + if (!value.isBlank()) { + return value; + } + } + } + return ""; + } + + private static void putIfNotBlank(Map query, String key, String value) { + String clean = clean(value); + if (!clean.isBlank()) { + query.put(key, clean); + } + } + + private static int clamp(Integer value, int fallback, int min, int max) { + int result = value == null ? fallback : value; + return Math.max(min, Math.min(max, result)); + } + + private static String bearer(String token) { + String clean = clean(token); + return clean.regionMatches(true, 0, "Bearer ", 0, 7) ? clean : "Bearer " + clean; + } + + private static String normalizeBaseUrl(String value) { + String clean = clean(value); + while (clean.endsWith("/")) { + clean = clean.substring(0, clean.length() - 1); + } + return clean; + } + + private static String queryString(Map query) { + List parts = new ArrayList<>(); + for (Map.Entry entry : query.entrySet()) { + parts.add(url(entry.getKey()) + "=" + url(entry.getValue())); + } + return String.join("&", parts); + } + + private static String url(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String sha256(String value) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + String clean = clean(value); + if (!clean.isBlank()) { + return clean; + } + } + return ""; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String truncate(String value, int maxChars) { + String text = clean(value); + return text.length() <= maxChars ? text : text.substring(0, maxChars); + } + + private record CompanyInfo(String code, String name) { + } + + private record DepartmentInfo(String code, String name, String companyId) { + } + + private record OrgRow( + String projectCode, + String projectName, + String deptName, + String extPartyId, + String personName, + String positionName, + String positionLevel, + String employmentStatus, + LocalDate snapshotDate + ) { + Object[] args() { + return new Object[] { + TENANT_ID, projectCode, projectName, deptName, extPartyId, personName, + positionName, positionLevel, employmentStatus, snapshotDate + }; + } + } +} diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 9bf6acc5..e9078968 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -12,6 +12,7 @@ | 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | +| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,映射并刷新 `aihr_org_snapshot`;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | | 移动端三端首页 `/h5/user`、`/h5/candidate`、`/h5/supervisor` | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | | 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化 | @@ -25,7 +26,7 @@ - 包名建议:`org.dromara.aihr` - Controller 返回统一用 `org.dromara.common.core.domain.R` -当前管理端 AI 面试和案例沉淀已改为真实模型/ASR 优先;移动端首页和部分演示态数据仍保留 fallback。知识库、模型能力、文档解析、RAG、chat 按 [ruoyi-ai 能力分片迁移计划](RUOYI_AI_INCREMENTAL_MIGRATION.md) 逐片引入;知识库 DDL 与住宅类 SOP seed 在 `backend/script/sql/aihr_knowledge_mysql8.sql`,模型 DDL 在 `backend/script/sql/aihr_model_mysql8.sql`,训练记录 DDL 在 `backend/script/sql/aihr_practice_mysql8.sql`,AI 面试结果 DDL 在 `backend/script/sql/aihr_interview_result_mysql8.sql`,候选人资料 DDL 在 `backend/script/sql/aihr_candidate_material_mysql8.sql`,组织人员静态快照(2 个住宅项目 22 人,支撑按项目看人数)在 `backend/script/sql/aihr_org_snapshot_mysql8.sql`。 +当前管理端 AI 面试和案例沉淀已改为真实模型/ASR 优先;移动端首页和部分演示态数据仍保留 fallback。知识库、模型能力、文档解析、RAG、chat 按 [ruoyi-ai 能力分片迁移计划](RUOYI_AI_INCREMENTAL_MIGRATION.md) 逐片引入;知识库 DDL 与住宅类 SOP seed 在 `backend/script/sql/aihr_knowledge_mysql8.sql`,模型 DDL 在 `backend/script/sql/aihr_model_mysql8.sql`,训练记录 DDL 在 `backend/script/sql/aihr_practice_mysql8.sql`,AI 面试结果 DDL 在 `backend/script/sql/aihr_interview_result_mysql8.sql`,候选人资料 DDL 在 `backend/script/sql/aihr_candidate_material_mysql8.sql`,组织人员快照表在 `backend/script/sql/aihr_org_snapshot_mysql8.sql`,本地 reset 带 2 个住宅项目 22 人 seed;配置开放组织同步系统后用 `POST /api/aihr/org/sync` 覆盖为外部快照。 直接打后端 `/api/**` 通常需要登录后的 `Authorization: Bearer `;浏览器内通过已登录前端和 `/dev-api` 代理访问。移动端登录接口为 `POST /auth/mobile/sms-login`,请求 `{ phonenumber, smsCode, tenantId }`,内部固定使用 app 客户端 `428a8310cd442757ae699df5d894f051` 和 `sms` grant;验证码通过后若手机号不存在,会创建 `app_user`,用户名为手机号,备注为“移动端短信自动注册”。移动端 MVP 首页接口 `GET /api/aihr/mobile/home/{role}` 目前仍是 `@SaIgnore` 的公开只读 seed 接口,避免 H5 首屏被后台管理登录态阻断;后续接小程序登录后再收紧为移动端 token。 @@ -87,6 +88,29 @@ Qdrant 本地默认值可不配;需要覆盖时用 JVM property 或环境变 | `AIHR_QDRANT_API_KEY` / `-Daihr.qdrant.apiKey` | 空 | 远端 Qdrant API Key,本地不用 | | `AIHR_IMPORT_ROOT` / `-Daihr.import.root` | `./.data/import` | 服务端资料目录导入根目录 | +组织人员同步配置: + +| 配置 | 默认值 | 用途 | +|---|---|---| +| `AIHR_ORG_SYNC_BASE_URL` / `-Daihr.org-sync.base-url` | 空 | 外部开放平台 `/open/v1` 前缀,例如 `https://hr.example.com/open/v1` | +| `AIHR_ORG_SYNC_ACCESS_TOKEN` / `-Daihr.org-sync.access-token` | 空 | 已有 Bearer token;为空时尝试用 client credentials 换 token | +| `AIHR_ORG_SYNC_CLIENT_ID` / `-Daihr.org-sync.client-id` | 空 | 开放平台应用 `client_id`,也用于签名头 `X-Client-Id` | +| `AIHR_ORG_SYNC_CLIENT_SECRET` / `-Daihr.org-sync.client-secret` | 空 | `POST /auth/token` 换取访问令牌 | +| `AIHR_ORG_SYNC_SIGNING_SECRET` / `-Daihr.org-sync.signing-secret` | 空 | 可选 HMAC-SHA256 签名密钥;配置后请求带 `X-Timestamp/X-Nonce/X-Signature` | + +```bash +TOKEN=<登录后 access_token> +curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"dryRun":true}' + +curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"replaceExisting":true}' +``` + ## 前端落点 - 管理端前端:`frontend/`,只承载后台管理、配置、审核、查看结果等页面。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 8ab204b4..9412ad22 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -17,7 +17,7 @@ - Qdrant REST:`http://127.0.0.1:6333`,默认 collection `aihr_knowledge` - 服务端资料导入根目录:`./.data/import` -Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。 +Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDRANT_URL`、`AIHR_QDRANT_COLLECTION`、`AIHR_QDRANT_API_KEY` 覆盖。服务端资料导入根目录可用 `AIHR_IMPORT_ROOT` 或 `-Daihr.import.root` 覆盖。组织人员同步可用 `AIHR_ORG_SYNC_BASE_URL` 指向外部开放平台 `/open/v1` 前缀,并配置 `AIHR_ORG_SYNC_ACCESS_TOKEN` 或 `AIHR_ORG_SYNC_CLIENT_ID`/`AIHR_ORG_SYNC_CLIENT_SECRET`;如外部要求 HMAC 签名,再配置 `AIHR_ORG_SYNC_SIGNING_SECRET`。移动端手机号登录的短信模板 ID、阿里云 AccessKey、Secret 和短信签名都通过环境变量注入;本地放根目录 `.env.local`,`scripts/dev-backend.sh` 会自动加载。 本地 `.env.local` 示例: @@ -28,6 +28,11 @@ ALIYUN_SMS_ACCESS_KEY_SECRET=xxx ALIYUN_SMS_SIGN_NAME=物业AI助手 # 演示兜底:非空则不真发短信,验证码固定为该值;dev 默认 123456,prod profile 代码级强制失效 AIHR_SMS_DEV_FIXED_CODE=123456 +AIHR_ORG_SYNC_BASE_URL=https://hr.example.com/open/v1 +AIHR_ORG_SYNC_ACCESS_TOKEN= +AIHR_ORG_SYNC_CLIENT_ID= +AIHR_ORG_SYNC_CLIENT_SECRET= +AIHR_ORG_SYNC_SIGNING_SECRET= ``` `application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。 @@ -68,7 +73,7 @@ AIHR_SMS_DEV_FIXED_CODE=123456 - `aihr_practice_mysql8.sql` 已导入;移动端员工训练记录落 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像聚合 - `aihr_interview_result_mysql8.sql` 已纳入 reset 脚本;AI 面试评分完成后结果落 `aihr_interview_result` - `aihr_candidate_material_mysql8.sql` 已纳入;候选人端补充资料文件写 `sys_oss`/MinIO,关系落 `aihr_candidate_material` -- `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员静态快照(2 个住宅项目 22 人,项目经理/主管/一线三层),支撑按项目看人数演示叙事 +- `aihr_org_snapshot_mysql8.sql` 已纳入 reset 脚本;组织人员本地 seed(2 个住宅项目 22 人,项目经理/主管/一线三层)支撑演示,外部开放组织系统配置完成后用 `POST /api/aihr/org/sync` 拉取 `company/department/employee` 快照并覆盖本地 `aihr_org_snapshot` - SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时 - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。 diff --git a/docs/open-org-sync-api-design-v1.md b/docs/open-org-sync-api-design-v1.md new file mode 100644 index 00000000..dbeab024 --- /dev/null +++ b/docs/open-org-sync-api-design-v1.md @@ -0,0 +1,1700 @@ +# 开放平台组织与预算同步 API 设计方案 v1 + +## 1. 文档目标 + +本文档用于定义本系统对外开放的组织与预算同步方案,覆盖以下对象: + +- 集团 +- 公司 +- 部门 +- 员工 +- 预算 + +本文档重点解决以下问题: + +- 如何对外提供统一、可控、可审计的开放接口 +- 如何通过“变更通知 + 主动拉取”的方式降低系统开销 +- 如何通过 7 天拉取窗口、过期处理、补数申请提高安全性 +- 如何在跨数据中心、跨公网传递时通过传输加密与内容加密保障数据安全 +- 如何记录通知、拉取、同步结果,便于事后追踪和排障 +- 如何在现有系统表结构基础上最小代价落地 + +--- + +## 2. 设计结论 + +本方案采用以下核心模式: + +1. 内部业务数据发生变化后,系统生成变更事件 +2. 系统将变更事件通知给订阅者 +3. 订阅者收到通知后,在 7 天有效窗口内主动拉取最新数据 +4. 超过 7 天后,不再允许直接拉取对应增量数据 +5. 如订阅者错过窗口,必须发起补数申请 +6. 全链路记录通知状态、拉取状态、同步结果和失败原因 + +这是一种兼顾性能、安全和可运维性的企业级设计。 + +--- + +## 3. 总体架构 + +### 3.1 角色 + +- 主系统:当前 `backend` 服务,对外统一提供开放接口 +- 订阅者系统:外部 ERP、HR、财务、BI、协同系统等 +- 开放平台应用:每个外部系统在本系统中注册的独立接入身份 +- 通知中心:负责事件生成、投递、重试、过期、补数审批 + +### 3.2 架构原则 + +- 对外统一入口:全部走 `backend` 的 `/open/v1/*` +- 内外账号隔离:外部系统不用内部员工登录账号 +- 事件通知最小化:通知消息只传事件元信息,不传完整业务数据 +- 数据主动拉取:业务明细通过开放接口主动拉取 +- 时间窗口受控:增量数据默认仅保留 7 天在线可拉取能力 +- 审计可追踪:每次通知、每次拉取、每次补数都有日志 +- 数据范围受控:必须按 `group_id / company_id / department_id` 控制可见范围 + +### 3.3 推荐流程 + +```text +组织/员工/预算数据变更 + ↓ +写入业务表 + ↓ +写入开放事件表 open_event_outbox + ↓ +按订阅关系分发通知 + ↓ +订阅者收到消息 + ↓ +7天内调用 /open/v1/sync/changes 或对象接口主动拉取 + ↓ +记录同步任务、同步明细、游标、审计日志 + ↓ +超过7天未拉取 → 事件窗口过期 + ↓ +订阅者发起补数申请 + ↓ +平台审批后执行事件重放或快照补数 +``` + +--- + +## 4. 时间窗口与过期策略 + +### 4.1 7 天拉取窗口 + +每条变更事件从 `occurred_at` 开始计算,有一个固定的在线增量拉取窗口: + +- 默认窗口:7 天 +- 字段体现:`available_until = occurred_at + 7天` +- 在 `available_until` 之前: + - 允许按 `event_id` + - 允许按 `cursor` + - 允许按 `since_time` + - 允许按资源对象主动拉取最新数据 + +### 4.2 为什么采用 7 天窗口 + +- 足以覆盖大多数下游系统短时故障、周末值守空档、版本发布波动 +- 可以显著降低系统长期保留高频增量拉取资源的压力 +- 可以降低历史数据被无限期遍历和恶意批量拉取的风险 + +### 4.3 过期处理 + +当当前时间大于 `available_until` 时: + +- 增量接口不再返回该事件对应的明细数据 +- 事件状态从 `available` 进入 `expired` +- 同步接口返回过期错误 + +建议错误返回: + +- HTTP:`410 Gone` +- 业务错误码:`EVENT_WINDOW_EXPIRED` + +返回示例: + +```json +{ + "success": false, + "code": "EVENT_WINDOW_EXPIRED", + "message": "该事件的在线拉取窗口已过期,请发起补数申请", + "data": { + "event_id": "evt_202607030001", + "occurred_at": "2026-07-03T09:00:00+08:00", + "available_until": "2026-07-10T09:00:00+08:00", + "replay_supported": true + } +} +``` + +### 4.4 过期后的补救方式 + +过期后不允许直接下载原增量,但允许走受控补数机制: + +1. 事件重放 +2. 时间段补数 +3. 指定对象补数 +4. 全量快照重建 + +### 4.5 推荐保留策略 + +- 在线可拉取增量明细:7 天 +- 事件元数据与同步日志:至少 180 天 +- 审计日志:至少 180 天,建议 1 年 +- 补数申请记录:长期保留 + +--- + +## 5. 安全设计 + +### 5.1 接入身份 + +每个订阅者系统必须创建独立开放平台应用,不复用内部用户账号。 + +推荐使用: + +- `client_id` +- `client_secret` +- 可选 `app_key` +- 可选 IP 白名单 +- 可选 mTLS + +### 5.2 鉴权模式 + +开放平台建议采用两层鉴权: + +1. `client_id + client_secret` 换取访问令牌 +2. 每次请求携带签名头做防篡改、防重放校验 + +### 5.3 请求头规范 + +- `Authorization: Bearer ` +- `X-Client-Id` +- `X-Timestamp` +- `X-Nonce` +- `X-Signature` +- `X-Trace-Id` +- `X-Content-Encrypted` +- `X-Encryption-Key-Id` +- `X-Encryption-Alg` + +### 5.4 签名规则 + +建议签名原文: + +```text +HTTP_METHOD + "\n" + +PATH + "\n" + +SORTED_QUERY_STRING + "\n" + +BODY_SHA256 + "\n" + +X-Timestamp + "\n" + +X-Nonce +``` + +签名算法: + +- `HMAC-SHA256` + +### 5.5 安全控制项 + +- 时间戳有效期:5 分钟 +- `nonce` 防重放:同一个 `client_id + nonce` 在有效期内不可重复使用 +- IP 白名单:按开放平台应用控制 +- 限流:按应用、IP、接口三级限流 +- 敏感字段脱敏:手机号、身份证、银行卡等默认脱敏 +- 预算权限分离:预算查询、预算调整、预算审批分开授权 +- 审计全留痕:所有请求、响应、失败原因、耗时必须可追踪 + +### 5.6 跨数据中心安全分层 + +如果接口调用需要跨数据中心并通过互联网传递,安全控制必须分为四层: + +- 网络层:专线、VPN、云企业网、IPSec 隧道优先;公网直连必须使用固定出口 IP 和白名单。 +- 传输层:强制 HTTPS TLS 1.2+,推荐 TLS 1.3;重要订阅者启用 mTLS 双向证书。 +- 消息层:请求签名、防重放、时间戳、nonce、trace_id 全量校验。 +- 内容层:涉及敏感字段或预算金额时,对响应内容进行字段级或报文级加密。 + +结论: + +- HTTPS 解决链路加密。 +- HMAC 签名解决身份校验和防篡改。 +- 内容加密解决数据离开系统边界后的二次保护。 +- mTLS 解决服务身份强校验。 + +### 5.7 内容级加密策略 + +内容级加密用于保护业务数据本身,即使 HTTPS 终止在网关、代理、负载均衡或跨中心链路中间节点,也不会暴露明文业务内容。 + +建议按数据敏感度启用: + +- 普通组织字段:可只使用 HTTPS + 签名。 +- 员工敏感字段:必须字段级加密或脱敏。 +- 身份证、银行卡、薪资、紧急联系人:默认不开放;获批后必须字段级加密。 +- 预算金额、预算计划、预算执行:跨公网传递时建议报文级加密。 +- 补数快照:必须报文级加密,并限制临时窗口有效期。 + +### 5.8 推荐加密方式 + +建议采用信封加密: + +1. 服务端生成一次性数据密钥 `data_key` +2. 使用 `AES-256-GCM` 加密业务响应内容 +3. 使用订阅者公钥或平台托管密钥加密 `data_key` +4. 响应中返回加密后的 `encrypted_key`、`iv`、`tag`、`ciphertext` +5. 订阅者使用自己的私钥或密钥管理服务解开 `data_key` +6. 再使用 `data_key` 解密业务内容 + +推荐算法: + +- 对称加密:`AES-256-GCM` +- 非对称加密:`RSA-OAEP-256` 或 `SM2` +- 摘要算法:`SHA-256` +- 签名算法:`HMAC-SHA256` + +如果国产化要求较强,可采用: + +- `SM2` 用于密钥交换或数字信封 +- `SM4-GCM` 用于内容加密 +- `SM3` 用于摘要 + +### 5.9 加密响应格式 + +当 `X-Content-Encrypted: true` 时,响应体建议统一为: + +```json +{ + "success": true, + "code": "OK", + "message": "操作成功", + "data": { + "encrypted": true, + "key_id": "key_20260703_001", + "alg": "AES-256-GCM", + "encrypted_key": "base64-rsa-oaep-encrypted-data-key", + "iv": "base64-iv", + "tag": "base64-gcm-tag", + "ciphertext": "base64-encrypted-json" + } +} +``` + +加密前明文示例: + +```json +{ + "event_id": "evt_202607030001", + "resource_type": "employee", + "resource_id": 135, + "payload": { + "name": "张三", + "phone": "13800000000", + "department_id": 108 + } +} +``` + +### 5.10 密钥管理要求 + +密钥管理必须独立于业务数据管理。 + +建议要求: + +- 不在数据库中明文保存任何 `client_secret`、私钥或数据密钥。 +- `client_secret` 只保存哈希。 +- 内容加密主密钥由 KMS、HSM 或独立密钥服务托管。 +- 每个订阅者至少独立配置一个 `key_id`。 +- 支持密钥轮换,旧密钥只保留解密能力,不再用于新数据加密。 +- 支持密钥吊销,吊销后订阅者不能继续拉取加密数据。 +- 审计日志只记录 `key_id` 和加密状态,不记录明文密钥或明文敏感数据。 + +### 5.11 公网传输安全基线 + +跨公网调用必须满足以下基线: + +- 强制 HTTPS,禁止 HTTP。 +- 启用服务端证书校验,禁止忽略证书错误。 +- 生产环境推荐 mTLS。 +- 配置固定出口 IP 和订阅方 IP 白名单。 +- 所有请求必须签名并校验 `nonce`。 +- 敏感响应必须启用内容加密。 +- 审计日志禁止落明文敏感字段。 +- 补数快照必须走临时窗口和内容加密。 +- 下载类接口必须设置短有效期 URL,且绑定订阅者身份与 IP。 + +--- + +## 6. 数据范围模型 + +### 6.1 基本原则 + +开放平台数据访问必须按租户和组织范围控制,不允许“接口全开放”。 + +### 6.2 推荐范围控制维度 + +- `group_id` +- `company_id` +- `department_id` +- 资源类型 +- 权限范围 `scope` + +### 6.3 建议权限范围 + +- `group.read` +- `company.read` +- `department.read` +- `employee.read` +- `budget.read` +- `sync.read` +- `sync.replay` +- `subscription.manage` +- `audit.read` + +### 6.4 订阅范围限制 + +每个订阅关系必须明确指定允许访问的范围: + +- 指定集团 +- 指定公司列表 +- 指定部门列表 +- 指定资源类型 +- 指定事件类型 + +--- + +## 7. 数据同步模型 + +### 7.1 事件类型 + +#### 组织事件 + +- `group.created` +- `group.updated` +- `group.deleted` +- `company.created` +- `company.updated` +- `company.deleted` +- `department.created` +- `department.updated` +- `department.deleted` +- `org.structure.changed` + +#### 员工事件 + +- `employee.created` +- `employee.updated` +- `employee.departed` +- `employee.reinstated` +- `employee.transferred` + +#### 预算事件 + +- `budget.version.created` +- `budget.version.updated` +- `budget.subject.updated` +- `budget.item.updated` +- `budget.plan.created` +- `budget.plan.updated` +- `budget.plan.locked` + +### 7.2 事件消息最小模型 + +```json +{ + "event_id": "evt_202607030001", + "trace_id": "trace_20260703_001", + "event_type": "employee.updated", + "resource_type": "employee", + "resource_id": 135, + "group_id": 209, + "company_id": 33, + "department_id": 108, + "version": 12, + "occurred_at": "2026-07-03T09:00:00+08:00", + "available_until": "2026-07-10T09:00:00+08:00", + "changed_fields": ["department_id", "job_title", "status"], + "replay_supported": true +} +``` + +### 7.3 为什么不在消息里直接放完整数据 + +- 降低通知体积 +- 降低敏感数据在消息通道暴露的风险 +- 便于版本控制 +- 便于重放和幂等处理 +- 便于订阅者自主控制拉取节奏 + +--- + +## 8. 状态机设计 + +### 8.1 事件状态 + +- `pending`:事件已生成,待投递 +- `sent`:已向订阅者下发通知 +- `received`:订阅者已收到通知 +- `acked`:订阅者已确认收到 +- `available`:处于 7 天在线拉取窗口 +- `expired`:已过期,不能再在线拉取 +- `replayed`:已触发重放 +- `closed`:生命周期已关闭 + +### 8.2 同步状态 + +- `pending`:待同步 +- `pulling`:订阅者正在拉取 +- `success`:同步成功 +- `partial_success`:部分成功 +- `failed`:同步失败 +- `retrying`:系统正在重试 +- `stale`:因版本过旧被丢弃 +- `expired`:窗口过期未同步 + +### 8.3 补数申请状态 + +- `pending_review`:待审批 +- `approved`:已批准 +- `rejected`:已拒绝 +- `processing`:补数处理中 +- `completed`:补数完成 +- `failed`:补数失败 +- `cancelled`:已取消 + +--- + +## 9. API 总览 + +开放平台统一前缀: + +```text +/open/v1 +``` + +### 9.1 认证接口 + +- `POST /open/v1/auth/token` +- `POST /open/v1/auth/refresh` +- `GET /open/v1/auth/me` + +### 9.2 订阅管理接口 + +- `POST /open/v1/subscriptions` +- `GET /open/v1/subscriptions` +- `GET /open/v1/subscriptions/{subscription_id}` +- `PATCH /open/v1/subscriptions/{subscription_id}` +- `POST /open/v1/subscriptions/{subscription_id}/enable` +- `POST /open/v1/subscriptions/{subscription_id}/disable` + +### 9.3 事件通知接口 + +- `GET /open/v1/events` +- `GET /open/v1/events/{event_id}` +- `POST /open/v1/events/{event_id}/ack` +- `POST /open/v1/events/{event_id}/nack` + +### 9.4 数据拉取接口 + +- `GET /open/v1/sync/changes` +- `GET /open/v1/sync/snapshot` +- `GET /open/v1/groups/{id}` +- `GET /open/v1/companies/{id}` +- `GET /open/v1/departments/{id}` +- `GET /open/v1/employees/{id}` +- `GET /open/v1/budget-versions/{id}` +- `GET /open/v1/budget-subjects/{id}` +- `GET /open/v1/budget-items/{id}` +- `GET /open/v1/budget-plans/{id}` + +### 9.5 同步任务接口 + +- `POST /open/v1/sync/jobs` +- `GET /open/v1/sync/jobs/{job_id}` +- `POST /open/v1/sync/jobs/{job_id}/complete` + +### 9.6 补数申请接口 + +- `POST /open/v1/replay-requests` +- `GET /open/v1/replay-requests` +- `GET /open/v1/replay-requests/{request_id}` +- `POST /open/v1/replay-requests/{request_id}/cancel` +- `POST /open/v1/replay-requests/{request_id}/approve` +- `POST /open/v1/replay-requests/{request_id}/reject` +- `POST /open/v1/replay-requests/{request_id}/execute` + +### 9.7 追踪与审计接口 + +- `GET /open/v1/traces/{trace_id}` +- `GET /open/v1/audits` +- `GET /open/v1/subscriptions/{subscription_id}/deliveries` +- `GET /open/v1/subscriptions/{subscription_id}/sync-records` + +--- + +## 10. 认证接口设计 + +### 10.1 获取令牌 + +`POST /open/v1/auth/token` + +请求示例: + +```json +{ + "grant_type": "client_credentials", + "client_id": "ext_hr_001", + "client_secret": "******" +} +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "access_token": "token_xxx", + "token_type": "Bearer", + "expires_in": 7200, + "scope": "company.read department.read employee.read sync.read" + } +} +``` + +### 10.2 刷新令牌 + +`POST /open/v1/auth/refresh` + +### 10.3 查询当前应用 + +`GET /open/v1/auth/me` + +返回内容: + +- 应用 ID +- 应用名称 +- 权限范围 +- 绑定集团 +- 可见公司 +- 可见部门 +- IP 白名单状态 + +--- + +## 11. 订阅管理接口设计 + +### 11.1 创建订阅 + +`POST /open/v1/subscriptions` + +请求示例: + +```json +{ + "subscriber_name": "外部HR系统", + "callback_url": "https://hr.example.com/open/events", + "pull_base_url": "https://hr.example.com/open/pull", + "event_types": [ + "department.created", + "department.updated", + "employee.created", + "employee.updated", + "employee.departed" + ], + "scope": { + "group_id": 209, + "company_ids": [33, 34], + "department_ids": [] + }, + "security": { + "signing_method": "hmac-sha256", + "ip_whitelist": ["10.10.10.10"], + "allow_replay": true, + "content_encryption_required": true, + "encryption_alg": "AES-256-GCM", + "encryption_key_id": "key_20260703_001" + } +} +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "subscription_id": 10001, + "status": "enabled" + } +} +``` + +### 11.2 查询订阅列表 + +`GET /open/v1/subscriptions` + +支持筛选: + +- `status` +- `subscriber_name` +- `group_id` + +### 11.3 启停订阅 + +- `POST /open/v1/subscriptions/{subscription_id}/enable` +- `POST /open/v1/subscriptions/{subscription_id}/disable` + +--- + +## 12. 事件通知接口设计 + +### 12.1 拉取事件列表 + +`GET /open/v1/events` + +查询参数: + +- `status` +- `event_type` +- `since_time` +- `limit` + +返回示例: + +```json +{ + "success": true, + "data": { + "items": [ + { + "event_id": "evt_202607030001", + "trace_id": "trace_20260703_001", + "event_type": "employee.updated", + "resource_type": "employee", + "resource_id": 135, + "occurred_at": "2026-07-03T09:00:00+08:00", + "available_until": "2026-07-10T09:00:00+08:00", + "status": "available" + } + ] + } +} +``` + +### 12.2 事件确认收到 + +`POST /open/v1/events/{event_id}/ack` + +请求示例: + +```json +{ + "received_at": "2026-07-03T09:00:05+08:00", + "receiver": "hr-sync-worker-01" +} +``` + +### 12.3 事件确认失败 + +`POST /open/v1/events/{event_id}/nack` + +请求示例: + +```json +{ + "error_code": "SIGNATURE_INVALID", + "error_message": "签名校验失败" +} +``` + +--- + +## 13. 数据拉取接口设计 + +### 13.1 增量拉取 + +`GET /open/v1/sync/changes` + +查询参数: + +- `since_time`:开始时间 +- `cursor`:上次同步游标 +- `resource_type`:资源类型 +- `event_type`:事件类型 +- `limit`:单次拉取条数,建议不超过 500 + +说明: + +- 优先使用 `cursor` +- 没有 `cursor` 时可降级使用 `since_time` +- 仅返回当前窗口内允许在线拉取的数据 + +响应示例: + +```json +{ + "success": true, + "data": { + "cursor": "cur_20260703090000123", + "has_more": true, + "items": [ + { + "event_id": "evt_202607030001", + "trace_id": "trace_20260703_001", + "event_type": "employee.updated", + "resource_type": "employee", + "resource_id": 135, + "version": 12, + "changed_fields": ["department_id", "job_title"], + "occurred_at": "2026-07-03T09:00:00+08:00", + "available_until": "2026-07-10T09:00:00+08:00" + } + ] + } +} +``` + +### 13.2 全量快照拉取 + +`GET /open/v1/sync/snapshot` + +查询参数: + +- `resource_type` +- `group_id` +- `company_id` +- `department_id` +- `page` +- `page_size` + +用途: + +- 首次接入 +- 下游系统重建 +- 补数审批后全量重拉 + +### 13.3 单对象拉取 + +#### 集团 + +`GET /open/v1/groups/{id}` + +#### 公司 + +`GET /open/v1/companies/{id}` + +#### 部门 + +`GET /open/v1/departments/{id}` + +#### 员工 + +`GET /open/v1/employees/{id}` + +#### 预算版本 + +`GET /open/v1/budget-versions/{id}` + +#### 预算科目 + +`GET /open/v1/budget-subjects/{id}` + +#### 预算项 + +`GET /open/v1/budget-items/{id}` + +#### 预算计划 + +`GET /open/v1/budget-plans/{id}` + +### 13.4 单对象接口通用参数 + +- `fields`:指定返回字段 +- `version`:期望版本号 +- `include_deleted`:是否包含已删除记录 + +--- + +## 14. 同步任务接口设计 + +### 14.1 创建同步任务 + +`POST /open/v1/sync/jobs` + +用途: + +- 订阅者在开始一轮同步前登记任务 +- 平台记录该次同步和哪些事件关联 + +请求示例: + +```json +{ + "sync_type": "incremental", + "subscription_id": 10001, + "start_cursor": "cur_20260703090000123", + "resource_types": ["department", "employee"] +} +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "job_id": "job_20260703_0001", + "job_status": "pending" + } +} +``` + +### 14.2 查询同步任务 + +`GET /open/v1/sync/jobs/{job_id}` + +### 14.3 回报同步结果 + +`POST /open/v1/sync/jobs/{job_id}/complete` + +请求示例: + +```json +{ + "result": "partial_success", + "pull_started_at": "2026-07-03T09:00:10+08:00", + "pull_finished_at": "2026-07-03T09:00:22+08:00", + "synced_count": 120, + "failed_count": 2, + "error_items": [ + { + "resource_type": "employee", + "resource_id": 138, + "error_code": "VERSION_CONFLICT", + "error_message": "下游版本较新,拒绝覆盖" + } + ] +} +``` + +--- + +## 15. 7 天过期处理接口行为 + +### 15.1 增量接口过期行为 + +当请求的数据窗口已超过 7 天: + +- 不返回业务明细 +- 返回过期错误 +- 返回推荐补数路径 + +### 15.2 对象接口过期行为 + +说明: + +- 单对象接口始终返回对象当前最新状态 +- 但不保证返回“某个过期事件时刻”的历史镜像 +- 如果要恢复当时增量链路,必须走补数申请 + +### 15.3 埋点建议 + +在同步链路中必须记录: + +- 事件通知时间 +- 订阅者确认收到时间 +- 开始拉取时间 +- 拉取完成时间 +- 同步成功/失败状态 +- 是否因过期进入补数流程 + +--- + +## 16. 补数申请接口设计 + +### 16.1 设计目标 + +补数申请用于处理以下场景: + +- 下游系统故障错过 7 天窗口 +- 某类资源同步失败需要补拉 +- 指定部门或员工数据需要重新对齐 +- 全量快照需要重建 + +### 16.2 补数模式 + +- `event_replay`:按事件重放 +- `time_range_replay`:按时间段补数 +- `resource_replay`:按对象补数 +- `snapshot_rebuild`:全量快照重建 + +### 16.3 发起补数申请 + +`POST /open/v1/replay-requests` + +请求示例: + +```json +{ + "replay_type": "time_range_replay", + "subscription_id": 10001, + "reason": "下游系统数据库锁表,7天内未完成同步", + "resource_types": ["employee", "department"], + "start_time": "2026-06-20T00:00:00+08:00", + "end_time": "2026-06-28T23:59:59+08:00", + "group_id": 209, + "company_ids": [33] +} +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "request_id": "rr_202607030001", + "status": "pending_review" + } +} +``` + +### 16.4 查询补数申请 + +`GET /open/v1/replay-requests` + +### 16.5 查询补数申请详情 + +`GET /open/v1/replay-requests/{request_id}` + +### 16.6 取消补数申请 + +`POST /open/v1/replay-requests/{request_id}/cancel` + +### 16.7 审批补数申请 + +#### 批准 + +`POST /open/v1/replay-requests/{request_id}/approve` + +#### 驳回 + +`POST /open/v1/replay-requests/{request_id}/reject` + +### 16.8 执行补数 + +`POST /open/v1/replay-requests/{request_id}/execute` + +执行方式: + +- 重新开放临时拉取窗口 +- 生成重放事件批次 +- 生成受控快照任务 + +--- + +## 17. 追踪与审计接口设计 + +### 17.1 查询链路追踪 + +`GET /open/v1/traces/{trace_id}` + +返回应包含: + +- 事件信息 +- 投递记录 +- ACK 记录 +- 同步任务 +- 同步结果 +- 失败原因 +- 是否过期 +- 是否补数 + +### 17.2 查询审计日志 + +`GET /open/v1/audits` + +可按以下条件筛选: + +- `client_id` +- `subscription_id` +- `trace_id` +- `api_path` +- `start_time` +- `end_time` +- `response_code` + +--- + +## 18. 错误码设计 + +### 18.1 通用错误码 + +- `INVALID_PARAM`:参数错误 +- `UNAUTHORIZED`:未授权 +- `FORBIDDEN_SCOPE`:超出授权范围 +- `SIGNATURE_INVALID`:签名错误 +- `TIMESTAMP_EXPIRED`:时间戳过期 +- `NONCE_REUSED`:随机串重复使用 +- `RATE_LIMITED`:接口限流 +- `RESOURCE_NOT_FOUND`:资源不存在 +- `VERSION_CONFLICT`:版本冲突 +- `INTERNAL_ERROR`:系统内部错误 + +### 18.2 同步相关错误码 + +- `EVENT_WINDOW_EXPIRED`:事件在线拉取窗口已过期 +- `REPLAY_NOT_ALLOWED`:当前应用无补数权限 +- `REPLAY_REVIEW_REQUIRED`:需要审批后方可补数 +- `SNAPSHOT_TOO_LARGE`:快照范围过大 +- `CURSOR_INVALID`:游标非法 +- `SYNC_JOB_NOT_FOUND`:同步任务不存在 +- `CONTENT_ENCRYPTION_REQUIRED`:当前资源必须启用内容加密 +- `ENCRYPTION_KEY_NOT_FOUND`:未找到可用加密密钥 +- `ENCRYPTION_KEY_REVOKED`:加密密钥已吊销 +- `DECRYPTION_FAILED`:订阅方解密失败或密文格式不正确 + +--- + +## 19. 分页与返回格式 + +### 19.1 统一响应格式 + +```json +{ + "success": true, + "code": "OK", + "message": "操作成功", + "data": {} +} +``` + +### 19.2 分页响应格式 + +```json +{ + "success": true, + "data": { + "items": [], + "page": 1, + "page_size": 50, + "total": 1200 + } +} +``` + +### 19.3 游标响应格式 + +```json +{ + "success": true, + "data": { + "items": [], + "cursor": "cur_xxx", + "has_more": true + } +} +``` + +--- + +## 20. 业务对象字段建议 + +### 20.1 集团对象 + +建议字段: + +- `id` +- `name` +- `code` +- `status` +- `created_at` +- `updated_at` + +### 20.2 公司对象 + +建议字段尽量复用现有 `companies`: + +- `id` +- `group_id` +- `name` +- `code` +- `status` +- `address` +- `contact_person` +- `contact_phone` +- `description` +- `updated_at` + +### 20.3 部门对象 + +建议字段尽量复用现有 `departments`: + +- `id` +- `group_id` +- `company_id` +- `name` +- `code` +- `parent_id` +- `level` +- `sort_order` +- `manager_id` +- `status` +- `updated_at` + +### 20.4 员工对象 + +建议字段尽量复用现有 `employees`,但对外输出做裁剪与脱敏: + +- `id` +- `group_id` +- `company_id` +- `department_id` +- `user_id` +- `employee_number` +- `name` +- `phone` +- `email` +- `status` +- `job_title` +- `hire_date` +- `leave_date` +- `updated_at` + +说明: + +- `id_card` +- `bank_account` +- `salary_*` +- `emergency_*` + +默认不对外开放,必须经过单独授权。 + +### 20.5 预算对象 + +为减少改造量,建议对外预算同步分 4 类: + +- `budget_versions` +- `budget_subjects` +- `budget_items` +- `budget_plans` + +--- + +## 21. 表结构设计 + +本节分为两类: + +1. 复用现有业务主表 +2. 新增开放平台支撑表 + +### 21.1 复用现有业务主表 + +#### 21.1.1 集团表 `groups` + +说明: + +- 当前 `companies.group_id` 已关联 `groups.id` +- 对外集团数据建议直接基于现有 `groups` 表输出 + +建议对外字段: + +- `id` +- `name` +- `code` +- `status` +- `created_at` +- `updated_at` + +#### 21.1.2 公司表 `companies` + +说明: + +- 已存在,建议直接复用 +- 当前关键字段已满足对外同步主诉求 + +关键字段: + +- `id` +- `group_id` +- `name` +- `code` +- `address` +- `logo_url` +- `description` +- `introduction` +- `legal_representative` +- `business_license` +- `status` +- `company_nature` +- `contact_person` +- `contact_phone` +- `created_at` +- `updated_at` + +#### 21.1.3 部门表 `departments` + +说明: + +- 已存在,建议直接复用 + +关键字段: + +- `id` +- `name` +- `code` +- `description` +- `parent_id` +- `level` +- `sort_order` +- `status` +- `company_id` +- `group_id` +- `manager_id` +- `created_at` +- `updated_at` + +#### 21.1.4 员工表 `employees` + +说明: + +- 已存在,建议直接复用 +- 只是在开放接口层做字段裁剪 + +关键字段: + +- `id` +- `employee_id` +- `name` +- `phone` +- `email` +- `hire_date` +- `department_id` +- `position_id` +- `status` +- `user_id` +- `company_id` +- `group_id` +- `employee_number` +- `leave_date` +- `updated_at` + +#### 21.1.5 用户表 `users` + +说明: + +- 认证身份仍然只使用 `users` +- 员工与账号通过 `employees.user_id -> users.id` 关联 +- 对外不建议直接暴露完整 `users` 表,只在员工对象中按需映射 + +#### 21.1.6 预算版本表 `budget_versions` + +关键字段: + +- `id` +- `version_code` +- `version_name` +- `version_type` +- `budget_year` +- `start_month` +- `end_month` +- `status` +- `description` +- `group_id` +- `company_id` +- `created_by` +- `updated_by` +- `created_at` +- `updated_at` + +#### 21.1.7 预算科目表 `budget_subjects` + +说明: + +- 已存在,建议直接复用 +- 对外用于同步预算科目树 + +#### 21.1.8 预算项表 `budget_items` + +说明: + +- 已存在,建议直接复用 +- 对外用于同步预算项 + +#### 21.1.9 预算计划表 `budget_plans` + +说明: + +- 已存在,建议直接复用 +- 对外用于同步预算计划主数据 + +### 21.2 新增开放平台表 + +以下表建议新增,统一使用 `utf8mb4`,并补齐中文注释。 + +#### 21.2.1 开放平台应用表 `open_client_apps` + +用途: + +- 管理每个外部接入系统的应用身份 + +建议字段: + +- `id` bigint 主键 +- `client_id` varchar(64) 唯一,应用标识 +- `client_name` varchar(128) 应用名称 +- `client_secret_hash` varchar(255) 密钥哈希 +- `status` varchar(32) 状态:enabled/disabled +- `group_id` int 允许访问的默认集团 +- `allow_scopes` json 允许的权限范围 +- `ip_whitelist` json 白名单 IP 列表 +- `token_expire_seconds` int 访问令牌有效期秒数 +- `allow_replay` tinyint 是否允许补数申请 +- `content_encryption_required` tinyint 是否强制内容加密 +- `encryption_mode` varchar(32) 加密模式:none/field/payload +- `encryption_alg` varchar(64) 内容加密算法 +- `active_key_id` varchar(64) 当前启用密钥 ID +- `mtls_required` tinyint 是否强制 mTLS +- `public_key_fingerprint` varchar(128) 订阅者公钥指纹 +- `remark` varchar(500) 备注 +- `created_by` int 创建人用户 ID +- `updated_by` int 更新人用户 ID +- `created_at` datetime 创建时间 +- `updated_at` datetime 更新时间 + +#### 21.2.2 开放平台订阅表 `open_subscriptions` + +用途: + +- 管理订阅者接收哪些事件、哪些范围的数据 + +建议字段: + +- `id` bigint 主键 +- `client_app_id` bigint 应用 ID +- `subscriber_name` varchar(128) 订阅者名称 +- `callback_url` varchar(255) 回调地址 +- `pull_base_url` varchar(255) 拉取基础地址 +- `event_types` json 订阅事件列表 +- `scope_group_id` int 订阅集团范围 +- `scope_company_ids` json 订阅公司范围 +- `scope_department_ids` json 订阅部门范围 +- `resource_types` json 订阅资源类型 +- `status` varchar(32) enabled/disabled +- `content_encryption_required` tinyint 是否强制内容加密 +- `encryption_key_id` varchar(64) 当前订阅使用的密钥 ID +- `last_sync_at` datetime 最近同步时间 +- `created_at` datetime 创建时间 +- `updated_at` datetime 更新时间 + +#### 21.2.3 开放事件主表 `open_event_outbox` + +用途: + +- 记录业务变更产生的标准事件 + +建议字段: + +- `id` bigint 主键 +- `event_id` varchar(64) 唯一事件号 +- `trace_id` varchar(64) 链路追踪号 +- `event_type` varchar(64) 事件类型 +- `resource_type` varchar(32) 资源类型 +- `resource_id` bigint 资源主键 +- `group_id` int 集团 ID +- `company_id` int 公司 ID +- `department_id` int 部门 ID +- `version` bigint 资源版本号 +- `payload` json 事件最小载荷 +- `payload_encrypted` tinyint 事件载荷是否已加密 +- `payload_key_id` varchar(64) 事件载荷加密密钥 ID +- `payload_alg` varchar(64) 事件载荷加密算法 +- `occurred_at` datetime 事件发生时间 +- `available_until` datetime 在线拉取截止时间 +- `status` varchar(32) pending/sent/available/expired/closed +- `replay_supported` tinyint 是否允许补数 +- `created_at` datetime 创建时间 + +#### 21.2.4 事件投递表 `open_event_deliveries` + +用途: + +- 记录每条事件针对每个订阅者的投递情况 + +建议字段: + +- `id` bigint 主键 +- `event_id` varchar(64) 事件号 +- `subscription_id` bigint 订阅 ID +- `trace_id` varchar(64) 链路追踪号 +- `delivery_status` varchar(32) sent/received/acked/failed/expired +- `sent_at` datetime 发送时间 +- `received_at` datetime 接收时间 +- `acked_at` datetime 确认时间 +- `retry_count` int 重试次数 +- `last_retry_at` datetime 最近重试时间 +- `http_status` int 回调响应码 +- `error_code` varchar(64) 错误码 +- `error_message` varchar(500) 错误信息 +- `created_at` datetime 创建时间 +- `updated_at` datetime 更新时间 + +#### 21.2.5 同步任务表 `open_sync_jobs` + +用途: + +- 记录每一轮增量或全量同步任务 + +建议字段: + +- `id` bigint 主键 +- `job_id` varchar(64) 唯一任务号 +- `subscription_id` bigint 订阅 ID +- `trace_id` varchar(64) 链路追踪号 +- `sync_type` varchar(32) incremental/full/replay/snapshot +- `start_cursor` varchar(255) 开始游标 +- `end_cursor` varchar(255) 结束游标 +- `since_time` datetime 增量开始时间 +- `until_time` datetime 增量结束时间 +- `job_status` varchar(32) pending/pulling/success/partial_success/failed +- `synced_count` int 成功条数 +- `failed_count` int 失败条数 +- `expired_count` int 过期条数 +- `pull_started_at` datetime 开始拉取时间 +- `pull_finished_at` datetime 拉取结束时间 +- `error_message` varchar(500) 错误信息 +- `created_at` datetime 创建时间 +- `updated_at` datetime 更新时间 + +#### 21.2.6 同步明细表 `open_sync_job_items` + +用途: + +- 记录同步任务内每个对象的执行结果 + +建议字段: + +- `id` bigint 主键 +- `job_id` varchar(64) 任务号 +- `event_id` varchar(64) 事件号 +- `resource_type` varchar(32) 资源类型 +- `resource_id` bigint 资源 ID +- `version` bigint 资源版本号 +- `item_status` varchar(32) success/failed/skipped/stale/expired +- `error_code` varchar(64) 错误码 +- `error_message` varchar(500) 错误信息 +- `created_at` datetime 创建时间 + +#### 21.2.7 订阅游标表 `open_sync_cursors` + +用途: + +- 保存每个订阅者的最近成功游标 + +建议字段: + +- `id` bigint 主键 +- `subscription_id` bigint 订阅 ID +- `resource_type` varchar(32) 资源类型 +- `current_cursor` varchar(255) 当前游标 +- `last_event_id` varchar(64) 最近事件号 +- `last_synced_at` datetime 最近同步时间 +- `version` bigint 游标版本 +- `updated_at` datetime 更新时间 + +#### 21.2.8 补数申请表 `open_replay_requests` + +用途: + +- 记录订阅者的补数申请和审批结果 + +建议字段: + +- `id` bigint 主键 +- `request_id` varchar(64) 唯一申请号 +- `subscription_id` bigint 订阅 ID +- `client_app_id` bigint 应用 ID +- `replay_type` varchar(32) event_replay/time_range_replay/resource_replay/snapshot_rebuild +- `reason` varchar(500) 申请原因 +- `resource_types` json 资源类型列表 +- `event_ids` json 事件号列表 +- `group_id` int 集团 ID +- `company_ids` json 公司列表 +- `department_ids` json 部门列表 +- `start_time` datetime 开始时间 +- `end_time` datetime 结束时间 +- `status` varchar(32) pending_review/approved/rejected/processing/completed/failed/cancelled +- `review_comment` varchar(500) 审批意见 +- `approved_by` int 审批人用户 ID +- `approved_at` datetime 审批时间 +- `executed_at` datetime 执行时间 +- `created_at` datetime 创建时间 +- `updated_at` datetime 更新时间 + +#### 21.2.9 临时补数窗口表 `open_replay_windows` + +用途: + +- 为已审批的补数申请生成临时可拉取窗口 + +建议字段: + +- `id` bigint 主键 +- `request_id` varchar(64) 关联申请号 +- `window_token` varchar(128) 补数窗口令牌 +- `start_time` datetime 允许拉取开始时间 +- `end_time` datetime 允许拉取结束时间 +- `expires_at` datetime 临时窗口过期时间 +- `status` varchar(32) active/used/expired/revoked +- `created_at` datetime 创建时间 +- `updated_at` datetime 更新时间 + +#### 21.2.10 审计日志表 `open_api_audit_logs` + +用途: + +- 记录开放接口全部访问痕迹 + +建议字段: + +- `id` bigint 主键 +- `trace_id` varchar(64) 链路追踪号 +- `client_id` varchar(64) 应用标识 +- `subscription_id` bigint 订阅 ID +- `api_path` varchar(255) 接口路径 +- `http_method` varchar(16) 请求方法 +- `request_ip` varchar(64) 请求 IP +- `request_headers` json 请求头摘要 +- `request_body` json 请求体摘要 +- `response_code` int 响应码 +- `response_body` json 响应体摘要 +- `content_encrypted` tinyint 响应内容是否加密 +- `encryption_key_id` varchar(64) 本次响应使用的密钥 ID +- `cost_ms` int 耗时毫秒 +- `created_at` datetime 创建时间 + +注意: + +- `request_body` 和 `response_body` 只能保存摘要、字段名、脱敏值或密文摘要。 +- 禁止在审计表中保存身份证、银行卡、薪资、手机号完整明文。 +- 如需排障,应通过 `trace_id`、`event_id`、`job_id` 关联业务链路,不通过审计日志还原明文数据。 + +--- + +## 22. 关键索引建议 + +### 22.1 `open_event_outbox` + +建议索引: + +- `uk_event_id (event_id)` +- `idx_event_type_occurred_at (event_type, occurred_at)` +- `idx_group_company_occurred_at (group_id, company_id, occurred_at)` +- `idx_status_available_until (status, available_until)` +- `idx_resource (resource_type, resource_id, version)` + +### 22.2 `open_event_deliveries` + +建议索引: + +- `idx_subscription_status (subscription_id, delivery_status)` +- `idx_event_subscription (event_id, subscription_id)` +- `idx_trace_id (trace_id)` + +### 22.3 `open_sync_jobs` + +建议索引: + +- `uk_job_id (job_id)` +- `idx_subscription_created_at (subscription_id, created_at)` +- `idx_job_status (job_status)` +- `idx_trace_id (trace_id)` + +### 22.4 `open_replay_requests` + +建议索引: + +- `uk_request_id (request_id)` +- `idx_subscription_status (subscription_id, status)` +- `idx_created_at (created_at)` + +--- + +## 23. 版本与幂等要求 + +### 23.1 版本号要求 + +建议所有开放资源同步时都带 `version` 字段。 + +规则: + +- 同一对象每次有效变更都递增版本号 +- 下游只允许更高版本覆盖更低版本 +- 避免旧消息覆盖新状态 + +### 23.2 幂等要求 + +以下动作必须幂等: + +- 事件 ACK +- 同步任务创建 +- 同步结果回报 +- 补数申请创建 +- 补数执行 + +幂等键建议: + +- `event_id` +- `job_id` +- `request_id` +- `trace_id` + +--- + +## 24. 落地实施建议 + +### 24.1 第一阶段 + +- 新增开放平台支撑表 +- 接入 `client_id + secret` +- 先开放组织和员工查询接口 +- 先做通知下发和增量拉取 + +### 24.2 第二阶段 + +- 接入 7 天窗口控制 +- 接入补数申请流程 +- 接入链路追踪和审计报表 + +### 24.3 第三阶段 + +- 接入预算开放接口 +- 接入审批流和预算高敏字段控制 +- 接入控制台页面 + +--- + +## 25. 最终建议 + +本方案最重要的 5 个落地原则如下: + +1. 对外通知只发事件,不直接发完整业务数据 +2. 增量数据只保留 7 天在线拉取窗口 +3. 过期后必须走受控补数,不允许无限制历史遍历 +4. 同步链路必须全埋点,保证“谁在什么时候同步了什么”可反查 +5. 跨公网传输必须采用 HTTPS + 签名 + 防重放,敏感内容必须做内容级加密 +6. 尽量复用现有 `companies / departments / employees / users / budget_*` 表,降低改造成本 + +--- + +## 26. 后续落地输出建议 + +基于本文档,下一步建议继续输出以下内容: + +- OpenAPI 3.0 Swagger 草稿 +- MySQL 建表 SQL 脚本 +- 后端模块目录设计 +- 接口权限矩阵 +- 事件发布与补数流程图