1
This commit is contained in:
+111
@@ -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<String, Object> 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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+814
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user