Parcourir la Source

增加企业画像

wwh il y a 3 semaines
Parent
commit
598bacad6a

+ 65 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mcp/McpEnterpriseProfileController.java

@@ -0,0 +1,65 @@
1
+package com.huimv.employment.controller.mcp;
2
+
3
+import com.huimv.employment.common.web.R;
4
+import com.huimv.employment.service.enterprise.McpEnterpriseProfileService;
5
+import com.huimv.employment.service.enterprise.dto.McpEnterpriseProfileResponse;
6
+import com.huimv.employment.service.enterprise.dto.McpEnterpriseProfileUpdateRequest;
7
+import com.huimv.employment.service.enterprise.dto.McpEnterpriseProfileUpdateResponse;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.Parameter;
10
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
11
+import io.swagger.v3.oas.annotations.tags.Tag;
12
+import org.springframework.validation.annotation.Validated;
13
+import org.springframework.web.bind.annotation.GetMapping;
14
+import org.springframework.web.bind.annotation.PutMapping;
15
+import org.springframework.web.bind.annotation.RequestBody;
16
+import org.springframework.web.bind.annotation.RequestHeader;
17
+import org.springframework.web.bind.annotation.RequestMapping;
18
+import org.springframework.web.bind.annotation.RestController;
19
+
20
+/**
21
+ * MCP 企业画像接口(交互规则 v2 §6.2)。
22
+ * <p>
23
+ * 通过 {@code X-Session-Id} 自动关联会话与企业,Agent 无需传递 enterprise_id。
24
+ * 鉴权见 {@code fe.internal.ai-key} 与 {@link com.huimv.employment.security.McpAuthFilter}。
25
+ * </p>
26
+ */
27
+@RestController
28
+@RequestMapping("/api/mcp/enterprise")
29
+@Tag(name = "MCP-企业画像", description = "智能体加载/更新企业画像,需 X-Fe-Ai-Key + X-Session-Id")
30
+public class McpEnterpriseProfileController {
31
+
32
+    public static final String HEADER_SESSION_ID = "X-Session-Id";
33
+
34
+    private final McpEnterpriseProfileService mcpEnterpriseProfileService;
35
+
36
+    public McpEnterpriseProfileController(McpEnterpriseProfileService mcpEnterpriseProfileService) {
37
+        this.mcpEnterpriseProfileService = mcpEnterpriseProfileService;
38
+    }
39
+
40
+    @GetMapping("/profile")
41
+    @Operation(summary = "获取企业画像",
42
+            description = "会话初始化时加载企业基础信息与偏好设置,供 Agent 注入上下文。",
43
+            parameters = {
44
+                    @Parameter(name = HEADER_SESSION_ID, in = ParameterIn.HEADER, required = true,
45
+                            description = "会话标识,与 AI chat session_id / conversation_no 一致"),
46
+                    @Parameter(name = "X-Fe-Ai-Key", in = ParameterIn.HEADER,
47
+                            description = "内网服务密钥")
48
+            })
49
+    public R<McpEnterpriseProfileResponse> getProfile(@RequestHeader(HEADER_SESSION_ID) String sessionId) {
50
+        return R.ok(mcpEnterpriseProfileService.getProfile(sessionId));
51
+    }
52
+
53
+    @PutMapping("/profile")
54
+    @Operation(summary = "增量更新企业画像",
55
+            description = "捕获对话中的新信息(如地址变更、结算偏好),支持 preferences.xxx 点路径。",
56
+            parameters = {
57
+                    @Parameter(name = HEADER_SESSION_ID, in = ParameterIn.HEADER, required = true),
58
+                    @Parameter(name = "X-Fe-Ai-Key", in = ParameterIn.HEADER)
59
+            })
60
+    public R<McpEnterpriseProfileUpdateResponse> updateProfile(
61
+            @RequestHeader(HEADER_SESSION_ID) String sessionId,
62
+            @Validated @RequestBody McpEnterpriseProfileUpdateRequest request) {
63
+        return R.ok(mcpEnterpriseProfileService.updateProfile(sessionId, request));
64
+    }
65
+}

+ 243 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/enterprise/McpEnterpriseProfileService.java

@@ -0,0 +1,243 @@
1
+package com.huimv.employment.service.enterprise;
2
+
3
+import com.fasterxml.jackson.core.type.TypeReference;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.huimv.employment.common.exception.BizException;
6
+import com.huimv.employment.common.exception.ErrorCode;
7
+import com.huimv.employment.dao.entity.FeConversation;
8
+import com.huimv.employment.dao.entity.FeEnterprise;
9
+import com.huimv.employment.dao.mapper.FeEnterpriseMapper;
10
+import com.huimv.employment.service.conversation.ConversationService;
11
+import com.huimv.employment.service.enterprise.dto.McpEnterpriseProfileResponse;
12
+import com.huimv.employment.service.enterprise.dto.McpEnterpriseProfileUpdateRequest;
13
+import com.huimv.employment.service.enterprise.dto.McpEnterpriseProfileUpdateResponse;
14
+import org.slf4j.Logger;
15
+import org.slf4j.LoggerFactory;
16
+import org.springframework.stereotype.Service;
17
+import org.springframework.transaction.annotation.Transactional;
18
+import org.springframework.util.StringUtils;
19
+
20
+import java.time.LocalDateTime;
21
+import java.util.Arrays;
22
+import java.util.Collections;
23
+import java.util.HashSet;
24
+import java.util.LinkedHashMap;
25
+import java.util.Map;
26
+import java.util.Set;
27
+
28
+/**
29
+ * MCP 企业画像接口(交互规则 v2 §6.2)。
30
+ * <p>通过 {@code X-Session-Id} 关联会话与企业,Agent 无需传递 enterprise_id。</p>
31
+ */
32
+@Service
33
+public class McpEnterpriseProfileService {
34
+
35
+    private static final Logger log = LoggerFactory.getLogger(McpEnterpriseProfileService.class);
36
+
37
+    private static final String STATUS_SUCCESS = "success";
38
+    private static final String PREF_PREFIX = "preferences.";
39
+
40
+    /** 可写入 profile_json.preferences 的偏好键 */
41
+    private static final Set<String> ALLOWED_PREFERENCE_KEYS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
42
+            "settlement_cycle",
43
+            "default_region",
44
+            "default_settlement_mode",
45
+            "default_work_type",
46
+            "default_insurance_level"
47
+    )));
48
+
49
+    /** settlement_cycle 合法值 */
50
+    private static final Set<String> ALLOWED_SETTLEMENT_CYCLES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
51
+            "daily", "weekly", "biweekly", "monthly"
52
+    )));
53
+
54
+    private final FeEnterpriseMapper feEnterpriseMapper;
55
+    private final ConversationService conversationService;
56
+    private final EnterpriseService enterpriseService;
57
+    private final ObjectMapper objectMapper;
58
+
59
+    public McpEnterpriseProfileService(FeEnterpriseMapper feEnterpriseMapper,
60
+                                       ConversationService conversationService,
61
+                                       EnterpriseService enterpriseService,
62
+                                       ObjectMapper objectMapper) {
63
+        this.feEnterpriseMapper = feEnterpriseMapper;
64
+        this.conversationService = conversationService;
65
+        this.enterpriseService = enterpriseService;
66
+        this.objectMapper = objectMapper;
67
+    }
68
+
69
+    public McpEnterpriseProfileResponse getProfile(String sessionId) {
70
+        FeEnterprise enterprise = requireEnterpriseBySession(sessionId);
71
+        return toResponse(enterprise);
72
+    }
73
+
74
+    @Transactional(rollbackFor = Exception.class)
75
+    public McpEnterpriseProfileUpdateResponse updateProfile(String sessionId, McpEnterpriseProfileUpdateRequest request) {
76
+        FeEnterprise enterprise = requireEnterpriseBySession(sessionId);
77
+        Map<String, Object> profileRoot = parseProfileJson(enterprise.getProfileJson());
78
+        Map<String, Object> preferences = extractPreferences(profileRoot);
79
+
80
+        int updatedCount = 0;
81
+        for (Map.Entry<String, Object> entry : request.getUpdates().entrySet()) {
82
+            if (entry.getKey() == null) {
83
+                continue;
84
+            }
85
+            String key = entry.getKey().trim();
86
+            if (!StringUtils.hasText(key)) {
87
+                continue;
88
+            }
89
+            Object value = entry.getValue();
90
+            if (applyUpdate(enterprise, profileRoot, preferences, key, value)) {
91
+                updatedCount++;
92
+            }
93
+        }
94
+
95
+        if (updatedCount == 0) {
96
+            throw new BizException(ErrorCode.BAD_REQUEST, "未识别任何可更新字段");
97
+        }
98
+
99
+        profileRoot.put("preferences", preferences);
100
+        enterprise.setProfileJson(writeProfileJson(profileRoot));
101
+        enterprise.setUpdateTime(LocalDateTime.now());
102
+        feEnterpriseMapper.updateById(enterprise);
103
+
104
+        McpEnterpriseProfileUpdateResponse response = new McpEnterpriseProfileUpdateResponse();
105
+        response.setUpdatedFields(updatedCount);
106
+        response.setStatus(STATUS_SUCCESS);
107
+        return response;
108
+    }
109
+
110
+    private FeEnterprise requireEnterpriseBySession(String sessionId) {
111
+        FeConversation conversation = conversationService.requireBySessionId(sessionId);
112
+        return enterpriseService.requireById(conversation.getEnterpriseId());
113
+    }
114
+
115
+    private McpEnterpriseProfileResponse toResponse(FeEnterprise enterprise) {
116
+        Map<String, Object> profileRoot = parseProfileJson(enterprise.getProfileJson());
117
+        Map<String, Object> preferences = extractPreferences(profileRoot);
118
+
119
+        McpEnterpriseProfileResponse response = new McpEnterpriseProfileResponse();
120
+        response.setCompanyName(enterprise.getName());
121
+        response.setCompanyAddress(enterprise.getRegisteredAddress());
122
+        response.setIndustry(enterprise.getIndustry());
123
+        response.setTaxQualification(readString(profileRoot.get("tax_qualification")));
124
+        response.setPreferences(preferences);
125
+        return response;
126
+    }
127
+
128
+    private boolean applyUpdate(FeEnterprise enterprise,
129
+                                Map<String, Object> profileRoot,
130
+                                Map<String, Object> preferences,
131
+                                String key,
132
+                                Object value) {
133
+        switch (key) {
134
+            case "company_name":
135
+                enterprise.setName(requireNonBlankText(value, key));
136
+                return true;
137
+            case "company_address":
138
+                enterprise.setRegisteredAddress(requireNonBlankText(value, key));
139
+                return true;
140
+            case "industry":
141
+                enterprise.setIndustry(toNullableText(value));
142
+                return true;
143
+            case "scale":
144
+                enterprise.setScale(toNullableText(value));
145
+                return true;
146
+            case "tax_qualification":
147
+                profileRoot.put("tax_qualification", toNullableText(value));
148
+                return true;
149
+            default:
150
+                break;
151
+        }
152
+
153
+        if (key.startsWith(PREF_PREFIX)) {
154
+            String prefKey = key.substring(PREF_PREFIX.length());
155
+            if (!StringUtils.hasText(prefKey)) {
156
+                throw new BizException(ErrorCode.BAD_REQUEST, "偏好键不能为空:" + key);
157
+            }
158
+            if (!ALLOWED_PREFERENCE_KEYS.contains(prefKey)) {
159
+                throw new BizException(ErrorCode.BAD_REQUEST, "不支持的偏好字段:" + prefKey);
160
+            }
161
+            String prefValue = toNullableText(value);
162
+            validatePreferenceValue(prefKey, prefValue);
163
+            if (prefValue == null) {
164
+                preferences.remove(prefKey);
165
+            } else {
166
+                preferences.put(prefKey, prefValue);
167
+            }
168
+            return true;
169
+        }
170
+
171
+        log.debug("忽略未知的企业画像更新字段:{}", key);
172
+        return false;
173
+    }
174
+
175
+    private void validatePreferenceValue(String prefKey, String value) {
176
+        if (value == null) {
177
+            return;
178
+        }
179
+        if ("settlement_cycle".equals(prefKey) && !ALLOWED_SETTLEMENT_CYCLES.contains(value)) {
180
+            throw new BizException(ErrorCode.BAD_REQUEST,
181
+                    "settlement_cycle 须为 daily/weekly/biweekly/monthly 之一");
182
+        }
183
+        if ("default_settlement_mode".equals(prefKey)) {
184
+            Set<String> modes = new HashSet<>(Arrays.asList("daily", "piece", "monthly"));
185
+            if (!modes.contains(value)) {
186
+                throw new BizException(ErrorCode.BAD_REQUEST,
187
+                        "default_settlement_mode 须为 daily/piece/monthly 之一");
188
+            }
189
+        }
190
+    }
191
+
192
+    private Map<String, Object> parseProfileJson(String profileJson) {
193
+        if (!StringUtils.hasText(profileJson)) {
194
+            return new LinkedHashMap<>();
195
+        }
196
+        try {
197
+            Map<String, Object> parsed = objectMapper.readValue(profileJson.trim(),
198
+                    new TypeReference<Map<String, Object>>() {
199
+                    });
200
+            return parsed != null ? new LinkedHashMap<>(parsed) : new LinkedHashMap<>();
201
+        } catch (Exception ex) {
202
+            log.warn("解析 profile_json 失败,将使用空对象:{}", ex.getMessage());
203
+            return new LinkedHashMap<>();
204
+        }
205
+    }
206
+
207
+    @SuppressWarnings("unchecked")
208
+    private Map<String, Object> extractPreferences(Map<String, Object> profileRoot) {
209
+        Object raw = profileRoot.get("preferences");
210
+        if (raw instanceof Map) {
211
+            return new LinkedHashMap<>((Map<String, Object>) raw);
212
+        }
213
+        return new LinkedHashMap<>();
214
+    }
215
+
216
+    private String writeProfileJson(Map<String, Object> profileRoot) {
217
+        try {
218
+            return objectMapper.writeValueAsString(profileRoot);
219
+        } catch (Exception ex) {
220
+            throw new BizException(ErrorCode.INTERNAL_ERROR);
221
+        }
222
+    }
223
+
224
+    private static String requireNonBlankText(Object value, String key) {
225
+        String text = toNullableText(value);
226
+        if (text == null) {
227
+            throw new BizException(ErrorCode.BAD_REQUEST, "字段 " + key + " 不能为空");
228
+        }
229
+        return text;
230
+    }
231
+
232
+    private static String toNullableText(Object value) {
233
+        if (value == null) {
234
+            return null;
235
+        }
236
+        String text = String.valueOf(value).trim();
237
+        return text.isEmpty() ? null : text;
238
+    }
239
+
240
+    private static String readString(Object value) {
241
+        return value != null ? String.valueOf(value) : null;
242
+    }
243
+}

+ 69 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/enterprise/dto/McpEnterpriseProfileResponse.java

@@ -0,0 +1,69 @@
1
+package com.huimv.employment.service.enterprise.dto;
2
+
3
+import com.fasterxml.jackson.annotation.JsonProperty;
4
+import io.swagger.v3.oas.annotations.media.Schema;
5
+
6
+import java.util.LinkedHashMap;
7
+import java.util.Map;
8
+
9
+@Schema(description = "MCP 企业画像响应(v2 §6.2.1)")
10
+public class McpEnterpriseProfileResponse {
11
+
12
+    @JsonProperty("company_name")
13
+    @Schema(description = "企业全称", example = "XX科技有限公司")
14
+    private String companyName;
15
+
16
+    @JsonProperty("company_address")
17
+    @Schema(description = "企业地址(注册地址)", example = "南京市玄武区...")
18
+    private String companyAddress;
19
+
20
+    @Schema(description = "所属行业", example = "互联网")
21
+    private String industry;
22
+
23
+    @JsonProperty("tax_qualification")
24
+    @Schema(description = "纳税人资质", example = "一般纳税人")
25
+    private String taxQualification;
26
+
27
+    @Schema(description = "偏好设置")
28
+    private Map<String, Object> preferences = new LinkedHashMap<>();
29
+
30
+    public String getCompanyName() {
31
+        return companyName;
32
+    }
33
+
34
+    public void setCompanyName(String companyName) {
35
+        this.companyName = companyName;
36
+    }
37
+
38
+    public String getCompanyAddress() {
39
+        return companyAddress;
40
+    }
41
+
42
+    public void setCompanyAddress(String companyAddress) {
43
+        this.companyAddress = companyAddress;
44
+    }
45
+
46
+    public String getIndustry() {
47
+        return industry;
48
+    }
49
+
50
+    public void setIndustry(String industry) {
51
+        this.industry = industry;
52
+    }
53
+
54
+    public String getTaxQualification() {
55
+        return taxQualification;
56
+    }
57
+
58
+    public void setTaxQualification(String taxQualification) {
59
+        this.taxQualification = taxQualification;
60
+    }
61
+
62
+    public Map<String, Object> getPreferences() {
63
+        return preferences;
64
+    }
65
+
66
+    public void setPreferences(Map<String, Object> preferences) {
67
+        this.preferences = preferences;
68
+    }
69
+}

+ 23 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/enterprise/dto/McpEnterpriseProfileUpdateRequest.java

@@ -0,0 +1,23 @@
1
+package com.huimv.employment.service.enterprise.dto;
2
+
3
+import io.swagger.v3.oas.annotations.media.Schema;
4
+
5
+import javax.validation.constraints.NotEmpty;
6
+import java.util.Map;
7
+
8
+@Schema(description = "MCP 企业画像增量更新请求(v2 §6.2.2)")
9
+public class McpEnterpriseProfileUpdateRequest {
10
+
11
+    @NotEmpty(message = "updates 不能为空")
12
+    @Schema(description = "增量更新字段,支持点路径如 preferences.settlement_cycle",
13
+            example = "{\"preferences.settlement_cycle\":\"monthly\",\"company_address\":\"上海市浦东新区...\"}")
14
+    private Map<String, Object> updates;
15
+
16
+    public Map<String, Object> getUpdates() {
17
+        return updates;
18
+    }
19
+
20
+    public void setUpdates(Map<String, Object> updates) {
21
+        this.updates = updates;
22
+    }
23
+}

+ 31 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/enterprise/dto/McpEnterpriseProfileUpdateResponse.java

@@ -0,0 +1,31 @@
1
+package com.huimv.employment.service.enterprise.dto;
2
+
3
+import com.fasterxml.jackson.annotation.JsonProperty;
4
+import io.swagger.v3.oas.annotations.media.Schema;
5
+
6
+@Schema(description = "MCP 企业画像更新响应(v2 §6.2.2)")
7
+public class McpEnterpriseProfileUpdateResponse {
8
+
9
+    @JsonProperty("updated_fields")
10
+    @Schema(description = "成功更新的字段数", example = "2")
11
+    private int updatedFields;
12
+
13
+    @Schema(description = "更新结果", example = "success")
14
+    private String status;
15
+
16
+    public int getUpdatedFields() {
17
+        return updatedFields;
18
+    }
19
+
20
+    public void setUpdatedFields(int updatedFields) {
21
+        this.updatedFields = updatedFields;
22
+    }
23
+
24
+    public String getStatus() {
25
+        return status;
26
+    }
27
+
28
+    public void setStatus(String status) {
29
+        this.status = status;
30
+    }
31
+}