From 70354148b4758f4cb1d4dd576220e30beb19abcd Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Thu, 30 Jul 2026 15:43:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=88=BF=E4=BA=A7AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/HouseAiChatController.java | 46 + .../controller/HouseAiConfigController.java | 103 ++ .../house/controller/HouseFaqController.java | 112 ++ .../controller/HouseMessageController.java | 145 +++ .../house/entity/HouseAiChatRequest.java | 24 + .../house/entity/HouseAiChatResponse.java | 35 + .../gxwebsoft/house/entity/HouseAiConfig.java | 53 + .../house/entity/HouseAiHouseCard.java | 51 + .../gxwebsoft/house/entity/HouseAiIntent.java | 87 ++ .../com/gxwebsoft/house/entity/HouseFaq.java | 59 + .../gxwebsoft/house/entity/HouseMessage.java | 62 + .../house/mapper/HouseAiConfigMapper.java | 19 + .../house/mapper/HouseFaqMapper.java | 19 + .../house/mapper/HouseInfoMapper.java | 5 + .../house/mapper/HouseMessageMapper.java | 19 + .../house/mapper/xml/HouseAiConfigMapper.xml | 56 + .../house/mapper/xml/HouseFaqMapper.xml | 65 + .../house/mapper/xml/HouseInfoMapper.xml | 21 + .../house/mapper/xml/HouseMessageMapper.xml | 64 + .../house/param/HouseAiConfigParam.java | 44 + .../gxwebsoft/house/param/HouseFaqParam.java | 57 + .../house/param/HouseMessageParam.java | 54 + .../house/service/HouseAiChatService.java | 15 + .../house/service/HouseAiConfigService.java | 22 + .../house/service/HouseFaqService.java | 22 + .../house/service/HouseMessageService.java | 20 + .../service/impl/HouseAiChatServiceImpl.java | 1049 +++++++++++++++++ .../impl/HouseAiConfigServiceImpl.java | 52 + .../service/impl/HouseFaqServiceImpl.java | 151 +++ .../service/impl/HouseMessageServiceImpl.java | 42 + src/main/resources/application-dev.yml | 11 +- src/main/resources/application.yml | 102 +- src/main/resources/sql/house_ai_config.sql | 24 + src/main/resources/sql/house_faq.sql | 24 + src/main/resources/sql/house_message.sql | 19 + websoft-modules.log.2025-08-11.0.gz | Bin 0 -> 8611 bytes 36 files changed, 2654 insertions(+), 99 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/house/controller/HouseAiChatController.java create mode 100644 src/main/java/com/gxwebsoft/house/controller/HouseAiConfigController.java create mode 100644 src/main/java/com/gxwebsoft/house/controller/HouseFaqController.java create mode 100644 src/main/java/com/gxwebsoft/house/controller/HouseMessageController.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseAiChatRequest.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseAiConfig.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseAiIntent.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseFaq.java create mode 100644 src/main/java/com/gxwebsoft/house/entity/HouseMessage.java create mode 100644 src/main/java/com/gxwebsoft/house/mapper/HouseAiConfigMapper.java create mode 100644 src/main/java/com/gxwebsoft/house/mapper/HouseFaqMapper.java create mode 100644 src/main/java/com/gxwebsoft/house/mapper/HouseMessageMapper.java create mode 100644 src/main/java/com/gxwebsoft/house/mapper/xml/HouseAiConfigMapper.xml create mode 100644 src/main/java/com/gxwebsoft/house/mapper/xml/HouseFaqMapper.xml create mode 100644 src/main/java/com/gxwebsoft/house/mapper/xml/HouseMessageMapper.xml create mode 100644 src/main/java/com/gxwebsoft/house/param/HouseAiConfigParam.java create mode 100644 src/main/java/com/gxwebsoft/house/param/HouseFaqParam.java create mode 100644 src/main/java/com/gxwebsoft/house/param/HouseMessageParam.java create mode 100644 src/main/java/com/gxwebsoft/house/service/HouseAiChatService.java create mode 100644 src/main/java/com/gxwebsoft/house/service/HouseAiConfigService.java create mode 100644 src/main/java/com/gxwebsoft/house/service/HouseFaqService.java create mode 100644 src/main/java/com/gxwebsoft/house/service/HouseMessageService.java create mode 100644 src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/house/service/impl/HouseAiConfigServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/house/service/impl/HouseFaqServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/house/service/impl/HouseMessageServiceImpl.java create mode 100644 src/main/resources/sql/house_ai_config.sql create mode 100644 src/main/resources/sql/house_faq.sql create mode 100644 src/main/resources/sql/house_message.sql create mode 100644 websoft-modules.log.2025-08-11.0.gz diff --git a/src/main/java/com/gxwebsoft/house/controller/HouseAiChatController.java b/src/main/java/com/gxwebsoft/house/controller/HouseAiChatController.java new file mode 100644 index 0000000..1b94639 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/controller/HouseAiChatController.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.house.controller; + +import com.gxwebsoft.common.core.utils.JSONUtil; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.websocket.WebSocketServer; +import com.gxwebsoft.house.entity.HouseAiChatRequest; +import com.gxwebsoft.house.entity.HouseAiChatResponse; +import com.gxwebsoft.house.service.HouseAiChatService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +import javax.annotation.Resource; + +/** + * AI找房聊天控制器 + */ +@Tag(name = "AI找房问答") +@RestController +@RequestMapping("/api/house/ai-chat") +public class HouseAiChatController extends BaseController { + + @Resource + private HouseAiChatService houseAiChatService; + @Resource + private WebSocketServer webSocketServer; + + @Operation(summary = "发送AI找房问题") + @PostMapping("/message") + public ApiResult message(@RequestBody HouseAiChatRequest request) { + if (request.getUserId() == null || request.getQuestion() == null || request.getQuestion().trim().isEmpty()) { + return fail("提问内容不能为空"); + } + try { + HouseAiChatResponse response = houseAiChatService.answer(request); + webSocketServer.sendMessage(String.valueOf(request.getUserId()), JSONUtil.toJSONString(response)); + return success("处理成功"); + } catch (Exception e) { + return fail("AI服务暂时不可用,请稍后再试。"); + } + } +} diff --git a/src/main/java/com/gxwebsoft/house/controller/HouseAiConfigController.java b/src/main/java/com/gxwebsoft/house/controller/HouseAiConfigController.java new file mode 100644 index 0000000..23ea034 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/controller/HouseAiConfigController.java @@ -0,0 +1,103 @@ +package com.gxwebsoft.house.controller; + +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseAiConfig; +import com.gxwebsoft.house.param.HouseAiConfigParam; +import com.gxwebsoft.house.service.HouseAiConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * AI找房配置控制器 + */ +@Tag(name = "AI找房配置管理") +@RestController +@RequestMapping("/api/house/ai-config") +public class HouseAiConfigController extends BaseController { + + @Resource + private HouseAiConfigService houseAiConfigService; + + @Operation(summary = "分页查询AI找房配置") + @GetMapping("/page") + public ApiResult> page(HouseAiConfigParam param) { + return success(houseAiConfigService.pageRel(param)); + } + + @Operation(summary = "查询全部AI找房配置") + @GetMapping() + public ApiResult> list(HouseAiConfigParam param) { + return success(houseAiConfigService.listRel(param)); + } + + @Operation(summary = "查询当前租户AI找房配置") + @GetMapping("/current") + public ApiResult current() { + return success(houseAiConfigService.getCurrentConfig(getTenantId())); + } + + @Operation(summary = "根据id查询AI找房配置") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(houseAiConfigService.getByIdRel(id)); + } + + @OperationLog + @Operation(summary = "添加AI找房配置") + @PostMapping() + public ApiResult save(@RequestBody HouseAiConfig houseAiConfig) { + if (houseAiConfigService.save(houseAiConfig)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @OperationLog + @Operation(summary = "修改AI找房配置") + @PutMapping() + public ApiResult update(@RequestBody HouseAiConfig houseAiConfig) { + if (houseAiConfigService.updateById(houseAiConfig)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @OperationLog + @Operation(summary = "删除AI找房配置") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (houseAiConfigService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @OperationLog + @Operation(summary = "批量修改AI找房配置") + @PutMapping("/batch") + public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(houseAiConfigService, "config_id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @OperationLog + @Operation(summary = "批量删除AI找房配置") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (houseAiConfigService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } +} diff --git a/src/main/java/com/gxwebsoft/house/controller/HouseFaqController.java b/src/main/java/com/gxwebsoft/house/controller/HouseFaqController.java new file mode 100644 index 0000000..a343936 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/controller/HouseFaqController.java @@ -0,0 +1,112 @@ +package com.gxwebsoft.house.controller; + +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.house.entity.HouseFaq; +import com.gxwebsoft.house.param.HouseFaqParam; +import com.gxwebsoft.house.service.HouseFaqService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +/** + * AI找房常见问题控制器 + */ +@Tag(name = "AI找房常见问题管理") +@RestController +@RequestMapping("/api/house/house-faq") +public class HouseFaqController extends BaseController { + + @Resource + private HouseFaqService houseFaqService; + + @Operation(summary = "分页查询AI找房常见问题") + @GetMapping("/page") + public ApiResult> page(HouseFaqParam param) { + return success(houseFaqService.pageRel(param)); + } + + @Operation(summary = "查询全部AI找房常见问题") + @GetMapping() + public ApiResult> list(HouseFaqParam param) { + return success(houseFaqService.listRel(param)); + } + + @Operation(summary = "根据id查询AI找房常见问题") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(houseFaqService.getByIdRel(id)); + } + + @OperationLog + @Operation(summary = "添加AI找房常见问题") + @PostMapping() + public ApiResult save(@RequestBody HouseFaq houseFaq) { + User loginUser = getLoginUser(); + if (loginUser != null) { + houseFaq.setUserId(loginUser.getUserId()); + } + if (houseFaqService.save(houseFaq)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @OperationLog + @Operation(summary = "修改AI找房常见问题") + @PutMapping() + public ApiResult update(@RequestBody HouseFaq houseFaq) { + if (houseFaqService.updateById(houseFaq)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @OperationLog + @Operation(summary = "删除AI找房常见问题") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (houseFaqService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @OperationLog + @Operation(summary = "批量添加AI找房常见问题") + @PostMapping("/batch") + public ApiResult saveBatch(@RequestBody List list) { + if (houseFaqService.saveBatch(list)) { + return success("添加成功"); + } + return fail("添加失败"); + } + + @OperationLog + @Operation(summary = "批量修改AI找房常见问题") + @PutMapping("/batch") + public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(houseFaqService, "faq_id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @OperationLog + @Operation(summary = "批量删除AI找房常见问题") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (houseFaqService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } +} diff --git a/src/main/java/com/gxwebsoft/house/controller/HouseMessageController.java b/src/main/java/com/gxwebsoft/house/controller/HouseMessageController.java new file mode 100644 index 0000000..52be34d --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/controller/HouseMessageController.java @@ -0,0 +1,145 @@ +package com.gxwebsoft.house.controller; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.house.entity.HouseMessage; +import com.gxwebsoft.house.param.HouseMessageParam; +import com.gxwebsoft.house.service.HouseMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; +import java.util.regex.Pattern; + +/** + * AI找房留言控制器 + */ +@Tag(name = "AI找房留言管理") +@RestController +@RequestMapping("/api/house/house-message") +public class HouseMessageController extends BaseController { + + private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); + private static final Pattern WECHAT_PATTERN = Pattern.compile("^[a-zA-Z][-_a-zA-Z0-9]{5,19}$"); + + @Resource + private HouseMessageService houseMessageService; + + @Operation(summary = "分页查询AI找房留言") + @GetMapping("/page") + public ApiResult> page(HouseMessageParam param) { + return success(houseMessageService.pageRel(param)); + } + + @Operation(summary = "查询全部AI找房留言") + @GetMapping() + public ApiResult> list(HouseMessageParam param) { + return success(houseMessageService.listRel(param)); + } + + @Operation(summary = "根据id查询AI找房留言") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + return success(houseMessageService.getByIdRel(id)); + } + + @OperationLog + @Operation(summary = "添加AI找房留言") + @PostMapping() + public ApiResult save(@RequestBody HouseMessage houseMessage) { + String error = validateMessage(houseMessage); + if (error != null) { + return fail(error); + } + User loginUser = getLoginUser(); + if (loginUser != null) { + houseMessage.setUserId(loginUser.getUserId()); + } + houseMessage.setRealName(houseMessage.getRealName().trim()); + houseMessage.setPhone(StrUtil.trimToNull(houseMessage.getPhone())); + houseMessage.setWechat(StrUtil.trimToNull(houseMessage.getWechat())); + houseMessage.setSource(StrUtil.blankToDefault(houseMessage.getSource(), "ai_house")); + if (houseMessage.getStatus() == null) { + houseMessage.setStatus(0); + } + if (houseMessageService.save(houseMessage)) { + return success("提交成功"); + } + return fail("提交失败"); + } + + @OperationLog + @Operation(summary = "修改AI找房留言") + @PutMapping() + public ApiResult update(@RequestBody HouseMessage houseMessage) { + String error = validateMessage(houseMessage); + if (error != null) { + return fail(error); + } + houseMessage.setRealName(houseMessage.getRealName().trim()); + houseMessage.setPhone(StrUtil.trimToNull(houseMessage.getPhone())); + houseMessage.setWechat(StrUtil.trimToNull(houseMessage.getWechat())); + if (houseMessageService.updateById(houseMessage)) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @OperationLog + @Operation(summary = "删除AI找房留言") + @DeleteMapping("/{id}") + public ApiResult remove(@PathVariable("id") Integer id) { + if (houseMessageService.removeById(id)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + @OperationLog + @Operation(summary = "批量修改AI找房留言") + @PutMapping("/batch") + public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + if (batchParam.update(houseMessageService, "message_id")) { + return success("修改成功"); + } + return fail("修改失败"); + } + + @OperationLog + @Operation(summary = "批量删除AI找房留言") + @DeleteMapping("/batch") + public ApiResult removeBatch(@RequestBody List ids) { + if (houseMessageService.removeByIds(ids)) { + return success("删除成功"); + } + return fail("删除失败"); + } + + private String validateMessage(HouseMessage houseMessage) { + if (houseMessage == null || StrUtil.isBlank(houseMessage.getRealName())) { + return "请输入姓名"; + } + if (houseMessage.getRealName().trim().length() > 30) { + return "姓名不能超过30个字符"; + } + boolean hasPhone = StrUtil.isNotBlank(houseMessage.getPhone()); + boolean hasWechat = StrUtil.isNotBlank(houseMessage.getWechat()); + if (!hasPhone && !hasWechat) { + return "手机号和微信号请至少填写一项"; + } + if (hasPhone && !PHONE_PATTERN.matcher(houseMessage.getPhone().trim()).matches()) { + return "手机号格式不正确"; + } + if (hasWechat && !WECHAT_PATTERN.matcher(houseMessage.getWechat().trim()).matches()) { + return "微信号格式不正确"; + } + return null; + } +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiChatRequest.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiChatRequest.java new file mode 100644 index 0000000..953176a --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiChatRequest.java @@ -0,0 +1,24 @@ +package com.gxwebsoft.house.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * AI找房提问请求 + */ +@Data +@Schema(name = "HouseAiChatRequest对象", description = "AI找房提问请求") +public class HouseAiChatRequest implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "会话ID") + private String conversationId; + + @Schema(description = "用户ID") + private Integer userId; + + @Schema(description = "问题") + private String question; +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java new file mode 100644 index 0000000..843edf9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiChatResponse.java @@ -0,0 +1,35 @@ +package com.gxwebsoft.house.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * AI找房应答 + */ +@Data +@Schema(name = "HouseAiChatResponse对象", description = "AI找房应答") +public class HouseAiChatResponse implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "消息类型") + private String type = "house_ai_result"; + + @Schema(description = "回答文本") + private String answer; + + @Schema(description = "命中的常见问题") + private List faqs = new ArrayList<>(); + + @Schema(description = "推荐房源") + private List houses = new ArrayList<>(); + + @Schema(description = "语义解析结果") + private HouseAiIntent intent; + + @Schema(description = "来源 faq/house/ai") + private String source; +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiConfig.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiConfig.java new file mode 100644 index 0000000..090b8b9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiConfig.java @@ -0,0 +1,53 @@ +package com.gxwebsoft.house.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.util.Date; + +/** + * AI找房配置 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "HouseAiConfig对象", description = "AI找房配置") +public class HouseAiConfig implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @TableId(value = "config_id", type = IdType.AUTO) + private Integer configId; + + @Schema(description = "AI形象照") + private String aiAvatar; + + @Schema(description = "首页AI入口图") + private String aiEntryImage; + + @Schema(description = "首页AI悬浮图") + private String aiFloatImage; + + @Schema(description = "欢迎词") + private String welcomeMessage; + + @Schema(description = "状态 0正常 1禁用") + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @TableLogic + private Integer deleted; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "创建时间") + private Date createTime; + + @Schema(description = "更新时间") + private Date updateTime; +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java new file mode 100644 index 0000000..a0a134e --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiHouseCard.java @@ -0,0 +1,51 @@ +package com.gxwebsoft.house.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * AI找房返回的轻量房源卡片 + */ +@Data +@Schema(name = "HouseAiHouseCard对象", description = "AI找房返回的轻量房源卡片") +public class HouseAiHouseCard implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "房源ID") + private Integer houseId; + + @Schema(description = "房源标题") + private String houseTitle; + + @Schema(description = "户型") + private String houseType; + + @Schema(description = "面积") + private String extent; + + @Schema(description = "楼层") + private String floor; + + @Schema(description = "朝向") + private String toward; + + @Schema(description = "月租金") + private String monthlyRent; + + @Schema(description = "所在城市") + private String city; + + @Schema(description = "所在辖区") + private String region; + + @Schema(description = "详细地址") + private String address; + + @Schema(description = "图片附件") + private String files; + + @Schema(description = "办公室配套") + private String supporting; +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseAiIntent.java b/src/main/java/com/gxwebsoft/house/entity/HouseAiIntent.java new file mode 100644 index 0000000..f965caa --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseAiIntent.java @@ -0,0 +1,87 @@ +package com.gxwebsoft.house.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * AI找房语义解析结果 + */ +@Data +@Schema(name = "HouseAiIntent对象", description = "AI找房语义解析结果") +public class HouseAiIntent implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "原始问题") + private String originalQuestion; + + @Schema(description = "意图类型 faq/house/mixed/unknown") + private String intentType; + + @Schema(description = "归一化问题") + private String normalizedQuestion; + + @Schema(description = "面积最小值") + private Integer extentMin; + + @Schema(description = "面积最大值") + private Integer extentMax; + + @Schema(description = "楼层最小值") + private Integer floorMin; + + @Schema(description = "楼层最大值") + private Integer floorMax; + + @Schema(description = "月租最小值") + private BigDecimal monthlyRentMin; + + @Schema(description = "月租最大值") + private BigDecimal monthlyRentMax; + + @Schema(description = "售价最小值") + private BigDecimal salePriceMin; + + @Schema(description = "售价最大值") + private BigDecimal salePriceMax; + + @Schema(description = "总价最小值") + private BigDecimal totalPriceMin; + + @Schema(description = "总价最大值") + private BigDecimal totalPriceMax; + + @Schema(description = "区域/地段") + private String regionKeyword; + + @Schema(description = "城市") + private String cityKeyword; + + @Schema(description = "租售类型 rent/sale") + private String tradeType; + + @Schema(description = "装修类型") + private String decorationType; + + @Schema(description = "配套要求") + private String supportingKeyword; + + @Schema(description = "朝向") + private String toward; + + @Schema(description = "房型") + private String houseType; + + @Schema(description = "AI生成的SQL筛选条件片段") + private String whereSql; + + @Schema(description = "AI生成的SQL排序片段") + private String orderSql; + + @Schema(description = "其他关键词") + private List tags = new ArrayList<>(); +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseFaq.java b/src/main/java/com/gxwebsoft/house/entity/HouseFaq.java new file mode 100644 index 0000000..d1d749c --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseFaq.java @@ -0,0 +1,59 @@ +package com.gxwebsoft.house.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.util.Date; + +/** + * AI找房常见问题 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "HouseFaq对象", description = "AI找房常见问题") +public class HouseFaq implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @TableId(value = "faq_id", type = IdType.AUTO) + private Integer faqId; + + @Schema(description = "问题") + private String question; + + @Schema(description = "关键词,多个用逗号分隔") + private String keywords; + + @Schema(description = "标准回答") + private String answer; + + @Schema(description = "分类") + private String category; + + @Schema(description = "排序号") + private Integer sortNumber; + + @Schema(description = "状态 0正常 1禁用") + private Integer status; + + @Schema(description = "创建用户ID") + private Integer userId; + + @Schema(description = "是否删除, 0否, 1是") + @TableLogic + private Integer deleted; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "创建时间") + private Date createTime; + + @Schema(description = "更新时间") + private Date updateTime; +} diff --git a/src/main/java/com/gxwebsoft/house/entity/HouseMessage.java b/src/main/java/com/gxwebsoft/house/entity/HouseMessage.java new file mode 100644 index 0000000..6a001c7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/entity/HouseMessage.java @@ -0,0 +1,62 @@ +package com.gxwebsoft.house.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * AI找房留言 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "HouseMessage对象", description = "AI找房留言") +public class HouseMessage implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @TableId(value = "message_id", type = IdType.AUTO) + private Integer messageId; + + @Schema(description = "用户ID") + private Integer userId; + + @Schema(description = "姓名") + private String realName; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "微信号") + private String wechat; + + @Schema(description = "来源") + private String source; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "状态 0未处理 1已处理") + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @TableLogic + private Integer deleted; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/house/mapper/HouseAiConfigMapper.java b/src/main/java/com/gxwebsoft/house/mapper/HouseAiConfigMapper.java new file mode 100644 index 0000000..b64a4db --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/mapper/HouseAiConfigMapper.java @@ -0,0 +1,19 @@ +package com.gxwebsoft.house.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.house.entity.HouseAiConfig; +import com.gxwebsoft.house.param.HouseAiConfigParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * AI找房配置Mapper + */ +public interface HouseAiConfigMapper extends BaseMapper { + + List selectPageRel(@Param("page") IPage page, @Param("param") HouseAiConfigParam param); + + List selectListRel(@Param("param") HouseAiConfigParam param); +} diff --git a/src/main/java/com/gxwebsoft/house/mapper/HouseFaqMapper.java b/src/main/java/com/gxwebsoft/house/mapper/HouseFaqMapper.java new file mode 100644 index 0000000..e0310ee --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/mapper/HouseFaqMapper.java @@ -0,0 +1,19 @@ +package com.gxwebsoft.house.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.house.entity.HouseFaq; +import com.gxwebsoft.house.param.HouseFaqParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * AI找房常见问题Mapper + */ +public interface HouseFaqMapper extends BaseMapper { + + List selectPageRel(@Param("page") IPage page, @Param("param") HouseFaqParam param); + + List selectListRel(@Param("param") HouseFaqParam param); +} diff --git a/src/main/java/com/gxwebsoft/house/mapper/HouseInfoMapper.java b/src/main/java/com/gxwebsoft/house/mapper/HouseInfoMapper.java index 3a15558..2c9bef6 100644 --- a/src/main/java/com/gxwebsoft/house/mapper/HouseInfoMapper.java +++ b/src/main/java/com/gxwebsoft/house/mapper/HouseInfoMapper.java @@ -34,4 +34,9 @@ public interface HouseInfoMapper extends BaseMapper { */ List selectListRel(@Param("param") HouseInfoParam param); + /** + * 执行AI生成的受控查询条件 + */ + List selectListByAiSql(@Param("whereSql") String whereSql, @Param("orderSql") String orderSql); + } diff --git a/src/main/java/com/gxwebsoft/house/mapper/HouseMessageMapper.java b/src/main/java/com/gxwebsoft/house/mapper/HouseMessageMapper.java new file mode 100644 index 0000000..d57693f --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/mapper/HouseMessageMapper.java @@ -0,0 +1,19 @@ +package com.gxwebsoft.house.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.gxwebsoft.house.entity.HouseMessage; +import com.gxwebsoft.house.param.HouseMessageParam; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * AI找房留言Mapper + */ +public interface HouseMessageMapper extends BaseMapper { + + List selectPageRel(@Param("page") IPage page, @Param("param") HouseMessageParam param); + + List selectListRel(@Param("param") HouseMessageParam param); +} diff --git a/src/main/java/com/gxwebsoft/house/mapper/xml/HouseAiConfigMapper.xml b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseAiConfigMapper.xml new file mode 100644 index 0000000..df4a431 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseAiConfigMapper.xml @@ -0,0 +1,56 @@ + + + + + + SELECT a.* + FROM house_ai_config a + + + AND a.config_id = #{param.configId} + + + AND a.ai_avatar LIKE CONCAT('%', #{param.aiAvatar}, '%') + + + AND a.ai_entry_image LIKE CONCAT('%', #{param.aiEntryImage}, '%') + + + AND a.ai_float_image LIKE CONCAT('%', #{param.aiFloatImage}, '%') + + + AND a.welcome_message LIKE CONCAT('%', #{param.welcomeMessage}, '%') + + + AND a.status = #{param.status} + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.tenant_id = #{param.tenantId} + + + AND ( + a.welcome_message LIKE CONCAT('%', #{param.keywords}, '%') + OR a.ai_avatar LIKE CONCAT('%', #{param.keywords}, '%') + OR a.ai_entry_image LIKE CONCAT('%', #{param.keywords}, '%') + OR a.ai_float_image LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + ORDER BY a.config_id DESC + + + + + + + diff --git a/src/main/java/com/gxwebsoft/house/mapper/xml/HouseFaqMapper.xml b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseFaqMapper.xml new file mode 100644 index 0000000..859bcee --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseFaqMapper.xml @@ -0,0 +1,65 @@ + + + + + + SELECT a.* + FROM house_faq a + + + AND a.faq_id = #{param.faqId} + + + AND a.question LIKE CONCAT('%', #{param.question}, '%') + + + AND a.keywords LIKE CONCAT('%', #{param.keywordsText}, '%') + + + AND a.answer LIKE CONCAT('%', #{param.answer}, '%') + + + AND a.category LIKE CONCAT('%', #{param.category}, '%') + + + AND a.sort_number = #{param.sortNumber} + + + AND a.status = #{param.status} + + + AND a.user_id = #{param.userId} + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND ( + a.question LIKE CONCAT('%', #{param.keywords}, '%') + OR a.keywords LIKE CONCAT('%', #{param.keywords}, '%') + OR a.answer LIKE CONCAT('%', #{param.keywords}, '%') + OR a.category LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + ORDER BY a.sort_number ASC, a.faq_id DESC + + + + + + + diff --git a/src/main/java/com/gxwebsoft/house/mapper/xml/HouseInfoMapper.xml b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseInfoMapper.xml index ea10913..6959398 100644 --- a/src/main/java/com/gxwebsoft/house/mapper/xml/HouseInfoMapper.xml +++ b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseInfoMapper.xml @@ -172,4 +172,25 @@ + + + diff --git a/src/main/java/com/gxwebsoft/house/mapper/xml/HouseMessageMapper.xml b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseMessageMapper.xml new file mode 100644 index 0000000..3fa84b4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/mapper/xml/HouseMessageMapper.xml @@ -0,0 +1,64 @@ + + + + + + SELECT a.* + FROM house_message a + + + AND a.message_id = #{param.messageId} + + + AND a.user_id = #{param.userId} + + + AND a.real_name LIKE CONCAT('%', #{param.realName}, '%') + + + AND a.phone LIKE CONCAT('%', #{param.phone}, '%') + + + AND a.wechat LIKE CONCAT('%', #{param.wechat}, '%') + + + AND a.source = #{param.source} + + + AND a.comments LIKE CONCAT('%', #{param.comments}, '%') + + + AND a.status = #{param.status} + + + AND a.deleted = #{param.deleted} + + + AND a.deleted = 0 + + + AND a.create_time >= #{param.createTimeStart} + + + AND a.create_time <= #{param.createTimeEnd} + + + AND ( + a.real_name LIKE CONCAT('%', #{param.keywords}, '%') + OR a.phone LIKE CONCAT('%', #{param.keywords}, '%') + OR a.wechat LIKE CONCAT('%', #{param.keywords}, '%') + OR a.comments LIKE CONCAT('%', #{param.keywords}, '%') + ) + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/house/param/HouseAiConfigParam.java b/src/main/java/com/gxwebsoft/house/param/HouseAiConfigParam.java new file mode 100644 index 0000000..56a7136 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/param/HouseAiConfigParam.java @@ -0,0 +1,44 @@ +package com.gxwebsoft.house.param; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI找房配置查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "HouseAiConfigParam对象", description = "AI找房配置查询参数") +public class HouseAiConfigParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @QueryField(type = QueryType.EQ) + private Integer configId; + + @Schema(description = "AI形象照") + private String aiAvatar; + + @Schema(description = "首页AI入口图") + private String aiEntryImage; + + @Schema(description = "首页AI悬浮图") + private String aiFloatImage; + + @Schema(description = "欢迎词") + private String welcomeMessage; + + @Schema(description = "状态 0正常 1禁用") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @QueryField(type = QueryType.EQ) + private Integer deleted; +} diff --git a/src/main/java/com/gxwebsoft/house/param/HouseFaqParam.java b/src/main/java/com/gxwebsoft/house/param/HouseFaqParam.java new file mode 100644 index 0000000..db4da40 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/param/HouseFaqParam.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.house.param; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI找房常见问题查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "HouseFaqParam对象", description = "AI找房常见问题查询参数") +public class HouseFaqParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @QueryField(type = QueryType.EQ) + private Integer faqId; + + @Schema(description = "问题") + private String question; + + @Schema(description = "关键词") + private String keywordsText; + + @Schema(description = "回答") + private String answer; + + @Schema(description = "分类") + private String category; + + @Schema(description = "排序号") + @QueryField(type = QueryType.EQ) + private Integer sortNumber; + + @Schema(description = "状态 0正常 1禁用") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "创建用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "是否删除, 0否, 1是") + @QueryField(type = QueryType.EQ) + private Integer deleted; + + @Schema(description = "语义搜索原句") + @TableField(exist = false) + private String queryText; +} diff --git a/src/main/java/com/gxwebsoft/house/param/HouseMessageParam.java b/src/main/java/com/gxwebsoft/house/param/HouseMessageParam.java new file mode 100644 index 0000000..1bf86c4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/param/HouseMessageParam.java @@ -0,0 +1,54 @@ +package com.gxwebsoft.house.param; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.gxwebsoft.common.core.annotation.QueryField; +import com.gxwebsoft.common.core.annotation.QueryType; +import com.gxwebsoft.common.core.web.BaseParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * AI找房留言查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "HouseMessageParam对象", description = "AI找房留言查询参数") +public class HouseMessageParam extends BaseParam { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + @QueryField(type = QueryType.EQ) + private Integer messageId; + + @Schema(description = "用户ID") + @QueryField(type = QueryType.EQ) + private Integer userId; + + @Schema(description = "姓名") + private String realName; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "微信号") + private String wechat; + + @Schema(description = "来源") + private String source; + + @Schema(description = "备注") + private String comments; + + @Schema(description = "状态 0未处理 1已处理") + @QueryField(type = QueryType.EQ) + private Integer status; + + @Schema(description = "是否删除, 0否, 1是") + @QueryField(type = QueryType.EQ) + private Integer deleted; + + @Schema(description = "关键词") + private String keywords; +} diff --git a/src/main/java/com/gxwebsoft/house/service/HouseAiChatService.java b/src/main/java/com/gxwebsoft/house/service/HouseAiChatService.java new file mode 100644 index 0000000..3d2053e --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/HouseAiChatService.java @@ -0,0 +1,15 @@ +package com.gxwebsoft.house.service; + +import com.gxwebsoft.house.entity.HouseAiChatRequest; +import com.gxwebsoft.house.entity.HouseAiChatResponse; +import com.gxwebsoft.house.entity.HouseAiIntent; + +/** + * AI找房问答Service + */ +public interface HouseAiChatService { + + HouseAiIntent analyzeIntent(String question); + + HouseAiChatResponse answer(HouseAiChatRequest request); +} diff --git a/src/main/java/com/gxwebsoft/house/service/HouseAiConfigService.java b/src/main/java/com/gxwebsoft/house/service/HouseAiConfigService.java new file mode 100644 index 0000000..91d9805 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/HouseAiConfigService.java @@ -0,0 +1,22 @@ +package com.gxwebsoft.house.service; + +import com.github.yulichang.base.MPJBaseService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseAiConfig; +import com.gxwebsoft.house.param.HouseAiConfigParam; + +import java.util.List; + +/** + * AI找房配置Service + */ +public interface HouseAiConfigService extends MPJBaseService { + + PageResult pageRel(HouseAiConfigParam param); + + List listRel(HouseAiConfigParam param); + + HouseAiConfig getByIdRel(Integer configId); + + HouseAiConfig getCurrentConfig(Integer tenantId); +} diff --git a/src/main/java/com/gxwebsoft/house/service/HouseFaqService.java b/src/main/java/com/gxwebsoft/house/service/HouseFaqService.java new file mode 100644 index 0000000..e9a8358 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/HouseFaqService.java @@ -0,0 +1,22 @@ +package com.gxwebsoft.house.service; + +import com.github.yulichang.base.MPJBaseService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseFaq; +import com.gxwebsoft.house.param.HouseFaqParam; + +import java.util.List; + +/** + * AI找房常见问题Service + */ +public interface HouseFaqService extends MPJBaseService { + + PageResult pageRel(HouseFaqParam param); + + List listRel(HouseFaqParam param); + + HouseFaq getByIdRel(Integer faqId); + + List findBestMatches(String queryText, int limit); +} diff --git a/src/main/java/com/gxwebsoft/house/service/HouseMessageService.java b/src/main/java/com/gxwebsoft/house/service/HouseMessageService.java new file mode 100644 index 0000000..e2c085b --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/HouseMessageService.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.house.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseMessage; +import com.gxwebsoft.house.param.HouseMessageParam; + +import java.util.List; + +/** + * AI找房留言Service + */ +public interface HouseMessageService extends IService { + + PageResult pageRel(HouseMessageParam param); + + List listRel(HouseMessageParam param); + + HouseMessage getByIdRel(Integer messageId); +} diff --git a/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java b/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java new file mode 100644 index 0000000..229b067 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/impl/HouseAiChatServiceImpl.java @@ -0,0 +1,1049 @@ +package com.gxwebsoft.house.service.impl; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.house.entity.HouseAiChatRequest; +import com.gxwebsoft.house.entity.HouseAiChatResponse; +import com.gxwebsoft.house.entity.HouseAiHouseCard; +import com.gxwebsoft.house.entity.HouseAiIntent; +import com.gxwebsoft.house.entity.HouseFaq; +import com.gxwebsoft.house.entity.HouseInfo; +import com.gxwebsoft.house.mapper.HouseInfoMapper; +import com.gxwebsoft.house.param.HouseInfoParam; +import com.gxwebsoft.house.service.HouseAiChatService; +import com.gxwebsoft.house.service.HouseFaqService; +import com.gxwebsoft.house.service.HouseInfoService; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.MatchResult; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * AI找房问答Service实现 + */ +@Service +public class HouseAiChatServiceImpl implements HouseAiChatService { + + private static final String QWEN_CHAT_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; + private static final String QWEN_API_KEY = "sk-3ce4f27d08ab4bdfac42b828119a694a"; + private static final String QWEN_MODEL = "qwen3.6-flash"; + private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)"); + private static final List FAQ_HINTS = Arrays.asList( + "怎么", "如何", "能不能", "可以吗", "流程", "材料", "多久", "联系客服", "人工", "押金", "佣金", "停车", "发票", "签约", "看房" + ); + private static final List CITY_HINTS = Arrays.asList("南宁", "柳州", "桂林", "北海", "玉林", "钦州", "防城港", "百色", "河池", "贵港", "崇左", "来宾", "梧州", "贺州"); + private static final List REGION_STOP_WORDS = Arrays.asList("房源", "写字楼", "办公室", "公寓", "住宅", "左右", "上下", "月租", "租金", "预算", "精装", "简装", "毛坯", "豪装", "朝南", "朝北", "朝东", "朝西", "带电梯", "有电梯", "电梯"); + private static final List SUPPORTING_HINTS = Arrays.asList("电梯", "停车位", "停车", "地铁", "近商圈", "拎包入住", "可办公", "空调"); + + @Resource + private HouseFaqService houseFaqService; + @Resource + private HouseInfoService houseInfoService; + @Resource + private HouseInfoMapper houseInfoMapper; + + @Override + public HouseAiIntent analyzeIntent(String question) { + HouseAiIntent fallbackIntent = buildFallbackIntent(question); + HouseAiIntent aiIntent = analyzeByAi(question); + if (aiIntent == null) { + return fallbackIntent; + } + fillMissingIntent(aiIntent, fallbackIntent); + return aiIntent; + } + + @Override + public HouseAiChatResponse answer(HouseAiChatRequest request) { + String question = request.getQuestion(); + HouseAiIntent intent = analyzeIntent(question); + HouseAiChatResponse response = new HouseAiChatResponse(); + response.setIntent(intent); + + List faqMatches = houseFaqService.findBestMatches(question, 3); + if ("faq".equals(intent.getIntentType()) || "mixed".equals(intent.getIntentType())) { + if (!faqMatches.isEmpty()) { + response.setFaqs(faqMatches); + response.setAnswer("优先为您匹配到以下常见问题答案:"); + response.setSource("faq"); + if (!requiresHouseSearch(intent)) { + return response; + } + } + } + + List houses = searchHouses(intent, question); + if (!houses.isEmpty()) { + response.setHouses(toHouseCards(houses)); + if (faqMatches.isEmpty()) { + response.setAnswer(buildHouseAnswer(intent, houses.size())); + response.setSource("house"); + } else { + response.setAnswer("优先为您匹配到常见问题答案,同时按您的需求筛选到以下房源:"); + response.setSource("faq"); + response.setFaqs(faqMatches); + } + return response; + } + + if (!faqMatches.isEmpty()) { + response.setFaqs(faqMatches); + response.setAnswer("优先为您匹配到以下常见问题答案:"); + response.setSource("faq"); + return response; + } + + response.setAnswer("我先帮您理解了需求,但暂时没有筛到完全匹配的房源。您可以再补充面积、楼层、预算、区域或装修要求,我继续帮您细筛。"); + response.setSource("ai"); + return response; + } + + private HouseAiIntent analyzeByAi(String question) { + if (StrUtil.isBlank(question)) { + return null; + } + try { + JSONObject paramsJson = new JSONObject(); + paramsJson.put("query", buildPrompt(question)); + paramsJson.put("opsType", "0"); + + JSONObject requestBody = new JSONObject(); + requestBody.put("model", QWEN_MODEL); + requestBody.put("stream", false); + requestBody.put("temperature", 0.1); + + JSONArray messages = new JSONArray(); + JSONObject systemMessage = new JSONObject(); + systemMessage.put("role", "system"); + systemMessage.put("content", "你是房源搜索意图解析器,只能输出JSON。"); + messages.add(systemMessage); + + JSONObject userMessage = new JSONObject(); + userMessage.put("role", "user"); + userMessage.put("content", paramsJson.getString("query")); + messages.add(userMessage); + requestBody.put("messages", messages); + + String body = postQwenChat(requestBody); + + if (StrUtil.isBlank(body)) { + return null; + } + JSONObject result = JSONObject.parseObject(body); + if (result == null) { + return null; + } + String answer = extractQwenAnswer(result); + if (StrUtil.isBlank(answer)) { + answer = extractAnswer(result); + } + if (StrUtil.isBlank(answer)) { + return null; + } + String json = extractJson(answer); + if (StrUtil.isBlank(json)) { + return null; + } + HouseAiIntent aiIntent = JSON.parseObject(json, HouseAiIntent.class); + if (aiIntent == null) { + return null; + } + aiIntent.setOriginalQuestion(question); + return aiIntent; + } catch (Exception e) { + return null; + } + } + + private String postQwenChat(JSONObject requestBody) throws Exception { + HttpURLConnection connection = (HttpURLConnection) new URL(QWEN_CHAT_URL).openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Authorization", "Bearer " + QWEN_API_KEY); + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + connection.setDoOutput(true); + connection.setConnectTimeout(20000); + connection.setReadTimeout(20000); + + try (OutputStream os = connection.getOutputStream()) { + os.write(requestBody.toJSONString().getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + + int status = connection.getResponseCode(); + InputStream inputStream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + if (inputStream == null) { + connection.disconnect(); + return null; + } + StringBuilder response = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } finally { + connection.disconnect(); + } + return response.toString(); + } + + private String extractQwenAnswer(JSONObject result) { + JSONArray choices = result.getJSONArray("choices"); + if (choices == null || choices.isEmpty()) { + return null; + } + JSONObject choice = choices.getJSONObject(0); + if (choice == null) { + return null; + } + JSONObject message = choice.getJSONObject("message"); + if (message == null) { + return null; + } + return message.getString("content"); + } + + private String extractAnswer(JSONObject result) { + if (result.get("data") instanceof JSONObject) { + JSONObject data = result.getJSONObject("data"); + if (data != null) { + String answer = data.getString("answer"); + if (StrUtil.isNotBlank(answer)) { + return answer; + } + } + } + return result.getString("message"); + } + + private String buildPrompt(String question) { + return "你是房源搜索意图解析器。请把用户找房问题解析为JSON,只返回JSON,不要Markdown,不要解释。" + + "必须返回字段:" + + "intentType(faq/house/mixed/unknown), normalizedQuestion, extentMin, extentMax, floorMin, floorMax," + + "monthlyRentMin, monthlyRentMax, salePriceMin, salePriceMax, totalPriceMin, totalPriceMax," + + "regionKeyword, cityKeyword, tradeType(rent/sale), decorationType, supportingKeyword, toward, houseType, whereSql, orderSql, tags(数组)。" + + "数字字段没有条件时返回null,字符串字段没有条件时返回空字符串,tags没有条件时返回空数组。" + + "如果用户有找房/租房/买房条件,intentType返回house或mixed,并且必须生成whereSql。" + + "whereSql只能是SQL条件片段,不能包含SELECT/UPDATE/DELETE/INSERT/DROP/TRUNCATE/UNION/WHERE/ORDER BY/分号/注释。" + + "whereSql只能使用house_info表别名a的字段,允许字段:" + + "a.house_type, a.monthly_rent, a.sale_price, a.total_price, a.extent, a.floor, a.city, a.city_by_house, a.region, a.area, a.address, a.house_label, a.supporting, a.content, a.toward, a.lease_method。" + + "不要生成a.status或a.deleted,系统会自动追加。" + + "文本条件使用LIKE,例如a.region LIKE '%青秀%';区域/地址可用(a.region LIKE '%关键词%' OR a.area LIKE '%关键词%' OR a.address LIKE '%关键词%')。" + + "配套/装修可用(a.supporting LIKE '%电梯%' OR a.content LIKE '%电梯%' OR a.house_label LIKE '%电梯%')。" + + "面积用a.extent,楼层用a.floor,月租用a.monthly_rent,售价用a.sale_price,总价用a.total_price。" + + "范围条件示例:a.extent >= 80 AND a.extent <= 120;a.monthly_rent <= 3000。" + + "orderSql只能是排序片段,允许字段a.sort_number,a.create_time,a.monthly_rent,a.sale_price,a.total_price,a.extent,a.floor。" + + "默认orderSql返回a.sort_number asc, a.create_time desc;便宜优先用a.monthly_rent asc;面积大优先用a.extent desc。" + + "如果是常见问题导向,如咨询流程/押金/签约/人工客服,则intentType返回faq,whereSql返回空字符串。" + + "如果同时有常见问题和找房条件,则intentType返回mixed,并生成whereSql。" + + "示例1 用户问题: 南宁青秀区找80平以上月租3000以内带电梯的房子。" + + "返回: {\"intentType\":\"house\",\"normalizedQuestion\":\"南宁青秀区 80平以上 月租3000以内 带电梯\",\"extentMin\":80,\"extentMax\":null,\"floorMin\":null,\"floorMax\":null,\"monthlyRentMin\":null,\"monthlyRentMax\":3000,\"salePriceMin\":null,\"salePriceMax\":null,\"totalPriceMin\":null,\"totalPriceMax\":null,\"regionKeyword\":\"青秀区\",\"cityKeyword\":\"南宁\",\"tradeType\":\"rent\",\"decorationType\":\"\",\"supportingKeyword\":\"电梯\",\"toward\":\"\",\"houseType\":\"\",\"whereSql\":\"(a.city LIKE '%南宁%' OR a.city_by_house LIKE '%南宁%') AND (a.region LIKE '%青秀%' OR a.area LIKE '%青秀%' OR a.address LIKE '%青秀%') AND a.extent >= 80 AND a.monthly_rent <= 3000 AND (a.supporting LIKE '%电梯%' OR a.content LIKE '%电梯%' OR a.house_label LIKE '%电梯%')\",\"orderSql\":\"a.sort_number asc, a.create_time desc\",\"tags\":[\"青秀区\",\"电梯\"]}。" + + "用户问题:" + question; + } + + private String extractJson(String text) { + String trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed; + } + int start = trimmed.indexOf('{'); + int end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return trimmed.substring(start, end + 1); + } + return null; + } + + private HouseAiIntent buildFallbackIntent(String question) { + HouseAiIntent intent = new HouseAiIntent(); + intent.setOriginalQuestion(question); + intent.setNormalizedQuestion(normalize(question)); + intent.setIntentType(detectIntentType(question)); + parseExtent(question, intent); + parseFloor(question, intent); + parseMonthlyRent(question, intent); + parseSaleAndTotalPrice(question, intent); + parseTradeType(question, intent); + parseCity(question, intent); + parseRegion(question, intent); + parseDecoration(question, intent); + parseSupporting(question, intent); + parseToward(question, intent); + parseHouseType(question, intent); + intent.setTags(extractTags(question)); + return intent; + } + + private void mergeIntent(HouseAiIntent base, HouseAiIntent aiIntent) { + if (StrUtil.isNotBlank(aiIntent.getIntentType())) { + base.setIntentType(aiIntent.getIntentType()); + } + if (StrUtil.isNotBlank(aiIntent.getNormalizedQuestion())) { + base.setNormalizedQuestion(aiIntent.getNormalizedQuestion()); + } + if (aiIntent.getExtentMin() != null) base.setExtentMin(aiIntent.getExtentMin()); + if (aiIntent.getExtentMax() != null) base.setExtentMax(aiIntent.getExtentMax()); + if (aiIntent.getFloorMin() != null) base.setFloorMin(aiIntent.getFloorMin()); + if (aiIntent.getFloorMax() != null) base.setFloorMax(aiIntent.getFloorMax()); + if (aiIntent.getMonthlyRentMin() != null) base.setMonthlyRentMin(aiIntent.getMonthlyRentMin()); + if (aiIntent.getMonthlyRentMax() != null) base.setMonthlyRentMax(aiIntent.getMonthlyRentMax()); + if (aiIntent.getSalePriceMin() != null) base.setSalePriceMin(aiIntent.getSalePriceMin()); + if (aiIntent.getSalePriceMax() != null) base.setSalePriceMax(aiIntent.getSalePriceMax()); + if (aiIntent.getTotalPriceMin() != null) base.setTotalPriceMin(aiIntent.getTotalPriceMin()); + if (aiIntent.getTotalPriceMax() != null) base.setTotalPriceMax(aiIntent.getTotalPriceMax()); + if (StrUtil.isNotBlank(aiIntent.getRegionKeyword())) base.setRegionKeyword(aiIntent.getRegionKeyword()); + if (StrUtil.isNotBlank(aiIntent.getCityKeyword())) base.setCityKeyword(aiIntent.getCityKeyword()); + if (StrUtil.isNotBlank(aiIntent.getTradeType())) base.setTradeType(aiIntent.getTradeType()); + if (StrUtil.isNotBlank(aiIntent.getDecorationType())) base.setDecorationType(aiIntent.getDecorationType()); + if (StrUtil.isNotBlank(aiIntent.getSupportingKeyword())) base.setSupportingKeyword(aiIntent.getSupportingKeyword()); + if (StrUtil.isNotBlank(aiIntent.getToward())) base.setToward(aiIntent.getToward()); + if (StrUtil.isNotBlank(aiIntent.getHouseType())) base.setHouseType(aiIntent.getHouseType()); + if (StrUtil.isNotBlank(aiIntent.getWhereSql())) base.setWhereSql(aiIntent.getWhereSql()); + if (StrUtil.isNotBlank(aiIntent.getOrderSql())) base.setOrderSql(aiIntent.getOrderSql()); + if (aiIntent.getTags() != null && !aiIntent.getTags().isEmpty()) { + Set merged = new LinkedHashSet<>(base.getTags()); + merged.addAll(aiIntent.getTags().stream().filter(StrUtil::isNotBlank).collect(Collectors.toList())); + base.setTags(new ArrayList<>(merged)); + } + } + + private void fillMissingIntent(HouseAiIntent target, HouseAiIntent fallback) { + if (fallback == null) { + return; + } + if (StrUtil.isBlank(target.getOriginalQuestion())) target.setOriginalQuestion(fallback.getOriginalQuestion()); + if (StrUtil.isBlank(target.getIntentType())) target.setIntentType(fallback.getIntentType()); + if (StrUtil.isBlank(target.getNormalizedQuestion())) target.setNormalizedQuestion(fallback.getNormalizedQuestion()); + if (target.getExtentMin() == null) target.setExtentMin(fallback.getExtentMin()); + if (target.getExtentMax() == null) target.setExtentMax(fallback.getExtentMax()); + if (target.getFloorMin() == null) target.setFloorMin(fallback.getFloorMin()); + if (target.getFloorMax() == null) target.setFloorMax(fallback.getFloorMax()); + if (target.getMonthlyRentMin() == null) target.setMonthlyRentMin(fallback.getMonthlyRentMin()); + if (target.getMonthlyRentMax() == null) target.setMonthlyRentMax(fallback.getMonthlyRentMax()); + if (target.getSalePriceMin() == null) target.setSalePriceMin(fallback.getSalePriceMin()); + if (target.getSalePriceMax() == null) target.setSalePriceMax(fallback.getSalePriceMax()); + if (target.getTotalPriceMin() == null) target.setTotalPriceMin(fallback.getTotalPriceMin()); + if (target.getTotalPriceMax() == null) target.setTotalPriceMax(fallback.getTotalPriceMax()); + if (StrUtil.isBlank(target.getRegionKeyword())) target.setRegionKeyword(fallback.getRegionKeyword()); + if (StrUtil.isBlank(target.getCityKeyword())) target.setCityKeyword(fallback.getCityKeyword()); + if (StrUtil.isBlank(target.getTradeType())) target.setTradeType(fallback.getTradeType()); + if (StrUtil.isBlank(target.getDecorationType())) target.setDecorationType(fallback.getDecorationType()); + if (StrUtil.isBlank(target.getSupportingKeyword())) target.setSupportingKeyword(fallback.getSupportingKeyword()); + if (StrUtil.isBlank(target.getToward())) target.setToward(fallback.getToward()); + if (StrUtil.isBlank(target.getHouseType())) target.setHouseType(fallback.getHouseType()); + if ((target.getTags() == null || target.getTags().isEmpty()) && fallback.getTags() != null) { + target.setTags(fallback.getTags()); + } + } + + private List searchHouses(HouseAiIntent intent, String question) { + List aiSqlHouses = searchHousesByAiSql(intent); + if (!aiSqlHouses.isEmpty()) { + return aiSqlHouses.stream().limit(10).collect(Collectors.toList()); + } + HouseInfoParam param = new HouseInfoParam(); + param.setStatus(0); + if (intent.getExtentMin() != null) { + param.setExtentStart(intent.getExtentMin()); + } + if (intent.getExtentMax() != null) { + param.setExtentEnd(intent.getExtentMax()); + } + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + param.setCity(intent.getCityKeyword()); + } + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + param.setRegion(intent.getRegionKeyword()); + } + if (StrUtil.isNotBlank(intent.getToward())) { + param.setToward(intent.getToward()); + } + if (StrUtil.isNotBlank(intent.getHouseType())) { + param.setHouseType(intent.getHouseType()); + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + param.setHouseLabel(intent.getDecorationType()); + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + param.setContent(intent.getSupportingKeyword()); + } + param.setKeywords(buildHouseKeywords(intent, question)); + + List houses = houseInfoService.listRel(param); + return filterHouses(houses, intent).stream().limit(10).collect(Collectors.toList()); + } + + private List searchHousesByAiSql(HouseAiIntent intent) { + if (StrUtil.isBlank(intent.getWhereSql())) { + return Collections.emptyList(); + } + String whereSql = sanitizeWhereSql(intent.getWhereSql()); + String orderSql = sanitizeOrderSql(intent.getOrderSql()); + if (StrUtil.isBlank(whereSql)) { + return Collections.emptyList(); + } + try { + return houseInfoMapper.selectListByAiSql(whereSql, orderSql); + } catch (Exception e) { + return Collections.emptyList(); + } + } + + private List filterHouses(List houses, HouseAiIntent intent) { + if (houses == null || houses.isEmpty()) { + return Collections.emptyList(); + } + return houses.stream() + .filter(item -> matchFloor(item.getFloor(), intent)) + .filter(item -> matchMoney(item.getMonthlyRent(), intent.getMonthlyRentMin(), intent.getMonthlyRentMax())) + .filter(item -> matchMoney(parseDecimal(item.getSalePrice()), intent.getSalePriceMin(), intent.getSalePriceMax())) + .filter(item -> matchMoney(parseDecimal(item.getTotalPrice()), intent.getTotalPriceMin(), intent.getTotalPriceMax())) + .filter(item -> matchTradeType(item, intent)) + .filter(item -> matchText(item, intent)) + .collect(Collectors.toList()); + } + + private List toHouseCards(List houses) { + return houses.stream().map(item -> { + HouseAiHouseCard card = new HouseAiHouseCard(); + card.setHouseId(item.getHouseId()); + card.setHouseTitle(item.getHouseTitle()); + card.setHouseType(item.getHouseType()); + card.setExtent(item.getExtent()); + card.setFloor(item.getFloor()); + card.setToward(item.getToward()); + card.setMonthlyRent(item.getMonthlyRent() == null ? null : item.getMonthlyRent().stripTrailingZeros().toPlainString()); + card.setCity(item.getCity()); + card.setRegion(item.getRegion()); + card.setAddress(item.getAddress()); + card.setFiles(item.getFiles()); + card.setSupporting(item.getSupporting()); + return card; + }).collect(Collectors.toList()); + } + + private boolean matchTradeType(HouseInfo item, HouseAiIntent intent) { + if (StrUtil.isBlank(intent.getTradeType())) { + return true; + } + if ("sale".equals(intent.getTradeType())) { + return parseDecimal(item.getSalePrice()) != null || parseDecimal(item.getTotalPrice()) != null; + } + if ("rent".equals(intent.getTradeType())) { + return item.getMonthlyRent() != null || item.getRent() != null; + } + return true; + } + + private boolean matchText(HouseInfo item, HouseAiIntent intent) { + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + String cityText = normalize(item.getCity()) + " " + normalize(item.getCityByHouse()); + if (!cityText.contains(normalize(intent.getCityKeyword()))) { + return false; + } + } + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + String text = normalize(item.getRegion()) + " " + normalize(item.getArea()) + " " + normalize(item.getAddress()) + " " + normalize(item.getCity()) + " " + normalize(item.getCityByHouse()); + if (!text.contains(normalize(intent.getRegionKeyword()))) { + return false; + } + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + String text = normalize(item.getHouseLabel()) + " " + normalize(item.getSupporting()) + " " + normalize(item.getContent()); + if (!text.contains(normalize(intent.getDecorationType()))) { + return false; + } + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + String text = normalize(item.getSupporting()) + " " + normalize(item.getContent()) + " " + normalize(item.getHouseLabel()); + if (!text.contains(normalize(intent.getSupportingKeyword()))) { + return false; + } + } + return true; + } + + private boolean matchFloor(String floor, HouseAiIntent intent) { + Integer currentFloor = extractFirstInteger(floor); + if (currentFloor == null) { + return true; + } + if (intent.getFloorMin() != null && currentFloor < intent.getFloorMin()) { + return false; + } + if (intent.getFloorMax() != null && currentFloor > intent.getFloorMax()) { + return false; + } + return true; + } + + private boolean matchMoney(BigDecimal current, BigDecimal min, BigDecimal max) { + if (current == null) { + return true; + } + if (min != null && current.compareTo(min) < 0) { + return false; + } + if (max != null && current.compareTo(max) > 0) { + return false; + } + return true; + } + + private String buildHouseKeywords(HouseAiIntent intent, String question) { + Set keywords = new LinkedHashSet<>(); + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + keywords.add(intent.getCityKeyword()); + } + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + keywords.add(intent.getRegionKeyword()); + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + keywords.add(intent.getDecorationType()); + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + keywords.add(intent.getSupportingKeyword()); + } + if (StrUtil.isNotBlank(intent.getToward())) { + keywords.add(intent.getToward()); + } + if (StrUtil.isNotBlank(intent.getHouseType())) { + keywords.add(intent.getHouseType()); + } + if (intent.getTags() != null) { + keywords.addAll(intent.getTags()); + } + if (!keywords.isEmpty()) { + return keywords.iterator().next(); + } + return shortenQuestion(question); + } + + private boolean requiresHouseSearch(HouseAiIntent intent) { + return hasHouseCondition(intent) || "mixed".equals(intent.getIntentType()) || "house".equals(intent.getIntentType()); + } + + private boolean hasHouseCondition(HouseAiIntent intent) { + return intent.getExtentMin() != null + || intent.getExtentMax() != null + || intent.getFloorMin() != null + || intent.getFloorMax() != null + || intent.getMonthlyRentMin() != null + || intent.getMonthlyRentMax() != null + || intent.getSalePriceMin() != null + || intent.getSalePriceMax() != null + || intent.getTotalPriceMin() != null + || intent.getTotalPriceMax() != null + || StrUtil.isNotBlank(intent.getCityKeyword()) + || StrUtil.isNotBlank(intent.getRegionKeyword()) + || StrUtil.isNotBlank(intent.getDecorationType()) + || StrUtil.isNotBlank(intent.getSupportingKeyword()) + || StrUtil.isNotBlank(intent.getToward()) + || StrUtil.isNotBlank(intent.getHouseType()); + } + + private String buildHouseAnswer(HouseAiIntent intent, int size) { + StringBuilder sb = new StringBuilder("已根据您的需求筛选到"); + sb.append(size).append("套较匹配的房源"); + List desc = new ArrayList<>(); + if (intent.getExtentMin() != null && intent.getExtentMax() != null) { + desc.add(intent.getExtentMin() + "-" + intent.getExtentMax() + "平"); + } else if (intent.getExtentMax() != null) { + desc.add(intent.getExtentMax() + "平以下"); + } else if (intent.getExtentMin() != null) { + desc.add(intent.getExtentMin() + "平以上"); + } + if (intent.getFloorMin() != null && intent.getFloorMax() != null) { + desc.add(intent.getFloorMin() + "-" + intent.getFloorMax() + "楼"); + } else if (intent.getFloorMin() != null) { + desc.add(intent.getFloorMin() + "楼以上"); + } else if (intent.getFloorMax() != null) { + desc.add(intent.getFloorMax() + "楼以下"); + } + if (intent.getMonthlyRentMin() != null && intent.getMonthlyRentMax() != null) { + desc.add("月租" + formatMoney(intent.getMonthlyRentMin()) + "-" + formatMoney(intent.getMonthlyRentMax()) + "元"); + } else if (intent.getMonthlyRentMax() != null) { + desc.add("月租" + intent.getMonthlyRentMax().stripTrailingZeros().toPlainString() + "元以内"); + } else if (intent.getMonthlyRentMin() != null) { + desc.add("月租" + intent.getMonthlyRentMin().stripTrailingZeros().toPlainString() + "元以上"); + } + if (intent.getSalePriceMin() != null || intent.getSalePriceMax() != null || intent.getTotalPriceMin() != null || intent.getTotalPriceMax() != null) { + String saleText = buildSaleText(intent); + if (StrUtil.isNotBlank(saleText)) { + desc.add(saleText); + } + } + if (StrUtil.isNotBlank(intent.getCityKeyword())) { + desc.add(intent.getCityKeyword()); + } + if (StrUtil.isNotBlank(intent.getRegionKeyword())) { + desc.add(intent.getRegionKeyword()); + } + if (StrUtil.isNotBlank(intent.getDecorationType())) { + desc.add(intent.getDecorationType()); + } + if (StrUtil.isNotBlank(intent.getSupportingKeyword())) { + desc.add(intent.getSupportingKeyword()); + } + if (!desc.isEmpty()) { + sb.append(",条件包括:").append(String.join("、", desc)); + } + sb.append("。"); + return sb.toString(); + } + + private String detectIntentType(String question) { + String normalized = normalize(question); + boolean faq = FAQ_HINTS.stream().anyMatch(normalized::contains); + boolean house = normalized.contains("平") || normalized.contains("楼") || normalized.contains("租") || + normalized.contains("预算") || normalized.contains("区域") || normalized.contains("地段") || + normalized.contains("装修") || normalized.contains("朝向") || normalized.contains("房型") || + normalized.contains("室") || normalized.contains("厅") || normalized.contains("电梯"); + if (faq && house) { + return "mixed"; + } + if (house) { + return "house"; + } + if (faq) { + return "faq"; + } + return "unknown"; + } + + private void parseExtent(String question, HouseAiIntent intent) { + String normalized = normalize(question); + Matcher rangeMatcher = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*(?:-|到|至)\\s*(\\d+(?:\\.\\d+)?)\\s*(?:平|平方)").matcher(normalized); + if (rangeMatcher.find()) { + intent.setExtentMin(NumberUtil.parseInt(rangeMatcher.group(1))); + intent.setExtentMax(NumberUtil.parseInt(rangeMatcher.group(2))); + } + Matcher matcher = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*(?:平|平方|m2|㎡)").matcher(normalized); + while (matcher.find()) { + Integer value = NumberUtil.parseInt(matcher.group(1)); + String context = normalized.substring(Math.max(0, matcher.start() - 6), Math.min(normalized.length(), matcher.end() + 6)); + if (containsAny(context, "以下", "以内", "不超过", "小于", "至多")) { + intent.setExtentMax(value); + } else if (containsAny(context, "以上", "不少于", "大于", "不低于")) { + intent.setExtentMin(value); + } else if (intent.getExtentMin() == null && intent.getExtentMax() == null) { + intent.setExtentMax(value); + } + } + } + + private void parseFloor(String question, HouseAiIntent intent) { + String normalized = normalize(question); + Matcher rangeMatcher = Pattern.compile("(\\d+)\\s*(?:-|到|至)\\s*(\\d+)\\s*楼").matcher(normalized); + if (rangeMatcher.find()) { + intent.setFloorMin(NumberUtil.parseInt(rangeMatcher.group(1))); + intent.setFloorMax(NumberUtil.parseInt(rangeMatcher.group(2))); + } + Matcher matcher = Pattern.compile("(\\d+)\\s*楼").matcher(normalized); + while (matcher.find()) { + Integer value = NumberUtil.parseInt(matcher.group(1)); + String context = normalized.substring(Math.max(0, matcher.start() - 6), Math.min(normalized.length(), matcher.end() + 6)); + if (containsAny(context, "以上", "起", "不低于", "大于")) { + intent.setFloorMin(value); + } else if (containsAny(context, "以下", "以内", "不高于", "小于")) { + intent.setFloorMax(value); + } else if (intent.getFloorMin() == null && intent.getFloorMax() == null) { + intent.setFloorMin(value); + } + } + } + + private void parseMonthlyRent(String question, HouseAiIntent intent) { + String normalized = normalize(question); + Matcher rangeMatcher = Pattern.compile("(月租|租金|预算)?\\s*(\\d+(?:\\.\\d+)?)\\s*(?:-|到|至)\\s*(\\d+(?:\\.\\d+)?)\\s*(元|块|w|万)").matcher(normalized); + if (rangeMatcher.find()) { + intent.setMonthlyRentMin(parseMoney(rangeMatcher.group(2), rangeMatcher.group(4))); + intent.setMonthlyRentMax(parseMoney(rangeMatcher.group(3), rangeMatcher.group(4))); + } + Matcher matcher = Pattern.compile("(月租|租金|预算)?\\s*(\\d+(?:\\.\\d+)?)\\s*(元|块|w|万)?").matcher(normalized); + while (matcher.find()) { + String prefix = matcher.group(1); + String raw = matcher.group(2); + String unit = matcher.group(3); + if (StrUtil.isBlank(prefix) && !normalized.contains("预算") && !normalized.contains("租")) { + continue; + } + BigDecimal value = parseMoney(raw, unit); + String context = normalized.substring(Math.max(0, matcher.start() - 8), Math.min(normalized.length(), matcher.end() + 8)); + if (containsAny(context, "月租", "租金", "预算", "元", "块", "w", "万")) { + if (containsAny(context, "以下", "以内", "不超过", "小于", "最多")) { + intent.setMonthlyRentMax(value); + } else if (containsAny(context, "以上", "不少于", "大于", "至少")) { + intent.setMonthlyRentMin(value); + } else if (intent.getMonthlyRentMax() == null && intent.getMonthlyRentMin() == null) { + intent.setMonthlyRentMax(value); + } + } + } + } + + private void parseSaleAndTotalPrice(String question, HouseAiIntent intent) { + String normalized = normalize(question); + if (normalized.contains("售价") || normalized.contains("卖价")) { + BigDecimal value = extractMoneyAfterKeyword(normalized, "售价", "卖价"); + if (value != null) { + if (containsAny(normalized, "以下", "以内", "不超过")) { + intent.setSalePriceMax(value); + } else if (containsAny(normalized, "以上", "不少于")) { + intent.setSalePriceMin(value); + } else { + intent.setSalePriceMax(value); + } + } + } + if (normalized.contains("售价") || normalized.contains("卖价")) { + parseRangeByKeyword(normalized, intent, true); + } + if (normalized.contains("总价")) { + BigDecimal value = extractMoneyAfterKeyword(normalized, "总价"); + if (value != null) { + if (containsAny(normalized, "以下", "以内", "不超过")) { + intent.setTotalPriceMax(value); + } else if (containsAny(normalized, "以上", "不少于")) { + intent.setTotalPriceMin(value); + } else { + intent.setTotalPriceMax(value); + } + } + parseRangeByKeyword(normalized, intent, false); + } + } + + private void parseTradeType(String question, HouseAiIntent intent) { + String normalized = normalize(question); + if (containsAny(normalized, "出售", "售价", "卖价", "总价", "买")) { + intent.setTradeType("sale"); + return; + } + if (containsAny(normalized, "出租", "月租", "租金", "租")) { + intent.setTradeType("rent"); + } + } + + private void parseCity(String question, HouseAiIntent intent) { + String normalized = normalize(question); + for (String city : CITY_HINTS) { + if (normalized.contains(normalize(city))) { + intent.setCityKeyword(city); + return; + } + } + } + + private BigDecimal extractMoneyAfterKeyword(String normalized, String... keywords) { + for (String keyword : keywords) { + int index = normalized.indexOf(keyword); + if (index >= 0) { + String part = normalized.substring(index, Math.min(normalized.length(), index + 18)); + Matcher matcher = NUMBER_PATTERN.matcher(part); + if (matcher.find()) { + String number = matcher.group(1); + String unit = part.contains("万") ? "万" : (part.contains("w") ? "w" : "元"); + return parseMoney(number, unit); + } + } + } + return null; + } + + private void parseRangeByKeyword(String normalized, HouseAiIntent intent, boolean salePrice) { + for (String keyword : salePrice ? Arrays.asList("售价", "卖价") : Arrays.asList("总价")) { + int index = normalized.indexOf(keyword); + if (index < 0) { + continue; + } + String part = normalized.substring(index, Math.min(normalized.length(), index + 24)); + Matcher matcher = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*(?:-|到|至)\\s*(\\d+(?:\\.\\d+)?)\\s*(万|w|元)?").matcher(part); + if (matcher.find()) { + BigDecimal min = parseMoney(matcher.group(1), matcher.group(3)); + BigDecimal max = parseMoney(matcher.group(2), matcher.group(3)); + if (salePrice) { + intent.setSalePriceMin(min); + intent.setSalePriceMax(max); + } else { + intent.setTotalPriceMin(min); + intent.setTotalPriceMax(max); + } + } + } + } + + private void parseRegion(String question, HouseAiIntent intent) { + String normalized = question == null ? "" : question.replace(",", " ").replace(",", " "); + for (String marker : Arrays.asList("区域", "地段", "附近", "位于", "在", "想要", "找")) { + int index = normalized.indexOf(marker); + if (index >= 0) { + String part = normalized.substring(index + marker.length()).trim(); + if (part.length() > 0) { + part = part.replaceAll("^(的|位于|靠近)", ""); + for (String stopWord : REGION_STOP_WORDS) { + int stopIndex = part.indexOf(stopWord); + if (stopIndex > 0) { + part = part.substring(0, stopIndex); + } + } + part = part.replaceAll("([++]|并且|而且|然后).*", ""); + part = part.trim(); + if (part.length() >= 2) { + intent.setRegionKeyword(part.length() > 12 ? part.substring(0, 12) : part); + return; + } + } + } + } + } + + private void parseDecoration(String question, HouseAiIntent intent) { + for (String item : Arrays.asList("精装", "简装", "毛坯", "豪装", "带装修", "装修好")) { + if (normalize(question).contains(normalize(item))) { + intent.setDecorationType(item); + return; + } + } + } + + private void parseSupporting(String question, HouseAiIntent intent) { + String normalized = normalize(question); + for (String item : SUPPORTING_HINTS) { + if (normalized.contains(normalize(item))) { + intent.setSupportingKeyword(item); + return; + } + } + if (normalized.contains("带电梯") || normalized.contains("有电梯")) { + intent.setSupportingKeyword("电梯"); + } + } + + private void parseToward(String question, HouseAiIntent intent) { + for (String item : Arrays.asList("朝南", "朝北", "朝东", "朝西", "东南", "西南", "东北", "西北")) { + if (normalize(question).contains(normalize(item))) { + intent.setToward(item); + return; + } + } + } + + private void parseHouseType(String question, HouseAiIntent intent) { + String normalized = normalize(question); + for (String item : Arrays.asList("一室一厅", "两室一厅", "三室一厅", "三室两厅", "四室两厅", "一房一厅", "两房一厅", "三房一厅", "一房", "两房", "三房", "四房", "开间", "loft", "写字楼", "办公室", "商铺")) { + if (normalized.contains(normalize(item))) { + intent.setHouseType(item); + return; + } + } + Matcher matcher = Pattern.compile("([一二三四五12345])\\s*室\\s*([一二三四五12345])\\s*厅").matcher(question); + if (matcher.find()) { + intent.setHouseType(matcher.group(1) + "室" + matcher.group(2) + "厅"); + return; + } + } + + private List extractTags(String question) { + if (StrUtil.isBlank(question)) { + return new ArrayList<>(); + } + String normalized = question + .replace(",", " ") + .replace(",", " ") + .replace("+", " ") + .replace("+", " ") + .replace("并且", " ") + .replace("而且", " ") + .replace("然后", " "); + return Arrays.stream(normalized.split("\\s+")) + .map(String::trim) + .filter(item -> item.length() >= 2) + .filter(item -> !item.matches(".*\\d.*")) + .filter(item -> !containsAny(item, "房源", "月租", "租金", "预算", "总价", "售价", "卖价", "楼层", "面积", "一套", "想租")) + .distinct() + .limit(6) + .collect(Collectors.toList()); + } + + private String shortenQuestion(String question) { + String normalized = normalize(question); + return normalized.length() > 12 ? normalized.substring(0, 12) : normalized; + } + + private String normalize(String text) { + if (text == null) { + return ""; + } + return text.toLowerCase(Locale.ROOT) + .replace("㎡", "平") + .replace("平方", "平") + .replace("m²", "平") + .replace("m2", "平") + .replace("M²", "平") + .replace("(", "(") + .replace(")", ")") + .replace("+", "+") + .trim(); + } + + private boolean containsAny(String text, String... values) { + if (text == null) { + return false; + } + for (String value : values) { + if (text.contains(value)) { + return true; + } + } + return false; + } + + private BigDecimal parseMoney(String raw, String unit) { + if (StrUtil.isBlank(raw)) { + return null; + } + BigDecimal value = new BigDecimal(raw); + if ("w".equalsIgnoreCase(unit) || "万".equals(unit)) { + value = value.multiply(new BigDecimal("10000")); + } + return value; + } + + private String formatMoney(BigDecimal value) { + if (value == null) { + return ""; + } + return value.stripTrailingZeros().toPlainString(); + } + + private String buildSaleText(HouseAiIntent intent) { + if (intent.getTradeType() != null && "sale".equals(intent.getTradeType())) { + if (intent.getTotalPriceMin() != null && intent.getTotalPriceMax() != null) { + return "总价" + formatMoney(intent.getTotalPriceMin()) + "-" + formatMoney(intent.getTotalPriceMax()) + "元"; + } + if (intent.getTotalPriceMax() != null) { + return "总价" + formatMoney(intent.getTotalPriceMax()) + "元以内"; + } + if (intent.getSalePriceMin() != null && intent.getSalePriceMax() != null) { + return "售价" + formatMoney(intent.getSalePriceMin()) + "-" + formatMoney(intent.getSalePriceMax()) + "元"; + } + if (intent.getSalePriceMax() != null) { + return "售价" + formatMoney(intent.getSalePriceMax()) + "元以内"; + } + } + return ""; + } + + private String sanitizeWhereSql(String whereSql) { + if (StrUtil.isBlank(whereSql)) { + return null; + } + String normalized = whereSql.trim() + .replaceAll("(?i)^\\s*where\\s+", "") + .replaceAll("(?i)\\bselect\\b", "") + .replaceAll("(?i)\\bupdate\\b", "") + .replaceAll("(?i)\\bdelete\\b", "") + .replaceAll("(?i)\\binsert\\b", "") + .replaceAll("(?i)\\bdrop\\b", "") + .replaceAll("(?i)\\btruncate\\b", "") + .replaceAll("(?i)\\bunion\\b", "") + .replaceAll(";", "") + .trim(); + if (StrUtil.isBlank(normalized)) { + return null; + } + if (Pattern.compile("(?i)\\border\\s+by\\b").matcher(normalized).find()) { + return null; + } + List allowedColumns = Arrays.asList( + "a.house_type", "a.monthly_rent", "a.sale_price", "a.total_price", "a.extent", "a.floor", + "a.city", "a.city_by_house", "a.region", "a.area", "a.address", "a.house_label", + "a.supporting", "a.content", "a.toward", "a.lease_method" + ); + Matcher matcher = Pattern.compile("a\\.[a-zA-Z_]+").matcher(normalized); + while (matcher.find()) { + String column = matcher.group(); + if (!allowedColumns.contains(column)) { + return null; + } + } + if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")) { + return null; + } + return normalized; + } + + private String sanitizeOrderSql(String orderSql) { + if (StrUtil.isBlank(orderSql)) { + return null; + } + String normalized = orderSql.trim() + .replaceAll("(?i)\\border\\s+by\\b", "") + .replaceAll(";", "") + .trim(); + if (StrUtil.isBlank(normalized)) { + return null; + } + List allowedColumns = Arrays.asList( + "a.sort_number", "a.create_time", "a.monthly_rent", "a.sale_price", "a.total_price", "a.extent", "a.floor" + ); + for (String item : normalized.split(",")) { + String[] parts = item.trim().split("\\s+"); + if (parts.length == 0 || !allowedColumns.contains(parts[0])) { + return null; + } + if (parts.length > 1 && !("asc".equalsIgnoreCase(parts[1]) || "desc".equalsIgnoreCase(parts[1]))) { + return null; + } + } + return normalized; + } + + private BigDecimal parseDecimal(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + String number = raw.replaceAll("[^0-9.]", ""); + if (StrUtil.isBlank(number)) { + return null; + } + try { + return new BigDecimal(number); + } catch (Exception e) { + return null; + } + } + + private Integer extractFirstInteger(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + Matcher matcher = NUMBER_PATTERN.matcher(raw); + if (matcher.find()) { + return NumberUtil.parseInt(matcher.group(1)); + } + return null; + } +} diff --git a/src/main/java/com/gxwebsoft/house/service/impl/HouseAiConfigServiceImpl.java b/src/main/java/com/gxwebsoft/house/service/impl/HouseAiConfigServiceImpl.java new file mode 100644 index 0000000..5ed945a --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/impl/HouseAiConfigServiceImpl.java @@ -0,0 +1,52 @@ +package com.gxwebsoft.house.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseAiConfig; +import com.gxwebsoft.house.mapper.HouseAiConfigMapper; +import com.gxwebsoft.house.param.HouseAiConfigParam; +import com.gxwebsoft.house.service.HouseAiConfigService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * AI找房配置Service实现 + */ +@Service +public class HouseAiConfigServiceImpl extends ServiceImpl implements HouseAiConfigService { + + @Override + public PageResult pageRel(HouseAiConfigParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("config_id desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(HouseAiConfigParam param) { + return baseMapper.selectListRel(param); + } + + @Override + public HouseAiConfig getByIdRel(Integer configId) { + HouseAiConfigParam param = new HouseAiConfigParam(); + param.setConfigId(configId); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + public HouseAiConfig getCurrentConfig(Integer tenantId) { + HouseAiConfigParam param = new HouseAiConfigParam(); + param.setStatus(0); + param.setTenantId(tenantId); + HouseAiConfig config = param.getOne(baseMapper.selectListRel(param)); + if (config != null) { + return config; + } + param.setTenantId(null); + return param.getOne(baseMapper.selectListRel(param)); + } +} diff --git a/src/main/java/com/gxwebsoft/house/service/impl/HouseFaqServiceImpl.java b/src/main/java/com/gxwebsoft/house/service/impl/HouseFaqServiceImpl.java new file mode 100644 index 0000000..22bc4c9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/impl/HouseFaqServiceImpl.java @@ -0,0 +1,151 @@ +package com.gxwebsoft.house.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseFaq; +import com.gxwebsoft.house.mapper.HouseFaqMapper; +import com.gxwebsoft.house.param.HouseFaqParam; +import com.gxwebsoft.house.service.HouseFaqService; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +/** + * AI找房常见问题Service实现 + */ +@Service +public class HouseFaqServiceImpl extends ServiceImpl implements HouseFaqService { + + @Override + public PageResult pageRel(HouseFaqParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("sort_number asc, faq_id desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(HouseFaqParam param) { + return baseMapper.selectListRel(param); + } + + @Override + public HouseFaq getByIdRel(Integer faqId) { + HouseFaqParam param = new HouseFaqParam(); + param.setFaqId(faqId); + return param.getOne(baseMapper.selectListRel(param)); + } + + @Override + public List findBestMatches(String queryText, int limit) { + HouseFaqParam param = new HouseFaqParam(); + param.setStatus(0); + List all = baseMapper.selectListRel(param); + if (StrUtil.isBlank(queryText) || all == null || all.isEmpty()) { + return new ArrayList<>(); + } + final String normalizedQuery = normalizeText(queryText); + return all.stream() + .map(item -> new ScoredFaq(item, score(item, normalizedQuery))) + .filter(item -> item.score > 0) + .sorted(Comparator.comparingInt(ScoredFaq::getScore).reversed() + .thenComparing(item -> item.faq.getSortNumber() == null ? Integer.MAX_VALUE : item.faq.getSortNumber())) + .limit(limit) + .map(ScoredFaq::getFaq) + .collect(Collectors.toList()); + } + + private int score(HouseFaq faq, String normalizedQuery) { + int score = 0; + String question = normalizeText(faq.getQuestion()); + String keywords = normalizeText(faq.getKeywords()); + String answer = normalizeText(faq.getAnswer()); + String category = normalizeText(faq.getCategory()); + if (StrUtil.isBlank(normalizedQuery)) { + return score; + } + if (question.contains(normalizedQuery)) { + score += 120; + } + if (keywords.contains(normalizedQuery)) { + score += 100; + } + if (answer.contains(normalizedQuery)) { + score += 40; + } + if (category.contains(normalizedQuery)) { + score += 30; + } + for (String token : tokenize(normalizedQuery)) { + if (token.length() < 2) { + continue; + } + if (question.contains(token)) { + score += 20; + } + if (keywords.contains(token)) { + score += 18; + } + if (answer.contains(token)) { + score += 8; + } + if (category.contains(token)) { + score += 6; + } + } + return score; + } + + private List tokenize(String text) { + String normalized = normalizeText(text); + List tokens = new ArrayList<>(); + for (String item : normalized.split("[,,\\s+/|]+")) { + if (StrUtil.isNotBlank(item)) { + tokens.add(item); + } + } + return tokens; + } + + private String normalizeText(String text) { + if (text == null) { + return ""; + } + return text.replace(" ", " ") + .replace(",", ",") + .replace("。", " ") + .replace(";", " ") + .replace(":", " ") + .replace("(", "(") + .replace(")", ")") + .replace("㎡", "平") + .replace("m²", "平") + .replace("M²", "平") + .toLowerCase(Locale.ROOT) + .trim(); + } + + private static class ScoredFaq { + private final HouseFaq faq; + private final int score; + + private ScoredFaq(HouseFaq faq, int score) { + this.faq = faq; + this.score = score; + } + + public HouseFaq getFaq() { + return faq; + } + + public int getScore() { + return score; + } + } +} diff --git a/src/main/java/com/gxwebsoft/house/service/impl/HouseMessageServiceImpl.java b/src/main/java/com/gxwebsoft/house/service/impl/HouseMessageServiceImpl.java new file mode 100644 index 0000000..d285288 --- /dev/null +++ b/src/main/java/com/gxwebsoft/house/service/impl/HouseMessageServiceImpl.java @@ -0,0 +1,42 @@ +package com.gxwebsoft.house.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.common.core.web.PageParam; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.house.entity.HouseMessage; +import com.gxwebsoft.house.mapper.HouseMessageMapper; +import com.gxwebsoft.house.param.HouseMessageParam; +import com.gxwebsoft.house.service.HouseMessageService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * AI找房留言Service实现 + */ +@Service +public class HouseMessageServiceImpl extends ServiceImpl implements HouseMessageService { + + @Override + public PageResult pageRel(HouseMessageParam param) { + PageParam page = new PageParam<>(param); + page.setDefaultOrder("create_time desc, message_id desc"); + List list = baseMapper.selectPageRel(page, param); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public List listRel(HouseMessageParam param) { + List list = baseMapper.selectListRel(param); + PageParam page = new PageParam<>(); + page.setDefaultOrder("create_time desc, message_id desc"); + return page.sortRecords(list); + } + + @Override + public HouseMessage getByIdRel(Integer messageId) { + HouseMessageParam param = new HouseMessageParam(); + param.setMessageId(messageId); + return param.getOne(baseMapper.selectListRel(param)); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2ff0825..049ee35 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -32,7 +32,7 @@ socketio: # MQTT配置 mqtt: enabled: false # 添加开关来禁用MQTT服务 - host: tcp://132.232.214.96:1883 + host: tcp://1.14.159.185:1883 username: swdev password: Sw20250523 client-id-prefix: hjm_car_ @@ -46,7 +46,7 @@ mqtt: config: # 开发环境接口 server-url: https://server.websoft.top/api - upload-path: /Users/gxwebsoft/Documents/uploads/ # window(D:\Temp) + upload-path: /Users/gxwebsoft/JAVA/mp-java/src/main/resources/ # window(D:\Temp) # 开发环境证书配置 certificate: @@ -56,10 +56,3 @@ certificate: private-key-file: "apiclient_key.pem" apiclient-cert-file: "apiclient_cert.pem" wechatpay-cert-file: "wechatpay_cert.pem" - -# 阿里云翻译配置 -aliyun: - translate: - access-key-id: LTAI5tEsyhW4GCKbds1qsopg - access-key-secret: zltFlQrYVAoq2KMFDWgLa3GhkMNeyO - endpoint: mt.cn-hangzhou.aliyuncs.com diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 368b20f..977deec 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,13 +22,6 @@ spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss - serialization: - write-dates-as-timestamps: false - deserialization: - fail-on-unknown-properties: false - # 确保启用Java 8时间支持 - modules: - - com.fasterxml.jackson.datatype.jsr310.JavaTimeModule # 连接池配置 datasource: @@ -99,11 +92,10 @@ config: # 主服务器 server-url: https://server.websoft.top/api # 文件服务器 - file-server: https://file.websoft.top - # 其他 - api-url: https://server.websoft.top/api - upload-path: /Users/gxwebsoft/Documents/uploads - local-upload-path: /Users/gxwebsoft/Documents/uploads + file-server: https://file.wsdns.cn + upload-path: /Users/gxwebsoft/Documents/uploads/ + local-upload-path: /Users/gxwebsoft/Documents/uploads/ + api-url: https://cms-api.websoft.top/api # 阿里云OSS云存储 endpoint: https://oss-cn-shenzhen.aliyuncs.com @@ -118,18 +110,18 @@ shop: order: # 测试账号配置 test-account: - enabled: true # 禁用测试账号功能 + enabled: true phone-numbers: - - "19163679581" # 改为其他测试手机号 + - "13737128880" test-pay-amount: 0.01 # 租户特殊规则配置 - # tenant-rules: - # - tenant-id: 10324 - # tenant-name: "百色中学" - # min-amount: 10 - # min-amount-message: "捐款金额最低不能少于10元,感谢您的爱心捐赠^_^" - # enabled: true + tenant-rules: + - tenant-id: 10324 + tenant-name: "百色中学" + min-amount: 10 + min-amount-message: "捐款金额最低不能少于10元,感谢您的爱心捐赠^_^" + enabled: true # 默认配置 default-config: @@ -138,32 +130,6 @@ shop: min-order-amount: 0 order-timeout-minutes: 30 - # 订单自动取消配置 - auto-cancel: - # 是否启用自动取消功能 - enabled: true - # 默认超时时间(分钟) - default-timeout-minutes: 30 - # 定时任务检查间隔(分钟) - check-interval-minutes: 1 - # 批量处理大小 - batch-size: 100 - # 定时任务执行时间(cron表达式) - # 生产环境:每5分钟执行一次 - cron: "0 */5 * * * ?" - - # 租户特殊配置 - # tenant-configs: - # - tenant-id: 10324 - # tenant-name: "百色中学" - # timeout-minutes: 120 # 捐款订单给更长的支付时间 - # enabled: true - # 可以添加更多租户配置 - # - tenant-id: 10550 - # tenant-name: "其他租户" - # timeout-minutes: 15 - # enabled: true - # 证书配置 certificate: # 证书加载模式: CLASSPATH, FILESYSTEM, VOLUME @@ -198,50 +164,6 @@ springdoc: swagger-ui: enabled: true -# LED - 排班接口(业务中台)对接配置 -led: - bme: - base-url: ${LED_BME_BASE_URL:http://16.1.4.201:7979} - appid: ${LED_BME_APPID:BQ73n58Lf} - secret-key: ${LED_BME_SECRET_KEY:jk720-DCPnGq@5t8} - mechanism-id: ${LED_BME_MECHANISM_ID:10001} - default-ext-user-id: ${LED_BME_DEFAULT_EXT_USER_ID:txzhyy} - default-hospital-id: ${LED_BME_DEFAULT_HOSPITAL_ID:} - timeout-ms: ${LED_BME_TIMEOUT_MS:10000} - # 启用 Knife4j knife4j: enable: true - -# 优惠券配置 -coupon: - # 过期处理定时任务配置 - expire: - # 定时任务执行时间(cron表达式) - # 生产环境:每天凌晨2点执行 - # 开发环境:每10分钟执行一次 - cron: "0 0 2 * * ?" - # 开发环境可以设置为: "0 */10 * * * ?" - - # 状态管理配置 - status: - # 是否启用自动状态更新 - auto-update: true - # 批量处理大小 - batch-size: 1000 - -# 支付配置 -payment: - # 开发环境配置 - dev: - # 开发环境回调地址(本地调试用) - notify-url: "http://frps-10550.s209.websoft.top/api/shop/shop-order/notify" - # 开发环境是否启用环境感知 - environment-aware: true - - # 生产环境配置 - prod: - # 生产环境回调地址 - notify-url: "https://cms-api.websoft.top/api/shop/shop-order/notify" - # 生产环境是否启用环境感知 - environment-aware: false diff --git a/src/main/resources/sql/house_ai_config.sql b/src/main/resources/sql/house_ai_config.sql new file mode 100644 index 0000000..bae29b4 --- /dev/null +++ b/src/main/resources/sql/house_ai_config.sql @@ -0,0 +1,24 @@ +CREATE TABLE `house_ai_config` ( + `config_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `ai_avatar` varchar(500) DEFAULT NULL COMMENT 'AI形象照', + `ai_entry_image` varchar(500) DEFAULT NULL COMMENT '首页AI入口图', + `ai_float_image` varchar(500) DEFAULT NULL COMMENT '首页AI悬浮图', + `welcome_message` varchar(500) DEFAULT NULL COMMENT '欢迎词', + `status` tinyint(1) DEFAULT 0 COMMENT '状态 0正常 1禁用', + `deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除, 0否, 1是', + `tenant_id` int(11) DEFAULT NULL COMMENT '租户ID', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`config_id`), + KEY `idx_house_ai_config_status` (`status`), + KEY `idx_house_ai_config_tenant` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI找房配置'; + +INSERT INTO `house_ai_config` (`ai_avatar`, `ai_entry_image`, `ai_float_image`, `welcome_message`, `status`) +VALUES +('https://oss.wsdns.cn/20260601/0d53634419a448919c90c99ab9779d8a.png?x-oss-process=image/resize,w_750/quality,Q_90', 'https://oss.wsdns.cn/20260626/f5f7d5996e4f45ce8bb24c7c455d07d4.png?x-oss-process=image/resize,m_fixed,w_750/quality,Q_90', 'https://oss.wsdns.cn/20260626/e666a52de6724578b6df007fcacbbe21.gif?x-oss-process=image/resize,m_fixed,w_750/quality,Q_90', '您好,我是AI找房助手,请告诉我面积、楼层、预算和地段,我来帮您筛选更合适的房源。', 0); + +-- 已有表升级时执行: +-- ALTER TABLE `house_ai_config` +-- ADD COLUMN `ai_entry_image` varchar(500) DEFAULT NULL COMMENT '首页AI入口图' AFTER `ai_avatar`, +-- ADD COLUMN `ai_float_image` varchar(500) DEFAULT NULL COMMENT '首页AI悬浮图' AFTER `ai_entry_image`; diff --git a/src/main/resources/sql/house_faq.sql b/src/main/resources/sql/house_faq.sql new file mode 100644 index 0000000..72eb912 --- /dev/null +++ b/src/main/resources/sql/house_faq.sql @@ -0,0 +1,24 @@ +CREATE TABLE `house_faq` ( + `faq_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `question` varchar(255) NOT NULL COMMENT '问题', + `keywords` varchar(500) DEFAULT NULL COMMENT '关键词,多个用逗号分隔', + `answer` text COMMENT '标准回答', + `category` varchar(100) DEFAULT NULL COMMENT '分类', + `sort_number` int(11) DEFAULT 0 COMMENT '排序号', + `status` tinyint(1) DEFAULT 0 COMMENT '状态 0正常 1禁用', + `user_id` int(11) DEFAULT NULL COMMENT '创建用户ID', + `deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除, 0否, 1是', + `tenant_id` int(11) DEFAULT NULL COMMENT '租户ID', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`faq_id`), + KEY `idx_house_faq_status` (`status`), + KEY `idx_house_faq_sort` (`sort_number`), + KEY `idx_house_faq_tenant` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI找房常见问题'; + +INSERT INTO `house_faq` (`question`, `keywords`, `answer`, `category`, `sort_number`, `status`) +VALUES +('怎么预约看房?', '预约看房,带看,流程', '在房源详情页点击预约看房,填写联系人和电话后提交即可,我们会尽快安排带看。', '看房流程', 1, 0), +('签约一般需要准备什么材料?', '签约,材料,合同', '通常需要准备身份证明、联系方式、企业主体资料(如为公司租赁)等,具体以顾问通知为准。', '签约流程', 2, 0), +('租房押金和佣金怎么收?', '押金,佣金,费用', '押金、佣金标准会根据具体房源和签约方式有所不同,您可以先告诉我目标房源或预算,我会优先帮您筛选合适房源,再由顾问说明费用细节。', '费用说明', 3, 0); diff --git a/src/main/resources/sql/house_message.sql b/src/main/resources/sql/house_message.sql new file mode 100644 index 0000000..be44fa8 --- /dev/null +++ b/src/main/resources/sql/house_message.sql @@ -0,0 +1,19 @@ +CREATE TABLE `house_message` ( + `message_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` int(11) DEFAULT NULL COMMENT '用户ID', + `real_name` varchar(100) NOT NULL COMMENT '姓名', + `phone` varchar(30) DEFAULT NULL COMMENT '手机号', + `wechat` varchar(100) DEFAULT NULL COMMENT '微信号', + `source` varchar(50) DEFAULT 'ai_house' COMMENT '来源', + `comments` varchar(500) DEFAULT NULL COMMENT '备注', + `status` tinyint(1) DEFAULT 0 COMMENT '状态 0未处理 1已处理', + `deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除, 0否, 1是', + `tenant_id` int(11) DEFAULT NULL COMMENT '租户ID', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`message_id`), + KEY `idx_house_message_user` (`user_id`), + KEY `idx_house_message_status` (`status`), + KEY `idx_house_message_tenant` (`tenant_id`), + KEY `idx_house_message_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI找房留言'; diff --git a/websoft-modules.log.2025-08-11.0.gz b/websoft-modules.log.2025-08-11.0.gz new file mode 100644 index 0000000000000000000000000000000000000000..feca1d3fc7c872e6a3835cd94cfc4c89476c8343 GIT binary patch literal 8611 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