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