feat(aihr): add open org snapshot sync endpoint

新增 POST /api/aihr/org/sync,从开放组织系统 /open/v1/sync/snapshot
拉取公司/部门/员工快照刷新 aihr_org_snapshot;支持 dryRun 预检、
Bearer/client-credentials 鉴权与可选 HMAC 签名;外部未配置时保留
SQL seed。附接口契约设计 v1 与 API/DEV_SETUP 配置说明。
This commit is contained in:
2026-07-07 11:05:41 +08:00
parent f84f5c6530
commit f5137a2b05
6 changed files with 2259 additions and 3 deletions
@@ -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<SyncResponse> sync(@RequestBody(required = false) SyncRequest request) {
return R.ok(orgSyncService.sync(request));
}
}
@@ -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<String> warnings
) {
}
}
@@ -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<String> warnings = new ArrayList<>();
List<JsonNode> companyItems = fetchOptional(baseUrl, token, req, "company", pageSize, maxPages, warnings);
List<JsonNode> departmentItems = fetchOptional(baseUrl, token, req, "department", pageSize, maxPages, warnings);
List<JsonNode> employeeItems = fetchSnapshot(baseUrl, token, req, "employee", pageSize, maxPages);
Map<String, CompanyInfo> companies = companyMap(companyItems);
Map<String, DepartmentInfo> departments = departmentMap(departmentItems);
List<OrgRow> 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<JsonNode> fetchOptional(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages, List<String> 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<JsonNode> fetchSnapshot(String baseUrl, String token, SyncRequest req, String resourceType,
int pageSize, int maxPages) {
List<JsonNode> items = new ArrayList<>();
for (int page = 1; page <= maxPages; page++) {
Map<String, String> 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<String, String> 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<String> 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<OrgRow> 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<String, CompanyInfo> companies, Map<String, DepartmentInfo> 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<String, CompanyInfo> companyMap(List<JsonNode> items) {
Map<String, CompanyInfo> 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<String, DepartmentInfo> departmentMap(List<JsonNode> items) {
Map<String, DepartmentInfo> 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<String, String> 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<String, String> query) {
List<String> parts = new ArrayList<>();
for (Map.Entry<String, String> 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
};
}
}
}
+25 -1
View File
@@ -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 <access_token>`;浏览器内通过已登录前端和 `/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/`,只承载后台管理、配置、审核、查看结果等页面。
+7 -2
View File
@@ -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` 保留为同步调试接口。
File diff suppressed because it is too large Load Diff