1.调试ai
This commit is contained in:
19
sql/mysql/ydoyun_ai_assistant_report_detail.sql
Normal file
19
sql/mysql/ydoyun_ai_assistant_report_detail.sql
Normal file
@@ -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决策助手每日汇报详表';
|
||||||
@@ -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 服务:地理编码 + 天气查询。
|
||||||
|
* <a href="https://lbs.amap.com/api/webservice/guide/api/weatherinfo">天气查询</a>
|
||||||
|
*/
|
||||||
|
@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";
|
||||||
|
}
|
||||||
@@ -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<AiAssistantReportDetailSaveItemVO> details;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<WeatherAggregateRespVO> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<Day> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<AiAssistantReportDetailDO> {
|
||||||
|
|
||||||
|
default List<AiAssistantReportDetailDO> selectListByReportId(Long reportId) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<AiAssistantReportDetailDO>()
|
||||||
|
.eq(AiAssistantReportDetailDO::getReportId, reportId)
|
||||||
|
.orderByAsc(AiAssistantReportDetailDO::getSortOrder)
|
||||||
|
.orderByAsc(AiAssistantReportDetailDO::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
default int deleteByReportId(Long reportId) {
|
||||||
|
return delete(new LambdaQueryWrapperX<AiAssistantReportDetailDO>()
|
||||||
|
.eq(AiAssistantReportDetailDO::getReportId, reportId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AiAssistantReportDetailRespVO> listByReportId(@NotNull Long reportId, @NotNull Long loginUserId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存(删除该主表下旧明细后插入新行)
|
||||||
|
*/
|
||||||
|
void saveBatch(@Valid AiAssistantReportDetailBatchSaveReqVO reqVO, @NotNull Long loginUserId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按主表 ID 删除所有明细(逻辑删除)
|
||||||
|
*/
|
||||||
|
void deleteByReportId(@NotNull Long reportId);
|
||||||
|
}
|
||||||
@@ -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<AiAssistantReportDetailRespVO> 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<AiAssistantReportDetailDO> 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<AiAssistantReportDetailSaveItemVO> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AiAssistantReportDetailSaveItemVO> parseReportToDiagnosisRows(@NotBlank String text);
|
||||||
|
}
|
||||||
@@ -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<AiAssistantReportDetailSaveItemVO> 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<String, Object> 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<String> entity = new HttpEntity<>(jsonBody, headers);
|
||||||
|
|
||||||
|
ResponseEntity<String> 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<AiAssistantReportDetailSaveItemVO> 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<AiAssistantReportDetailSaveItemVO> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package cn.iocoder.yudao.module.ydoyun.service.weather;
|
||||||
|
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高德仅返回中文现象,图标需自行映射。默认使用 OpenWeatherMap 公开图标名(01d、10d 等),由 {@code urlPattern} 拼出最终地址。
|
||||||
|
* <a href="https://openweathermap.org/weather-conditions">图标与现象对照</a>
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WeatherAggregateRespVO.Day> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user