From 607720b971e6759b3b95ba37af41ae35efe2c642 Mon Sep 17 00:00:00 2001 From: van Date: Sun, 6 Sep 2026 21:37:43 +0800 Subject: [PATCH] 1 --- .../jarvis/OrderProfitAnalysisController.java | 111 +++ .../src/main/resources/application-dev.yml | 8 + .../src/main/resources/application-prod.yml | 6 + .../domain/dto/OrderProfitAnalyzeRequest.java | 25 + .../service/IOrderProfitAnalysisService.java | 39 + .../impl/OrderProfitAnalysisServiceImpl.java | 814 ++++++++++++++++++ .../service/impl/SocialMediaServiceImpl.java | 5 +- .../jarvis/task/OrderProfitAnalysisTask.java | 44 + 8 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/OrderProfitAnalysisController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/OrderProfitAnalyzeRequest.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IOrderProfitAnalysisService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OrderProfitAnalysisServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/task/OrderProfitAnalysisTask.java diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/OrderProfitAnalysisController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/OrderProfitAnalysisController.java new file mode 100644 index 0000000..c82e8af --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/OrderProfitAnalysisController.java @@ -0,0 +1,111 @@ +package com.ruoyi.web.controller.jarvis; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.jarvis.domain.dto.OrderProfitAnalyzeRequest; +import com.ruoyi.jarvis.service.IOrderProfitAnalysisService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 慢单订单/利润 AI 分析 + */ +@RestController +@RequestMapping("/jarvis/order-profit-analysis") +public class OrderProfitAnalysisController extends BaseController { + + private final IOrderProfitAnalysisService orderProfitAnalysisService; + + public OrderProfitAnalysisController(IOrderProfitAnalysisService orderProfitAnalysisService) { + this.orderProfitAnalysisService = orderProfitAnalysisService; + } + + /** + * 预览统计 JSON(本期 / 上期 / 对比),不调用大模型。 + */ + @GetMapping("/stats") + public AjaxResult stats(@RequestParam(value = "periodType", required = false) String periodType, + @RequestParam(value = "date", required = false) String date) { + try { + return AjaxResult.success(orderProfitAnalysisService.buildStats(periodType, date)); + } catch (Exception e) { + logger.error("预览利润统计失败", e); + return AjaxResult.error("预览失败: " + e.getMessage()); + } + } + + /** + * 套固定提示词调用已配置大模型;push=true 时推企微。 + */ + @Log(title = "慢单利润AI分析", businessType = BusinessType.OTHER) + @PostMapping("/analyze") + public AjaxResult analyze(@RequestBody(required = false) OrderProfitAnalyzeRequest request) { + try { + if (request == null) { + request = new OrderProfitAnalyzeRequest(); + } + boolean push = Boolean.TRUE.equals(request.getPush()); + String trigger = request.getTrigger(); + Map data = orderProfitAnalysisService.analyze( + request.getPeriodType(), request.getDate(), push, request.getProfileId(), trigger); + return AjaxResult.success(data); + } catch (Exception e) { + logger.error("利润 AI 分析失败", e); + return AjaxResult.error("分析失败: " + e.getMessage()); + } + } + + /** + * 测试推送:按所选周期分析并立即推企微,记入发送记录(trigger=test)。 + */ + @Log(title = "慢单利润AI分析测试推送", businessType = BusinessType.OTHER) + @PostMapping("/test") + public AjaxResult test(@RequestBody(required = false) OrderProfitAnalyzeRequest request) { + try { + if (request == null) { + request = new OrderProfitAnalyzeRequest(); + } + Map data = orderProfitAnalysisService.analyze( + request.getPeriodType(), request.getDate(), true, request.getProfileId(), "test"); + return AjaxResult.success(data); + } catch (Exception e) { + logger.error("利润分析测试推送失败", e); + return AjaxResult.error("测试失败: " + e.getMessage()); + } + } + + /** + * 发送记录(新的在前)。 + */ + @GetMapping("/send-logs") + public AjaxResult sendLogs(@RequestParam(value = "limit", required = false) Integer limit) { + try { + int n = limit == null ? 30 : limit; + return AjaxResult.success(orderProfitAnalysisService.listSendLogs(n)); + } catch (Exception e) { + logger.error("读取利润分析发送记录失败", e); + return AjaxResult.error("读取失败: " + e.getMessage()); + } + } + + /** + * 最近一次分析结果(按周期)。 + */ + @GetMapping("/latest") + public AjaxResult latest(@RequestParam(value = "periodType", required = false) String periodType) { + try { + return AjaxResult.success(orderProfitAnalysisService.latest(periodType)); + } catch (Exception e) { + logger.error("读取最近利润分析失败", e); + return AjaxResult.error("读取失败: " + e.getMessage()); + } + } +} diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index c26daf5..2b55aba 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -222,6 +222,14 @@ jarvis: # 获取评论接口服务地址(后端转发,避免前端跨域) fetch-comments: base-url: http://192.168.8.60:5008 + # 慢单利润/订单 AI 分析:聚合成 JSON → 固定提示词 → 已激活大模型 → 企微推送 + order-profit-analysis: + enabled: true + # 接收人 UserID,多个逗号或 |;留空则回退 goofish-notify-touser + touser: "" + daily-cron: "0 0 21 * * ?" + weekly-cron: "0 20 8 ? * MON" + monthly-cron: "0 30 8 1 * ?" # 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致) wecom: inbound-secret: jarvis_wecom_bridge_change_me diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 5687a5e..e1e5b93 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -219,6 +219,12 @@ jarvis: # 获取评论接口服务地址(后端转发) fetch-comments: base-url: http://192.168.8.60:5008 + order-profit-analysis: + enabled: true + touser: "" + daily-cron: "0 0 23 * * ?" + weekly-cron: "0 20 8 ? * MON" + monthly-cron: "0 30 8 1 * ?" wecom: inbound-secret: jarvis_wecom_bridge_change_me wxsend-base-url: https://wxts.van333.cn diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/OrderProfitAnalyzeRequest.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/OrderProfitAnalyzeRequest.java new file mode 100644 index 0000000..9d6b6f8 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/OrderProfitAnalyzeRequest.java @@ -0,0 +1,25 @@ +package com.ruoyi.jarvis.domain.dto; + +import lombok.Data; + +/** + * 慢单利润 AI 分析请求 + */ +@Data +public class OrderProfitAnalyzeRequest { + + /** day / week / month */ + private String periodType; + + /** 锚点日期 yyyy-MM-dd */ + private String date; + + /** 是否推送到企微 */ + private Boolean push; + + /** 可选:指定 LLM profileId,空则用当前激活配置 */ + private String profileId; + + /** scheduled / manual / test */ + private String trigger; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IOrderProfitAnalysisService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IOrderProfitAnalysisService.java new file mode 100644 index 0000000..0a199a5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IOrderProfitAnalysisService.java @@ -0,0 +1,39 @@ +package com.ruoyi.jarvis.service; + +import java.util.Map; + +/** + * 慢单订单/利润 AI 分析:先聚合成 JSON,再套固定提示词调用已配置大模型。 + */ +public interface IOrderProfitAnalysisService { + + /** + * 仅生成统计 JSON(含本期、上期、对比),不调用 AI。 + * + * @param periodType day / week / month + * @param date 锚点日期 yyyy-MM-dd,空则按周期取最近完整区间 + */ + Map buildStats(String periodType, String date); + + /** + * 统计 + AI 分析;push=true 时推送到企微。 + * + * @param trigger scheduled / manual / test,写入发送记录 + */ + Map analyze(String periodType, String date, boolean push, String profileId, String trigger); + + /** + * 读取最近一次分析结果(Redis)。 + */ + Map latest(String periodType); + + /** + * 推送/测试发送记录,新的在前。 + */ + java.util.List> listSendLogs(int limit); + + /** + * 定时任务入口:日报推当天,周报/月报推上一完整区间。 + */ + void runScheduled(String periodType); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OrderProfitAnalysisServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OrderProfitAnalysisServiceImpl.java new file mode 100644 index 0000000..3c5d553 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OrderProfitAnalysisServiceImpl.java @@ -0,0 +1,814 @@ +package com.ruoyi.jarvis.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONWriter; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.jarvis.domain.JDOrder; +import com.ruoyi.jarvis.service.IJDOrderService; +import com.ruoyi.jarvis.service.IOrderProfitAnalysisService; +import com.ruoyi.jarvis.service.IPhoneForwardActivePush; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 慢单利润/订单 AI 分析。数据来自 jd_order,提示词走 Redis social_media:prompt:order:profit_analysis, + * 推理转发 Jarvis /jarvis/social-media/llm/test(与闲鱼种草同一套激活配置)。 + */ +@Service +public class OrderProfitAnalysisServiceImpl implements IOrderProfitAnalysisService { + + private static final Logger log = LoggerFactory.getLogger(OrderProfitAnalysisServiceImpl.class); + + public static final String PROMPT_KEY = "order:profit_analysis"; + private static final String REDIS_PROMPT_PREFIX = "social_media:prompt:"; + private static final String REDIS_LATEST_PREFIX = "order_profit_analysis:latest:"; + private static final String REDIS_SEND_LOG = "order_profit_analysis:send_log"; + private static final int SEND_LOG_MAX = 80; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int TOP_N = 15; + /** 企微文本约 2048 字节,按 UTF-8 切段并留余量 */ + private static final int WECOM_CHUNK_BYTES = 1800; + + public static final String DEFAULT_PROMPT = + "你是电商分销订单与利润分析助手。下面是已经汇总好的 JSON 统计(慢单 jd_order;" + + "仅 is_count_enabled=1 参与;已退款单不计入付款/后返/利润)。\n\n" + + "请基于数据写一份简洁中文报告,不要编造 JSON 里没有的数字。结构如下:\n" + + "1. 周期结论(1-2 句)\n" + + "2. 订单与利润对比:本期 vs 上期,写明增减和幅度\n" + + "3. 结构拆解:分销标记 / 型号 / 下单人前缀的亮点与异常\n" + + "4. 风险:退款、利润缺失、额外成本\n" + + "5. 建议:1-3 条可执行动作\n\n" + + "输出纯文本,适合企业微信阅读,控制在 800 字以内,可用简单条目,不要用 Markdown 代码块。\n\n" + + "统计数据:\n{{stats_json}}"; + + @Autowired + private IJDOrderService jdOrderService; + + @Autowired(required = false) + private StringRedisTemplate redisTemplate; + + @Autowired(required = false) + private IPhoneForwardActivePush phoneForwardActivePush; + + @Value("${jarvis.server.jarvis-java.base-url:http://127.0.0.1:6666}") + private String jarvisBaseUrl; + + @Value("${jarvis.order-profit-analysis.enabled:true}") + private boolean enabled; + + @Value("${jarvis.order-profit-analysis.touser:}") + private String analysisTouser; + + @Value("${jarvis.wecom.goofish-notify-touser:}") + private String fallbackTouser; + + @Override + public Map buildStats(String periodType, String date) { + PeriodRange current = resolvePeriod(periodType, date, false); + return assemblePayload(periodType, current, false); + } + + @Override + public Map analyze(String periodType, String date, boolean push, String profileId, String trigger) { + PeriodRange current = resolvePeriod(periodType, date, false); + return doAnalyze(periodType, current, push, profileId, trigger); + } + + @Override + public Map latest(String periodType) { + String type = normalizePeriod(periodType); + if (redisTemplate == null) { + return Collections.emptyMap(); + } + String raw = redisTemplate.opsForValue().get(REDIS_LATEST_PREFIX + type); + if (StringUtils.isEmpty(raw)) { + return Collections.emptyMap(); + } + JSONObject jo = JSON.parseObject(raw); + return jo == null ? Collections.emptyMap() : new LinkedHashMap<>(jo); + } + + @Override + public List> listSendLogs(int limit) { + if (redisTemplate == null) { + return Collections.emptyList(); + } + int n = limit <= 0 ? 30 : Math.min(limit, SEND_LOG_MAX); + List raw = redisTemplate.opsForList().range(REDIS_SEND_LOG, 0, n - 1); + if (raw == null || raw.isEmpty()) { + return Collections.emptyList(); + } + List> out = new ArrayList<>(); + for (String s : raw) { + if (StringUtils.isEmpty(s)) { + continue; + } + JSONObject jo = JSON.parseObject(s); + if (jo != null) { + out.add(new LinkedHashMap<>(jo)); + } + } + return out; + } + + @Override + public void runScheduled(String periodType) { + if (!enabled) { + log.info("慢单利润分析定时任务已关闭 periodType={}", periodType); + return; + } + PeriodRange current = resolvePeriod(periodType, null, true); + try { + Map result = doAnalyze(periodType, current, true, null, "scheduled"); + log.info("慢单利润分析定时完成 period={} pushed={} reportLen={}", + current.label, result.get("pushed"), + result.get("report") == null ? 0 : String.valueOf(result.get("report")).length()); + } catch (Exception e) { + log.error("慢单利润分析定时失败 periodType={} range={}", periodType, current.label, e); + } + } + + private Map doAnalyze(String periodType, PeriodRange current, boolean push, String profileId, + String trigger) { + Map stats = assemblePayload(periodType, current, true); + String statsJson = JSON.toJSONString(stats, JSONWriter.Feature.PrettyFormat); + String prompt = loadPrompt().replace("{{stats_json}}", statsJson); + + String report; + String aiError = null; + try { + report = callJarvisLlm(prompt, profileId); + if (StringUtils.isEmpty(report)) { + throw new RuntimeException("大模型返回为空"); + } + } catch (Exception e) { + aiError = e.getMessage(); + log.warn("慢单利润 AI 分析失败,改用数字摘要: {}", aiError); + report = buildFallbackReport(stats, aiError); + } + + boolean pushed = false; + String pushError = null; + String toUser = resolveTouser(); + if (push) { + try { + pushed = pushReport(current.titlePrefix, report, toUser); + if (!pushed) { + pushError = StringUtils.isEmpty(toUser) + ? "未配置接收人(touser / goofish-notify-touser)" + : "推送未成功(检查 wxSend)"; + } + } catch (Exception e) { + pushError = e.getMessage(); + log.warn("慢单利润分析推送失败: {}", pushError); + } + } + + Map result = new LinkedHashMap<>(); + result.put("periodType", normalizePeriod(periodType)); + result.put("periodLabel", current.label); + result.put("generatedAt", java.time.LocalDateTime.now(ZONE).format(TS)); + result.put("trigger", normalizeTrigger(trigger)); + result.put("stats", stats); + result.put("report", report); + result.put("aiError", aiError); + result.put("pushed", pushed); + result.put("pushError", pushError); + result.put("toUser", toUser); + cacheLatest(normalizePeriod(periodType), result); + if (push) { + appendSendLog(result, stats); + } + return result; + } + + private Map assemblePayload(String periodType, PeriodRange current, boolean forLlm) { + PeriodRange previous = current.previous(); + Map cur = summarize(current); + Map prev = summarize(previous); + + Map payload = new LinkedHashMap<>(); + payload.put("periodType", current.type); + payload.put("periodLabel", current.label); + payload.put("timezone", ZONE.getId()); + payload.put("current", cur); + payload.put("previous", prev); + payload.put("compare", compare(cur, prev)); + if (!forLlm) { + payload.put("generatedAt", java.time.LocalDateTime.now(ZONE).format(TS)); + } + return payload; + } + + private Map summarize(PeriodRange range) { + JDOrder query = new JDOrder(); + query.getParams().put("beginTime", range.begin.format(DAY)); + query.getParams().put("endTime", range.end.format(DAY)); + List rows = jdOrderService.selectJDOrderList(query); + if (rows == null) { + rows = Collections.emptyList(); + } + + int orderCount = 0; + int refundCount = 0; + int missingProfitCount = 0; + BigDecimal payment = BigDecimal.ZERO; + BigDecimal rebate = BigDecimal.ZERO; + BigDecimal profit = BigDecimal.ZERO; + BigDecimal extraCost = BigDecimal.ZERO; + BigDecimal selling = BigDecimal.ZERO; + Map byMark = new LinkedHashMap<>(); + Map byModel = new LinkedHashMap<>(); + Map byBuyer = new LinkedHashMap<>(); + + for (JDOrder o : rows) { + if (!isCountEnabled(o)) { + continue; + } + orderCount++; + boolean refunded = o.getIsRefunded() != null && o.getIsRefunded() == 1; + if (refunded) { + refundCount++; + continue; + } + BigDecimal pay = money(o.getPaymentAmount()); + BigDecimal reb = money(o.getRebateAmount()); + BigDecimal extra = money(o.getExtraCost()); + BigDecimal sell = money(o.getSellingPrice()); + payment = payment.add(pay); + rebate = rebate.add(reb); + extraCost = extraCost.add(extra); + selling = selling.add(sell); + if (o.getProfit() == null) { + missingProfitCount++; + } else { + profit = profit.add(money(o.getProfit())); + } + addGroup(byMark, blankTo(o.getDistributionMark(), "未标记"), pay, reb, extra, o.getProfit()); + addGroup(byModel, modelKey(o), pay, reb, extra, o.getProfit()); + addGroup(byBuyer, buyerGroupKey(o.getBuyer()), pay, reb, extra, o.getProfit()); + } + + int valid = orderCount - refundCount; + Map out = new LinkedHashMap<>(); + out.put("beginDate", range.begin.format(DAY)); + out.put("endDate", range.end.format(DAY)); + out.put("label", range.label); + out.put("orderCount", orderCount); + out.put("refundCount", refundCount); + out.put("validOrderCount", valid); + out.put("missingProfitCount", missingProfitCount); + out.put("totalPayment", num(payment)); + out.put("totalRebate", num(rebate)); + out.put("totalCost", num(payment.subtract(rebate))); + out.put("totalSellingPrice", num(selling)); + out.put("totalExtraCost", num(extraCost)); + out.put("totalProfit", num(profit)); + BigDecimal avg = valid > 0 + ? profit.divide(BigDecimal.valueOf(valid), 2, RoundingMode.HALF_UP) + : BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + out.put("avgProfit", num(avg)); + BigDecimal rate = payment.compareTo(BigDecimal.ZERO) > 0 + ? profit.multiply(BigDecimal.valueOf(100)).divide(payment, 2, RoundingMode.HALF_UP) + : BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + out.put("profitRatePct", num(rate)); + out.put("byMark", toGroupList(byMark, Integer.MAX_VALUE)); + out.put("byModel", toGroupList(byModel, TOP_N)); + out.put("byBuyer", toGroupList(byBuyer, TOP_N)); + return out; + } + + private static Map compare(Map cur, Map prev) { + Map c = new LinkedHashMap<>(); + putDelta(c, "orderCount", cur, prev); + putDelta(c, "refundCount", cur, prev); + putDelta(c, "totalPayment", cur, prev); + putDelta(c, "totalRebate", cur, prev); + putDelta(c, "totalProfit", cur, prev); + putDelta(c, "avgProfit", cur, prev); + putDelta(c, "profitRatePct", cur, prev); + return c; + } + + private static void putDelta(Map out, String key, Map cur, Map prev) { + BigDecimal a = toDecimal(cur.get(key)); + BigDecimal b = toDecimal(prev.get(key)); + BigDecimal delta = a.subtract(b).setScale(2, RoundingMode.HALF_UP); + Map item = new LinkedHashMap<>(); + item.put("current", num(a)); + item.put("previous", num(b)); + item.put("delta", num(delta)); + BigDecimal pct; + if (b.compareTo(BigDecimal.ZERO) == 0) { + pct = a.compareTo(BigDecimal.ZERO) == 0 + ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) + : BigDecimal.valueOf(100).setScale(2, RoundingMode.HALF_UP); + } else { + pct = delta.multiply(BigDecimal.valueOf(100)).divide(b.abs(), 2, RoundingMode.HALF_UP); + } + item.put("changePct", num(pct)); + out.put(key, item); + } + + private String loadPrompt() { + if (redisTemplate != null) { + try { + String stored = redisTemplate.opsForValue().get(REDIS_PROMPT_PREFIX + PROMPT_KEY); + if (StringUtils.isNotEmpty(stored)) { + return stored; + } + } catch (Exception e) { + log.warn("读取利润分析提示词失败,使用内置默认: {}", e.getMessage()); + } + } + return DEFAULT_PROMPT; + } + + private String callJarvisLlm(String message, String profileId) throws Exception { + String base = jarvisBaseUrl == null ? "" : jarvisBaseUrl.trim(); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + String url = base + "/jarvis/social-media/llm/test"; + JSONObject body = new JSONObject(); + body.put("message", message); + if (StringUtils.isNotEmpty(profileId)) { + body.put("profileId", profileId.trim()); + } + String resp = postJson(url, body.toJSONString(), 15000, 180000); + if (StringUtils.isEmpty(resp)) { + throw new RuntimeException("Jarvis 返回为空,请检查服务可达性"); + } + Object parsed = JSON.parse(resp); + if (!(parsed instanceof JSONObject)) { + throw new RuntimeException("Jarvis 响应格式错误"); + } + JSONObject jo = (JSONObject) parsed; + if (!Integer.valueOf(200).equals(jo.getInteger("code"))) { + throw new RuntimeException(StringUtils.isEmpty(jo.getString("msg")) ? "Jarvis 调用失败" : jo.getString("msg")); + } + Object dataObj = jo.get("data"); + if (!(dataObj instanceof JSONObject)) { + throw new RuntimeException("Jarvis data 结构异常"); + } + JSONObject data = (JSONObject) dataObj; + if (!Boolean.TRUE.equals(data.getBoolean("success"))) { + throw new RuntimeException(StringUtils.isEmpty(data.getString("error")) ? "大模型调用失败" : data.getString("error")); + } + return data.getString("reply"); + } + + private boolean pushReport(String title, String report, String toUser) { + if (phoneForwardActivePush == null) { + log.warn("利润分析推送跳过:IPhoneForwardActivePush 不可用"); + return false; + } + if (StringUtils.isEmpty(toUser)) { + log.warn("利润分析推送跳过:未配置 jarvis.order-profit-analysis.touser 或 jarvis.wecom.goofish-notify-touser"); + return false; + } + String full = (title == null ? "" : title) + "\n" + (report == null ? "" : report.trim()); + List chunks = splitUtf8Chunks(full, WECOM_CHUNK_BYTES); + return phoneForwardActivePush.schedulePushChunks(toUser, null, chunks); + } + + private String resolveTouser() { + return normalizeTouser(StringUtils.isNotEmpty(analysisTouser) ? analysisTouser : fallbackTouser); + } + + @SuppressWarnings("unchecked") + private void appendSendLog(Map result, Map stats) { + if (redisTemplate == null) { + return; + } + try { + Map row = new LinkedHashMap<>(); + row.put("id", UUID.randomUUID().toString().replace("-", "")); + row.put("sentAt", result.get("generatedAt")); + row.put("trigger", result.get("trigger")); + row.put("periodType", result.get("periodType")); + row.put("periodLabel", result.get("periodLabel")); + row.put("toUser", result.get("toUser")); + row.put("pushed", result.get("pushed")); + row.put("pushError", result.get("pushError")); + row.put("aiError", result.get("aiError")); + Map cur = stats == null ? null : (Map) stats.get("current"); + if (cur != null) { + row.put("orderCount", cur.get("orderCount")); + row.put("totalProfit", cur.get("totalProfit")); + } + String report = result.get("report") == null ? "" : String.valueOf(result.get("report")); + row.put("report", report); + redisTemplate.opsForList().leftPush(REDIS_SEND_LOG, JSON.toJSONString(row)); + redisTemplate.opsForList().trim(REDIS_SEND_LOG, 0, SEND_LOG_MAX - 1); + } catch (Exception e) { + log.warn("写入利润分析发送记录失败: {}", e.getMessage()); + } + } + + private static String normalizeTrigger(String trigger) { + if (StringUtils.isEmpty(trigger)) { + return "manual"; + } + String t = trigger.trim().toLowerCase(); + if ("scheduled".equals(t) || "test".equals(t) || "manual".equals(t)) { + return t; + } + return "manual"; + } + + private void cacheLatest(String periodType, Map result) { + if (redisTemplate == null) { + return; + } + try { + redisTemplate.opsForValue().set(REDIS_LATEST_PREFIX + periodType, + JSON.toJSONString(result), 40, TimeUnit.DAYS); + } catch (Exception e) { + log.warn("缓存利润分析结果失败: {}", e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private String buildFallbackReport(Map stats, String aiError) { + Map cur = (Map) stats.get("current"); + Map cmp = (Map) stats.get("compare"); + StringBuilder sb = new StringBuilder(); + sb.append(stats.get("periodLabel")).append('\n'); + sb.append("订单 ").append(cur.get("orderCount")) + .append("(退款 ").append(cur.get("refundCount")).append(") ") + .append("利润 ").append(cur.get("totalProfit")) + .append(formatCmp(cmp, "totalProfit")).append('\n'); + sb.append("付款 ").append(cur.get("totalPayment")) + .append(formatCmp(cmp, "totalPayment")) + .append(" 后返 ").append(cur.get("totalRebate")).append('\n'); + List> marks = (List>) cur.get("byMark"); + if (marks != null && !marks.isEmpty()) { + sb.append("分销:"); + int n = 0; + for (Map m : marks) { + if (n > 0) { + sb.append(";"); + } + sb.append(m.get("name")).append(' ').append(m.get("orderCount")).append("单/") + .append(m.get("profit")); + if (++n >= 6) { + break; + } + } + sb.append('\n'); + } + if (StringUtils.isNotEmpty(aiError)) { + sb.append("(AI 分析失败:").append(aiError).append(')'); + } + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private static String formatCmp(Map cmp, String key) { + if (cmp == null || !(cmp.get(key) instanceof Map)) { + return ""; + } + Map item = (Map) cmp.get(key); + Object pct = item.get("changePct"); + Object delta = item.get("delta"); + if (pct == null) { + return ""; + } + return "(环比 " + signed(delta) + " / " + signed(pct) + "%)"; + } + + private static String signed(Object v) { + BigDecimal d = toDecimal(v); + if (d.compareTo(BigDecimal.ZERO) > 0) { + return "+" + num(d); + } + return String.valueOf(num(d)); + } + + private PeriodRange resolvePeriod(String periodType, String date, boolean scheduled) { + String type = normalizePeriod(periodType); + LocalDate today = LocalDate.now(ZONE); + LocalDate anchor = scheduled ? null : parseDate(date); + if (anchor == null) { + if ("week".equals(type)) { + anchor = today.minusWeeks(1).with(DayOfWeek.MONDAY); + } else if ("month".equals(type)) { + anchor = today.minusMonths(1).withDayOfMonth(1); + } else { + // 日报:当天晚上推当天,页面预览默认也是今天 + anchor = today; + } + } + if ("week".equals(type)) { + LocalDate begin = anchor.with(DayOfWeek.MONDAY); + LocalDate end = begin.plusDays(6); + return new PeriodRange(type, begin, end, + begin.format(DAY) + " ~ " + end.format(DAY) + " 周报", + "【慢单利润周报】" + begin.format(DAY) + " ~ " + end.format(DAY)); + } + if ("month".equals(type)) { + LocalDate begin = anchor.with(TemporalAdjusters.firstDayOfMonth()); + LocalDate end = anchor.with(TemporalAdjusters.lastDayOfMonth()); + return new PeriodRange(type, begin, end, + begin.getYear() + "年" + begin.getMonthValue() + "月 月报", + "【慢单利润月报】" + begin.getYear() + "年" + begin.getMonthValue() + "月"); + } + return new PeriodRange("day", anchor, anchor, + anchor.format(DAY) + " 日报", + "【慢单利润日报】" + anchor.format(DAY)); + } + + private static String normalizePeriod(String periodType) { + if (periodType == null) { + return "day"; + } + String t = periodType.trim().toLowerCase(); + if ("week".equals(t) || "month".equals(t) || "day".equals(t)) { + return t; + } + if ("周".equals(periodType) || "weekly".equals(t)) { + return "week"; + } + if ("月".equals(periodType) || "monthly".equals(t)) { + return "month"; + } + return "day"; + } + + private static LocalDate parseDate(String date) { + if (StringUtils.isEmpty(date)) { + return null; + } + try { + return LocalDate.parse(date.trim(), DAY); + } catch (Exception e) { + return null; + } + } + + private static boolean isCountEnabled(JDOrder o) { + return o.getIsCountEnabled() == null || o.getIsCountEnabled() == 1; + } + + private static String modelKey(JDOrder o) { + String a = o.getModelNumber() == null ? "" : o.getModelNumber().trim(); + String b = o.getModelShop() == null ? "" : o.getModelShop().trim(); + String k = a + b; + return k.isEmpty() ? "未填型号" : k; + } + + private static String buyerGroupKey(String buyer) { + if (buyer == null) { + return "未填下单人"; + } + String t = buyer.trim(); + if (t.isEmpty()) { + return "未填下单人"; + } + int idx = t.indexOf('-'); + if (idx > 0) { + String prefix = t.substring(0, idx).trim(); + return prefix.isEmpty() ? t : prefix; + } + return t; + } + + private static void addGroup(Map map, String name, BigDecimal pay, BigDecimal reb, + BigDecimal extra, Double profit) { + GroupAcc acc = map.computeIfAbsent(name, k -> new GroupAcc()); + acc.orderCount++; + acc.payment = acc.payment.add(pay); + acc.rebate = acc.rebate.add(reb); + acc.extraCost = acc.extraCost.add(extra); + if (profit != null) { + acc.profit = acc.profit.add(money(profit)); + } + } + + private static List> toGroupList(Map map, int limit) { + List> list = new ArrayList<>(map.entrySet()); + list.sort(Comparator + .comparing((Map.Entry e) -> e.getValue().profit, Comparator.reverseOrder()) + .thenComparing(e -> e.getValue().orderCount, Comparator.reverseOrder())); + List> out = new ArrayList<>(); + int n = 0; + for (Map.Entry e : list) { + if (n++ >= limit) { + break; + } + GroupAcc a = e.getValue(); + Map row = new LinkedHashMap<>(); + row.put("name", e.getKey()); + row.put("orderCount", a.orderCount); + row.put("payment", num(a.payment)); + row.put("rebate", num(a.rebate)); + row.put("extraCost", num(a.extraCost)); + row.put("profit", num(a.profit)); + out.add(row); + } + return out; + } + + private static BigDecimal money(Double v) { + if (v == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP); + } + + private static Object num(BigDecimal v) { + return v == null ? 0.00 : v.setScale(2, RoundingMode.HALF_UP); + } + + private static BigDecimal toDecimal(Object v) { + if (v == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + if (v instanceof BigDecimal) { + return ((BigDecimal) v).setScale(2, RoundingMode.HALF_UP); + } + if (v instanceof Number) { + return BigDecimal.valueOf(((Number) v).doubleValue()).setScale(2, RoundingMode.HALF_UP); + } + try { + return new BigDecimal(String.valueOf(v)).setScale(2, RoundingMode.HALF_UP); + } catch (Exception e) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + } + + private static String blankTo(String s, String def) { + return StringUtils.isEmpty(s) ? def : s.trim(); + } + + private static String normalizeTouser(String raw) { + if (StringUtils.isEmpty(raw)) { + return ""; + } + String[] parts = raw.split("[,|,;;\\s]+"); + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (StringUtils.isEmpty(p)) { + continue; + } + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(p.trim()); + } + return sb.toString(); + } + + private static List splitUtf8Chunks(String text, int maxBytes) { + List out = new ArrayList<>(); + if (text == null) { + return out; + } + String s = text.trim(); + if (s.isEmpty()) { + return out; + } + int i = 0; + while (i < s.length()) { + int end = i; + int bytes = 0; + int lastBreak = i; + while (end < s.length()) { + int cp = s.codePointAt(end); + int charLen = Character.charCount(cp); + int b = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length; + if (bytes + b > maxBytes) { + break; + } + bytes += b; + end += charLen; + if (s.charAt(end - 1) == '\n') { + lastBreak = end; + } + } + if (end == i) { + end = i + Character.charCount(s.codePointAt(i)); + } else if (lastBreak > i && end < s.length()) { + end = lastBreak; + } + String piece = s.substring(i, end).trim(); + if (!piece.isEmpty()) { + out.add(piece); + } + i = end; + while (i < s.length() && s.charAt(i) == '\n') { + i++; + } + } + return out; + } + + private static String postJson(String url, String json, int connectMs, int readMs) throws Exception { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + try { + conn.setRequestMethod("POST"); + conn.setConnectTimeout(connectMs); + conn.setReadTimeout(readMs); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); + try (OutputStream os = conn.getOutputStream()) { + os.write(bytes); + } + int code = conn.getResponseCode(); + InputStream is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + String resp = readAll(is); + if (code < 200 || code >= 300) { + throw new RuntimeException("HTTP " + code + (StringUtils.isEmpty(resp) ? "" : (" " + resp))); + } + return resp; + } finally { + conn.disconnect(); + } + } + + private static String readAll(InputStream is) throws java.io.IOException { + if (is == null) { + return ""; + } + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int n; + while ((n = is.read(buf)) >= 0) { + sb.append(new String(buf, 0, n, StandardCharsets.UTF_8)); + } + return sb.toString(); + } + + private static final class PeriodRange { + final String type; + final LocalDate begin; + final LocalDate end; + final String label; + final String titlePrefix; + + PeriodRange(String type, LocalDate begin, LocalDate end, String label, String titlePrefix) { + this.type = type; + this.begin = begin; + this.end = end; + this.label = label; + this.titlePrefix = titlePrefix; + } + + PeriodRange previous() { + if ("week".equals(type)) { + LocalDate b = begin.minusWeeks(1); + LocalDate e = end.minusWeeks(1); + return new PeriodRange(type, b, e, + b.format(DAY) + " ~ " + e.format(DAY) + " 上周", + titlePrefix); + } + if ("month".equals(type)) { + LocalDate b = begin.minusMonths(1).with(TemporalAdjusters.firstDayOfMonth()); + LocalDate e = b.with(TemporalAdjusters.lastDayOfMonth()); + return new PeriodRange(type, b, e, + b.getYear() + "年" + b.getMonthValue() + "月 上月", + titlePrefix); + } + LocalDate d = begin.minusDays(1); + return new PeriodRange(type, d, d, d.format(DAY) + " 昨日对比", titlePrefix); + } + } + + private static final class GroupAcc { + int orderCount; + BigDecimal payment = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + BigDecimal rebate = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + BigDecimal extraCost = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + BigDecimal profit = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/SocialMediaServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/SocialMediaServiceImpl.java index 6dd9e59..40d06ce 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/SocialMediaServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/SocialMediaServiceImpl.java @@ -51,7 +51,8 @@ public class SocialMediaServiceImpl implements ISocialMediaService "xianyu:wenan_base", "xianyu:jiaonixiadan_extra", "xianyu:title_clean_regex", - "xianyu:seed_note_prompt" + "xianyu:seed_note_prompt", + "order:profit_analysis" }; // 模板说明 @@ -64,6 +65,7 @@ public class SocialMediaServiceImpl implements ISocialMediaService put("xianyu:jiaonixiadan_extra", "闲鱼文案·教你下单版尾部附加说明\n接在「更新日期:yyyy-MM-dd」之后(纯文本)"); put("xianyu:title_clean_regex", "闲鱼文案·标题/型号清洗正则\n从标题与型号备注中移除营销敏感片段;须为 Java 正则,匹配到的内容会被删除"); put("xianyu:seed_note_prompt", "闲鱼文案·种草文案提示词模板\n占位符:{{goods_title}} {{goods_model}} {{goods_type}} {{goods_brand}} {{channel_source}} {{official_price}} {{sell_price}} {{warranty}} {{delivery_install}}"); + put("order:profit_analysis", "慢单利润/订单 AI 分析提示词\n占位符:{{stats_json}} — 本期/上期/对比统计 JSON"); }}; /** 多套大模型 JSON 存储,与 Jarvis_java SocialMediaLlmClient 一致 */ @@ -186,6 +188,7 @@ public class SocialMediaServiceImpl implements ISocialMediaService put("xianyu:jiaonixiadan_extra", DEFAULT_XIANYU_JIAONIXIADAN_EXTRA); put("xianyu:title_clean_regex", DEFAULT_XIANYU_TITLE_CLEAN_REGEX); put("xianyu:seed_note_prompt", DEFAULT_XIANYU_SEED_NOTE_PROMPT); + put("order:profit_analysis", OrderProfitAnalysisServiceImpl.DEFAULT_PROMPT); }}; /** diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/task/OrderProfitAnalysisTask.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/task/OrderProfitAnalysisTask.java new file mode 100644 index 0000000..5ac5db5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/task/OrderProfitAnalysisTask.java @@ -0,0 +1,44 @@ +package com.ruoyi.jarvis.task; + +import com.ruoyi.jarvis.service.IOrderProfitAnalysisService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 慢单利润/订单 AI 分析定时推送:日报(当天晚上推当天)、周报(上周一至周日)、月报(上月)。 + */ +@Component +public class OrderProfitAnalysisTask { + + private static final Logger log = LoggerFactory.getLogger(OrderProfitAnalysisTask.class); + + @Resource + private IOrderProfitAnalysisService orderProfitAnalysisService; + + @Scheduled(cron = "${jarvis.order-profit-analysis.daily-cron:0 0 21 * * ?}") + public void daily() { + run("day"); + } + + @Scheduled(cron = "${jarvis.order-profit-analysis.weekly-cron:0 20 8 ? * MON}") + public void weekly() { + run("week"); + } + + @Scheduled(cron = "${jarvis.order-profit-analysis.monthly-cron:0 30 8 1 * ?}") + public void monthly() { + run("month"); + } + + private void run(String periodType) { + try { + orderProfitAnalysisService.runScheduled(periodType); + } catch (Exception e) { + log.error("慢单利润分析定时任务异常 periodType={}", periodType, e); + } + } +}