1.调试ai

This commit is contained in:
2026-04-01 08:59:58 +08:00
parent 5fdc31c77c
commit 2c594a6b3a
16 changed files with 1086 additions and 0 deletions

View 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 '每日汇报主表IDydoyun_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决策助手每日汇报详表';

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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());
}
}
}

View File

@@ -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 = "图标完整 URLCDN")
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;
}
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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 完整 URLpattern 为空则返回空串(不展示图标)
*/
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";
}
}

View File

@@ -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 服务 Keyydoyun.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();
}
}