Explorar o código

增加与ai大模型对话

wwh hai 3 semanas
pai
achega
be074610ae

+ 41 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/internal/InternalDraftController.java

@@ -0,0 +1,41 @@
1
+package com.huimv.employment.controller.internal;
2
+
3
+import com.huimv.employment.common.web.R;
4
+import com.huimv.employment.service.draft.DraftService;
5
+import com.huimv.employment.service.draft.dto.DraftCreateRequest;
6
+import com.huimv.employment.service.draft.dto.DraftDetailResponse;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.Parameter;
9
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
10
+import io.swagger.v3.oas.annotations.tags.Tag;
11
+import org.springframework.validation.annotation.Validated;
12
+import org.springframework.web.bind.annotation.PostMapping;
13
+import org.springframework.web.bind.annotation.RequestBody;
14
+import org.springframework.web.bind.annotation.RequestMapping;
15
+import org.springframework.web.bind.annotation.RestController;
16
+
17
+/**
18
+ * AI 内网工具:用工草稿写入。
19
+ * <p>鉴权:请求头 {@code X-Fe-Ai-Key}(见 {@code fe.internal.ai-key}),不走 JWT。</p>
20
+ */
21
+@RestController
22
+@RequestMapping("/internal/fe/drafts")
23
+@Tag(name = "内网-AI工具", description = "供智能体 HTTP 回调,使用 X-Fe-Ai-Key 鉴权")
24
+public class InternalDraftController {
25
+
26
+    private final DraftService draftService;
27
+
28
+    public InternalDraftController(DraftService draftService) {
29
+        this.draftService = draftService;
30
+    }
31
+
32
+    @PostMapping
33
+    @Operation(summary = "创建用工草稿",
34
+            description = "AI 子模块根据对话上下文生成草稿后调用。写入 fe_employment_draft 与 fe_draft_field,"
35
+                    + "返回 draftId 供小程序 card_payload / next_action 跳转。",
36
+            parameters = @Parameter(name = "X-Fe-Ai-Key", in = ParameterIn.HEADER,
37
+                    description = "内网服务密钥,与 fe.internal.ai-key 一致"))
38
+    public R<DraftDetailResponse> create(@Validated @RequestBody DraftCreateRequest request) {
39
+        return R.ok(draftService.createFromAi(request));
40
+    }
41
+}

+ 84 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/security/InternalAiAuthFilter.java

@@ -0,0 +1,84 @@
1
+package com.huimv.employment.security;
2
+
3
+import com.fasterxml.jackson.databind.ObjectMapper;
4
+import com.huimv.employment.common.exception.ErrorCode;
5
+import com.huimv.employment.common.web.R;
6
+import com.huimv.employment.service.config.InternalApiProperties;
7
+import org.springframework.core.Ordered;
8
+import org.springframework.core.annotation.Order;
9
+import org.springframework.http.MediaType;
10
+import org.springframework.stereotype.Component;
11
+import org.springframework.util.StringUtils;
12
+import org.springframework.web.filter.OncePerRequestFilter;
13
+
14
+import javax.servlet.FilterChain;
15
+import javax.servlet.ServletException;
16
+import javax.servlet.http.HttpServletRequest;
17
+import javax.servlet.http.HttpServletResponse;
18
+import java.io.IOException;
19
+import java.nio.charset.StandardCharsets;
20
+
21
+/**
22
+ * 内网 AI 工具接口鉴权:保护 {@code /internal/fe/**},校验 {@code X-Fe-Ai-Key} 与可选 IP 白名单。
23
+ */
24
+@Component
25
+@Order(Ordered.HIGHEST_PRECEDENCE + 10)
26
+public class InternalAiAuthFilter extends OncePerRequestFilter {
27
+
28
+    private static final String HEADER_AI_KEY = "X-Fe-Ai-Key";
29
+
30
+    private final InternalApiProperties internalApiProperties;
31
+    private final ObjectMapper objectMapper;
32
+
33
+    public InternalAiAuthFilter(InternalApiProperties internalApiProperties, ObjectMapper objectMapper) {
34
+        this.internalApiProperties = internalApiProperties;
35
+        this.objectMapper = objectMapper;
36
+    }
37
+
38
+    @Override
39
+    protected boolean shouldNotFilter(HttpServletRequest request) {
40
+        String path = request.getRequestURI();
41
+        return path == null || !path.startsWith("/internal/fe/");
42
+    }
43
+
44
+    @Override
45
+    protected void doFilterInternal(HttpServletRequest request,
46
+                                    HttpServletResponse response,
47
+                                    FilterChain filterChain) throws ServletException, IOException {
48
+        if (!internalApiProperties.isEnabled()) {
49
+            writeError(response, ErrorCode.INTERNAL_API_DISABLED);
50
+            return;
51
+        }
52
+        if (internalApiProperties.requiresAiKey()) {
53
+            String providedKey = request.getHeader(HEADER_AI_KEY);
54
+            if (!internalApiProperties.getAiKey().equals(providedKey)) {
55
+                writeError(response, ErrorCode.INTERNAL_API_UNAUTHORIZED);
56
+                return;
57
+            }
58
+        }
59
+        if (internalApiProperties.hasIpRestriction()) {
60
+            String clientIp = resolveClientIp(request);
61
+            if (!internalApiProperties.isAllowedIp(clientIp)) {
62
+                writeError(response, ErrorCode.FORBIDDEN);
63
+                return;
64
+            }
65
+        }
66
+        filterChain.doFilter(request, response);
67
+    }
68
+
69
+    private String resolveClientIp(HttpServletRequest request) {
70
+        String forwarded = request.getHeader("X-Forwarded-For");
71
+        if (StringUtils.hasText(forwarded)) {
72
+            int commaIndex = forwarded.indexOf(',');
73
+            return commaIndex > 0 ? forwarded.substring(0, commaIndex).trim() : forwarded.trim();
74
+        }
75
+        return request.getRemoteAddr();
76
+    }
77
+
78
+    private void writeError(HttpServletResponse response, ErrorCode errorCode) throws IOException {
79
+        response.setStatus(HttpServletResponse.SC_OK);
80
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
81
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
82
+        response.getWriter().write(objectMapper.writeValueAsString(R.fail(errorCode.getCode(), errorCode.getMessage())));
83
+    }
84
+}

+ 73 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/config/InternalApiProperties.java

@@ -0,0 +1,73 @@
1
+package com.huimv.employment.service.config;
2
+
3
+import org.springframework.boot.context.properties.ConfigurationProperties;
4
+import org.springframework.util.StringUtils;
5
+
6
+import java.util.ArrayList;
7
+import java.util.Collections;
8
+import java.util.List;
9
+
10
+/**
11
+ * AI 内网工具接口配置,绑定 {@code fe.internal.*}。
12
+ */
13
+@ConfigurationProperties(prefix = "fe.internal")
14
+public class InternalApiProperties {
15
+
16
+    /** 是否启用 /internal/fe/** 接口 */
17
+    private boolean enabled = true;
18
+
19
+    /**
20
+     * 服务密钥,请求头 {@code X-Fe-Ai-Key} 须与此一致。
21
+     * 为空时不校验密钥(仅建议本地开发)。
22
+     */
23
+    private String aiKey = "";
24
+
25
+    /** 可选 IP 白名单;为空表示不限制来源 IP */
26
+    private List<String> allowedIps = new ArrayList<>();
27
+
28
+    public boolean isEnabled() {
29
+        return enabled;
30
+    }
31
+
32
+    public void setEnabled(boolean enabled) {
33
+        this.enabled = enabled;
34
+    }
35
+
36
+    public String getAiKey() {
37
+        return aiKey;
38
+    }
39
+
40
+    public void setAiKey(String aiKey) {
41
+        this.aiKey = aiKey;
42
+    }
43
+
44
+    public List<String> getAllowedIps() {
45
+        return allowedIps;
46
+    }
47
+
48
+    public void setAllowedIps(List<String> allowedIps) {
49
+        this.allowedIps = allowedIps != null ? allowedIps : new ArrayList<>();
50
+    }
51
+
52
+    public boolean requiresAiKey() {
53
+        return StringUtils.hasText(aiKey);
54
+    }
55
+
56
+    public boolean hasIpRestriction() {
57
+        return allowedIps != null && !allowedIps.isEmpty();
58
+    }
59
+
60
+    public boolean isAllowedIp(String clientIp) {
61
+        if (!hasIpRestriction()) {
62
+            return true;
63
+        }
64
+        if (!StringUtils.hasText(clientIp)) {
65
+            return false;
66
+        }
67
+        return allowedIps.contains(clientIp.trim());
68
+    }
69
+
70
+    public List<String> getAllowedIpsView() {
71
+        return allowedIps == null ? Collections.emptyList() : Collections.unmodifiableList(allowedIps);
72
+    }
73
+}

+ 309 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftCreateRequest.java

@@ -0,0 +1,309 @@
1
+package com.huimv.employment.service.draft.dto;
2
+
3
+import io.swagger.v3.oas.annotations.media.Schema;
4
+
5
+import javax.validation.Valid;
6
+import javax.validation.constraints.DecimalMin;
7
+import javax.validation.constraints.Max;
8
+import javax.validation.constraints.Min;
9
+import javax.validation.constraints.NotBlank;
10
+import javax.validation.constraints.NotNull;
11
+import javax.validation.constraints.Size;
12
+import java.math.BigDecimal;
13
+import java.time.LocalDate;
14
+import java.util.List;
15
+
16
+/**
17
+ * AI 内网创建用工草稿请求。
18
+ */
19
+@Schema(description = "AI 创建用工草稿请求")
20
+public class DraftCreateRequest {
21
+
22
+    @Schema(description = "所属企业 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
23
+    @NotNull(message = "enterpriseId 不能为空")
24
+    private Long enterpriseId;
25
+
26
+    @Schema(description = "来源会话 ID(可选)", example = "1")
27
+    private Long conversationId;
28
+
29
+    @Schema(description = "场景:existing_worker_regularization / new_demand",
30
+            requiredMode = Schema.RequiredMode.REQUIRED, example = "new_demand")
31
+    @NotBlank(message = "scenario 不能为空")
32
+    private String scenario;
33
+
34
+    @Schema(description = "自定义草稿编号(可选,须全局唯一;不传则自动生成)", example = "draft_001")
35
+    @Size(max = 32, message = "draftNo 不能超过 32 字符")
36
+    private String draftNo;
37
+
38
+    @Schema(description = "标题")
39
+    @Size(max = 200, message = "标题不能超过 200 字")
40
+    private String title;
41
+
42
+    @Schema(description = "计划用工人数")
43
+    @Min(value = 1, message = "用工人数至少为 1")
44
+    private Integer workerCount;
45
+
46
+    @Schema(description = "计划工作天数")
47
+    @Min(value = 1, message = "工作天数至少为 1")
48
+    private Integer workDays;
49
+
50
+    @Schema(description = "岗位/工种")
51
+    @Size(max = 100)
52
+    private String workType;
53
+
54
+    @Schema(description = "工作内容")
55
+    private String workContent;
56
+
57
+    @Schema(description = "工作地点")
58
+    @Size(max = 500)
59
+    private String workLocation;
60
+
61
+    @Schema(description = "工作地点来源:enterprise_registered_address / user_input / ai_inferred")
62
+    private String workLocationSource;
63
+
64
+    @Schema(description = "结算方式:daily / piece / monthly")
65
+    private String settlementMode;
66
+
67
+    @Schema(description = "日薪(元)")
68
+    @DecimalMin(value = "0.01", message = "日薪必须大于 0")
69
+    private BigDecimal dailyWage;
70
+
71
+    @Schema(description = "是否购买保险,默认 true")
72
+    private Boolean insuranceRequired;
73
+
74
+    @Schema(description = "保险档次:basic / standard / premium")
75
+    private String insuranceLevel;
76
+
77
+    @Schema(description = "是否要求打卡,默认 false")
78
+    private Boolean checkInRequired;
79
+
80
+    @Schema(description = "登记二维码有效天数,默认 7")
81
+    @Min(value = 1, message = "二维码有效天数至少为 1")
82
+    @Max(value = 90, message = "二维码有效天数不能超过 90")
83
+    private Integer qrValidDays;
84
+
85
+    @Schema(description = "计划开工日期")
86
+    private LocalDate workStartDate;
87
+
88
+    @Schema(description = "计划结束日期")
89
+    private LocalDate workEndDate;
90
+
91
+    @Schema(description = "岗位要求")
92
+    private String jobRequirements;
93
+
94
+    @Schema(description = "待补充字段名列表", example = "[\"daily_wage\",\"work_location\"]")
95
+    private List<String> missingFields;
96
+
97
+    @Schema(description = "风险提示", example = "[\"未签劳动合同存在用工风险\"]")
98
+    private List<String> riskPrompts;
99
+
100
+    @Schema(description = "预估总支出(元)")
101
+    private BigDecimal estimatedTotal;
102
+
103
+    @Schema(description = "预估人均成本(元)")
104
+    private BigDecimal estimatedPerCapita;
105
+
106
+    @Schema(description = "字段级明细(来源、可编辑性)")
107
+    @Valid
108
+    private List<DraftFieldCreateRequest> fields;
109
+
110
+    public Long getEnterpriseId() {
111
+        return enterpriseId;
112
+    }
113
+
114
+    public void setEnterpriseId(Long enterpriseId) {
115
+        this.enterpriseId = enterpriseId;
116
+    }
117
+
118
+    public Long getConversationId() {
119
+        return conversationId;
120
+    }
121
+
122
+    public void setConversationId(Long conversationId) {
123
+        this.conversationId = conversationId;
124
+    }
125
+
126
+    public String getScenario() {
127
+        return scenario;
128
+    }
129
+
130
+    public void setScenario(String scenario) {
131
+        this.scenario = scenario;
132
+    }
133
+
134
+    public String getDraftNo() {
135
+        return draftNo;
136
+    }
137
+
138
+    public void setDraftNo(String draftNo) {
139
+        this.draftNo = draftNo;
140
+    }
141
+
142
+    public String getTitle() {
143
+        return title;
144
+    }
145
+
146
+    public void setTitle(String title) {
147
+        this.title = title;
148
+    }
149
+
150
+    public Integer getWorkerCount() {
151
+        return workerCount;
152
+    }
153
+
154
+    public void setWorkerCount(Integer workerCount) {
155
+        this.workerCount = workerCount;
156
+    }
157
+
158
+    public Integer getWorkDays() {
159
+        return workDays;
160
+    }
161
+
162
+    public void setWorkDays(Integer workDays) {
163
+        this.workDays = workDays;
164
+    }
165
+
166
+    public String getWorkType() {
167
+        return workType;
168
+    }
169
+
170
+    public void setWorkType(String workType) {
171
+        this.workType = workType;
172
+    }
173
+
174
+    public String getWorkContent() {
175
+        return workContent;
176
+    }
177
+
178
+    public void setWorkContent(String workContent) {
179
+        this.workContent = workContent;
180
+    }
181
+
182
+    public String getWorkLocation() {
183
+        return workLocation;
184
+    }
185
+
186
+    public void setWorkLocation(String workLocation) {
187
+        this.workLocation = workLocation;
188
+    }
189
+
190
+    public String getWorkLocationSource() {
191
+        return workLocationSource;
192
+    }
193
+
194
+    public void setWorkLocationSource(String workLocationSource) {
195
+        this.workLocationSource = workLocationSource;
196
+    }
197
+
198
+    public String getSettlementMode() {
199
+        return settlementMode;
200
+    }
201
+
202
+    public void setSettlementMode(String settlementMode) {
203
+        this.settlementMode = settlementMode;
204
+    }
205
+
206
+    public BigDecimal getDailyWage() {
207
+        return dailyWage;
208
+    }
209
+
210
+    public void setDailyWage(BigDecimal dailyWage) {
211
+        this.dailyWage = dailyWage;
212
+    }
213
+
214
+    public Boolean getInsuranceRequired() {
215
+        return insuranceRequired;
216
+    }
217
+
218
+    public void setInsuranceRequired(Boolean insuranceRequired) {
219
+        this.insuranceRequired = insuranceRequired;
220
+    }
221
+
222
+    public String getInsuranceLevel() {
223
+        return insuranceLevel;
224
+    }
225
+
226
+    public void setInsuranceLevel(String insuranceLevel) {
227
+        this.insuranceLevel = insuranceLevel;
228
+    }
229
+
230
+    public Boolean getCheckInRequired() {
231
+        return checkInRequired;
232
+    }
233
+
234
+    public void setCheckInRequired(Boolean checkInRequired) {
235
+        this.checkInRequired = checkInRequired;
236
+    }
237
+
238
+    public Integer getQrValidDays() {
239
+        return qrValidDays;
240
+    }
241
+
242
+    public void setQrValidDays(Integer qrValidDays) {
243
+        this.qrValidDays = qrValidDays;
244
+    }
245
+
246
+    public LocalDate getWorkStartDate() {
247
+        return workStartDate;
248
+    }
249
+
250
+    public void setWorkStartDate(LocalDate workStartDate) {
251
+        this.workStartDate = workStartDate;
252
+    }
253
+
254
+    public LocalDate getWorkEndDate() {
255
+        return workEndDate;
256
+    }
257
+
258
+    public void setWorkEndDate(LocalDate workEndDate) {
259
+        this.workEndDate = workEndDate;
260
+    }
261
+
262
+    public String getJobRequirements() {
263
+        return jobRequirements;
264
+    }
265
+
266
+    public void setJobRequirements(String jobRequirements) {
267
+        this.jobRequirements = jobRequirements;
268
+    }
269
+
270
+    public List<String> getMissingFields() {
271
+        return missingFields;
272
+    }
273
+
274
+    public void setMissingFields(List<String> missingFields) {
275
+        this.missingFields = missingFields;
276
+    }
277
+
278
+    public List<String> getRiskPrompts() {
279
+        return riskPrompts;
280
+    }
281
+
282
+    public void setRiskPrompts(List<String> riskPrompts) {
283
+        this.riskPrompts = riskPrompts;
284
+    }
285
+
286
+    public BigDecimal getEstimatedTotal() {
287
+        return estimatedTotal;
288
+    }
289
+
290
+    public void setEstimatedTotal(BigDecimal estimatedTotal) {
291
+        this.estimatedTotal = estimatedTotal;
292
+    }
293
+
294
+    public BigDecimal getEstimatedPerCapita() {
295
+        return estimatedPerCapita;
296
+    }
297
+
298
+    public void setEstimatedPerCapita(BigDecimal estimatedPerCapita) {
299
+        this.estimatedPerCapita = estimatedPerCapita;
300
+    }
301
+
302
+    public List<DraftFieldCreateRequest> getFields() {
303
+        return fields;
304
+    }
305
+
306
+    public void setFields(List<DraftFieldCreateRequest> fields) {
307
+        this.fields = fields;
308
+    }
309
+}

+ 93 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftFieldCreateRequest.java

@@ -0,0 +1,93 @@
1
+package com.huimv.employment.service.draft.dto;
2
+
3
+import io.swagger.v3.oas.annotations.media.Schema;
4
+
5
+import javax.validation.constraints.NotBlank;
6
+import javax.validation.constraints.Size;
7
+
8
+/**
9
+ * AI 创建草稿时的字段明细。
10
+ */
11
+@Schema(description = "草稿字段明细(创建)")
12
+public class DraftFieldCreateRequest {
13
+
14
+    @Schema(description = "字段键名", example = "work_location", requiredMode = Schema.RequiredMode.REQUIRED)
15
+    @NotBlank(message = "fieldKey 不能为空")
16
+    @Size(max = 100, message = "fieldKey 不能超过 100 字符")
17
+    private String fieldKey;
18
+
19
+    @Schema(description = "字段中文标签", example = "工作地点")
20
+    @Size(max = 100, message = "fieldLabel 不能超过 100 字符")
21
+    private String fieldLabel;
22
+
23
+    @Schema(description = "文本值")
24
+    private String fieldValue;
25
+
26
+    @Schema(description = "复杂类型 JSON 值")
27
+    private String valueJson;
28
+
29
+    @Schema(description = "来源:enterprise_profile / ai_inferred / default 等", example = "ai_inferred")
30
+    private String source;
31
+
32
+    @Schema(description = "是否可编辑", example = "true")
33
+    private Boolean editable;
34
+
35
+    @Schema(description = "是否默认值(未修改)", example = "true")
36
+    private Boolean isDefault;
37
+
38
+    public String getFieldKey() {
39
+        return fieldKey;
40
+    }
41
+
42
+    public void setFieldKey(String fieldKey) {
43
+        this.fieldKey = fieldKey;
44
+    }
45
+
46
+    public String getFieldLabel() {
47
+        return fieldLabel;
48
+    }
49
+
50
+    public void setFieldLabel(String fieldLabel) {
51
+        this.fieldLabel = fieldLabel;
52
+    }
53
+
54
+    public String getFieldValue() {
55
+        return fieldValue;
56
+    }
57
+
58
+    public void setFieldValue(String fieldValue) {
59
+        this.fieldValue = fieldValue;
60
+    }
61
+
62
+    public String getValueJson() {
63
+        return valueJson;
64
+    }
65
+
66
+    public void setValueJson(String valueJson) {
67
+        this.valueJson = valueJson;
68
+    }
69
+
70
+    public String getSource() {
71
+        return source;
72
+    }
73
+
74
+    public void setSource(String source) {
75
+        this.source = source;
76
+    }
77
+
78
+    public Boolean getEditable() {
79
+        return editable;
80
+    }
81
+
82
+    public void setEditable(Boolean editable) {
83
+        this.editable = editable;
84
+    }
85
+
86
+    public Boolean getIsDefault() {
87
+        return isDefault;
88
+    }
89
+
90
+    public void setIsDefault(Boolean isDefault) {
91
+        this.isDefault = isDefault;
92
+    }
93
+}