调整实时定位推送为直接转换WebSocket消息

dev
shenzhidan 2026-07-23 16:50:00 +08:00
parent 1c630fa960
commit 7bfab8159f
9 changed files with 844 additions and 133 deletions

View File

@ -0,0 +1,32 @@
package com.zcloud.personnel.positioning.plan;
import com.jjb.saas.framework.job.Job;
import com.jjb.saas.framework.job.annotation.JobRegister;
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import com.zcloud.personnel.positioning.command.query.FindsStaffMappingSyncService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class FindsStaffMappingSyncJob implements Job {
private final FindsStaffMappingSyncService syncService;
@Override
@JobRegister(cron = "0 */5 * * * ?", jobDesc = "FindS人员映射同步", author = "系统", triggerStatus = 1)
@XxlJob("com.zcloud.personnel.positioning.plan.FindsStaffMappingSyncJob")
public ReturnT<String> execute(String param) {
try {
int count = syncService.syncAll();
String message = "FindS人员映射同步完成本次处理 " + count + " 条";
XxlJobHelper.log(message);
return new ReturnT<>(ReturnT.SUCCESS_CODE, message);
} catch (Exception e) {
String message = "FindS人员映射同步失败" + e.getMessage();
XxlJobHelper.log(message);
return new ReturnT<>(ReturnT.FAIL_CODE, message);
}
}
}

View File

@ -0,0 +1,287 @@
package com.zcloud.personnel.positioning.command.query;
import com.alibaba.cola.exception.BizException;
import com.fasterxml.jackson.databind.JsonNode;
import com.zcloud.personnel.positioning.integration.finds.FindsOpenApiClient;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Refreshes the FindS-to-local personnel mapping cache.
*
* <p>The cache contains identity and terminal snapshots only. Location coordinates are deliberately
* not persisted here and continue to come from FindS at query time.</p>
*/
@Component
@RequiredArgsConstructor
public class FindsStaffMappingSyncService {
private static final Logger LOGGER = LoggerFactory.getLogger(FindsStaffMappingSyncService.class);
private static final String STAFF_LIST_API = "finds.staff.list";
private static final String SOURCE_CONFIG_CODE = "FINDS";
private static final int PAGE_SIZE = 2000;
private static final int MAX_PAGE_COUNT = 50;
private static final int BATCH_CONCURRENCY = 4;
private final FindsOpenApiClient findsOpenApiClient;
private final PositionPersonLocalLookupService localLookupService;
private final JdbcTemplate jdbcTemplate;
public int syncAll() {
// The schema uses second precision; keep the same precision for the stale-row cutoff.
LocalDateTime syncTime = LocalDateTime.now().withNano(0);
List<StaffSnapshot> staffRows = loadAllStaff();
Set<String> staffNos = new LinkedHashSet<>();
for (StaffSnapshot row : staffRows) {
staffNos.add(row.staffNo);
}
Map<String, PositionPersonLocalLookupService.LocalPerson> localPeople =
localLookupService.findUniquePeopleByPhones(staffNos);
for (StaffSnapshot row : staffRows) {
upsert(row, localPeople.get(row.staffNo), syncTime);
}
disableMissingRows(syncTime);
LOGGER.info("FindS staff mapping synchronization completed, total={}, matched={}",
staffRows.size(), countMatched(staffRows, localPeople));
return staffRows.size();
}
private List<StaffSnapshot> loadAllStaff() {
List<StaffSnapshot> result = new ArrayList<>();
JsonNode firstData = successData(
findsOpenApiClient.postFailFast(STAFF_LIST_API, pageRequest(1)));
int firstPageSize = appendRows(result, extractRows(firstData));
if (firstPageSize == 0) {
return result;
}
long total = totalValue(firstData, firstPageSize);
int pageCount = (int) Math.min((total + PAGE_SIZE - 1) / PAGE_SIZE, MAX_PAGE_COUNT);
if (pageCount <= 1) {
return result;
}
List<Map<String, Object>> requests = new ArrayList<>(pageCount - 1);
for (int pageNo = 2; pageNo <= pageCount; pageNo++) {
requests.add(pageRequest(pageNo));
}
for (JsonNode root : findsOpenApiClient.postFailFastBatch(
STAFF_LIST_API, requests, BATCH_CONCURRENCY)) {
appendRows(result, extractRows(successData(root)));
}
return result;
}
private Map<String, Object> pageRequest(int pageNo) {
Map<String, Object> request = new LinkedHashMap<>();
request.put("pageNo", pageNo);
request.put("pageSize", PAGE_SIZE);
return request;
}
private int appendRows(List<StaffSnapshot> result, JsonNode rows) {
if (rows == null || !rows.isArray()) {
return 0;
}
for (JsonNode row : rows) {
StaffSnapshot snapshot = map(row);
if (snapshot != null) {
result.add(snapshot);
}
}
return rows.size();
}
private StaffSnapshot map(JsonNode row) {
if (row == null || row.isNull()) {
return null;
}
JsonNode terminalInfo = row.path("terminalInfo");
StaffSnapshot snapshot = new StaffSnapshot();
snapshot.staffNo = firstText(
text(firstPresent(row, "trackNo")),
text(firstPresent(terminalInfo, "trackNo")),
text(firstPresent(row, "workNo")),
text(firstPresent(row, "employeeNo")),
text(firstPresent(row, "staffNo")),
text(firstPresent(row, "code")),
text(firstPresent(terminalInfo, "staffNo")));
if (!StringUtils.hasText(snapshot.staffNo)) {
return null;
}
snapshot.staffNoType = firstText(
text(firstPresent(row, "trackNo")),
text(firstPresent(terminalInfo, "trackNo"))) != null
? "TRACK_NO" : "STAFF_NO";
snapshot.findsTrackId = longValue(firstPresent(row, "trackId", "id"));
snapshot.idCardNo = firstText(
text(firstPresent(row, "idCardNo", "identityNo", "certificateNo", "cardNo")),
text(firstPresent(terminalInfo, "idCardNo")));
snapshot.staffName = firstText(text(firstPresent(row, "staffName", "name", "realName", "userName")));
snapshot.corpinfoId = firstText(text(firstPresent(row,
"corpinfoId", "companyId", "corpId", "enterpriseId")));
snapshot.corpinfoName = firstText(text(firstPresent(row,
"companyName", "corpName", "enterpriseName", "orgName")));
snapshot.departmentName = firstText(text(firstPresent(row,
"departmentName", "deptName", "orgDepartmentName")));
snapshot.terminalNo = firstText(
text(firstPresent(row, "terminalNo")),
text(firstPresent(terminalInfo, "terminalNo")),
text(firstPresent(row.path("terminal"), "terminalNo")),
text(firstPresent(row.path("bindTerminal"), "terminalNo")),
terminalNoFromList(row.path("takenTerminalList")));
return snapshot;
}
private void upsert(StaffSnapshot row,
PositionPersonLocalLookupService.LocalPerson local,
LocalDateTime syncTime) {
String matchStatus = local == null ? "UNMATCHED" : "MATCHED";
String matchType = local == null ? null : "MOBILE";
if (local != null && StringUtils.hasText(row.idCardNo) && StringUtils.hasText(local.getIdCardNo())
&& !row.idCardNo.equals(local.getIdCardNo().trim())) {
matchStatus = "AMBIGUOUS";
matchType = null;
local = null;
}
String sql = "INSERT INTO personnel_match (source_system, source_config_code, finds_staff_no, "
+ "finds_staff_no_type, finds_track_id, finds_id_card_no, finds_staff_name, "
+ "finds_corpinfo_id, finds_corpinfo_name, finds_department_name, finds_terminal_no, "
+ "local_user_id, local_mobile_no, local_id_card_no, match_status, match_type, "
+ "last_seen_time, last_sync_time, sync_status, sync_error, delete_enum) "
+ "VALUES ('FINDS', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'SUCCESS', NULL, 'FALSE') "
+ "ON DUPLICATE KEY UPDATE finds_staff_no_type = VALUES(finds_staff_no_type), "
+ "finds_track_id = VALUES(finds_track_id), finds_id_card_no = VALUES(finds_id_card_no), "
+ "finds_staff_name = VALUES(finds_staff_name), finds_corpinfo_id = VALUES(finds_corpinfo_id), "
+ "finds_corpinfo_name = VALUES(finds_corpinfo_name), finds_department_name = VALUES(finds_department_name), "
+ "finds_terminal_no = VALUES(finds_terminal_no), local_user_id = VALUES(local_user_id), "
+ "local_mobile_no = VALUES(local_mobile_no), local_id_card_no = VALUES(local_id_card_no), "
+ "match_status = VALUES(match_status), match_type = VALUES(match_type), "
+ "last_seen_time = VALUES(last_seen_time), last_sync_time = VALUES(last_sync_time), "
+ "sync_status = 'SUCCESS', sync_error = NULL, delete_enum = 'FALSE', version = version + 1";
jdbcTemplate.update(sql, SOURCE_CONFIG_CODE, row.staffNo, row.staffNoType, row.findsTrackId,
row.idCardNo, row.staffName, row.corpinfoId, row.corpinfoName, row.departmentName,
row.terminalNo, local == null ? null : local.getUserId(),
local == null ? null : local.getMobileNo(), local == null ? null : local.getIdCardNo(),
matchStatus, matchType, syncTime, syncTime);
}
private void disableMissingRows(LocalDateTime syncTime) {
jdbcTemplate.update("UPDATE personnel_match SET match_status = 'DISABLED', "
+ "sync_status = 'SUCCESS', last_sync_time = ?, version = version + 1 "
+ "WHERE source_config_code = ? AND (delete_enum IS NULL OR delete_enum = 'FALSE') "
+ "AND (last_seen_time IS NULL OR last_seen_time < ?)",
syncTime, SOURCE_CONFIG_CODE, syncTime);
}
private int countMatched(List<StaffSnapshot> rows,
Map<String, PositionPersonLocalLookupService.LocalPerson> localPeople) {
int count = 0;
for (StaffSnapshot row : rows) {
if (localPeople.containsKey(row.staffNo)) {
count++;
}
}
return count;
}
private JsonNode successData(JsonNode root) {
int code = root == null ? -1 : root.path("code").asInt(-1);
if (code != 200 && code != 0) {
throw new BizException("FindS staff list synchronization failed, code=" + code);
}
return root.path("data");
}
private JsonNode extractRows(JsonNode data) {
if (data == null || data.isNull() || data.isMissingNode()) {
return null;
}
if (data.isArray()) {
return data;
}
for (String field : new String[]{"data", "rows", "list", "records"}) {
JsonNode value = data.get(field);
if (value != null && value.isArray()) {
return value;
}
}
return null;
}
private long totalValue(JsonNode data, long defaultValue) {
Long total = longValue(firstPresent(data, "total", "totalCount", "count", "totalRecords"));
return total == null ? defaultValue : total;
}
private JsonNode firstPresent(JsonNode node, String... fields) {
if (node == null || node.isNull() || node.isMissingNode()) {
return null;
}
for (String field : fields) {
JsonNode value = node.get(field);
if (value != null && !value.isNull() && !value.isMissingNode()) {
return value;
}
}
return null;
}
private String terminalNoFromList(JsonNode terminals) {
if (terminals != null && terminals.isArray()) {
for (JsonNode terminal : terminals) {
String terminalNo = text(firstPresent(terminal, "terminalNo"));
if (StringUtils.hasText(terminalNo)) {
return terminalNo.trim();
}
}
}
return null;
}
private String text(JsonNode node) {
return node == null || node.isNull() || node.isMissingNode() || !node.isValueNode()
? null : node.asText();
}
private String firstText(String... values) {
for (String value : values) {
if (StringUtils.hasText(value)) {
return value.trim();
}
}
return null;
}
private Long longValue(JsonNode node) {
if (node == null || node.isNull() || node.isMissingNode()) {
return null;
}
try {
return node.isNumber() ? node.asLong() : Long.valueOf(node.asText());
} catch (NumberFormatException ignored) {
return null;
}
}
private static class StaffSnapshot {
private String staffNo;
private String staffNoType;
private Long findsTrackId;
private String idCardNo;
private String staffName;
private String corpinfoId;
private String corpinfoName;
private String departmentName;
private String terminalNo;
}
}

View File

@ -1,7 +1,9 @@
package com.zcloud.personnel.positioning.command.query;
import com.zcloud.personnel.positioning.dto.PositionPersonPageQry;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@ -22,6 +24,90 @@ class PositionPersonLocalLookupService {
private final JdbcTemplate jdbcTemplate;
boolean hasPersonnelMappings() {
try {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM personnel_match "
+ "WHERE (delete_enum IS NULL OR delete_enum = 'FALSE')",
Integer.class);
return count != null && count > 0;
} catch (DataAccessException ignored) {
// Keep the pre-migration FindS fallback available during rolling deployment.
return false;
}
}
List<PersonMapping> findMatchedPersonnel(PositionPersonPageQry query) {
StringBuilder sql = new StringBuilder(
"SELECT pm.finds_staff_no, pm.finds_staff_no_type, pm.finds_id_card_no, "
+ "pm.finds_staff_name, pm.finds_corpinfo_id, pm.finds_corpinfo_name, "
+ "pm.finds_department_name, pm.finds_terminal_no, "
+ "u.id AS user_id, u.username AS local_staff_no, u.name AS staff_name, "
+ "TRIM(u.phone) AS mobile_no, u.user_id_card AS id_card_no, u.corpinfo_id, "
+ "ci.corp_name AS corpinfo_name, ci.code AS credit_code, ci.port_area, "
+ "u.department_id, d.name AS department_name "
+ "FROM personnel_match pm JOIN user_scope_v u ON u.id = pm.local_user_id "
+ "LEFT JOIN corp_info ci ON CAST(ci.corpinfo_id AS CHAR) = CAST(u.corpinfo_id AS CHAR) "
+ "LEFT JOIN department d ON d.id = u.department_id "
+ "AND (d.delete_enum IS NULL OR d.delete_enum = 'FALSE') "
+ "WHERE pm.match_status = 'MATCHED' "
+ "AND (pm.delete_enum IS NULL OR pm.delete_enum = 'FALSE') "
+ "AND (u.delete_enum IS NULL OR u.delete_enum = 'FALSE')");
List<Object> args = new ArrayList<>();
if (query != null && query.getUserId() != null) {
sql.append(" AND u.id = ?");
args.add(query.getUserId());
}
if (query != null && StringUtils.hasText(query.getStaffName())) {
sql.append(" AND u.name LIKE ?");
args.add("%" + query.getStaffName().trim() + "%");
}
if (query != null && query.getCorpinfoId() != null) {
sql.append(" AND CAST(u.corpinfo_id AS CHAR) = ?");
args.add(String.valueOf(query.getCorpinfoId()));
}
if (query != null && StringUtils.hasText(query.getCorpinfoName())) {
sql.append(" AND ci.corp_name LIKE ?");
args.add("%" + query.getCorpinfoName().trim() + "%");
}
if (query != null && query.getDepartmentId() != null) {
sql.append(" AND u.department_id = ?");
args.add(query.getDepartmentId());
}
if (query != null && StringUtils.hasText(query.getDepartmentName())) {
sql.append(" AND d.name LIKE ?");
args.add("%" + query.getDepartmentName().trim() + "%");
}
sql.append(" ORDER BY u.id DESC, pm.finds_staff_no");
return jdbcTemplate.query(sql.toString(), args.toArray(), (rs, rowNum) -> {
LocalPerson local = new LocalPerson();
local.setUserId(nullableLong(rs.getLong("user_id"), rs.wasNull()));
local.setLocalStaffNo(rs.getString("local_staff_no"));
local.setStaffName(rs.getString("staff_name"));
local.setMobileNo(normalize(rs.getString("mobile_no")));
local.setIdCardNo(normalize(rs.getString("id_card_no")));
local.setCorpinfoId(rs.getString("corpinfo_id"));
local.setCorpinfoName(rs.getString("corpinfo_name"));
local.setCreditCode(rs.getString("credit_code"));
int portArea = rs.getInt("port_area");
local.setPortArea(rs.wasNull() ? null : portArea);
local.setDepartmentId(nullableLong(rs.getLong("department_id"), rs.wasNull()));
local.setDepartmentName(rs.getString("department_name"));
PersonMapping mapping = new PersonMapping();
mapping.setFindsStaffNo(normalize(rs.getString("finds_staff_no")));
mapping.setFindsStaffNoType(normalize(rs.getString("finds_staff_no_type")));
mapping.setFindsIdCardNo(normalize(rs.getString("finds_id_card_no")));
mapping.setFindsStaffName(rs.getString("finds_staff_name"));
mapping.setFindsCorpinfoId(rs.getString("finds_corpinfo_id"));
mapping.setFindsCorpinfoName(rs.getString("finds_corpinfo_name"));
mapping.setFindsDepartmentName(rs.getString("finds_department_name"));
mapping.setFindsTerminalNo(normalize(rs.getString("finds_terminal_no")));
mapping.setLocal(local);
return mapping;
});
}
Map<String, LocalPerson> findUniquePeopleByPhones(Set<String> phones) {
Map<String, List<LocalPerson>> grouped = new LinkedHashMap<>();
for (List<String> batch : batches(phones)) {
@ -110,6 +196,34 @@ class PositionPersonLocalLookupService {
return unique;
}
Map<String, String> findTerminalNosByIdCardNos(Set<String> idCardNos) {
Map<String, String> result = new LinkedHashMap<>();
for (List<String> batch : batches(idCardNos)) {
String placeholders = String.join(",", Collections.nCopies(batch.size(), "?"));
String sql = "SELECT COALESCE(NULLIF(TRIM(tb.id_card_no), ''), "
+ "NULLIF(TRIM(t.bind_id_card_no), '')) AS id_card_no, t.terminal_no "
+ "FROM terminal t LEFT JOIN terminal_bind tb ON tb.id = ("
+ "SELECT tb2.id FROM terminal_bind tb2 WHERE tb2.terminal_no = t.terminal_no "
+ "AND tb2.bind_status = 'BOUND' "
+ "AND (tb2.delete_enum IS NULL OR tb2.delete_enum = 'FALSE') "
+ "ORDER BY tb2.bind_time DESC, tb2.id DESC LIMIT 1) "
+ "WHERE (t.delete_enum IS NULL OR t.delete_enum = 'FALSE') "
+ "AND COALESCE(NULLIF(TRIM(tb.id_card_no), ''), "
+ "NULLIF(TRIM(t.bind_id_card_no), '')) IN (" + placeholders + ") "
+ "ORDER BY id_card_no, t.update_time DESC, t.id DESC";
jdbcTemplate.query(sql, batch.toArray(), (rs, rowNum) -> {
String idCardNo = normalize(rs.getString("id_card_no"));
String terminalNo = normalize(rs.getString("terminal_no"));
return new TerminalBinding(idCardNo, terminalNo);
}).forEach(binding -> {
if (StringUtils.hasText(binding.idCardNo) && StringUtils.hasText(binding.terminalNo)) {
result.putIfAbsent(binding.idCardNo, binding.terminalNo);
}
});
}
return result;
}
Map<String, TerminalSnapshot> findTerminals(Set<String> terminalNos) {
Map<String, TerminalSnapshot> result = new LinkedHashMap<>();
for (List<String> batch : batches(terminalNos)) {
@ -178,6 +292,19 @@ class PositionPersonLocalLookupService {
private String lastLocationName;
}
@Data
static class PersonMapping {
private String findsStaffNo;
private String findsStaffNoType;
private String findsIdCardNo;
private String findsStaffName;
private String findsCorpinfoId;
private String findsCorpinfoName;
private String findsDepartmentName;
private String findsTerminalNo;
private LocalPerson local;
}
private static class TerminalPerson {
private final String terminalNo;
private final LocalPerson person;
@ -187,4 +314,14 @@ class PositionPersonLocalLookupService {
this.person = person;
}
}
private static class TerminalBinding {
private final String idCardNo;
private final String terminalNo;
private TerminalBinding(String idCardNo, String terminalNo) {
this.idCardNo = idCardNo;
this.terminalNo = terminalNo;
}
}
}

View File

@ -30,10 +30,10 @@ public class PositionPersonQueryExe {
static final String STATUS_OFFLINE = "OFFLINE";
private static final String STAFF_LIST_API = "finds.staff.list";
private static final String STAFF_TERMINAL_QUERY_API = "finds.staff.taken.terminal.query";
private static final String POINT_LOCATE_API = "finds.point.locate";
private static final int FINDS_PAGE_SIZE = 200;
private static final int FINDS_PAGE_SIZE = 2000;
private static final int MAX_FINDS_PAGE_COUNT = 50;
private static final int FINDS_BATCH_CONCURRENCY = 4;
private static final int LOCATE_BATCH_SIZE = 100;
private final FindsOpenApiClient findsOpenApiClient;
@ -41,34 +41,26 @@ public class PositionPersonQueryExe {
public PageResponse<PositionPersonCO> list(PositionPersonPageQry qry) {
PositionPersonPageQry query = qry == null ? new PositionPersonPageQry() : qry;
List<FindsStaff> findsStaff = loadAllFindsStaff();
Set<String> phones = new LinkedHashSet<>();
for (FindsStaff staff : findsStaff) {
if (StringUtils.hasText(staff.staffNo)) {
phones.add(staff.staffNo);
List<MatchedPerson> matched = loadMatchedPeople(query);
Set<String> idCardNos = new LinkedHashSet<>();
for (MatchedPerson person : matched) {
if (StringUtils.hasText(person.local.getIdCardNo())) {
idCardNos.add(person.local.getIdCardNo());
}
}
Map<String, PositionPersonLocalLookupService.LocalPerson> localPeople =
localLookupService.findUniquePeopleByPhones(phones);
List<MatchedPerson> matched = new ArrayList<>();
Map<String, String> localTerminalNos = localLookupService.findTerminalNosByIdCardNos(idCardNos);
if (localTerminalNos == null) {
localTerminalNos = Collections.emptyMap();
}
Set<String> terminalNos = new LinkedHashSet<>();
Set<Long> matchedUserIds = new LinkedHashSet<>();
for (FindsStaff staff : findsStaff) {
PositionPersonLocalLookupService.LocalPerson local = localPeople.get(staff.staffNo);
if (local == null || !matchesLocalPerson(local, query)) {
continue;
for (MatchedPerson person : matched) {
if (!StringUtils.hasText(person.finds.terminalNo)) {
person.finds.terminalNo = localTerminalNos.get(normalize(person.local.getIdCardNo()));
}
if (local.getUserId() != null && !matchedUserIds.add(local.getUserId())) {
continue;
if (StringUtils.hasText(person.finds.terminalNo)) {
terminalNos.add(person.finds.terminalNo);
}
if (!StringUtils.hasText(staff.terminalNo)) {
staff.terminalNo = resolveTerminalNo(staff.staffNo);
}
if (StringUtils.hasText(staff.terminalNo)) {
terminalNos.add(staff.terminalNo);
}
matched.add(new MatchedPerson(staff, local));
}
Map<String, JsonNode> currentLocations = locateTerminals(terminalNos);
@ -102,6 +94,54 @@ public class PositionPersonQueryExe {
return PageResponse.of(rows.subList(fromIndex, toIndex), rows.size(), pageSize, pageIndex);
}
private List<MatchedPerson> loadMatchedPeople(PositionPersonPageQry query) {
if (localLookupService.hasPersonnelMappings()) {
return loadPersistedMatches(query);
}
List<FindsStaff> findsStaff = loadAllFindsStaff();
Set<String> phones = new LinkedHashSet<>();
for (FindsStaff staff : findsStaff) {
if (StringUtils.hasText(staff.staffNo)) {
phones.add(staff.staffNo);
}
}
Map<String, PositionPersonLocalLookupService.LocalPerson> localPeople =
localLookupService.findUniquePeopleByPhones(phones);
List<MatchedPerson> matched = new ArrayList<>();
Set<Long> matchedUserIds = new LinkedHashSet<>();
for (FindsStaff staff : findsStaff) {
PositionPersonLocalLookupService.LocalPerson local = localPeople.get(staff.staffNo);
if (local == null || !matchesLocalPerson(local, query)) {
continue;
}
if (local.getUserId() != null && !matchedUserIds.add(local.getUserId())) {
continue;
}
matched.add(new MatchedPerson(staff, local));
}
return matched;
}
private List<MatchedPerson> loadPersistedMatches(PositionPersonPageQry query) {
List<MatchedPerson> result = new ArrayList<>();
Set<Long> matchedUserIds = new LinkedHashSet<>();
for (PositionPersonLocalLookupService.PersonMapping mapping
: localLookupService.findMatchedPersonnel(query)) {
PositionPersonLocalLookupService.LocalPerson local = mapping.getLocal();
if (local == null || (local.getUserId() != null && !matchedUserIds.add(local.getUserId()))) {
continue;
}
FindsStaff staff = new FindsStaff();
staff.staffNo = mapping.getFindsStaffNo();
staff.sourceCompanyName = mapping.getFindsCorpinfoName();
staff.sourceDepartmentName = mapping.getFindsDepartmentName();
staff.terminalNo = mapping.getFindsTerminalNo();
result.add(new MatchedPerson(staff, local));
}
return result;
}
public PositionPersonCO queryByUserId(Long userId) {
if (userId == null) {
throw new BizException("本平台用户ID不能为空");
@ -119,30 +159,53 @@ public class PositionPersonQueryExe {
private List<FindsStaff> loadAllFindsStaff() {
List<FindsStaff> result = new ArrayList<>();
long total = Long.MAX_VALUE;
int loaded = 0;
for (int pageNo = 1; pageNo <= MAX_FINDS_PAGE_COUNT && loaded < total; pageNo++) {
Map<String, Object> request = new LinkedHashMap<>();
request.put("pageNo", pageNo);
request.put("pageSize", FINDS_PAGE_SIZE);
JsonNode root = findsOpenApiClient.post(STAFF_LIST_API, request);
JsonNode firstRoot = findsOpenApiClient.postFailFast(STAFF_LIST_API, staffPageRequest(1));
JsonNode firstData = assertSuccess(firstRoot, STAFF_LIST_API).path("data");
int firstPageSize = appendFindsStaff(result, extractRows(firstData));
if (firstPageSize == 0) {
return result;
}
long total = totalValue(firstData, firstPageSize);
int pageCount = (int) Math.min((total + FINDS_PAGE_SIZE - 1) / FINDS_PAGE_SIZE,
MAX_FINDS_PAGE_COUNT);
if (pageCount <= 1) {
return result;
}
List<Map<String, Object>> requests = new ArrayList<>(pageCount - 1);
for (int pageNo = 2; pageNo <= pageCount; pageNo++) {
requests.add(staffPageRequest(pageNo));
}
List<JsonNode> roots = findsOpenApiClient.postFailFastBatch(
STAFF_LIST_API, requests, FINDS_BATCH_CONCURRENCY);
for (JsonNode root : roots) {
JsonNode data = assertSuccess(root, STAFF_LIST_API).path("data");
JsonNode rows = extractRows(data);
if (rows == null || !rows.isArray() || rows.size() == 0) {
break;
}
for (JsonNode row : rows) {
FindsStaff staff = mapFindsStaff(row);
if (StringUtils.hasText(staff.staffNo)) {
result.add(staff);
}
}
loaded += rows.size();
total = totalValue(data, loaded);
appendFindsStaff(result, extractRows(data));
}
return result;
}
private Map<String, Object> staffPageRequest(int pageNo) {
Map<String, Object> request = new LinkedHashMap<>();
request.put("pageNo", pageNo);
request.put("pageSize", FINDS_PAGE_SIZE);
return request;
}
private int appendFindsStaff(List<FindsStaff> result, JsonNode rows) {
if (rows == null || !rows.isArray() || rows.size() == 0) {
return 0;
}
for (JsonNode row : rows) {
FindsStaff staff = mapFindsStaff(row);
if (StringUtils.hasText(staff.staffNo)) {
result.add(staff);
}
}
return rows.size();
}
private FindsStaff mapFindsStaff(JsonNode row) {
FindsStaff staff = new FindsStaff();
JsonNode terminalInfo = row.path("terminalInfo");
@ -164,23 +227,6 @@ public class PositionPersonQueryExe {
return staff;
}
private String resolveTerminalNo(String staffNo) {
if (!StringUtils.hasText(staffNo)) {
return null;
}
Map<String, Object> request = Collections.singletonMap("staffNo", staffNo);
JsonNode root = findsOpenApiClient.post(STAFF_TERMINAL_QUERY_API, request);
JsonNode data = assertSuccess(root, STAFF_TERMINAL_QUERY_API).path("data");
JsonNode rows = extractRows(data);
if (rows == null || !rows.isArray() || rows.size() == 0) {
return null;
}
JsonNode first = rows.get(0);
return firstText(text(firstPresent(first, "terminalNo")),
text(firstPresent(first.path("bindTerminal"), "terminalNo")),
terminalNoFromList(first.path("takenTerminalList")));
}
private Map<String, JsonNode> locateTerminals(Set<String> terminalNos) {
if (terminalNos == null || terminalNos.isEmpty()) {
return Collections.emptyMap();
@ -202,7 +248,7 @@ public class PositionPersonQueryExe {
private void locateTerminalBatch(List<String> terminalNos, Map<String, JsonNode> result) {
Map<String, Object> request = Collections.singletonMap("terminalNoList", new ArrayList<>(terminalNos));
JsonNode root = findsOpenApiClient.post(POINT_LOCATE_API, request);
JsonNode root = findsOpenApiClient.postFailFast(POINT_LOCATE_API, request);
JsonNode data = assertSuccess(root, POINT_LOCATE_API).path("data");
JsonNode rows = extractRows(data);
if (rows != null && rows.isArray()) {

View File

@ -11,10 +11,7 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
@RequiredArgsConstructor
@ -23,48 +20,21 @@ public class RealtimePersonLocationMatcher {
private static final String[] ENVELOPE_FIELDS = {"data", "payload", "message", "body"};
private final ObjectMapper objectMapper;
private final PositionPersonLocalLookupService localLookupService;
public List<BiPersonLocationCO> match(byte[] payload) {
JsonNode root = parse(payload);
List<LocationCandidate> candidates = new ArrayList<>();
Set<String> staffNos = new LinkedHashSet<>();
for (JsonNode row : extractRows(root)) {
String staffNo = staffNo(row);
if (!StringUtils.hasText(staffNo)) {
continue;
}
candidates.add(new LocationCandidate(row, staffNo));
staffNos.add(staffNo);
candidates.add(new LocationCandidate(row, staffNo, terminalNo(row)));
}
if (staffNos.isEmpty()) {
return Collections.emptyList();
}
Map<String, PositionPersonLocalLookupService.LocalPerson> localPeople =
localLookupService.findUniquePeopleByPhones(staffNos);
if (localPeople.isEmpty()) {
return Collections.emptyList();
}
Set<String> terminalNos = new LinkedHashSet<>();
for (LocationCandidate candidate : candidates) {
if (!localPeople.containsKey(candidate.staffNo)) {
continue;
}
candidate.terminalNo = terminalNo(candidate.row);
if (StringUtils.hasText(candidate.terminalNo)) {
terminalNos.add(candidate.terminalNo);
}
}
Map<String, PositionPersonLocalLookupService.TerminalSnapshot> terminals =
localLookupService.findTerminals(terminalNos);
List<BiPersonLocationCO> result = new ArrayList<>();
for (LocationCandidate candidate : candidates) {
PositionPersonLocalLookupService.LocalPerson local = localPeople.get(candidate.staffNo);
if (local != null) {
result.add(toClientObject(candidate, local, terminals.get(candidate.terminalNo)));
}
result.add(toClientObject(candidate));
}
return result;
}
@ -167,29 +137,49 @@ public class RealtimePersonLocationMatcher {
directText(locationNode(row), "terminalNo"));
}
private BiPersonLocationCO toClientObject(LocationCandidate candidate,
PositionPersonLocalLookupService.LocalPerson local,
PositionPersonLocalLookupService.TerminalSnapshot terminal) {
private BiPersonLocationCO toClientObject(LocationCandidate candidate) {
JsonNode row = candidate.row;
JsonNode location = locationNode(row);
JsonNode staff = firstObject(row, "staff", "person", "personInfo", "staffInfo");
String orgName = firstText(
directText(row, "orgName", "organizationName", "companyName", "corpName", "enterpriseName"),
directText(staff, "orgName", "organizationName", "companyName", "corpName", "enterpriseName"));
String orgCode = firstText(
directText(row, "orgCode", "companyCode", "corpCode", "enterpriseCode"),
directText(staff, "orgCode", "companyCode", "corpCode", "enterpriseCode"));
BiPersonLocationCO co = new BiPersonLocationCO();
co.setUserId(local.getUserId());
co.setStaffName(local.getStaffName());
co.setLocalStaffNo(local.getLocalStaffNo());
co.setStaffName(firstText(
directText(row, "staffName", "name", "realName", "userName"),
directText(staff, "staffName", "name", "realName", "userName")));
co.setStaffNo(candidate.staffNo);
co.setIdCardNo(local.getIdCardNo());
co.setMobileNo(local.getMobileNo());
co.setDepartmentId(local.getDepartmentId());
co.setDepartmentName(local.getDepartmentName());
co.setCompanyName(local.getCorpinfoName());
co.setCorpinfoId(local.getCorpinfoId());
co.setCorpinfoName(local.getCorpinfoName());
co.setCreditCode(local.getCreditCode());
co.setPortArea(local.getPortArea());
co.setOrgName(directText(row, "orgName", "companyName", "corpName", "enterpriseName"));
co.setOrgCode(directText(row, "orgCode", "companyCode", "corpCode", "enterpriseCode"));
co.setIdCardNo(firstText(
directText(row, "idCardNo", "identityNo", "certificateNo", "cardNo"),
directText(staff, "idCardNo", "identityNo", "certificateNo", "cardNo")));
co.setMobileNo(firstText(
directText(row, "mobileNo", "phone", "phoneNo", "tel"),
directText(staff, "mobileNo", "phone", "phoneNo", "tel")));
co.setDepartmentName(firstText(
directText(row, "departmentName", "deptName"),
directText(child(row, "department"), "name", "departmentName"),
directText(child(staff, "department"), "name", "departmentName")));
co.setCompanyName(firstText(
directText(row, "companyName", "corpName", "enterpriseName"),
directText(staff, "companyName", "corpName", "enterpriseName"),
orgName));
co.setCorpinfoId(firstText(
directText(row, "corpinfoId", "companyId", "corpId", "enterpriseId"),
directText(staff, "corpinfoId", "companyId", "corpId", "enterpriseId")));
co.setCorpinfoName(firstText(co.getCompanyName(), orgName));
co.setOrgName(orgName);
co.setOrgCode(orgCode);
co.setCreditCode(firstText(
directText(row, "creditCode", "socialCreditCode", "unifiedSocialCreditCode",
"unifiedCreditCode", "creditNo"),
directText(staff, "creditCode", "socialCreditCode", "unifiedSocialCreditCode",
"unifiedCreditCode", "creditNo")));
co.setPortArea(integerValue(firstValue(row, staff, "portArea", "portAreaCode")));
co.setTerminalNo(candidate.terminalNo);
co.setPositionMode(positionMode(terminal));
co.setPositionMode(positionMode(row));
co.setLon(decimalValue(firstValue(location, row, "lon", "lng", "longitude")));
co.setLat(decimalValue(firstValue(location, row, "lat", "latitude")));
co.setAlt(decimalValue(firstValue(location, row, "alt", "altitude")));
@ -200,8 +190,12 @@ public class RealtimePersonLocationMatcher {
co.setCurrentLocation(firstText(
directText(location, "fenceName", "areaName", "positionName", "location", "address"),
directText(row, "fenceName", "areaName", "positionName", "locationName", "address")));
co.setOnline(co.getLon() != null && co.getLat() != null);
co.setLocationRawJson(row.toString());
Boolean online = booleanValue(firstValue(location, row, "online", "isOnline"));
co.setOnline(online == null ? co.getLon() != null && co.getLat() != null : online);
co.setAlarmStatus(booleanValue(firstValue(location, row, "alarmStatus", "isAlarm")));
co.setAlarmCount(integerValue(firstValue(location, row, "alarmCount", "unclosedAlarmCount")));
co.setStaffRawJson(row.toString());
co.setLocationRawJson(location.toString());
return co;
}
@ -272,8 +266,11 @@ public class RealtimePersonLocationMatcher {
}
}
private String positionMode(PositionPersonLocalLookupService.TerminalSnapshot terminal) {
String deviceType = terminal == null ? null : terminal.getDeviceType();
private String positionMode(JsonNode row) {
JsonNode terminal = firstObject(row, "terminalInfo", "terminal", "bindTerminal");
String deviceType = firstText(
directText(row, "positionMode", "deviceType", "terminalType"),
directText(terminal, "positionMode", "deviceType", "terminalType", "type"));
if ("CARD".equalsIgnoreCase(deviceType)) {
return "\u5b9a\u4f4d\u5361";
}
@ -286,6 +283,37 @@ public class RealtimePersonLocationMatcher {
return StringUtils.hasText(deviceType) ? deviceType : "\u5b9a\u4f4d\u7ec8\u7aef";
}
private Integer integerValue(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
try {
return node.isNumber() ? node.asInt() : Integer.valueOf(node.asText());
} catch (NumberFormatException ignored) {
return null;
}
}
private Boolean booleanValue(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isBoolean()) {
return node.asBoolean();
}
String value = node.asText();
if (!StringUtils.hasText(value)) {
return null;
}
if ("1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value)) {
return Boolean.TRUE;
}
if ("0".equals(value) || "false".equalsIgnoreCase(value) || "no".equalsIgnoreCase(value)) {
return Boolean.FALSE;
}
return null;
}
private JsonNode child(JsonNode node, String fieldName) {
return node == null || !node.isObject() ? null : node.get(fieldName);
}
@ -302,11 +330,12 @@ public class RealtimePersonLocationMatcher {
private static class LocationCandidate {
private final JsonNode row;
private final String staffNo;
private String terminalNo;
private final String terminalNo;
private LocationCandidate(JsonNode row, String staffNo) {
private LocationCandidate(JsonNode row, String staffNo, String terminalNo) {
this.row = row;
this.staffNo = staffNo;
this.terminalNo = terminalNo;
}
}
}

View File

@ -6,7 +6,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.jjb.saas.framework.auth.model.SSOUser;
import com.jjb.saas.framework.auth.utils.AuthContext;
import com.zcloud.personnel.positioning.persistence.dataobject.OpenapiConfigDO;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@ -20,8 +23,14 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/**
* FindS OpenAPI signed client.
@ -30,12 +39,25 @@ import java.util.Map;
* @Date 2026-07-07
*/
@Component
@AllArgsConstructor
@RequiredArgsConstructor
public class FindsOpenApiClient {
private static final Logger LOGGER = LoggerFactory.getLogger(FindsOpenApiClient.class);
private static final String SIK_PATH_SEGMENT = "sik";
private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 60000;
private static final int MAX_REQUEST_ATTEMPTS = 3;
@Value("${finds.openapi.connect-timeout-ms:10000}")
private int connectTimeoutMs = 10000;
@Value("${finds.openapi.read-timeout-ms:60000}")
private int readTimeoutMs = 60000;
@Value("${finds.openapi.max-request-attempts:3}")
private int maxRequestAttempts = 3;
@Value("${finds.openapi.fail-fast-connect-timeout-ms:5000}")
private int failFastConnectTimeoutMs = 5000;
@Value("${finds.openapi.fail-fast-read-timeout-ms:15000}")
private int failFastReadTimeoutMs = 15000;
private final OpenapiConfigCache openapiConfigCache;
private final ObjectMapper objectMapper;
@ -45,7 +67,60 @@ public class FindsOpenApiClient {
return post(config, apiName, request);
}
public JsonNode postFailFast(String apiName, Map<String, Object> request) {
OpenapiConfigDO config = loadEnabledConfig();
return post(config, apiName, request,
failFastConnectTimeoutMs, failFastReadTimeoutMs, 1);
}
public List<JsonNode> postFailFastBatch(String apiName, List<Map<String, Object>> requests,
int maxConcurrency) {
if (requests == null || requests.isEmpty()) {
return Collections.emptyList();
}
OpenapiConfigDO config = loadEnabledConfig();
int concurrency = Math.max(1, Math.min(Math.min(maxConcurrency, 8), requests.size()));
if (concurrency == 1) {
List<JsonNode> result = new ArrayList<>(requests.size());
for (Map<String, Object> request : requests) {
result.add(post(config, apiName, request,
failFastConnectTimeoutMs, failFastReadTimeoutMs, 1));
}
return result;
}
ExecutorService executor = Executors.newFixedThreadPool(concurrency);
try {
List<Future<JsonNode>> futures = new ArrayList<>(requests.size());
for (Map<String, Object> request : requests) {
futures.add(executor.submit(() -> post(config, apiName, request,
failFastConnectTimeoutMs, failFastReadTimeoutMs, 1)));
}
List<JsonNode> result = new ArrayList<>(requests.size());
for (Future<JsonNode> future : futures) {
result.add(future.get());
}
return result;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("FindS OpenAPI批量请求被中断");
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
}
throw new BizException("FindS OpenAPI批量请求失败" + cause.getMessage());
} finally {
executor.shutdownNow();
}
}
public JsonNode post(OpenapiConfigDO config, String apiName, Map<String, Object> request) {
return post(config, apiName, request, connectTimeoutMs, readTimeoutMs, maxRequestAttempts);
}
private JsonNode post(OpenapiConfigDO config, String apiName, Map<String, Object> request,
int connectTimeout, int readTimeout, int requestAttempts) {
validateConfig(config);
String requestJson = writeJson(request == null ? Collections.emptyMap() : request);
String timestamp = String.valueOf(System.currentTimeMillis());
@ -55,7 +130,8 @@ public class FindsOpenApiClient {
String requestPath = buildApiPath(internalPrefix, apiName, config.getSik());
String signature = sign(signPath, requestJson, config.getSisCipherText(), timestamp);
String url = trimTrailingSlash(config.getHostUrl()) + requestPath + "?_sign=" + signature;
String response = doPost(url, requestJson, timestamp);
String response = doPost(url, requestJson, timestamp, apiName,
connectTimeout, readTimeout, requestAttempts);
try {
return objectMapper.readTree(response);
} catch (IOException e) {
@ -105,16 +181,22 @@ public class FindsOpenApiClient {
}
}
private String doPost(String url, String requestJson, String timestamp) {
private String doPost(String url, String requestJson, String timestamp, String apiName,
int configuredConnectTimeout, int configuredReadTimeout,
int configuredRequestAttempts) {
IOException lastException = null;
for (int attempt = 1; attempt <= MAX_REQUEST_ATTEMPTS; attempt++) {
int attempts = Math.max(configuredRequestAttempts, 1);
int connectTimeout = Math.max(configuredConnectTimeout, 1);
int readTimeout = Math.max(configuredReadTimeout, 1);
for (int attempt = 1; attempt <= attempts; attempt++) {
HttpURLConnection connection = null;
long startedAt = System.nanoTime();
try {
byte[] body = ("request=" + URLEncoder.encode(requestJson, "UTF-8"))
.getBytes(StandardCharsets.UTF_8);
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setConnectTimeout(connectTimeout);
connection.setReadTimeout(readTimeout);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
@ -129,10 +211,14 @@ public class FindsOpenApiClient {
if (status != HttpURLConnection.HTTP_OK) {
throw new BizException("FindS OpenAPI HTTP调用失败status=" + status + "body=" + response);
}
LOGGER.debug("FindS OpenAPI request succeeded, api={}, attempt={}, elapsedMs={}",
apiName, attempt, elapsedMillis(startedAt));
return response;
} catch (IOException e) {
lastException = e;
if (attempt < MAX_REQUEST_ATTEMPTS) {
LOGGER.warn("FindS OpenAPI request failed, api={}, attempt={}/{}, elapsedMs={}, message={}",
apiName, attempt, attempts, elapsedMillis(startedAt), e.getMessage());
if (attempt < attempts) {
sleepBeforeRetry(attempt);
}
} finally {
@ -145,6 +231,10 @@ public class FindsOpenApiClient {
? "unknown error" : lastException.getMessage()));
}
private long elapsedMillis(long startedAt) {
return (System.nanoTime() - startedAt) / 1_000_000L;
}
private void sleepBeforeRetry(int attempt) {
try {
Thread.sleep(500L * attempt);

View File

@ -72,18 +72,18 @@ public class LocationKafkaProbeListener {
);
try {
List<BiPersonLocationCO> matchedLocations = locationMatcher.match(record.value());
List<BiPersonLocationCO> locations = locationMatcher.match(record.value());
int deliveries = 0;
for (BiPersonLocationCO location : matchedLocations) {
for (BiPersonLocationCO location : locations) {
String payload = serialize(location);
for (RealtimeLocationPublisher publisher : publishers) {
deliveries += publisher.publish(payload);
}
}
acknowledgment.acknowledge();
LOGGER.info("Kafka location message processed, topic={}, partition={}, offset={}, matched={}, "
LOGGER.info("Kafka location message processed, topic={}, partition={}, offset={}, converted={}, "
+ "websocketDeliveries={}",
record.topic(), record.partition(), record.offset(), matchedLocations.size(), deliveries);
record.topic(), record.partition(), record.offset(), locations.size(), deliveries);
} catch (RealtimeLocationPayloadException e) {
acknowledgment.acknowledge();
LOGGER.warn("Ignored invalid Kafka location message, topic={}, partition={}, offset={}, reason={}",
@ -105,7 +105,7 @@ public class LocationKafkaProbeListener {
payload.remove(nullFields);
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize matched personnel location", e);
throw new IllegalStateException("Failed to serialize FindS personnel location", e);
}
}

View File

@ -170,6 +170,51 @@ CREATE TABLE IF NOT EXISTS `terminal_bind` (
KEY `idx_tenant_org` (`tenant_id`, `org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人员定位终端绑定记录';
-- FindS staff to local platform user mapping. This is a mapping cache, not a personnel master table.
CREATE TABLE IF NOT EXISTS `personnel_match` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`source_system` varchar(32) NOT NULL DEFAULT 'FINDS' COMMENT 'external source system',
`source_config_code` varchar(64) NOT NULL DEFAULT 'FINDS' COMMENT 'FindS config scope',
`finds_staff_no` varchar(64) NOT NULL COMMENT 'FindS business staff/track number',
`finds_staff_no_type` varchar(32) NOT NULL DEFAULT 'TRACK_NO' COMMENT 'TRACK_NO/WORK_NO/STAFF_NO',
`finds_track_id` bigint DEFAULT NULL COMMENT 'FindS track object id',
`finds_id_card_no` varchar(64) DEFAULT NULL COMMENT 'FindS id card number',
`finds_staff_name` varchar(128) DEFAULT NULL COMMENT 'FindS staff name snapshot',
`finds_corpinfo_id` varchar(64) DEFAULT NULL COMMENT 'FindS company id snapshot',
`finds_corpinfo_name` varchar(255) DEFAULT NULL COMMENT 'FindS company name snapshot',
`finds_department_name` varchar(255) DEFAULT NULL COMMENT 'FindS department name snapshot',
`finds_terminal_no` varchar(64) DEFAULT NULL COMMENT 'FindS terminal number snapshot',
`local_user_id` bigint DEFAULT NULL COMMENT 'matched local platform user id',
`local_mobile_no` varchar(64) DEFAULT NULL COMMENT 'local platform mobile snapshot used for matching',
`local_id_card_no` varchar(64) DEFAULT NULL COMMENT 'matched local id card snapshot',
`match_status` varchar(32) NOT NULL DEFAULT 'UNMATCHED' COMMENT 'MATCHED/UNMATCHED/AMBIGUOUS/DISABLED',
`match_type` varchar(32) DEFAULT NULL COMMENT 'MOBILE/ID_CARD/MOBILE_AND_ID_CARD',
`last_seen_time` datetime DEFAULT NULL COMMENT 'last time observed from FindS',
`last_sync_time` datetime DEFAULT NULL COMMENT 'last mapping synchronization time',
`sync_status` varchar(32) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAIL',
`sync_error` varchar(1000) DEFAULT NULL COMMENT 'last synchronization error',
`delete_enum` varchar(32) NOT NULL DEFAULT 'FALSE' COMMENT 'soft delete flag',
`remarks` varchar(500) DEFAULT NULL COMMENT 'remarks',
`tenant_id` bigint DEFAULT NULL COMMENT 'tenant id',
`org_id` bigint DEFAULT NULL COMMENT 'organization id',
`env` varchar(32) DEFAULT NULL COMMENT 'environment',
`version` int NOT NULL DEFAULT 0 COMMENT 'version',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time',
`create_id` bigint DEFAULT NULL COMMENT 'creator id',
`update_id` bigint DEFAULT NULL COMMENT 'updater id',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_personnel_match_source_staff_delete` (`source_config_code`, `finds_staff_no`, `delete_enum`),
KEY `idx_personnel_match_local_user` (`local_user_id`, `match_status`),
KEY `idx_personnel_match_local_mobile` (`local_mobile_no`),
KEY `idx_personnel_match_find_id_card` (`finds_id_card_no`),
KEY `idx_personnel_match_terminal_no` (`finds_terminal_no`),
KEY `idx_personnel_match_local_id_card` (`local_id_card_no`),
KEY `idx_personnel_match_scope_status` (`finds_corpinfo_id`, `match_status`),
KEY `idx_personnel_match_last_seen` (`last_seen_time`),
KEY `idx_personnel_match_tenant_org` (`tenant_id`, `org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='FindS to local personnel mapping';
CREATE TABLE IF NOT EXISTS `fence` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`fence_code` varchar(64) NOT NULL COMMENT '围栏编码',

View File

@ -0,0 +1,45 @@
-- Cache the cross-system personnel mapping used by the local-first personnel list.
-- The table stores identity/mapping snapshots only. Realtime coordinates remain in FindS/Redis.
CREATE TABLE IF NOT EXISTS `personnel_match` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'primary key',
`source_system` varchar(32) NOT NULL DEFAULT 'FINDS' COMMENT 'external source system',
`source_config_code` varchar(64) NOT NULL DEFAULT 'FINDS' COMMENT 'FindS config scope',
`finds_staff_no` varchar(64) NOT NULL COMMENT 'FindS business staff/track number',
`finds_staff_no_type` varchar(32) NOT NULL DEFAULT 'TRACK_NO' COMMENT 'TRACK_NO/WORK_NO/STAFF_NO',
`finds_track_id` bigint DEFAULT NULL COMMENT 'FindS track object id',
`finds_id_card_no` varchar(64) DEFAULT NULL COMMENT 'FindS id card number',
`finds_staff_name` varchar(128) DEFAULT NULL COMMENT 'FindS staff name snapshot',
`finds_corpinfo_id` varchar(64) DEFAULT NULL COMMENT 'FindS company id snapshot',
`finds_corpinfo_name` varchar(255) DEFAULT NULL COMMENT 'FindS company name snapshot',
`finds_department_name` varchar(255) DEFAULT NULL COMMENT 'FindS department name snapshot',
`finds_terminal_no` varchar(64) DEFAULT NULL COMMENT 'FindS terminal number snapshot',
`local_user_id` bigint DEFAULT NULL COMMENT 'matched local platform user id',
`local_mobile_no` varchar(64) DEFAULT NULL COMMENT 'local platform mobile snapshot used for matching',
`local_id_card_no` varchar(64) DEFAULT NULL COMMENT 'matched local id card snapshot',
`match_status` varchar(32) NOT NULL DEFAULT 'UNMATCHED' COMMENT 'MATCHED/UNMATCHED/AMBIGUOUS/DISABLED',
`match_type` varchar(32) DEFAULT NULL COMMENT 'MOBILE/ID_CARD/MOBILE_AND_ID_CARD',
`last_seen_time` datetime DEFAULT NULL COMMENT 'last time observed from FindS',
`last_sync_time` datetime DEFAULT NULL COMMENT 'last mapping synchronization time',
`sync_status` varchar(32) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAIL',
`sync_error` varchar(1000) DEFAULT NULL COMMENT 'last synchronization error',
`delete_enum` varchar(32) NOT NULL DEFAULT 'FALSE' COMMENT 'soft delete flag',
`remarks` varchar(500) DEFAULT NULL COMMENT 'remarks',
`tenant_id` bigint DEFAULT NULL COMMENT 'tenant id',
`org_id` bigint DEFAULT NULL COMMENT 'organization id',
`env` varchar(32) DEFAULT NULL COMMENT 'environment',
`version` int NOT NULL DEFAULT 0 COMMENT 'version',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time',
`create_id` bigint DEFAULT NULL COMMENT 'creator id',
`update_id` bigint DEFAULT NULL COMMENT 'updater id',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_personnel_match_source_staff_delete` (`source_config_code`, `finds_staff_no`, `delete_enum`),
KEY `idx_personnel_match_local_user` (`local_user_id`, `match_status`),
KEY `idx_personnel_match_local_mobile` (`local_mobile_no`),
KEY `idx_personnel_match_find_id_card` (`finds_id_card_no`),
KEY `idx_personnel_match_terminal_no` (`finds_terminal_no`),
KEY `idx_personnel_match_local_id_card` (`local_id_card_no`),
KEY `idx_personnel_match_scope_status` (`finds_corpinfo_id`, `match_status`),
KEY `idx_personnel_match_last_seen` (`last_seen_time`),
KEY `idx_personnel_match_tenant_org` (`tenant_id`, `org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='FindS to local personnel mapping';