diff --git a/sql/mysql/ydoyun_ai_assistant_report_detail.sql b/sql/mysql/ydoyun_ai_assistant_report_detail.sql new file mode 100644 index 0000000..4f91f69 --- /dev/null +++ b/sql/mysql/ydoyun_ai_assistant_report_detail.sql @@ -0,0 +1,19 @@ +-- 每日汇报详表:与 ydoyun_ai_assistant_report 一对多 +CREATE TABLE IF NOT EXISTS `ydoyun_ai_assistant_report_detail` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `report_id` bigint NOT NULL COMMENT '每日汇报主表ID(ydoyun_ai_assistant_report.id)', + `diagnosis_item` varchar(256) NOT NULL DEFAULT '' COMMENT '诊断项目', + `value1` varchar(512) DEFAULT '' COMMENT '指标异常', + `value2` varchar(512) DEFAULT '' COMMENT '归因分析', + `value3` varchar(512) DEFAULT '' COMMENT '改善对策', + `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序', + `creator` varchar(64) DEFAULT '' NULL COMMENT '创建者', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间', + `updater` varchar(64) DEFAULT '' NULL COMMENT '更新者', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) DEFAULT b'0' NOT NULL COMMENT '是否删除', + `tenant_id` bigint DEFAULT 0 NOT NULL COMMENT '租户编号', + PRIMARY KEY (`id`), + KEY `idx_report_id` (`report_id`), + KEY `idx_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI决策助手每日汇报详表'; diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/config/AmapWeatherProperties.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/config/AmapWeatherProperties.java new file mode 100644 index 0000000..2bbe7bb --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/config/AmapWeatherProperties.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ydoyun.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 高德开放平台 Web 服务:地理编码 + 天气查询。 + * 天气查询 + */ +@Data +@Component +@ConfigurationProperties(prefix = "ydoyun.amap.weather") +public class AmapWeatherProperties { + + /** + * 高德「Web 服务」类型 Key(勿与 JS API Key 混用) + */ + private String key = ""; + + /** + * 地理编码接口,默认官方 v3 + */ + private String geocodeUrl = "https://restapi.amap.com/v3/geocode/geo"; + + /** + * 天气查询接口,默认官方 v3 + */ + private String weatherUrl = "https://restapi.amap.com/v3/weather/weatherInfo"; + + /** + * 行政区划查询(城市名→adcode,地理编码失败时的回退) + */ + private String districtUrl = "https://restapi.amap.com/v3/config/district"; + + /** + * 天气图标 URL 模板,必须包含占位符 {@code {code}}(OpenWeatherMap 图标名,如 01d、10d)。 + * 默认使用 OpenWeatherMap 官方 CDN;若环境无法访问外网,可改为相对路径,例如: + * {@code /weather-icons/{code}.png},并将图标放入前端 {@code public/weather-icons/}(文件名 01d.png、10d.png 等)。 + * 设为空字符串则不返回图标。 + */ + private String weatherIconUrlPattern = "/weather-icons/{code}.png"; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailBatchSaveReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailBatchSaveReqVO.java new file mode 100644 index 0000000..e229064 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailBatchSaveReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import java.util.List; + +@Schema(description = "每日汇报详表批量保存(全量替换)") +@Data +public class AiAssistantReportDetailBatchSaveReqVO { + + @Schema(description = "每日汇报主表 ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "报告主键不能为空") + private Long reportId; + + @Schema(description = "详表行列表(空行会在服务端忽略;先删后插)") + @Valid + private List details; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailRespVO.java new file mode 100644 index 0000000..eb2a4b5 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI 决策助手每日汇报详表") +@Data +public class AiAssistantReportDetailRespVO { + + @Schema(description = "主键") + private Long id; + @Schema(description = "每日汇报主表 ID") + private Long reportId; + @Schema(description = "诊断项目") + private String diagnosisItem; + @Schema(description = "指标异常") + private String value1; + @Schema(description = "归因分析") + private String value2; + @Schema(description = "改善对策") + private String value3; + @Schema(description = "排序") + private Integer sortOrder; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailSaveItemVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailSaveItemVO.java new file mode 100644 index 0000000..5a5407d --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDetailSaveItemVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "每日汇报详表单行保存项") +@Data +public class AiAssistantReportDetailSaveItemVO { + + @Schema(description = "诊断项目") + private String diagnosisItem; + @Schema(description = "指标异常") + private String value1; + @Schema(description = "归因分析") + private String value2; + @Schema(description = "改善对策") + private String value3; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDifyParseReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDifyParseReqVO.java new file mode 100644 index 0000000..6754b05 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/AiAssistantReportDifyParseReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Schema(description = "每日汇报 - Dify 智能解析报告正文") +@Data +public class AiAssistantReportDifyParseReqVO { + + @Schema(description = "报告内容全文", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "报告内容不能为空") + private String text; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/weather/WeatherWttrController.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/weather/WeatherWttrController.java new file mode 100644 index 0000000..3c47243 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/weather/WeatherWttrController.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.weather; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.ydoyun.controller.admin.weather.vo.WeatherAggregateRespVO; +import cn.iocoder.yudao.module.ydoyun.service.weather.AmapWeatherService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.error; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 高德天气:服务端代理地理编码 + 实况 + 预报,避免浏览器直连 Key。 + * 路径沿用 /wttr,返回结构为 {@link WeatherAggregateRespVO}。 + */ +@Tag(name = "管理后台 - 天气代理") +@Slf4j +@RestController +@RequestMapping("/ydoyun/weather") +public class WeatherWttrController { + + @Resource + private AmapWeatherService amapWeatherService; + + @GetMapping("/wttr") + @Operation(summary = "高德天气:按城市名或 adcode 聚合实况 + 近 3 日预报") + public CommonResult getWeather(@RequestParam("location") String location) { + if (location == null || location.trim().isEmpty()) { + return error(400, "location 不能为空"); + } + try { + return success(amapWeatherService.getAggregate(location.trim())); + } catch (IllegalStateException e) { + log.warn("[weather] 高德天气业务失败 location={} msg={}", location, e.getMessage()); + return error(502, e.getMessage()); + } catch (Exception e) { + log.warn("[weather] 高德天气请求失败 location={}", location, e); + return error(502, "获取天气失败: " + e.getMessage()); + } + } + +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/weather/vo/WeatherAggregateRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/weather/vo/WeatherAggregateRespVO.java new file mode 100644 index 0000000..c1b1fde --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/weather/vo/WeatherAggregateRespVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.ydoyun.controller.admin.weather.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "销售日报等页面使用的天气聚合数据(高德实况 + 预报)") +@Data +public class WeatherAggregateRespVO { + + @Schema(description = "展示用城市名") + private String cityLabel; + + @Schema(description = "实况") + private Current current; + + @Schema(description = "未来 3 天预报") + private List days; + + @Data + public static class Current { + @Schema(description = "温度(字符串)") + private String temp; + @Schema(description = "天气文字") + private String desc; + @Schema(description = "图标完整 URL(CDN)") + private String icon; + } + + @Data + public static class Day { + @Schema(description = "预报日期 yyyy-MM-dd") + private String fxDate; + @Schema(description = "周几,如 周二") + private String weekLabel; + @Schema(description = "月/日,如 3/25") + private String dateLabel; + @Schema(description = "最低~最高,如 12~22°C") + private String range; + @Schema(description = "白天天气简述") + private String desc; + @Schema(description = "白天图标 URL") + private String icon; + } + +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantreport/AiAssistantReportDetailDO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantreport/AiAssistantReportDetailDO.java new file mode 100644 index 0000000..72a8661 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantreport/AiAssistantReportDetailDO.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantreport; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI 决策助手每日汇报详表 DO(与主表一对多) + * + * @author 衣朵云 + */ +@TableName("ydoyun_ai_assistant_report_detail") +@KeySequence("ydoyun_ai_assistant_report_detail_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiAssistantReportDetailDO extends TenantBaseDO { + + @TableId + private Long id; + /** 主表 ID */ + private Long reportId; + /** 诊断项目 */ + private String diagnosisItem; + /** 指标异常 */ + private String value1; + /** 归因分析 */ + private String value2; + /** 改善对策 */ + private String value3; + /** 排序 */ + private Integer sortOrder; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantreport/AiAssistantReportDetailMapper.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantreport/AiAssistantReportDetailMapper.java new file mode 100644 index 0000000..996c06d --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantreport/AiAssistantReportDetailMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantreport; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantreport.AiAssistantReportDetailDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI 决策助手每日汇报详表 Mapper + * + * @author 衣朵云 + */ +@Mapper +public interface AiAssistantReportDetailMapper extends BaseMapperX { + + default List selectListByReportId(Long reportId) { + return selectList(new LambdaQueryWrapperX() + .eq(AiAssistantReportDetailDO::getReportId, reportId) + .orderByAsc(AiAssistantReportDetailDO::getSortOrder) + .orderByAsc(AiAssistantReportDetailDO::getId)); + } + + default int deleteByReportId(Long reportId) { + return delete(new LambdaQueryWrapperX() + .eq(AiAssistantReportDetailDO::getReportId, reportId)); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/AiAssistantReportDetailService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/AiAssistantReportDetailService.java new file mode 100644 index 0000000..f31d62c --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/AiAssistantReportDetailService.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.ydoyun.service.aiassistantreport; + +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailBatchSaveReqVO; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailRespVO; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 每日汇报详表 Service + * + * @author 衣朵云 + */ +public interface AiAssistantReportDetailService { + + /** + * 按主表 ID 查询详表(校验报告归属当前登录人) + */ + List listByReportId(@NotNull Long reportId, @NotNull Long loginUserId); + + /** + * 批量保存(删除该主表下旧明细后插入新行) + */ + void saveBatch(@Valid AiAssistantReportDetailBatchSaveReqVO reqVO, @NotNull Long loginUserId); + + /** + * 按主表 ID 删除所有明细(逻辑删除) + */ + void deleteByReportId(@NotNull Long reportId); +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/AiAssistantReportDetailServiceImpl.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/AiAssistantReportDetailServiceImpl.java new file mode 100644 index 0000000..5fce982 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/AiAssistantReportDetailServiceImpl.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.ydoyun.service.aiassistantreport; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailBatchSaveReqVO; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailRespVO; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailSaveItemVO; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantreport.AiAssistantReportDO; +import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantreport.AiAssistantReportDetailDO; +import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantreport.AiAssistantReportDetailMapper; +import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantreport.AiAssistantReportMapper; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 每日汇报详表 Service 实现 + * + * @author 衣朵云 + */ +@Service +@Validated +public class AiAssistantReportDetailServiceImpl implements AiAssistantReportDetailService { + + @Resource + private AiAssistantReportMapper aiAssistantReportMapper; + @Resource + private AiAssistantReportDetailMapper aiAssistantReportDetailMapper; + + @Override + public List listByReportId(Long reportId, Long loginUserId) { + AiAssistantReportDO report = aiAssistantReportMapper.selectById(reportId); + if (report == null) { + throw exception(BAD_REQUEST); + } + if (!loginUserId.equals(report.getReporterId())) { + throw exception(FORBIDDEN); + } + List list = aiAssistantReportDetailMapper.selectListByReportId(reportId); + return BeanUtils.toBean(list, AiAssistantReportDetailRespVO.class); + } + + @Override + public void saveBatch(AiAssistantReportDetailBatchSaveReqVO reqVO, Long loginUserId) { + Long reportId = reqVO.getReportId(); + AiAssistantReportDO report = aiAssistantReportMapper.selectById(reportId); + if (report == null) { + throw exception(BAD_REQUEST); + } + if (!loginUserId.equals(report.getReporterId())) { + throw exception(FORBIDDEN); + } + aiAssistantReportDetailMapper.deleteByReportId(reportId); + List details = reqVO.getDetails(); + if (details == null) { + details = Collections.emptyList(); + } + int sort = 0; + for (AiAssistantReportDetailSaveItemVO item : details) { + if (isAllBlank(item)) { + continue; + } + AiAssistantReportDetailDO row = new AiAssistantReportDetailDO(); + row.setReportId(reportId); + row.setDiagnosisItem(nullToEmpty(item.getDiagnosisItem())); + row.setValue1(nullToEmpty(item.getValue1())); + row.setValue2(nullToEmpty(item.getValue2())); + row.setValue3(nullToEmpty(item.getValue3())); + row.setSortOrder(sort++); + aiAssistantReportDetailMapper.insert(row); + } + } + + @Override + public void deleteByReportId(Long reportId) { + aiAssistantReportDetailMapper.deleteByReportId(reportId); + } + + private static boolean isAllBlank(AiAssistantReportDetailSaveItemVO item) { + return isBlank(item.getDiagnosisItem()) && isBlank(item.getValue1()) + && isBlank(item.getValue2()) && isBlank(item.getValue3()); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/DailyReportDifyParseService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/DailyReportDifyParseService.java new file mode 100644 index 0000000..15c56c2 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/DailyReportDifyParseService.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.ydoyun.service.aiassistantreport; + +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailSaveItemVO; + +import javax.validation.constraints.NotBlank; +import java.util.List; + +/** + * 每日汇报正文 → Dify 应用解析 → 经营诊断明细行 + */ +public interface DailyReportDifyParseService { + + /** + * 调用 Dify blocking 接口,将返回的 JSON 解析为详表行 + */ + List parseReportToDiagnosisRows(@NotBlank String text); +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/DailyReportDifyParseServiceImpl.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/DailyReportDifyParseServiceImpl.java new file mode 100644 index 0000000..a411f4b --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantreport/DailyReportDifyParseServiceImpl.java @@ -0,0 +1,195 @@ +package cn.iocoder.yudao.module.ydoyun.service.aiassistantreport; + +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.AiAssistantReportDetailSaveItemVO; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.client.RestTemplate; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * 调用 Dify Chat 应用(blocking),解析 answer 为经营诊断 JSON 数组 + */ +@Service +@Validated +@Slf4j +public class DailyReportDifyParseServiceImpl implements DailyReportDifyParseService { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Value("${ydoyun.dify.daily-report-parse.base-url:http://118.253.178.8:5001/v1}") + private String baseUrl; + + @Value("${ydoyun.dify.daily-report-parse.api-key:app-aaqeCTv9ywLOIl4WvhD3P9xS}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public List parseReportToDiagnosisRows(String text) { + String answer = callDifyBlocking(text); + return parseAnswerToRows(answer); + } + + private String callDifyBlocking(String text) { + String base = baseUrl.replaceAll("/$", ""); + String chatUrl = base + "/chat-messages"; + Long uid = SecurityFrameworkUtils.getLoginUserId(); + String user = uid != null ? "daily-report-" + uid : "daily-report-anon"; + + Map requestBody = new HashMap<>(); + requestBody.put("inputs", new HashMap<>()); + requestBody.put("query", text); + requestBody.put("response_mode", "blocking"); + requestBody.put("user", user); + + try { + String jsonBody = OBJECT_MAPPER.writeValueAsString(requestBody); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + + ResponseEntity resp = restTemplate.exchange( + chatUrl, + HttpMethod.POST, + entity, + String.class + ); + if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) { + log.error("[DailyReportDify] HTTP 非成功: status={}, body={}", resp.getStatusCode(), resp.getBody()); + throw exception(BAD_REQUEST); + } + JsonNode root = OBJECT_MAPPER.readTree(resp.getBody().getBytes(StandardCharsets.UTF_8)); + if (root.has("code") && "400".equals(root.path("code").asText())) { + log.warn("[DailyReportDify] 业务错误: {}", resp.getBody()); + throw exception(BAD_REQUEST); + } + if (root.has("answer")) { + return root.get("answer").asText(""); + } + log.warn("[DailyReportDify] 响应无 answer 字段: {}", resp.getBody()); + throw exception(BAD_REQUEST); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.error("[DailyReportDify] 调用失败", e); + throw exception(BAD_REQUEST); + } + } + + private List parseAnswerToRows(String answer) { + if (isBlank(answer)) { + throw exception(BAD_REQUEST); + } + String s = answer.trim(); + s = stripMarkdownFence(s); + s = extractJsonArray(s); + + try { + JsonNode root = OBJECT_MAPPER.readTree(s); + JsonNode arr = root; + if (root.isObject()) { + if (root.has("items") && root.get("items").isArray()) { + arr = root.get("items"); + } else if (root.has("list") && root.get("list").isArray()) { + arr = root.get("list"); + } else if (root.has("data") && root.get("data").isArray()) { + arr = root.get("data"); + } else if (root.has("rows") && root.get("rows").isArray()) { + arr = root.get("rows"); + } + } + if (!arr.isArray()) { + throw exception(BAD_REQUEST); + } + List out = new ArrayList<>(); + for (JsonNode item : arr) { + if (!item.isObject()) { + continue; + } + AiAssistantReportDetailSaveItemVO row = new AiAssistantReportDetailSaveItemVO(); + // AI 标准 JSON:诊断项 / 指标异常 / 归因分析 / 改善对策(与英文字段名兼容) + row.setDiagnosisItem( + firstText(item, "诊断项", "诊断项目", "diagnosisItem", "diagnosis_item", "title", "name")); + row.setValue1(firstText(item, "指标异常", "value1", "indicator", "abnormal")); + row.setValue2(firstText(item, "归因分析", "value2", "attribution")); + row.setValue3(firstText(item, "改善对策", "value3", "improvement")); + if (isAllBlank(row)) { + continue; + } + out.add(row); + } + // AI 可能直接返回 [] 或有效 JSON 但无有效行:返回空列表,由前端提示「没有异常」 + return out; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.warn("[DailyReportDify] JSON 解析失败, raw={}", s, e); + throw exception(BAD_REQUEST); + } + } + + private static boolean isAllBlank(AiAssistantReportDetailSaveItemVO row) { + return isBlank(row.getDiagnosisItem()) && isBlank(row.getValue1()) + && isBlank(row.getValue2()) && isBlank(row.getValue3()); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private static String firstText(JsonNode node, String... keys) { + for (String k : keys) { + if (node.has(k) && !node.get(k).isNull()) { + return node.get(k).asText("").trim(); + } + } + return ""; + } + + private static String stripMarkdownFence(String s) { + if (!s.startsWith("```")) { + return s; + } + int nl = s.indexOf('\n'); + if (nl < 0) { + return s; + } + int end = s.lastIndexOf("```"); + if (end <= nl) { + return s; + } + return s.substring(nl + 1, end).trim(); + } + + /** + * 从可能含前后说明文字的字符串中截取 JSON 数组 [...] + */ + private static String extractJsonArray(String s) { + int a = s.indexOf('['); + int b = s.lastIndexOf(']'); + if (a >= 0 && b > a) { + return s.substring(a, b + 1); + } + int o1 = s.indexOf('{'); + int o2 = s.lastIndexOf('}'); + if (o1 >= 0 && o2 > o1) { + return s.substring(o1, o2 + 1); + } + return s; + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/weather/AmapWeatherIconMapper.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/weather/AmapWeatherIconMapper.java new file mode 100644 index 0000000..ce18fc9 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/weather/AmapWeatherIconMapper.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.ydoyun.service.weather; + +import org.springframework.util.StringUtils; + +/** + * 高德仅返回中文现象,图标需自行映射。默认使用 OpenWeatherMap 公开图标名(01d、10d 等),由 {@code urlPattern} 拼出最终地址。 + * 图标与现象对照 + */ +final class AmapWeatherIconMapper { + + private AmapWeatherIconMapper() { + } + + /** + * @param chineseWeather 如「阴」「阵雨」 + * @param urlPattern 须包含占位符 {@code {code}},如 {@code https://openweathermap.org/img/wn/{code}@2x.png}; + * 也可用相对路径 {@code /weather-icons/{code}.png}(图标文件随前端 public 部署) + * @return 完整 URL;pattern 为空则返回空串(不展示图标) + */ + static String iconUrlFromDesc(String chineseWeather, String urlPattern) { + if (!StringUtils.hasText(urlPattern) || !StringUtils.hasText(urlPattern.trim())) { + return ""; + } + if (!StringUtils.hasText(chineseWeather)) { + return ""; + } + String d = chineseWeather.trim(); + String code = matchOpenWeatherMapIconCode(d); + return urlPattern.trim().replace("{code}", code); + } + + /** OpenWeatherMap 白天图标代码(统一用 d,避免昼夜判断) */ + private static String matchOpenWeatherMapIconCode(String d) { + String[][] rules = { + {"雷阵雨", "11d"}, + {"阵雨", "09d"}, + {"暴雨", "10d"}, + {"大雨", "10d"}, + {"中雨", "10d"}, + {"小雨", "10d"}, + {"雨夹雪", "13d"}, + {"暴雪", "13d"}, + {"大雪", "13d"}, + {"中雪", "13d"}, + {"小雪", "13d"}, + {"雨雪", "13d"}, + {"雪", "13d"}, + {"冰雹", "11d"}, + {"冻雨", "13d"}, + {"雾", "50d"}, + {"霾", "50d"}, + {"沙尘暴", "50d"}, + {"扬沙", "50d"}, + {"浮尘", "50d"}, + {"阴", "04d"}, + {"多云", "03d"}, + {"晴", "01d"}, + {"雨", "10d"}, + }; + for (String[] r : rules) { + if (d.contains(r[0])) { + return r[1]; + } + } + return "02d"; + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/weather/AmapWeatherService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/weather/AmapWeatherService.java new file mode 100644 index 0000000..b83362c --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/weather/AmapWeatherService.java @@ -0,0 +1,376 @@ +package cn.iocoder.yudao.module.ydoyun.service.weather; + +import cn.iocoder.yudao.module.ydoyun.config.AmapWeatherProperties; +import cn.iocoder.yudao.module.ydoyun.controller.admin.weather.vo.WeatherAggregateRespVO; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.Resource; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 高德天气:6 位 {@code adcode} 或城市名(先地理编码,失败则行政区划查询)→ 实况 + 预报。 + */ +@Service +@Slf4j +public class AmapWeatherService { + + @Resource + private RestTemplate restTemplate; + @Resource + private AmapWeatherProperties properties; + + public WeatherAggregateRespVO getAggregate(String locationKeyword) { + String trimmed = locationKeyword == null ? "" : locationKeyword.trim(); + if (!StringUtils.hasText(trimmed)) { + throw new IllegalStateException("location 不能为空"); + } + String apiKey = trimKey(properties.getKey()); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalStateException("未配置高德 Web 服务 Key:ydoyun.amap.weather.key"); + } + + String adcode = resolveAdcode(trimmed, apiKey); + if (!StringUtils.hasText(adcode)) { + throw new IllegalStateException("无法解析城市「" + trimmed + "」,请核对名称或传入 6 位行政区划编码 adcode"); + } + + JSONObject baseRoot = requestWeather(adcode, apiKey, "base"); + JSONObject allRoot = requestWeather(adcode, apiKey, "all"); + return buildVo(baseRoot, allRoot); + } + + /** + * 纯城市名用「地理编码」易触发 ENGINE_RESPONSE_DATA_ERROR;失败时改用「行政区划查询」解析 adcode。 + */ + private String resolveAdcode(String keyword, String apiKey) { + if (keyword.matches("^\\d{6}$")) { + return keyword; + } + String adcode = tryGeocode(keyword, apiKey); + if (StringUtils.hasText(adcode)) { + return adcode; + } + adcode = tryDistrict(keyword, apiKey); + if (StringUtils.hasText(adcode)) { + return adcode; + } + adcode = tryInputTipsCityAdcode(keyword, apiKey); + if (StringUtils.hasText(adcode)) { + return adcode; + } + // 去掉「市」再试(如 郴州市 → 郴州) + if (keyword.endsWith("市") && keyword.length() > 1) { + String shortName = keyword.substring(0, keyword.length() - 1); + adcode = tryGeocode(shortName, apiKey); + if (StringUtils.hasText(adcode)) { + return adcode; + } + adcode = tryDistrict(shortName, apiKey); + if (StringUtils.hasText(adcode)) { + return adcode; + } + adcode = tryInputTipsCityAdcode(shortName, apiKey); + if (StringUtils.hasText(adcode)) { + return adcode; + } + } + return null; + } + + /** 地理编码:成功返回 adcode,失败返回 null(不抛错,便于走 district) */ + private String tryGeocode(String keyword, String apiKey) { + try { + String url = UriComponentsBuilder.fromHttpUrl(properties.getGeocodeUrl()) + .queryParam("address", keyword) + .queryParam("key", apiKey) + .build() + .toUriString(); + String body = restTemplate.getForObject(url, String.class); + JSONObject root = body == null ? new JSONObject() : JSONObject.parseObject(body); + if (!isAmapHttpSuccess(root)) { + log.debug("[amap] geocode 未成功 keyword={} info={}", keyword, root.getString("info")); + return null; + } + JSONArray geocodes = root.getJSONArray("geocodes"); + if (geocodes == null || geocodes.isEmpty()) { + return null; + } + return geocodes.getJSONObject(0).getString("adcode"); + } catch (Exception e) { + log.warn("[amap] geocode 异常 keyword={}", keyword, e); + return null; + } + } + + /** 行政区划查询:适合「郴州市」等城市名 → adcode(须用 UriComponentsBuilder 编码中文查询串) */ + private String tryDistrict(String keyword, String apiKey) { + try { + String url = UriComponentsBuilder.fromHttpUrl(properties.getDistrictUrl()) + .queryParam("keywords", keyword) + .queryParam("subdistrict", "0") + .queryParam("key", apiKey) + .build() + .toUriString(); + String body = restTemplate.getForObject(url, String.class); + JSONObject root = body == null ? new JSONObject() : JSONObject.parseObject(body); + if (!isAmapHttpSuccess(root)) { + log.debug("[amap] district 未成功 keyword={} info={}", keyword, root.getString("info")); + return null; + } + JSONArray districts = root.getJSONArray("districts"); + String ad = extractAdcodeFromDistrictTree(districts); + if (!StringUtils.hasText(ad) && districts != null && !districts.isEmpty()) { + log.warn("[amap] district 返回成功但未解析出 adcode keyword={} districtsSample={}", + keyword, districts.size() > 0 ? districts.getJSONObject(0).toJSONString() : "[]"); + } + return ad; + } catch (Exception e) { + log.warn("[amap] district 异常 keyword={}", keyword, e); + return null; + } + } + + /** + * 输入提示:在行政区划仍失败时兜底;优先取与关键词完全同名且 adcode 为「市级」形态的条目(末三位常为 000)。 + */ + private String tryInputTipsCityAdcode(String keyword, String apiKey) { + try { + String url = UriComponentsBuilder.fromHttpUrl("https://restapi.amap.com/v3/assistant/inputtips") + .queryParam("keywords", keyword) + .queryParam("datatype", "all") + .queryParam("key", apiKey) + .build() + .toUriString(); + String body = restTemplate.getForObject(url, String.class); + JSONObject root = body == null ? new JSONObject() : JSONObject.parseObject(body); + if (!isAmapHttpSuccess(root)) { + return null; + } + JSONArray tips = root.getJSONArray("tips"); + if (tips == null || tips.isEmpty()) { + return null; + } + for (int i = 0; i < tips.size(); i++) { + JSONObject t = tips.getJSONObject(i); + if (!keyword.equals(t.getString("name"))) { + continue; + } + String ad = t.getString("adcode"); + if (!StringUtils.hasText(ad) || ad.length() != 6) { + continue; + } + if (ad.endsWith("000")) { + return ad; + } + } + for (int i = 0; i < tips.size(); i++) { + JSONObject t = tips.getJSONObject(i); + if (!keyword.equals(t.getString("name"))) { + continue; + } + String ad = t.getString("adcode"); + if (StringUtils.hasText(ad) && ad.length() == 6) { + return ad; + } + } + return null; + } catch (Exception e) { + log.debug("[amap] inputtips 异常 keyword={}", keyword, e); + return null; + } + } + + /** 高德 Web 服务 status 可能为字符串 "1" 或数字 1 */ + private static boolean isAmapHttpSuccess(JSONObject root) { + if (root == null) { + return false; + } + Object s = root.get("status"); + if (s == null) { + return false; + } + if (s instanceof Number) { + return ((Number) s).intValue() == 1; + } + return "1".equals(String.valueOf(s).trim()); + } + + /** + * 从行政区划树取 6 位 adcode:优先市级,再区县、省;跳过全国 100000。 + */ + private static String extractAdcodeFromDistrictTree(JSONArray districts) { + if (districts == null || districts.isEmpty()) { + return null; + } + String ad = pickAdcodeByLevel(districts, "city"); + if (StringUtils.hasText(ad)) { + return ad; + } + ad = pickAdcodeByLevel(districts, "district"); + if (StringUtils.hasText(ad)) { + return ad; + } + for (int i = 0; i < districts.size(); i++) { + JSONArray sub = districts.getJSONObject(i).getJSONArray("districts"); + ad = extractAdcodeFromDistrictTree(sub); + if (StringUtils.hasText(ad)) { + return ad; + } + } + ad = pickAdcodeByLevel(districts, "province"); + if (StringUtils.hasText(ad)) { + return ad; + } + for (int i = 0; i < districts.size(); i++) { + String a = districts.getJSONObject(i).getString("adcode"); + if (StringUtils.hasText(a) && a.length() == 6 && !"100000".equals(a)) { + return a; + } + } + return null; + } + + private static String pickAdcodeByLevel(JSONArray districts, String wantLevel) { + if (districts == null) { + return null; + } + for (int i = 0; i < districts.size(); i++) { + JSONObject d = districts.getJSONObject(i); + String level = d.getString("level"); + if (level == null || !wantLevel.equalsIgnoreCase(level.trim())) { + continue; + } + String ad = d.getString("adcode"); + if (StringUtils.hasText(ad) && ad.length() == 6 && !"100000".equals(ad)) { + return ad; + } + } + return null; + } + + private JSONObject requestWeather(String adcode, String apiKey, String extensions) { + String url = UriComponentsBuilder.fromHttpUrl(properties.getWeatherUrl()) + .queryParam("city", adcode) + .queryParam("key", apiKey) + .queryParam("extensions", extensions) + .build() + .toUriString(); + String body = restTemplate.getForObject(url, String.class); + JSONObject root = body == null ? new JSONObject() : JSONObject.parseObject(body); + if (!isAmapHttpSuccess(root)) { + throw new IllegalStateException("高德天气接口失败: " + root.getString("info")); + } + return root; + } + + private WeatherAggregateRespVO buildVo(JSONObject baseRoot, JSONObject allRoot) { + WeatherAggregateRespVO vo = new WeatherAggregateRespVO(); + + JSONArray lives = baseRoot.getJSONArray("lives"); + if (lives != null && !lives.isEmpty()) { + JSONObject live = lives.getJSONObject(0); + vo.setCityLabel(firstNonBlank(live.getString("city"), live.getString("province"), "—")); + WeatherAggregateRespVO.Current c = new WeatherAggregateRespVO.Current(); + c.setTemp(live.getString("temperature")); + c.setDesc(live.getString("weather")); + c.setIcon(AmapWeatherIconMapper.iconUrlFromDesc(live.getString("weather"), + properties.getWeatherIconUrlPattern())); + vo.setCurrent(c); + } + + JSONArray forecasts = allRoot.getJSONArray("forecasts"); + if (forecasts == null || forecasts.isEmpty()) { + vo.setDays(Collections.emptyList()); + if (vo.getCityLabel() == null) { + vo.setCityLabel("—"); + } + return vo; + } + JSONObject firstFc = forecasts.getJSONObject(0); + if (vo.getCityLabel() == null || vo.getCityLabel().isEmpty()) { + vo.setCityLabel(firstNonBlank(firstFc.getString("city"), firstFc.getString("province"), "—")); + } + + JSONArray casts = firstFc.getJSONArray("casts"); + if (casts == null || casts.isEmpty()) { + vo.setDays(Collections.emptyList()); + return vo; + } + + DateTimeFormatter iso = DateTimeFormatter.ISO_LOCAL_DATE; + DateTimeFormatter md = DateTimeFormatter.ofPattern("M/d"); + List days = new ArrayList<>(); + int n = Math.min(3, casts.size()); + for (int i = 0; i < n; i++) { + JSONObject cast = casts.getJSONObject(i); + WeatherAggregateRespVO.Day day = new WeatherAggregateRespVO.Day(); + String dateStr = cast.getString("date"); + day.setFxDate(dateStr); + if (StringUtils.hasText(dateStr)) { + try { + LocalDate ld = LocalDate.parse(dateStr, iso); + int dow = ld.getDayOfWeek().getValue(); + String[] wz = {"一", "二", "三", "四", "五", "六", "日"}; + day.setWeekLabel("周" + wz[dow - 1]); + day.setDateLabel(ld.format(md)); + } catch (Exception e) { + day.setWeekLabel(cast.getString("week")); + day.setDateLabel(dateStr); + } + } else { + day.setWeekLabel(cast.getString("week")); + day.setDateLabel(""); + } + String daytemp = cast.getString("daytemp"); + String nighttemp = cast.getString("nighttemp"); + day.setRange(formatRange(nighttemp, daytemp)); + day.setDesc(firstNonBlank(cast.getString("dayweather"), cast.getString("nightweather"), "—")); + day.setIcon(AmapWeatherIconMapper.iconUrlFromDesc(cast.getString("dayweather"), + properties.getWeatherIconUrlPattern())); + days.add(day); + } + vo.setDays(days); + return vo; + } + + private static String formatRange(String nighttemp, String daytemp) { + try { + int d = Integer.parseInt(daytemp.trim()); + int n = Integer.parseInt(nighttemp.trim()); + int min = Math.min(d, n); + int max = Math.max(d, n); + return min + "~" + max + "°C"; + } catch (Exception e) { + if (StringUtils.hasText(daytemp) && StringUtils.hasText(nighttemp)) { + return nighttemp + "~" + daytemp + "°C"; + } + return "—"; + } + } + + private static String firstNonBlank(String... ss) { + if (ss == null) { + return "—"; + } + for (String s : ss) { + if (StringUtils.hasText(s)) { + return s.trim(); + } + } + return "—"; + } + + private static String trimKey(String key) { + return key == null ? "" : key.trim(); + } +}