This commit is contained in:
van
2026-09-06 21:37:43 +08:00
parent b46edff651
commit 607720b971
8 changed files with 1051 additions and 1 deletions
@@ -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;
}
@@ -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<String, Object> buildStats(String periodType, String date);
/**
* 统计 + AI 分析;push=true 时推送到企微。
*
* @param trigger scheduled / manual / test,写入发送记录
*/
Map<String, Object> analyze(String periodType, String date, boolean push, String profileId, String trigger);
/**
* 读取最近一次分析结果(Redis)。
*/
Map<String, Object> latest(String periodType);
/**
* 推送/测试发送记录,新的在前。
*/
java.util.List<Map<String, Object>> listSendLogs(int limit);
/**
* 定时任务入口:日报推当天,周报/月报推上一完整区间。
*/
void runScheduled(String periodType);
}
@@ -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<String, Object> buildStats(String periodType, String date) {
PeriodRange current = resolvePeriod(periodType, date, false);
return assemblePayload(periodType, current, false);
}
@Override
public Map<String, Object> 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<String, Object> 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<Map<String, Object>> listSendLogs(int limit) {
if (redisTemplate == null) {
return Collections.emptyList();
}
int n = limit <= 0 ? 30 : Math.min(limit, SEND_LOG_MAX);
List<String> raw = redisTemplate.opsForList().range(REDIS_SEND_LOG, 0, n - 1);
if (raw == null || raw.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> 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<String, Object> 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<String, Object> doAnalyze(String periodType, PeriodRange current, boolean push, String profileId,
String trigger) {
Map<String, Object> 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<String, Object> 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<String, Object> assemblePayload(String periodType, PeriodRange current, boolean forLlm) {
PeriodRange previous = current.previous();
Map<String, Object> cur = summarize(current);
Map<String, Object> prev = summarize(previous);
Map<String, Object> 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<String, Object> summarize(PeriodRange range) {
JDOrder query = new JDOrder();
query.getParams().put("beginTime", range.begin.format(DAY));
query.getParams().put("endTime", range.end.format(DAY));
List<JDOrder> 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<String, GroupAcc> byMark = new LinkedHashMap<>();
Map<String, GroupAcc> byModel = new LinkedHashMap<>();
Map<String, GroupAcc> 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<String, Object> 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<String, Object> compare(Map<String, Object> cur, Map<String, Object> prev) {
Map<String, Object> 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<String, Object> out, String key, Map<String, Object> cur, Map<String, Object> prev) {
BigDecimal a = toDecimal(cur.get(key));
BigDecimal b = toDecimal(prev.get(key));
BigDecimal delta = a.subtract(b).setScale(2, RoundingMode.HALF_UP);
Map<String, Object> 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<String> 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<String, Object> result, Map<String, Object> stats) {
if (redisTemplate == null) {
return;
}
try {
Map<String, Object> 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<String, Object> cur = stats == null ? null : (Map<String, Object>) 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<String, Object> 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<String, Object> stats, String aiError) {
Map<String, Object> cur = (Map<String, Object>) stats.get("current");
Map<String, Object> cmp = (Map<String, Object>) 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<Map<String, Object>> marks = (List<Map<String, Object>>) cur.get("byMark");
if (marks != null && !marks.isEmpty()) {
sb.append("分销:");
int n = 0;
for (Map<String, Object> 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<String, Object> cmp, String key) {
if (cmp == null || !(cmp.get(key) instanceof Map)) {
return "";
}
Map<String, Object> item = (Map<String, Object>) 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<String, GroupAcc> 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<Map<String, Object>> toGroupList(Map<String, GroupAcc> map, int limit) {
List<Map.Entry<String, GroupAcc>> list = new ArrayList<>(map.entrySet());
list.sort(Comparator
.comparing((Map.Entry<String, GroupAcc> e) -> e.getValue().profit, Comparator.reverseOrder())
.thenComparing(e -> e.getValue().orderCount, Comparator.reverseOrder()));
List<Map<String, Object>> out = new ArrayList<>();
int n = 0;
for (Map.Entry<String, GroupAcc> e : list) {
if (n++ >= limit) {
break;
}
GroupAcc a = e.getValue();
Map<String, Object> 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<String> splitUtf8Chunks(String text, int maxBytes) {
List<String> 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);
}
}
@@ -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);
}};
/**
@@ -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);
}
}
}