Compare commits
69 Commits
7eb31a1d4a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
791a19839a | ||
|
|
88ae4affa4 | ||
|
|
3dabd23dd7 | ||
|
|
7c7076f4ef | ||
|
|
a2f32dc7c4 | ||
|
|
ad1caa64b8 | ||
|
|
8bd5ffc53d | ||
|
|
f08fc60857 | ||
|
|
f541962b21 | ||
|
|
fcc934f8e5 | ||
|
|
8572faf072 | ||
|
|
d78e77530b | ||
|
|
f89ed66bcc | ||
|
|
a893f3cd61 | ||
|
|
c2be15e3f5 | ||
|
|
8445b500ae | ||
|
|
ee67d1ae8f | ||
|
|
a20e92d7bf | ||
| 69d1d91f5e | |||
|
|
570fcb0b93 | ||
|
|
7fda3da9ed | ||
|
|
e7687c8909 | ||
|
|
8e12076225 | ||
|
|
5b48727fb2 | ||
|
|
bb6c907cda | ||
|
|
bdd33581f1 | ||
|
|
ef358cc6b3 | ||
|
|
e76c6d4451 | ||
| 31ecfa6a2f | |||
| 127a5b71c6 | |||
| 1872908dae | |||
| efdb727e48 | |||
| 4af64b58d6 | |||
| 424cf37260 | |||
| 47fd91b948 | |||
| 89b37907e7 | |||
| 6b36f0ee52 | |||
| 5f75603532 | |||
| a2d011fb01 | |||
| 1a6ddce3f0 | |||
| a82ff0d39f | |||
| 1c9c9cfa06 | |||
| 8905ce179c | |||
| 2114f5d0f6 | |||
| 4010910846 | |||
| bd9b0f9384 | |||
|
|
b9de9ed7f4 | ||
|
|
fd940bbd66 | ||
|
|
473e305bb7 | ||
|
|
c1fbe3bb4b | ||
|
|
41083d4519 | ||
| 595642677f | |||
| d74af8a07f | |||
| 6e68591991 | |||
|
|
5543b5bcde | ||
| c3a23bf6fa | |||
| b7528dc077 | |||
| 3883cd76b4 | |||
| 49320e35ed | |||
| a95956a73e | |||
| b983219502 | |||
| cbf1600497 | |||
|
|
cce7ffad00 | ||
|
|
841c4a6a5a | ||
|
|
e736ad9a96 | ||
| d582014f24 | |||
| 1cd75c8384 | |||
| c80d3b05b8 | |||
| bdba500fa2 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ build/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
/logs/app.log
|
||||
/logs/
|
||||
|
||||
115
Redis清理说明.md
Normal file
115
Redis清理说明.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Redis键清理功能说明
|
||||
|
||||
## 功能概述
|
||||
本功能用于清理Redis中超过93天(3个月)的旧数据,支持两种类型的键:
|
||||
1. **tag键**:格式为 `tag:hash值:YYYY-MM-DD HH`,例如 `tag:01381d95e4936f1f3fe643bba2171894:2025-01-12 00`
|
||||
2. **jd:refresh:tag键**:格式为 `jd:refresh:tag:hash值:YYYY-MM-DD HH:mm:ss` 或 `jd:refresh:tag:YYYY-MM-DD HH:mm:ss`
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:手动调用API接口(推荐)
|
||||
|
||||
⚠️ **注意端口号**:
|
||||
- **jd项目端口**:6666(直接访问,无需认证)
|
||||
- RuoYi框架端口:30313(需要认证,不推荐)
|
||||
|
||||
发送POST请求到:`http://your-server:6666/jd/cleanRedisData`
|
||||
|
||||
**请求示例:**
|
||||
```bash
|
||||
curl -X POST http://192.168.8.88:6666/jd/cleanRedisData \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"skey": "2192057370ef8140c201079969c956a3"
|
||||
}'
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Redis清理任务已执行完成,详情请查看日志"
|
||||
}
|
||||
```
|
||||
|
||||
### 方式二:使用Postman等工具
|
||||
|
||||
1. 创建新的POST请求
|
||||
2. URL: `http://192.168.8.88:6666/jd/cleanRedisData`(注意是6666端口)
|
||||
3. Headers: `Content-Type: application/json`
|
||||
4. Body 标签选择 **raw** 格式,类型选择 **JSON**
|
||||
5. Body 内容:
|
||||
```json
|
||||
{
|
||||
"skey": "2192057370ef8140c201079969c956a3"
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **常见错误**:
|
||||
- ❌ 不要把skey放在Query参数中
|
||||
- ❌ 不要使用30313端口(会遇到401认证错误)
|
||||
- ✅ 确保在Body标签中使用JSON格式
|
||||
|
||||
### 方式三:自动定时执行
|
||||
|
||||
系统已配置定时任务,每天凌晨3点自动执行清理:
|
||||
- **tag键清理**:每天凌晨3点执行(cron: `0 0 3 * * ?`)
|
||||
- **jd:refresh:tag键清理**:每月1日11:45执行(cron: `0 45 11 * * ?`)
|
||||
|
||||
## 清理规则
|
||||
|
||||
- **截止日期**:当前时间减去93天
|
||||
- **清理对象**:所有早于截止日期的键
|
||||
- **安全性**:只删除符合特定格式且过期的键
|
||||
|
||||
例如:
|
||||
- 当前时间:2025-10-27
|
||||
- 截止日期:2025-07-26
|
||||
- 将删除:2025-07-26之前的所有符合格式的键
|
||||
- 保留:2025-07-26及之后的键
|
||||
|
||||
## 日志查看
|
||||
|
||||
清理任务执行时会输出详细日志,可通过以下日志查看执行情况:
|
||||
|
||||
```
|
||||
开始清理93天前的tag键数据,截止时间:YYYY-MM-DD HH:mm:ss
|
||||
找到 X 个tag相关的键
|
||||
已删除 100 个过期的tag键
|
||||
已删除 200 个过期的tag键
|
||||
...
|
||||
tag键清理完成,共删除 X 个过期键
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **skey验证**:调用接口需要提供正确的skey(密钥)
|
||||
2. **执行时间**:建议在业务低峰期手动执行清理,避免影响性能
|
||||
3. **备份建议**:首次执行前建议备份Redis数据
|
||||
4. **监控日志**:执行后及时查看日志,确认清理结果
|
||||
|
||||
## 代码位置
|
||||
|
||||
- **清理逻辑**:`d:\code\jd\src\main\java\cn\van\business\util\JDScheduleJob.java`
|
||||
- `cleanOldTagRedisData()` - 清理tag键
|
||||
- `cleanOldRedisHashData()` - 清理jd:refresh:tag键
|
||||
- `manualCleanOldRedisData()` - 手动触发清理
|
||||
|
||||
- **API接口**:`d:\code\jd\src\main\java\cn\van\business\controller\jd\JDInnerController.java`
|
||||
- `/jd/cleanRedisData` - POST接口
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 如何查看当前Redis中有多少符合条件的键?**
|
||||
A: 可以使用Redis命令:
|
||||
```bash
|
||||
redis-cli KEYS "tag:*" | wc -l
|
||||
redis-cli KEYS "jd:refresh:tag:*" | wc -l
|
||||
```
|
||||
|
||||
**Q: 清理后可以恢复吗?**
|
||||
A: 不可以,删除操作是不可逆的,请谨慎操作。
|
||||
|
||||
**Q: 如果需要调整清理天数怎么办?**
|
||||
A: 修改 `JDScheduleJob.java` 中的 `minusDays(93)` 参数,例如改为 `minusDays(60)` 清理60天前的数据。
|
||||
|
||||
176
doc/图片转换功能说明.md
Normal file
176
doc/图片转换功能说明.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# 评论图片WebP转JPG功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
评论模块中的图片如果是webp格式,会自动转换为jpg格式。转换后的图片会被缓存,下次不需要再次转换。
|
||||
|
||||
## 功能特性
|
||||
|
||||
1. **自动检测**:自动检测图片URL中的webp格式
|
||||
2. **格式转换**:将webp格式图片转换为jpg格式
|
||||
3. **结果缓存**:转换结果存储在数据库中,避免重复转换
|
||||
4. **文件存储**:转换后的jpg图片保存到本地目录
|
||||
5. **HTTP访问**:通过ImageController提供HTTP访问接口
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `application.yml` 中配置:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
convert:
|
||||
# 图片存储路径(转换后的jpg图片存储目录)
|
||||
storage-path: ${user.home}/comment-images
|
||||
# 图片访问基础URL(如果配置,转换后的图片将通过此URL访问)
|
||||
# 例如: http://your-domain.com/images 或 http://localhost:6666/images
|
||||
# 如果为空,则返回本地文件路径
|
||||
base-url: http://localhost:6666/images
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
- **storage-path**:转换后的jpg图片存储目录,默认使用 `${user.home}/comment-images`
|
||||
- **base-url**:图片访问的基础URL,如果配置,转换后的图片URL将使用此地址;如果不配置,则返回本地文件路径
|
||||
|
||||
## 数据库表
|
||||
|
||||
需要执行以下SQL创建图片转换记录表:
|
||||
|
||||
```sql
|
||||
-- 执行 sql/image_conversions.sql
|
||||
```
|
||||
|
||||
表结构:
|
||||
- `id`:主键ID
|
||||
- `original_url`:原始webp图片URL(唯一索引)
|
||||
- `converted_url`:转换后的jpg图片URL或路径
|
||||
- `converted_at`:转换时间
|
||||
- `file_size`:文件大小(字节)
|
||||
- `created_at`:创建时间
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **图片URL解析**:从评论数据中解析出图片URL列表
|
||||
2. **格式检测**:检查URL中是否包含webp格式标识
|
||||
3. **缓存查询**:查询数据库,检查是否已转换过
|
||||
4. **格式转换**(如果未转换过):
|
||||
- 下载原始webp图片
|
||||
- 使用webp-imageio库读取webp格式
|
||||
- 转换为BufferedImage
|
||||
- 保存为jpg格式到本地目录
|
||||
- 保存转换记录到数据库
|
||||
5. **返回结果**:返回转换后的图片URL(或原URL,如果不是webp)
|
||||
|
||||
## 依赖库
|
||||
|
||||
项目已添加以下依赖:
|
||||
|
||||
1. **Thumbnailator** (0.4.20):图片处理库
|
||||
|
||||
**注意**:WebP格式支持需要系统或JVM本身支持webp格式。如果系统不支持webp,转换功能会跳过webp图片并返回原URL,不会影响系统正常运行。
|
||||
|
||||
如需支持webp格式转换,可以:
|
||||
- 使用支持webp的JVM版本
|
||||
- 手动添加webp-imageio库到项目中
|
||||
- 使用其他webp解码库
|
||||
|
||||
## API接口
|
||||
|
||||
### 图片访问接口
|
||||
|
||||
**GET** `/images/{filename}`
|
||||
|
||||
访问转换后的jpg图片文件。
|
||||
|
||||
**参数:**
|
||||
- `filename`:文件名(通常是MD5值.jpg)
|
||||
|
||||
**返回:**
|
||||
- 成功:图片文件(Content-Type: image/jpeg)
|
||||
- 失败:404 Not Found 或 400 Bad Request
|
||||
|
||||
**安全特性:**
|
||||
- 防止路径遍历攻击
|
||||
- 文件路径验证
|
||||
- 仅允许访问存储目录内的文件
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 前端调用
|
||||
|
||||
评论生成接口返回的图片URL列表已经过转换处理:
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"commentText": "评论内容",
|
||||
"images": [
|
||||
"http://localhost:6666/images/abc123.jpg",
|
||||
"http://localhost:6666/images/def456.jpg"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 后端调用
|
||||
|
||||
图片转换服务会自动集成到评论生成流程中:
|
||||
|
||||
```java
|
||||
// 在 JDInnerController.commentGenerate 方法中
|
||||
List<String> imageUrls = parsePictureUrls(commentToUse.getPictureUrls());
|
||||
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **首次转换**:首次转换webp图片时,需要下载图片并转换,可能耗时较长
|
||||
2. **存储空间**:转换后的jpg图片会占用磁盘空间,建议定期清理旧文件
|
||||
3. **网络依赖**:转换过程需要下载原始图片,确保网络连接正常
|
||||
4. **WebP支持**:确保webp-imageio库正确加载,否则转换会失败
|
||||
5. **并发处理**:多个请求同时转换同一张图片时,可能会导致重复转换(建议后续优化为加锁机制)
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1:转换失败,提示"无法读取webp图片格式"
|
||||
|
||||
**解决方案:**
|
||||
1. 确认 `webp-imageio` 依赖已正确添加到pom.xml
|
||||
2. 确认依赖已成功下载(检查Maven本地仓库)
|
||||
3. 检查日志中的WebP支持检测信息
|
||||
|
||||
### 问题2:图片无法访问(404)
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `storage-path` 配置是否正确
|
||||
2. 确认图片文件已成功转换并保存
|
||||
3. 检查文件权限
|
||||
4. 如果配置了 `base-url`,确认URL是否正确
|
||||
|
||||
### 问题3:转换后的图片URL是本地路径
|
||||
|
||||
**解决方案:**
|
||||
在 `application.yml` 中配置 `base-url`:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
convert:
|
||||
base-url: http://your-domain:6666/images
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **异步转换**:对于大量图片,可以考虑异步转换
|
||||
2. **CDN加速**:将转换后的图片上传到CDN,提供更快的访问速度
|
||||
3. **定期清理**:定期清理长时间未使用的转换图片
|
||||
4. **缓存预热**:提前转换常用的图片
|
||||
|
||||
## 后续优化
|
||||
|
||||
1. 添加转换任务队列,支持批量转换
|
||||
2. 添加转换状态监控和统计
|
||||
3. 支持其他图片格式转换(如png、gif等)
|
||||
4. 添加图片压缩功能,减小文件大小
|
||||
|
||||
15
fix_line_endings.sh
Normal file
15
fix_line_endings.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# 修复 setup_centos.sh 的行尾符问题
|
||||
|
||||
# 方法1: 使用 dos2unix (如果已安装)
|
||||
if command -v dos2unix >/dev/null 2>&1; then
|
||||
dos2unix setup_centos.sh
|
||||
echo "✅ 已使用 dos2unix 修复行尾符"
|
||||
# 方法2: 使用 sed 删除 \r 字符
|
||||
else
|
||||
sed -i 's/\r$//' setup_centos.sh
|
||||
echo "✅ 已使用 sed 修复行尾符"
|
||||
fi
|
||||
|
||||
chmod +x setup_centos.sh
|
||||
echo "✅ 已设置执行权限"
|
||||
9
logs/app.log
Normal file
9
logs/app.log
Normal file
@@ -0,0 +1,9 @@
|
||||
2025-11-03 15:29:34 [main] INFO cn.van.Application - Starting Application using Java 17.0.14 with PID 56676 (D:\code\jd\target\classes started by CC in D:\code\jd)
|
||||
2025-11-03 15:29:34 [main] DEBUG cn.van.Application - Running with Spring Boot v3.1.5, Spring v6.0.13
|
||||
2025-11-03 15:29:34 [main] INFO cn.van.Application - The following 1 profile is active: "dev"
|
||||
2025-11-03 15:29:37 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-6666"]
|
||||
2025-11-03 15:29:37 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
|
||||
2025-11-03 15:29:37 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.15]
|
||||
2025-11-03 15:29:37 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
|
||||
2025-11-03 15:29:39 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean - Failed to initialize JPA EntityManagerFactory: Unable to create index (originalUrl) on table 'image_conversions' since the column 'originalUrl' was not found (specify the correct column name, which depends on the naming strategy, and may not be the same as the entity property name)
|
||||
2025-11-03 15:29:39 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Unable to create index (originalUrl) on table 'image_conversions' since the column 'originalUrl' was not found (specify the correct column name, which depends on the naming strategy, and may not be the same as the entity property name)
|
||||
85
manage_service.sh
Normal file
85
manage_service.sh
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# 物流服务管理脚本
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
PID_FILE="logistics_service.pid"
|
||||
LOG_FILE="logistics_service.log"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "启动物流服务..."
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "⚠️ 服务已在运行,PID: $PID"
|
||||
exit 1
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
./run_logistics.sh --background
|
||||
;;
|
||||
stop)
|
||||
echo "停止物流服务..."
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "⚠️ PID 文件不存在,服务可能未运行"
|
||||
exit 1
|
||||
fi
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill $PID
|
||||
sleep 1
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "强制停止服务..."
|
||||
kill -9 $PID
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
echo "✅ 服务已停止"
|
||||
else
|
||||
echo "⚠️ 进程不存在,清理 PID 文件"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
;;
|
||||
restart)
|
||||
echo "重启物流服务..."
|
||||
$0 stop
|
||||
sleep 2
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ 服务正在运行"
|
||||
echo "PID: $PID"
|
||||
echo "进程信息:"
|
||||
ps -p $PID -o pid,ppid,cmd,etime
|
||||
else
|
||||
echo "❌ 服务未运行(PID 文件存在但进程不存在)"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
else
|
||||
echo "❌ 服务未运行"
|
||||
fi
|
||||
;;
|
||||
logs)
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
tail -f "$LOG_FILE"
|
||||
else
|
||||
echo "日志文件不存在: $LOG_FILE"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|restart|status|logs}"
|
||||
echo ""
|
||||
echo "命令说明:"
|
||||
echo " start - 启动服务(后台运行)"
|
||||
echo " stop - 停止服务"
|
||||
echo " restart - 重启服务"
|
||||
echo " status - 查看服务状态"
|
||||
echo " logs - 查看实时日志"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
19
out/production/jd/cn/van/business/model/jd/update-schema.sql
Normal file
19
out/production/jd/cn/van/business/model/jd/update-schema.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE product_order
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT NOT NULL,
|
||||
sku_name VARCHAR(255) NULL,
|
||||
sku_type INT NULL,
|
||||
order_id VARCHAR(255) NULL,
|
||||
order_time datetime NULL,
|
||||
order_account VARCHAR(255) NULL,
|
||||
is_reviewed BIT(1) NULL,
|
||||
review_time datetime NULL,
|
||||
is_cashback_received BIT(1) NULL,
|
||||
cashback_time datetime NULL,
|
||||
recipient_name VARCHAR(255) NULL,
|
||||
recipient_phone VARCHAR(255) NULL,
|
||||
recipient_address VARCHAR(255) NULL,
|
||||
who_order VARCHAR(255) NULL,
|
||||
from_wxid VARCHAR(255) NULL,
|
||||
CONSTRAINT pk_product_order PRIMARY KEY (id)
|
||||
);
|
||||
18
out/production/jd/cn/van/business/model/update-schema.sql
Normal file
18
out/production/jd/cn/van/business/model/update-schema.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE product_order
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT NOT NULL,
|
||||
sku_name VARCHAR(255) NULL,
|
||||
sku_type INT NULL,
|
||||
order_id VARCHAR(255) NULL,
|
||||
order_time datetime NULL,
|
||||
order_account VARCHAR(255) NULL,
|
||||
is_reviewed BIT(1) NULL,
|
||||
review_time datetime NULL,
|
||||
is_cashback_received BIT(1) NULL,
|
||||
cashback_time datetime NULL,
|
||||
recipient_name VARCHAR(255) NULL,
|
||||
recipient_phone VARCHAR(255) NULL,
|
||||
recipient_address VARCHAR(255) NULL,
|
||||
who_order VARCHAR(255) NULL,
|
||||
CONSTRAINT pk_product_order PRIMARY KEY (id)
|
||||
);
|
||||
18
pom.xml
18
pom.xml
@@ -113,6 +113,12 @@
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.13</version>
|
||||
</dependency>
|
||||
<!-- 图片处理库 Thumbnailator -->
|
||||
<dependency>
|
||||
<groupId>net.coobird</groupId>
|
||||
<artifactId>thumbnailator</artifactId>
|
||||
<version>0.4.20</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -158,5 +164,17 @@
|
||||
<name>RocketMQ Repository</name>
|
||||
<url>https://repo1.maven.org/maven2/org/apache/rocketmq/</url>
|
||||
</repository>
|
||||
<!-- Maven 中央仓库 -->
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<name>Maven Central Repository</name>
|
||||
<url>https://repo1.maven.org/maven2</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
|
||||
284
setup_centos.sh
Normal file
284
setup_centos.sh
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/bin/bash
|
||||
# CentOS 环境快速设置脚本
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
# 确保使用 bash 运行(兼容性问题处理)
|
||||
if [ -z "$BASH_VERSION" ]; then
|
||||
echo "警告: 此脚本需要使用 bash 运行"
|
||||
echo "请使用: bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "京东物流提取工具 - Linux 环境设置"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 检测系统类型和包管理器
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_NAME="$ID"
|
||||
else
|
||||
echo "❌ 错误: 无法检测操作系统类型"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 根据系统类型选择包管理器
|
||||
if [ "$OS_NAME" = "ubuntu" ] || [ "$OS_NAME" = "debian" ]; then
|
||||
# Ubuntu/Debian 系统
|
||||
PKG_MANAGER="apt"
|
||||
PKG_INSTALL="apt install -y"
|
||||
PKG_UPDATE="apt update"
|
||||
PKG_CHECK="dpkg -l"
|
||||
IS_UBUNTU=true
|
||||
elif [ "$OS_NAME" = "centos" ] || [ "$OS_NAME" = "rhel" ] || [ "$OS_NAME" = "fedora" ] || [ "$OS_NAME" = "rocky" ] || [ "$OS_NAME" = "almalinux" ]; then
|
||||
# CentOS/RHEL/Fedora 系统
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
PKG_MANAGER="dnf"
|
||||
PKG_INSTALL="dnf install -y"
|
||||
PKG_UPDATE="dnf update -y"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
PKG_MANAGER="yum"
|
||||
PKG_INSTALL="yum install -y"
|
||||
PKG_UPDATE="yum update -y"
|
||||
else
|
||||
echo "❌ 错误: 未找到 yum 或 dnf 包管理器"
|
||||
exit 1
|
||||
fi
|
||||
PKG_CHECK="rpm -q"
|
||||
IS_UBUNTU=false
|
||||
else
|
||||
echo "❌ 错误: 不支持的操作系统: $OS_NAME"
|
||||
echo "支持的系统: Ubuntu, Debian, CentOS, RHEL, Fedora, Rocky Linux, AlmaLinux"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "检测到操作系统: $OS_NAME"
|
||||
echo "检测到包管理器: $PKG_MANAGER"
|
||||
|
||||
# 1. 检查并安装系统依赖
|
||||
echo "步骤 1: 检查系统依赖..."
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "安装 Python3..."
|
||||
$PKG_UPDATE
|
||||
$PKG_INSTALL python3 python3-pip
|
||||
# CentOS 可能需要单独安装 venv 模块
|
||||
$PKG_INSTALL python3-devel || true
|
||||
else
|
||||
echo "✅ Python3 已安装"
|
||||
fi
|
||||
|
||||
# 检查 Chrome/Chromium
|
||||
CHROME_PATH=""
|
||||
if command -v google-chrome >/dev/null 2>&1; then
|
||||
CHROME_PATH=$(which google-chrome)
|
||||
echo "✅ 找到 Google Chrome: $CHROME_PATH"
|
||||
elif [ -f "/usr/bin/google-chrome" ]; then
|
||||
CHROME_PATH="/usr/bin/google-chrome"
|
||||
echo "✅ 找到 Google Chrome: $CHROME_PATH"
|
||||
elif command -v chromium >/dev/null 2>&1; then
|
||||
CHROME_PATH=$(which chromium)
|
||||
echo "✅ 找到 Chromium: $CHROME_PATH"
|
||||
elif [ -f "/usr/bin/chromium" ]; then
|
||||
CHROME_PATH="/usr/bin/chromium"
|
||||
echo "✅ 找到 Chromium: $CHROME_PATH"
|
||||
elif [ -f "/usr/bin/chromium-browser" ]; then
|
||||
CHROME_PATH="/usr/bin/chromium-browser"
|
||||
echo "✅ 找到 Chromium: $CHROME_PATH"
|
||||
else
|
||||
echo "⚠️ 未找到 Chrome/Chromium,将尝试安装..."
|
||||
echo "选择要安装的浏览器:"
|
||||
echo "1) Google Chrome (推荐)"
|
||||
echo "2) Chromium (开源版本)"
|
||||
read -p "请选择 [1-2]: " choice
|
||||
|
||||
if [ "$choice" = "1" ]; then
|
||||
echo "正在安装 Google Chrome..."
|
||||
# 创建临时目录
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
# 根据系统类型下载对应的包
|
||||
if [ "$(uname -m)" = "x86_64" ]; then
|
||||
if [ "$IS_UBUNTU" = "true" ]; then
|
||||
# Ubuntu/Debian 使用 DEB 包
|
||||
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
sudo $PKG_INSTALL ./google-chrome-stable_current_amd64.deb
|
||||
rm -f google-chrome-stable_current_amd64.deb
|
||||
else
|
||||
# CentOS/RHEL 使用 RPM 包
|
||||
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
|
||||
sudo $PKG_INSTALL ./google-chrome-stable_current_x86_64.rpm
|
||||
rm -f google-chrome-stable_current_x86_64.rpm
|
||||
fi
|
||||
else
|
||||
echo "❌ 错误: 仅支持 x86_64 架构"
|
||||
cd - >/dev/null
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd - >/dev/null
|
||||
rm -rf "$TEMP_DIR"
|
||||
CHROME_PATH="/usr/bin/google-chrome"
|
||||
elif [ "$choice" = "2" ]; then
|
||||
echo "正在安装 Chromium..."
|
||||
$PKG_UPDATE
|
||||
if [ "$IS_UBUNTU" = "true" ]; then
|
||||
# Ubuntu/Debian
|
||||
$PKG_INSTALL chromium-browser
|
||||
CHROME_PATH="/usr/bin/chromium-browser"
|
||||
else
|
||||
# CentOS/RHEL 可能需要启用 EPEL 仓库
|
||||
if ! rpm -qa | grep -q epel-release; then
|
||||
echo "安装 EPEL 仓库..."
|
||||
$PKG_INSTALL epel-release || true
|
||||
fi
|
||||
$PKG_INSTALL chromium
|
||||
CHROME_PATH="/usr/bin/chromium"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. 安装 Chrome 运行时依赖
|
||||
echo ""
|
||||
echo "步骤 2: 检查 Chrome 运行时依赖..."
|
||||
# 根据系统类型设置依赖包名
|
||||
if [ "$IS_UBUNTU" = "true" ]; then
|
||||
# Ubuntu/Debian 依赖包名
|
||||
DEPS="libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2"
|
||||
else
|
||||
# CentOS/RHEL 依赖包名
|
||||
DEPS="nss atk at-spi2-atk libdrm libxkbcommon libXcomposite libXdamage libXfixes libXrandr mesa-libgbm alsa-lib"
|
||||
fi
|
||||
|
||||
MISSING_DEPS=""
|
||||
|
||||
for dep in $DEPS; do
|
||||
if [ "$IS_UBUNTU" = "true" ]; then
|
||||
# Ubuntu/Debian 使用 dpkg 检查
|
||||
if ! dpkg -l 2>/dev/null | grep -q "^ii.*$dep"; then
|
||||
if [ -z "$MISSING_DEPS" ]; then
|
||||
MISSING_DEPS="$dep"
|
||||
else
|
||||
MISSING_DEPS="$MISSING_DEPS $dep"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# CentOS/RHEL 使用 rpm 检查
|
||||
if ! rpm -q "$dep" >/dev/null 2>&1; then
|
||||
if [ -z "$MISSING_DEPS" ]; then
|
||||
MISSING_DEPS="$dep"
|
||||
else
|
||||
MISSING_DEPS="$MISSING_DEPS $dep"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$MISSING_DEPS" ]; then
|
||||
echo "安装缺失的依赖: $MISSING_DEPS"
|
||||
# CentOS/RHEL 需要确保 EPEL 仓库已启用
|
||||
if [ "$IS_UBUNTU" = "false" ]; then
|
||||
if ! rpm -qa | grep -q epel-release; then
|
||||
echo "安装 EPEL 仓库..."
|
||||
$PKG_INSTALL epel-release || true
|
||||
fi
|
||||
fi
|
||||
$PKG_INSTALL $MISSING_DEPS
|
||||
else
|
||||
echo "✅ 所有依赖已安装"
|
||||
fi
|
||||
|
||||
# 3. 创建虚拟环境
|
||||
echo ""
|
||||
echo "步骤 3: 设置 Python 虚拟环境..."
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "创建虚拟环境..."
|
||||
python3 -m venv venv
|
||||
echo "✅ 虚拟环境创建成功"
|
||||
else
|
||||
echo "✅ 虚拟环境已存在"
|
||||
# 检查虚拟环境是否正常
|
||||
if [ -f "venv/bin/pip" ]; then
|
||||
# 检查 pip 脚本是否有行尾符问题
|
||||
if file venv/bin/pip | grep -q "CRLF"; then
|
||||
echo "⚠️ 检测到虚拟环境中的脚本有行尾符问题,正在修复..."
|
||||
find venv/bin -type f -name "pip*" -exec sed -i 's/\r$//' {} \;
|
||||
find venv/bin -type f -name "activate" -exec sed -i 's/\r$//' {} \;
|
||||
find venv/bin -type f -name "python*" -exec sed -i 's/\r$//' {} \;
|
||||
echo "✅ 行尾符已修复"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 虚拟环境可能已损坏,正在重新创建..."
|
||||
rm -rf venv
|
||||
python3 -m venv venv
|
||||
echo "✅ 虚拟环境重新创建成功"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 激活虚拟环境并安装 Python 包
|
||||
echo ""
|
||||
echo "步骤 4: 安装 Python 依赖包..."
|
||||
source venv/bin/activate
|
||||
|
||||
# 验证 pip 是否可用
|
||||
if ! command -v pip >/dev/null 2>&1; then
|
||||
echo "❌ 错误: pip 命令不可用,尝试修复虚拟环境..."
|
||||
deactivate 2>/dev/null || true
|
||||
rm -rf venv
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# 升级 pip
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
# 安装依赖
|
||||
python3 -m pip install DrissionPage
|
||||
|
||||
# 可选:如果需要数据库功能
|
||||
read -p "是否需要数据库功能?(sqlalchemy, pymysql) [y/N]: " need_db
|
||||
if [ "$need_db" = "y" ] || [ "$need_db" = "Y" ]; then
|
||||
python3 -m pip install sqlalchemy pymysql
|
||||
fi
|
||||
|
||||
deactivate
|
||||
|
||||
# 5. 创建运行脚本
|
||||
echo ""
|
||||
echo "步骤 5: 创建便捷运行脚本..."
|
||||
cat > run_logistics.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# 激活虚拟环境并运行物流提取脚本
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
source venv/bin/activate
|
||||
|
||||
# 运行脚本
|
||||
python jd/fetch_logistics_centos.py "$@"
|
||||
|
||||
deactivate
|
||||
EOF
|
||||
|
||||
chmod +x run_logistics.sh
|
||||
|
||||
# 6. 完成
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ 环境设置完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "快速开始:"
|
||||
echo " 方式1: 使用便捷脚本"
|
||||
echo " ./run_logistics.sh"
|
||||
echo ""
|
||||
echo " 方式2: 手动运行"
|
||||
echo " source venv/bin/activate"
|
||||
echo " python jd/fetch_logistics_centos.py"
|
||||
echo " deactivate"
|
||||
echo ""
|
||||
echo "浏览器路径: $CHROME_PATH"
|
||||
echo "虚拟环境: $(pwd)/venv"
|
||||
echo ""
|
||||
417
setup_ubuntu.sh
Normal file
417
setup_ubuntu.sh
Normal file
@@ -0,0 +1,417 @@
|
||||
#!/bin/bash
|
||||
# Ubuntu 环境快速设置脚本
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
# 确保使用 bash 运行(兼容性问题处理)
|
||||
if [ -z "$BASH_VERSION" ]; then
|
||||
echo "警告: 此脚本需要使用 bash 运行"
|
||||
echo "请使用: bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "京东物流提取工具 - Ubuntu 环境设置"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 1. 检查并安装系统依赖
|
||||
echo "步骤 1: 检查系统依赖..."
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "安装 Python3..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv
|
||||
else
|
||||
echo "✅ Python3 已安装"
|
||||
fi
|
||||
|
||||
# 检查 Chrome/Chromium
|
||||
CHROME_PATH=""
|
||||
if command -v google-chrome >/dev/null 2>&1; then
|
||||
CHROME_PATH=$(which google-chrome)
|
||||
echo "✅ 找到 Google Chrome: $CHROME_PATH"
|
||||
elif [ -f "/usr/bin/google-chrome" ]; then
|
||||
CHROME_PATH="/usr/bin/google-chrome"
|
||||
echo "✅ 找到 Google Chrome: $CHROME_PATH"
|
||||
elif command -v chromium-browser >/dev/null 2>&1; then
|
||||
CHROME_PATH=$(which chromium-browser)
|
||||
echo "✅ 找到 Chromium: $CHROME_PATH"
|
||||
elif [ -f "/usr/bin/chromium-browser" ]; then
|
||||
CHROME_PATH="/usr/bin/chromium-browser"
|
||||
echo "✅ 找到 Chromium: $CHROME_PATH"
|
||||
else
|
||||
echo "⚠️ 未找到 Chrome/Chromium,将尝试安装..."
|
||||
echo "选择要安装的浏览器:"
|
||||
echo "1) Google Chrome (推荐)"
|
||||
echo "2) Chromium (开源版本)"
|
||||
read -p "请选择 [1-2]: " choice
|
||||
|
||||
if [ "$choice" = "1" ]; then
|
||||
echo "正在安装 Google Chrome..."
|
||||
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
sudo apt install -y ./google-chrome-stable_current_amd64.deb
|
||||
rm -f google-chrome-stable_current_amd64.deb
|
||||
CHROME_PATH="/usr/bin/google-chrome"
|
||||
elif [ "$choice" = "2" ]; then
|
||||
echo "正在安装 Chromium..."
|
||||
sudo apt update
|
||||
sudo apt install -y chromium-browser
|
||||
CHROME_PATH="/usr/bin/chromium-browser"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. 安装 Chrome 运行时依赖
|
||||
echo ""
|
||||
echo "步骤 2: 检查 Chrome 运行时依赖..."
|
||||
DEPS="libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2"
|
||||
MISSING_DEPS=""
|
||||
|
||||
for dep in $DEPS; do
|
||||
if ! dpkg -l 2>/dev/null | grep -q "^ii.*$dep"; then
|
||||
if [ -z "$MISSING_DEPS" ]; then
|
||||
MISSING_DEPS="$dep"
|
||||
else
|
||||
MISSING_DEPS="$MISSING_DEPS $dep"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$MISSING_DEPS" ]; then
|
||||
echo "安装缺失的依赖: $MISSING_DEPS"
|
||||
sudo apt install -y $MISSING_DEPS
|
||||
else
|
||||
echo "✅ 所有依赖已安装"
|
||||
fi
|
||||
|
||||
# 3. 创建虚拟环境
|
||||
echo ""
|
||||
echo "步骤 3: 设置 Python 虚拟环境..."
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "创建虚拟环境..."
|
||||
python3 -m venv venv
|
||||
echo "✅ 虚拟环境创建成功"
|
||||
else
|
||||
echo "✅ 虚拟环境已存在"
|
||||
# 检查虚拟环境是否正常
|
||||
if [ ! -f "venv/bin/pip" ]; then
|
||||
echo "⚠️ 虚拟环境可能已损坏,正在重新创建..."
|
||||
rm -rf venv
|
||||
python3 -m venv venv
|
||||
echo "✅ 虚拟环境重新创建成功"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. 激活虚拟环境并安装 Python 包
|
||||
echo ""
|
||||
echo "步骤 4: 安装 Python 依赖包..."
|
||||
|
||||
# 检查并修复 activate 脚本的行尾符问题
|
||||
if [ -f "venv/bin/activate" ]; then
|
||||
# 修复可能的行尾符问题
|
||||
sed -i 's/\r$//' venv/bin/activate 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 验证虚拟环境是否正常激活
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "❌ 错误: 虚拟环境激活失败,尝试重新创建..."
|
||||
deactivate 2>/dev/null || true
|
||||
rm -rf venv
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# 升级 pip(使用 python3 -m pip 避免行尾符问题)
|
||||
echo "升级 pip..."
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
# 安装依赖
|
||||
echo "安装 DrissionPage..."
|
||||
python3 -m pip install DrissionPage
|
||||
|
||||
# 安装 Flask(API 服务需要)
|
||||
echo "安装 Flask..."
|
||||
python3 -m pip install flask
|
||||
|
||||
# 可选:如果需要数据库功能
|
||||
read -p "是否需要数据库功能?(sqlalchemy, pymysql) [y/N]: " need_db
|
||||
if [ "$need_db" = "y" ] || [ "$need_db" = "Y" ]; then
|
||||
echo "安装数据库相关包..."
|
||||
python3 -m pip install sqlalchemy pymysql
|
||||
fi
|
||||
|
||||
deactivate
|
||||
|
||||
# 5. 创建运行脚本
|
||||
echo ""
|
||||
echo "步骤 5: 创建便捷运行脚本..."
|
||||
cat > run_logistics.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# 激活虚拟环境并运行物流提取脚本
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 检查虚拟环境是否存在
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "❌ 错误: 虚拟环境不存在,请先运行 setup_ubuntu.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 修复可能的行尾符问题
|
||||
if [ -f "venv/bin/activate" ]; then
|
||||
sed -i 's/\r$//' venv/bin/activate 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 验证虚拟环境是否激活成功
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "❌ 错误: 虚拟环境激活失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 使用虚拟环境中的 Python3
|
||||
PYTHON_CMD="$VIRTUAL_ENV/bin/python3"
|
||||
if [ ! -f "$PYTHON_CMD" ]; then
|
||||
PYTHON_CMD="python3"
|
||||
fi
|
||||
|
||||
# 检查脚本文件是否存在
|
||||
SCRIPT_FILE="jd/fetch_logistics_ubuntu.py"
|
||||
if [ ! -f "$SCRIPT_FILE" ]; then
|
||||
echo "❌ 错误: 找不到脚本文件 $SCRIPT_FILE"
|
||||
echo "当前目录: $(pwd)"
|
||||
echo "文件列表:"
|
||||
ls -la jd/ 2>/dev/null || echo " jd/ 目录不存在"
|
||||
echo ""
|
||||
echo "请确保脚本文件存在"
|
||||
deactivate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查文件是否为空
|
||||
if [ ! -s "$SCRIPT_FILE" ]; then
|
||||
echo "❌ 错误: 脚本文件 $SCRIPT_FILE 为空"
|
||||
deactivate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 修复脚本文件的行尾符问题
|
||||
sed -i 's/\r$//' "$SCRIPT_FILE" 2>/dev/null || true
|
||||
|
||||
# 检查是否要后台运行
|
||||
BACKGROUND=false
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--background" ] || [ "$arg" = "-b" ] || [ "$arg" = "--daemon" ] || [ "$arg" = "-d" ]; then
|
||||
BACKGROUND=true
|
||||
else
|
||||
ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
# 运行脚本
|
||||
echo "使用 Python: $PYTHON_CMD"
|
||||
echo "运行脚本: $SCRIPT_FILE"
|
||||
echo "脚本大小: $(wc -c < "$SCRIPT_FILE") 字节"
|
||||
|
||||
if [ "$BACKGROUND" = "true" ]; then
|
||||
# 后台运行模式
|
||||
LOG_FILE="logistics_service.log"
|
||||
PID_FILE="logistics_service.pid"
|
||||
|
||||
echo "以后台模式启动服务..."
|
||||
echo "日志文件: $LOG_FILE"
|
||||
echo "PID 文件: $PID_FILE"
|
||||
echo ""
|
||||
|
||||
# 使用 nohup 后台运行,确保使用虚拟环境的 Python
|
||||
# 注意:不传递 --background 参数给 Python 脚本
|
||||
nohup $PYTHON_CMD -u "$SCRIPT_FILE" "${ARGS[@]}" > "$LOG_FILE" 2>&1 &
|
||||
PID=$!
|
||||
echo $PID > "$PID_FILE"
|
||||
|
||||
# 等待一下,检查进程是否启动成功
|
||||
sleep 3
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ 服务已启动,PID: $PID"
|
||||
echo ""
|
||||
echo "使用以下命令管理服务:"
|
||||
echo " 查看日志: tail -f $LOG_FILE"
|
||||
echo " 停止服务: ./manage_service.sh stop"
|
||||
echo " 查看状态: ./manage_service.sh status"
|
||||
echo ""
|
||||
# 后台模式下,不退出虚拟环境(因为后台进程已经独立运行)
|
||||
# 直接退出脚本
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 服务启动失败,请查看日志: $LOG_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
deactivate
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 前台运行模式
|
||||
echo "参数: ${ARGS[@]}"
|
||||
echo ""
|
||||
echo "以前台模式启动服务(按 Ctrl+C 停止)..."
|
||||
echo ""
|
||||
# 使用 -u 参数确保输出不被缓冲,并明确指定以脚本模式运行
|
||||
# 使用 exec 替换当前进程,这样 deactivate 不会执行
|
||||
exec $PYTHON_CMD -u "$SCRIPT_FILE" "${ARGS[@]}"
|
||||
# 如果 exec 失败(不应该发生),才会执行到这里
|
||||
EXIT_CODE=$?
|
||||
deactivate
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
EOF
|
||||
|
||||
chmod +x run_logistics.sh
|
||||
|
||||
# 创建服务管理脚本
|
||||
cat > manage_service.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# 物流服务管理脚本
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
PID_FILE="logistics_service.pid"
|
||||
LOG_FILE="logistics_service.log"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "启动物流服务..."
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "⚠️ 服务已在运行,PID: $PID"
|
||||
exit 1
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
# 重定向输出,避免显示在终端
|
||||
./run_logistics.sh --background > /dev/null 2>&1
|
||||
sleep 2
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ 服务已启动,PID: $PID"
|
||||
echo "查看日志: tail -f $LOG_FILE"
|
||||
else
|
||||
echo "❌ 服务启动失败,请查看日志: $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 服务启动失败,请查看日志: $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
stop)
|
||||
echo "停止物流服务..."
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "⚠️ PID 文件不存在,服务可能未运行"
|
||||
exit 1
|
||||
fi
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill $PID
|
||||
sleep 1
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "强制停止服务..."
|
||||
kill -9 $PID
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
echo "✅ 服务已停止"
|
||||
else
|
||||
echo "⚠️ 进程不存在,清理 PID 文件"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
;;
|
||||
restart)
|
||||
echo "重启物流服务..."
|
||||
$0 stop
|
||||
sleep 2
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ 服务正在运行"
|
||||
echo "PID: $PID"
|
||||
echo "进程信息:"
|
||||
ps -p $PID -o pid,ppid,cmd,etime
|
||||
else
|
||||
echo "❌ 服务未运行(PID 文件存在但进程不存在)"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
else
|
||||
echo "❌ 服务未运行"
|
||||
fi
|
||||
;;
|
||||
logs)
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
tail -f "$LOG_FILE"
|
||||
else
|
||||
echo "日志文件不存在: $LOG_FILE"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|restart|status|logs}"
|
||||
echo ""
|
||||
echo "命令说明:"
|
||||
echo " start - 启动服务(后台运行)"
|
||||
echo " stop - 停止服务"
|
||||
echo " restart - 重启服务"
|
||||
echo " status - 查看服务状态"
|
||||
echo " logs - 查看实时日志"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
chmod +x manage_service.sh
|
||||
|
||||
# 6. 完成
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ 环境设置完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "快速开始:"
|
||||
echo " 方式1: 使用便捷脚本(前台运行)"
|
||||
echo " ./run_logistics.sh"
|
||||
echo ""
|
||||
echo " 方式2: 后台运行服务"
|
||||
echo " ./run_logistics.sh --background"
|
||||
echo " 或"
|
||||
echo " ./run_logistics.sh -b"
|
||||
echo ""
|
||||
echo " 方式3: 手动运行"
|
||||
echo " source venv/bin/activate"
|
||||
echo " python jd/fetch_logistics_ubuntu.py"
|
||||
echo " deactivate"
|
||||
echo ""
|
||||
echo "后台运行管理:"
|
||||
echo " 方式1: 使用管理脚本(推荐)"
|
||||
echo " ./manage_service.sh start - 启动服务"
|
||||
echo " ./manage_service.sh stop - 停止服务"
|
||||
echo " ./manage_service.sh restart - 重启服务"
|
||||
echo " ./manage_service.sh status - 查看状态"
|
||||
echo " ./manage_service.sh logs - 查看日志"
|
||||
echo ""
|
||||
echo " 方式2: 直接使用 run_logistics.sh"
|
||||
echo " ./run_logistics.sh --background - 后台启动"
|
||||
echo " tail -f logistics_service.log - 查看日志"
|
||||
echo " kill \$(cat logistics_service.pid) - 停止服务"
|
||||
echo ""
|
||||
echo "浏览器路径: $CHROME_PATH"
|
||||
echo "虚拟环境: $(pwd)/venv"
|
||||
echo ""
|
||||
18
sql/image_conversions.sql
Normal file
18
sql/image_conversions.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 图片转换记录表
|
||||
-- 用于存储webp格式图片转换为jpg格式的映射关系
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `image_conversions` (
|
||||
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`original_url` VARCHAR(2048) NOT NULL COMMENT '原始webp图片URL',
|
||||
`converted_url` VARCHAR(2048) NOT NULL COMMENT '转换后的jpg图片URL或本地路径',
|
||||
`converted_at` DATETIME NOT NULL COMMENT '转换时间',
|
||||
`file_size` BIGINT(20) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||
`created_at` DATETIME NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_original_url` (`original_url`(255)),
|
||||
KEY `idx_original_url` (`original_url`(255)),
|
||||
KEY `idx_converted_url` (`converted_url`(255)),
|
||||
KEY `idx_converted_at` (`converted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图片转换记录表';
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package cn.van.business.controller;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 图片访问控制器
|
||||
* 用于访问转换后的jpg图片
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/images")
|
||||
public class ImageController {
|
||||
|
||||
/**
|
||||
* 图片存储根目录
|
||||
*/
|
||||
@Value("${image.convert.storage-path:${java.io.tmpdir}/comment-images}")
|
||||
private String storagePath;
|
||||
|
||||
/**
|
||||
* 获取转换后的图片
|
||||
*
|
||||
* @param filename 文件名(通常是MD5值.jpg)
|
||||
* @return 图片文件
|
||||
*/
|
||||
@GetMapping("/{filename:.+}")
|
||||
public ResponseEntity<Resource> getImage(@PathVariable String filename) {
|
||||
try {
|
||||
// 安全检查:防止路径遍历攻击
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
log.warn("非法的文件名请求: {}", filename);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Path filePath = Paths.get(storagePath, filename);
|
||||
File file = filePath.toFile();
|
||||
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
log.warn("图片文件不存在: {}", filePath);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// 检查文件是否在存储目录内(防止路径遍历)
|
||||
Path storageDir = Paths.get(storagePath).toAbsolutePath().normalize();
|
||||
Path resolvedPath = filePath.toAbsolutePath().normalize();
|
||||
if (!resolvedPath.startsWith(storageDir)) {
|
||||
log.warn("非法的文件路径访问: {}", resolvedPath);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Resource resource = new FileSystemResource(file);
|
||||
|
||||
// 判断文件类型
|
||||
String contentType = Files.probeContentType(filePath);
|
||||
if (contentType == null) {
|
||||
// 如果无法探测,默认使用image/jpeg
|
||||
contentType = MediaType.IMAGE_JPEG_VALUE;
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\"")
|
||||
.body(resource);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("获取图片失败: {}", filename, e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package cn.van.business.controller;
|
||||
|
||||
import cn.van.business.model.ApiResponse;
|
||||
import cn.van.business.service.MarketingImageService;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 营销图片合成控制器
|
||||
* 提供营销图片生成的HTTP接口
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/jarvis/marketing-image")
|
||||
public class MarketingImageController {
|
||||
|
||||
@Autowired
|
||||
private MarketingImageService marketingImageService;
|
||||
|
||||
/**
|
||||
* 生成单张营销图片
|
||||
*
|
||||
* POST /jarvis/marketing-image/generate
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* "productImageUrl": "商品主图URL",
|
||||
* "originalPrice": 499.0,
|
||||
* "finalPrice": 199.0,
|
||||
* "productName": "商品名称(可选)"
|
||||
* }
|
||||
*
|
||||
* 返回:
|
||||
* {
|
||||
* "code": 200,
|
||||
* "msg": "操作成功",
|
||||
* "data": {
|
||||
* "imageBase64": "data:image/jpg;base64,..."
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/generate")
|
||||
public JSONObject generateMarketingImage(@RequestBody Map<String, Object> request) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
String productImageUrl = (String) request.get("productImageUrl");
|
||||
Object originalPriceObj = request.get("originalPrice");
|
||||
Object finalPriceObj = request.get("finalPrice");
|
||||
String productName = (String) request.get("productName");
|
||||
|
||||
if (productImageUrl == null || originalPriceObj == null || finalPriceObj == null) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "缺少必要参数: productImageUrl, originalPrice, finalPrice");
|
||||
return response;
|
||||
}
|
||||
|
||||
Double originalPrice = parseDouble(originalPriceObj);
|
||||
Double finalPrice = parseDouble(finalPriceObj);
|
||||
|
||||
if (originalPrice == null || finalPrice == null) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "价格参数格式错误");
|
||||
return response;
|
||||
}
|
||||
|
||||
String base64Image = marketingImageService.generateMarketingImage(
|
||||
productImageUrl, originalPrice, finalPrice, productName);
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("imageBase64", base64Image);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", data);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成营销图片失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "生成营销图片失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成营销图片
|
||||
*
|
||||
* POST /jarvis/marketing-image/batch-generate
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* "requests": [
|
||||
* {
|
||||
* "productImageUrl": "商品主图URL1",
|
||||
* "originalPrice": 499.0,
|
||||
* "finalPrice": 199.0,
|
||||
* "productName": "商品名称1(可选)"
|
||||
* },
|
||||
* {
|
||||
* "productImageUrl": "商品主图URL2",
|
||||
* "originalPrice": 699.0,
|
||||
* "finalPrice": 349.0,
|
||||
* "productName": "商品名称2(可选)"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* 返回:
|
||||
* {
|
||||
* "code": 200,
|
||||
* "msg": "操作成功",
|
||||
* "data": {
|
||||
* "results": [
|
||||
* {
|
||||
* "success": true,
|
||||
* "imageBase64": "data:image/jpg;base64,...",
|
||||
* "index": 0
|
||||
* },
|
||||
* {
|
||||
* "success": false,
|
||||
* "error": "错误信息",
|
||||
* "index": 1
|
||||
* }
|
||||
* ],
|
||||
* "total": 2,
|
||||
* "successCount": 1,
|
||||
* "failCount": 1
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/batch-generate")
|
||||
public JSONObject batchGenerateMarketingImages(@RequestBody Map<String, Object> request) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> requests = (List<Map<String, Object>>) request.get("requests");
|
||||
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "请求列表不能为空");
|
||||
return response;
|
||||
}
|
||||
|
||||
Map<String, Object> result = marketingImageService.batchGenerateMarketingImages(requests);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("批量生成营销图片失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "批量生成营销图片失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Double值
|
||||
*/
|
||||
private Double parseDouble(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Double) {
|
||||
return (Double) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(value.toString());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package cn.van.business.controller;
|
||||
|
||||
import cn.van.business.service.SocialMediaService;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 小红书/抖音内容生成控制器
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/jarvis/social-media")
|
||||
public class SocialMediaController {
|
||||
|
||||
@Autowired
|
||||
private SocialMediaService socialMediaService;
|
||||
|
||||
/**
|
||||
* 提取关键词
|
||||
*
|
||||
* POST /jarvis/social-media/extract-keywords
|
||||
*
|
||||
* {
|
||||
* "productName": "商品名称"
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/extract-keywords")
|
||||
public JSONObject extractKeywords(@RequestBody Map<String, Object> request) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
String productName = (String) request.get("productName");
|
||||
|
||||
if (productName == null || productName.trim().isEmpty()) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "商品名称不能为空");
|
||||
return response;
|
||||
}
|
||||
|
||||
Map<String, Object> result = socialMediaService.extractKeywords(productName);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("提取关键词失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "提取关键词失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文案
|
||||
*
|
||||
* POST /jarvis/social-media/generate-content
|
||||
*
|
||||
* {
|
||||
* "productName": "商品名称",
|
||||
* "originalPrice": 499.0,
|
||||
* "finalPrice": 199.0,
|
||||
* "keywords": "关键词1、关键词2",
|
||||
* "style": "xhs" // xhs/douyin/both
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/generate-content")
|
||||
public JSONObject generateContent(@RequestBody Map<String, Object> request) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
String productName = (String) request.get("productName");
|
||||
Object originalPriceObj = request.get("originalPrice");
|
||||
Object finalPriceObj = request.get("finalPrice");
|
||||
String keywords = (String) request.get("keywords");
|
||||
String style = (String) request.getOrDefault("style", "both");
|
||||
|
||||
if (productName == null || productName.trim().isEmpty()) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "商品名称不能为空");
|
||||
return response;
|
||||
}
|
||||
|
||||
Double originalPrice = parseDouble(originalPriceObj);
|
||||
Double finalPrice = parseDouble(finalPriceObj);
|
||||
|
||||
Map<String, Object> result = socialMediaService.generateContent(
|
||||
productName, originalPrice, finalPrice, keywords, style
|
||||
);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成文案失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "生成文案失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键生成完整内容(关键词 + 文案 + 图片)
|
||||
*
|
||||
* POST /jarvis/social-media/generate-complete
|
||||
*
|
||||
* {
|
||||
* "productImageUrl": "商品主图URL",
|
||||
* "productName": "商品名称",
|
||||
* "originalPrice": 499.0,
|
||||
* "finalPrice": 199.0,
|
||||
* "style": "xhs"
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/generate-complete")
|
||||
public JSONObject generateComplete(@RequestBody Map<String, Object> request) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
String productImageUrl = (String) request.get("productImageUrl");
|
||||
String productName = (String) request.get("productName");
|
||||
Object originalPriceObj = request.get("originalPrice");
|
||||
Object finalPriceObj = request.get("finalPrice");
|
||||
String style = (String) request.getOrDefault("style", "both");
|
||||
|
||||
if (productName == null || productName.trim().isEmpty()) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "商品名称不能为空");
|
||||
return response;
|
||||
}
|
||||
|
||||
Double originalPrice = parseDouble(originalPriceObj);
|
||||
Double finalPrice = parseDouble(finalPriceObj);
|
||||
|
||||
Map<String, Object> result = socialMediaService.generateCompleteContent(
|
||||
productImageUrl, productName, originalPrice, finalPrice, style
|
||||
);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成完整内容失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "生成完整内容失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Double值
|
||||
*/
|
||||
private Double parseDouble(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Double) {
|
||||
return (Double) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(value.toString());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package cn.van.business.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 小红书/抖音提示词模板配置Controller
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/jarvis/social-media/prompt")
|
||||
public class SocialMediaPromptController {
|
||||
|
||||
@Autowired(required = false)
|
||||
private StringRedisTemplate redisTemplate;
|
||||
|
||||
// Redis Key 前缀
|
||||
private static final String REDIS_KEY_PREFIX = "social_media:prompt:";
|
||||
|
||||
// 模板键名列表
|
||||
private static final String[] TEMPLATE_KEYS = {
|
||||
"keywords",
|
||||
"content:xhs",
|
||||
"content:douyin",
|
||||
"content:both"
|
||||
};
|
||||
|
||||
// 模板说明
|
||||
private static final Map<String, String> TEMPLATE_DESCRIPTIONS = new HashMap<String, String>() {{
|
||||
put("keywords", "关键词提取提示词模板\n占位符:%s - 商品名称");
|
||||
put("content:xhs", "小红书文案生成提示词模板\n占位符:%s - 商品名称,%s - 价格信息,%s - 关键词信息");
|
||||
put("content:douyin", "抖音文案生成提示词模板\n占位符:%s - 商品名称,%s - 价格信息,%s - 关键词信息");
|
||||
put("content:both", "通用文案生成提示词模板\n占位符:%s - 商品名称,%s - 价格信息,%s - 关键词信息");
|
||||
}};
|
||||
|
||||
/**
|
||||
* 获取所有提示词模板
|
||||
*
|
||||
* GET /jarvis/social-media/prompt/list
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public JSONObject listTemplates() {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
Map<String, Object> templates = new HashMap<>();
|
||||
|
||||
for (String key : TEMPLATE_KEYS) {
|
||||
Map<String, Object> templateInfo = new HashMap<>();
|
||||
templateInfo.put("key", key);
|
||||
templateInfo.put("description", TEMPLATE_DESCRIPTIONS.get(key));
|
||||
|
||||
String template = getTemplateFromRedis(key);
|
||||
templateInfo.put("template", template);
|
||||
templateInfo.put("isDefault", template == null);
|
||||
|
||||
templates.put(key, templateInfo);
|
||||
}
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", templates);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("获取提示词模板列表失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "获取失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个提示词模板
|
||||
*
|
||||
* GET /jarvis/social-media/prompt/{key}
|
||||
*/
|
||||
@GetMapping("/{key}")
|
||||
public JSONObject getTemplate(@PathVariable String key) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
if (!isValidKey(key)) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "无效的模板键名");
|
||||
return response;
|
||||
}
|
||||
|
||||
String template = getTemplateFromRedis(key);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("key", key);
|
||||
data.put("description", TEMPLATE_DESCRIPTIONS.get(key));
|
||||
data.put("template", template);
|
||||
data.put("isDefault", template == null);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "操作成功");
|
||||
response.put("data", data);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("获取提示词模板失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "获取失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存提示词模板
|
||||
*
|
||||
* POST /jarvis/social-media/prompt/save
|
||||
*
|
||||
* {
|
||||
* "key": "keywords",
|
||||
* "template": "提示词模板内容..."
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/save")
|
||||
public JSONObject saveTemplate(@RequestBody Map<String, Object> request) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
String key = (String) request.get("key");
|
||||
String template = (String) request.get("template");
|
||||
|
||||
if (!isValidKey(key)) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "无效的模板键名");
|
||||
return response;
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(template)) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "模板内容不能为空");
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redisTemplate == null) {
|
||||
response.put("code", 500);
|
||||
response.put("msg", "Redis未配置,无法保存模板");
|
||||
return response;
|
||||
}
|
||||
|
||||
String redisKey = REDIS_KEY_PREFIX + key;
|
||||
redisTemplate.opsForValue().set(redisKey, template);
|
||||
|
||||
log.info("保存提示词模板成功: {}", key);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "保存成功");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("保存提示词模板失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "保存失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除提示词模板(恢复默认)
|
||||
*
|
||||
* DELETE /jarvis/social-media/prompt/{key}
|
||||
*/
|
||||
@DeleteMapping("/{key}")
|
||||
public JSONObject deleteTemplate(@PathVariable String key) {
|
||||
JSONObject response = new JSONObject();
|
||||
try {
|
||||
if (!isValidKey(key)) {
|
||||
response.put("code", 400);
|
||||
response.put("msg", "无效的模板键名");
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redisTemplate == null) {
|
||||
response.put("code", 500);
|
||||
response.put("msg", "Redis未配置,无法删除模板");
|
||||
return response;
|
||||
}
|
||||
|
||||
String redisKey = REDIS_KEY_PREFIX + key;
|
||||
redisTemplate.delete(redisKey);
|
||||
|
||||
log.info("删除提示词模板成功: {}", key);
|
||||
|
||||
response.put("code", 200);
|
||||
response.put("msg", "删除成功,已恢复默认模板");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("删除提示词模板失败", e);
|
||||
response.put("code", 500);
|
||||
response.put("msg", "删除失败: " + e.getMessage());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 获取模板
|
||||
*/
|
||||
private String getTemplateFromRedis(String key) {
|
||||
if (redisTemplate == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = REDIS_KEY_PREFIX + key;
|
||||
return redisTemplate.opsForValue().get(redisKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("读取Redis模板失败: {}", key, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模板键名是否有效
|
||||
*/
|
||||
private boolean isValidKey(String key) {
|
||||
if (StrUtil.isBlank(key)) {
|
||||
return false;
|
||||
}
|
||||
for (String validKey : TEMPLATE_KEYS) {
|
||||
if (validKey.equals(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package cn.van.business.controller.jd;
|
||||
|
||||
import cn.van.business.model.pl.TaobaoComment;
|
||||
import cn.van.business.repository.TaobaoCommentRepository;
|
||||
import cn.van.business.service.ImageConvertService;
|
||||
import cn.van.business.util.JDProductService;
|
||||
import cn.van.business.util.JDScheduleJob;
|
||||
import cn.van.business.util.JDUtil;
|
||||
import cn.van.business.repository.CommentRepository;
|
||||
import cn.van.business.model.pl.Comment;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -10,6 +17,9 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/jd")
|
||||
@@ -24,10 +34,20 @@ public class JDInnerController {
|
||||
}
|
||||
|
||||
private final JDProductService jdProductService;
|
||||
private final JDUtil jdUtil;
|
||||
private final JDScheduleJob jdScheduleJob;
|
||||
private final CommentRepository commentRepository;
|
||||
private final TaobaoCommentRepository taobaoCommentRepository;
|
||||
private final ImageConvertService imageConvertService;
|
||||
|
||||
@Autowired
|
||||
public JDInnerController(JDProductService jdProductService) {
|
||||
public JDInnerController(JDProductService jdProductService, JDUtil jdUtil, JDScheduleJob jdScheduleJob, CommentRepository commentRepository, TaobaoCommentRepository taobaoCommentRepository, ImageConvertService imageConvertService) {
|
||||
this.jdProductService = jdProductService;
|
||||
this.jdUtil = jdUtil;
|
||||
this.jdScheduleJob = jdScheduleJob;
|
||||
this.commentRepository = commentRepository;
|
||||
this.taobaoCommentRepository = taobaoCommentRepository;
|
||||
this.imageConvertService = imageConvertService;
|
||||
}
|
||||
|
||||
@PostMapping("/generatePromotionContent")
|
||||
@@ -45,6 +65,357 @@ public class JDInnerController {
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评论可选类型(型号)
|
||||
* 返回:[{name, value}],按 name 排序
|
||||
*/
|
||||
@GetMapping("/comment/types")
|
||||
public Object commentTypes(@RequestParam(value = "skey", required = false) String skey) {
|
||||
if (checkSkey(skey)) {
|
||||
return error("invalid skey");
|
||||
}
|
||||
try {
|
||||
HashMap<String, String> map = jdUtil.getProductTypeMap();
|
||||
List<JSONObject> list = map.entrySet().stream()
|
||||
.map(e -> {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("name", e.getKey());
|
||||
o.put("value", e.getValue());
|
||||
return o;
|
||||
})
|
||||
.sorted(Comparator.comparing(o -> o.getString("name")))
|
||||
.collect(Collectors.toList());
|
||||
return list;
|
||||
} catch (Exception e) {
|
||||
logger.error("commentTypes error", e);
|
||||
return error("commentTypes failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成评论(严格按照JDUtil.generateComment的业务流程)
|
||||
* 优先使用京东评论,若无可用评论则尝试从淘宝获取
|
||||
* 入参:{ skey, productType }
|
||||
* 返回:{ productType, list: [ { commentText, images:[] } ] }
|
||||
*/
|
||||
@PostMapping("/comment/generate")
|
||||
public Object commentGenerate(@RequestBody Map<String, Object> body) {
|
||||
String skey = body.get("skey") != null ? String.valueOf(body.get("skey")) : null;
|
||||
if (checkSkey(skey)) {
|
||||
return error("invalid skey");
|
||||
}
|
||||
String productType = body.get("productType") != null ? String.valueOf(body.get("productType")) : null;
|
||||
if (productType == null || productType.trim().isEmpty()) {
|
||||
return error("productType is required");
|
||||
}
|
||||
try {
|
||||
// 评论统计变量初始化
|
||||
int allCommentCount = 0;
|
||||
int usedCommentCount = 0;
|
||||
int canUseCommentCount = 0;
|
||||
int addCommentCount = 0;
|
||||
int allTbCommentCount = 0;
|
||||
int usedTbCommentCount = 0;
|
||||
int canUseTbCommentCount = 0;
|
||||
boolean isTb = false;
|
||||
|
||||
HashMap<String, String> map = jdUtil.getProductTypeMap();
|
||||
String productId = map.get(productType);
|
||||
if (productId == null || productId.trim().isEmpty()) {
|
||||
return error("unknown productType");
|
||||
}
|
||||
|
||||
// 获取本地可用的京东评论并统计
|
||||
// 查询未使用的评论:isUse != 1(即 isUse = 0 或 isUse is null)
|
||||
List<Comment> availableComments = commentRepository.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(productId, 1);
|
||||
// 查询已使用的评论:isUse != 0(即 isUse = 1)
|
||||
List<Comment> usedComments = commentRepository.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(productId, 0);
|
||||
|
||||
canUseCommentCount = availableComments.size();
|
||||
usedCommentCount = usedComments.size();
|
||||
allCommentCount = canUseCommentCount + usedCommentCount;
|
||||
|
||||
// 获取淘宝评论统计信息
|
||||
HashMap<String, String> tbMap = jdUtil.getProductTypeMapForTB();
|
||||
String taobaoProductId = tbMap.getOrDefault(productId, productId);
|
||||
// 查询未使用的淘宝评论:isUse != 1
|
||||
List<TaobaoComment> availableTbComments = taobaoCommentRepository.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(taobaoProductId, 1);
|
||||
// 查询已使用的淘宝评论:isUse != 0
|
||||
List<TaobaoComment> usedTbComments = taobaoCommentRepository.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(taobaoProductId, 0);
|
||||
canUseTbCommentCount = availableTbComments.size();
|
||||
usedTbCommentCount = usedTbComments.size();
|
||||
allTbCommentCount = canUseTbCommentCount + usedTbCommentCount;
|
||||
|
||||
Comment commentToUse = null;
|
||||
|
||||
// 按优先级获取评论,确保有图片:
|
||||
// 1️⃣ 先尝试使用未使用过的京东评论
|
||||
if (!availableComments.isEmpty()) {
|
||||
Collections.shuffle(availableComments);
|
||||
for (Comment comment : availableComments) {
|
||||
List<String> imageUrls = parsePictureUrls(comment.getPictureUrls());
|
||||
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
|
||||
if (convertedImageUrls != null && !convertedImageUrls.isEmpty()) {
|
||||
commentToUse = comment;
|
||||
logger.info("使用未使用过的京东评论(有图片)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ 尝试使用未使用过的淘宝评论
|
||||
if (commentToUse == null) {
|
||||
String taobaoProductIdMap = tbMap.getOrDefault(productId, null);
|
||||
if (taobaoProductIdMap != null && !taobaoProductIdMap.isEmpty()) {
|
||||
logger.info("发现淘宝映射ID,尝试获取未使用过的淘宝评论(有图片)");
|
||||
Comment taobaoComment = generateTaobaoCommentWithImages(productType, false);
|
||||
if (taobaoComment != null) {
|
||||
commentToUse = taobaoComment;
|
||||
isTb = true;
|
||||
logger.info("使用未使用过的淘宝评论(有图片)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ 尝试使用已使用过的评论(随机从京东和淘宝中选择)
|
||||
if (commentToUse == null) {
|
||||
// 准备候选评论列表
|
||||
List<Comment> candidateComments = new ArrayList<>();
|
||||
List<String> candidateSources = new ArrayList<>(); // 记录来源,用于标识是京东还是淘宝
|
||||
|
||||
// 添加已使用过的京东评论(确保有图片)
|
||||
if (!usedComments.isEmpty()) {
|
||||
Collections.shuffle(usedComments);
|
||||
for (Comment comment : usedComments) {
|
||||
List<String> imageUrls = parsePictureUrls(comment.getPictureUrls());
|
||||
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
|
||||
if (convertedImageUrls != null && !convertedImageUrls.isEmpty()) {
|
||||
candidateComments.add(comment);
|
||||
candidateSources.add("JD");
|
||||
logger.info("已添加已使用过的京东评论到候选列表(有图片)");
|
||||
break; // 只添加第一个有图片的
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加已使用过的淘宝评论(确保有图片)
|
||||
String taobaoProductIdMap = tbMap.getOrDefault(productId, null);
|
||||
if (taobaoProductIdMap != null && !taobaoProductIdMap.isEmpty()) {
|
||||
Comment taobaoComment = generateTaobaoCommentWithImages(productType, true);
|
||||
if (taobaoComment != null) {
|
||||
candidateComments.add(taobaoComment);
|
||||
candidateSources.add("TB");
|
||||
logger.info("已添加已使用过的淘宝评论到候选列表(有图片)");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果候选列表不为空,随机选择
|
||||
if (!candidateComments.isEmpty()) {
|
||||
Random random = new Random();
|
||||
int selectedIndex = random.nextInt(candidateComments.size());
|
||||
commentToUse = candidateComments.get(selectedIndex);
|
||||
String selectedSource = candidateSources.get(selectedIndex);
|
||||
|
||||
if ("TB".equals(selectedSource)) {
|
||||
isTb = true;
|
||||
logger.info("随机选择:使用已使用过的淘宝评论(有图片)");
|
||||
} else {
|
||||
logger.info("随机选择:使用已使用过的京东评论(有图片)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (commentToUse == null) {
|
||||
return error("no comment with images available");
|
||||
}
|
||||
|
||||
// 解析图片URL并转换webp格式为jpg
|
||||
List<String> imageUrls = parsePictureUrls(commentToUse.getPictureUrls());
|
||||
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
|
||||
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("commentText", commentToUse.getCommentText());
|
||||
item.put("images", convertedImageUrls);
|
||||
|
||||
JSONArray arr = new JSONArray();
|
||||
arr.add(item);
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("productType", productType);
|
||||
resp.put("list", arr);
|
||||
|
||||
// 添加评论统计信息到响应中
|
||||
JSONObject stats = new JSONObject();
|
||||
if (!isTb) {
|
||||
// 查询最后一条京东评论的创建时间
|
||||
List<Comment> allComments = commentRepository.findByProductIdAndPictureUrlsIsNotNull(productId);
|
||||
java.time.LocalDateTime lastCommentUpdateTime = null;
|
||||
if (!allComments.isEmpty()) {
|
||||
lastCommentUpdateTime = allComments.stream()
|
||||
.map(Comment::getCreatedAt)
|
||||
.filter(createdAt -> createdAt != null)
|
||||
.max(java.time.LocalDateTime::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
stats.put("source", "京东评论");
|
||||
stats.put("productType", productType);
|
||||
stats.put("newAdded", addCommentCount);
|
||||
stats.put("used", usedCommentCount);
|
||||
stats.put("available", canUseCommentCount);
|
||||
stats.put("total", allCommentCount);
|
||||
if (lastCommentUpdateTime != null) {
|
||||
// 转换为Date格式(前端期望的格式)
|
||||
java.util.Date updateDate = java.sql.Timestamp.valueOf(lastCommentUpdateTime);
|
||||
stats.put("lastCommentUpdateTime", updateDate.getTime());
|
||||
}
|
||||
stats.put("statisticsText",
|
||||
"京东评论统计:\n" +
|
||||
"型号 " + productType + "\n" +
|
||||
"新增:" + addCommentCount + "\n" +
|
||||
"已使用:" + usedCommentCount + "\n" +
|
||||
"可用:" + canUseCommentCount + "\n" +
|
||||
"总数:" + allCommentCount);
|
||||
} else {
|
||||
// 查询最后一条淘宝评论的创建时间
|
||||
List<TaobaoComment> allTbComments = taobaoCommentRepository.findByProductIdAndPictureUrlsIsNotNull(taobaoProductId);
|
||||
java.time.LocalDateTime lastCommentUpdateTime = null;
|
||||
if (!allTbComments.isEmpty()) {
|
||||
lastCommentUpdateTime = allTbComments.stream()
|
||||
.map(TaobaoComment::getCreatedAt)
|
||||
.filter(createdAt -> createdAt != null)
|
||||
.max(java.time.LocalDateTime::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
stats.put("source", "淘宝评论");
|
||||
stats.put("productType", productType);
|
||||
stats.put("used", usedTbCommentCount);
|
||||
stats.put("available", canUseTbCommentCount);
|
||||
stats.put("total", allTbCommentCount);
|
||||
if (lastCommentUpdateTime != null) {
|
||||
// 转换为Date格式(前端期望的格式)
|
||||
java.util.Date updateDate = java.sql.Timestamp.valueOf(lastCommentUpdateTime);
|
||||
stats.put("lastCommentUpdateTime", updateDate.getTime());
|
||||
}
|
||||
stats.put("statisticsText",
|
||||
"淘宝评论统计:\n" +
|
||||
"型号 " + productType + "\n" +
|
||||
"已使用:" + usedTbCommentCount + "\n" +
|
||||
"可用:" + canUseTbCommentCount + "\n" +
|
||||
"总数:" + allTbCommentCount);
|
||||
}
|
||||
resp.put("statistics", stats);
|
||||
|
||||
// 标记为已使用(仅当原本未使用且不是淘宝评论时)
|
||||
try {
|
||||
if (!isTb && commentToUse.getId() != null && (commentToUse.getIsUse() == null || commentToUse.getIsUse() == 0)) {
|
||||
commentToUse.setIsUse(1);
|
||||
commentRepository.save(commentToUse);
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
logger.error("commentGenerate error", e);
|
||||
return error("commentGenerate failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从淘宝评论中生成Comment对象(参考JDUtil.generateTaobaoComment)
|
||||
* @param productType 商品类型
|
||||
* @param includeUsed 是否包含已使用的评论(true=获取已使用的,false=获取未使用的)
|
||||
*/
|
||||
private Comment generateTaobaoComment(String productType, boolean includeUsed) {
|
||||
return generateTaobaoCommentWithImages(productType, includeUsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从淘宝评论中生成Comment对象,确保有图片
|
||||
* @param productType 商品类型
|
||||
* @param includeUsed 是否包含已使用的评论(true=获取已使用的,false=获取未使用的)
|
||||
*/
|
||||
private Comment generateTaobaoCommentWithImages(String productType, boolean includeUsed) {
|
||||
HashMap<String, String> map = jdUtil.getProductTypeMap(); // 加载京东的 productTypeMap
|
||||
HashMap<String, String> tbMap = jdUtil.getProductTypeMapForTB(); // 加载淘宝的 productTypeMapTB
|
||||
|
||||
String product_id = map.get(productType); // 先查京东SKU
|
||||
|
||||
if (product_id == null) {
|
||||
logger.info("未找到对应的京东商品ID:{}", productType);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ✅ 在这里进行淘宝的 product_id 映射转换
|
||||
String taobaoProductId = tbMap.getOrDefault(product_id, product_id);
|
||||
|
||||
// 根据 includeUsed 参数查询不同的淘宝评论
|
||||
List<TaobaoComment> taobaoComments;
|
||||
if (includeUsed) {
|
||||
// 查询已使用的评论(isUse = 1)
|
||||
taobaoComments = taobaoCommentRepository.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(taobaoProductId, 0);
|
||||
} else {
|
||||
// 查询未使用的评论(isUse != 1,即0或null)
|
||||
taobaoComments = taobaoCommentRepository.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(taobaoProductId, 1);
|
||||
}
|
||||
|
||||
logger.info("taobaoComments.size() {} (includeUsed={})", taobaoComments.size(), includeUsed);
|
||||
|
||||
if (!taobaoComments.isEmpty()) {
|
||||
Collections.shuffle(taobaoComments);
|
||||
// 循环查找有图片的评论
|
||||
for (TaobaoComment selected : taobaoComments) {
|
||||
// 检查图片是否存在
|
||||
List<String> imageUrls = parsePictureUrls(selected.getPictureUrls());
|
||||
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
|
||||
if (convertedImageUrls != null && !convertedImageUrls.isEmpty()) {
|
||||
// 将淘宝评论转换为京东评论返回
|
||||
Comment comment = new Comment();
|
||||
comment.setCommentText(selected.getCommentText());
|
||||
String pictureUrls = selected.getPictureUrls();
|
||||
if (pictureUrls != null) {
|
||||
pictureUrls = pictureUrls.replace("//img.", "https://img.");
|
||||
}
|
||||
comment.setPictureUrls(pictureUrls);
|
||||
comment.setCommentId(selected.getCommentId());
|
||||
comment.setProductId(product_id);
|
||||
comment.setUserName(selected.getUserName());
|
||||
comment.setCreatedAt(selected.getCreatedAt());
|
||||
|
||||
// 只在获取未使用的评论时才标记为已使用
|
||||
if (!includeUsed) {
|
||||
selected.setIsUse(1);
|
||||
taobaoCommentRepository.save(selected);
|
||||
}
|
||||
|
||||
// 返回京东评论
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<String> parsePictureUrls(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) return Collections.emptyList();
|
||||
try {
|
||||
Object parsed = com.alibaba.fastjson2.JSON.parse(raw);
|
||||
if (parsed instanceof JSONArray) {
|
||||
JSONArray ja = (JSONArray) parsed;
|
||||
List<String> list = new ArrayList<>();
|
||||
for (int i = 0; i < ja.size(); i++) {
|
||||
Object v = ja.get(i);
|
||||
if (v != null) list.add(String.valueOf(v));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
// 非 JSON,按逗号或空白分隔
|
||||
return Arrays.stream(raw.split("[\n,\t ]+"))
|
||||
.filter(s -> s != null && !s.trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/createGiftCoupon")
|
||||
public Object createGiftCoupon(@RequestBody Map<String, Object> body) {
|
||||
String skey = body.get("skey") != null ? String.valueOf(body.get("skey")) : null;
|
||||
@@ -68,12 +439,21 @@ public class JDInnerController {
|
||||
|
||||
try {
|
||||
String giftKey = jdProductService.createGiftCoupon(idOrUrl, amount, quantity, owner, skuName);
|
||||
|
||||
// 如果giftKey为null,返回错误而不是成功响应
|
||||
if (giftKey == null || giftKey.trim().isEmpty()) {
|
||||
String errorDetail = String.format("礼金创建失败,giftCouponKey为null。参数: idOrUrl=%s, amount=%.2f, quantity=%d, owner=%s, skuName=%s。可能原因:商品不支持创建礼金、商品类型错误、京东API调用失败。请查看JD项目日志获取详细信息。",
|
||||
idOrUrl, amount, quantity, owner, skuName);
|
||||
logger.error("礼金创建失败 - giftKey为null, {}", errorDetail);
|
||||
return error(errorDetail);
|
||||
}
|
||||
|
||||
// 创建成功,保存到Redis
|
||||
jdProductService.saveGiftCouponToRedis(idOrUrl, giftKey, skuName, owner);
|
||||
logger.info("礼金创建成功 - giftKey={}, idOrUrl={}, owner={}, amount={}, quantity={}", giftKey, idOrUrl, owner, amount, quantity);
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("giftCouponKey", giftKey);
|
||||
// 可选:入库/缓存
|
||||
if (giftKey != null) {
|
||||
jdProductService.saveGiftCouponToRedis(idOrUrl, giftKey, skuName, owner);
|
||||
}
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
logger.error("createGiftCoupon error", e);
|
||||
@@ -103,6 +483,102 @@ public class JDInnerController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建礼金券并生成包含礼金的推广链接
|
||||
* 入参:{ skey, skuId/materialUrl, amount, quantity, batchSize, owner, skuName }
|
||||
* 返回:{ results: [ {index, success, giftCouponKey, shortURL, error} ], total, successCount, failCount }
|
||||
*/
|
||||
@PostMapping("/batchCreateGiftCoupons")
|
||||
public Object batchCreateGiftCoupons(@RequestBody Map<String, Object> body) {
|
||||
String skey = body.get("skey") != null ? String.valueOf(body.get("skey")) : null;
|
||||
if (checkSkey(skey)) {
|
||||
return error("invalid skey");
|
||||
}
|
||||
|
||||
String skuId = body.get("skuId") != null ? String.valueOf(body.get("skuId")) : null;
|
||||
String materialUrl = body.get("materialUrl") != null ? String.valueOf(body.get("materialUrl")) : null;
|
||||
String owner = body.get("owner") != null ? String.valueOf(body.get("owner")) : "g";
|
||||
String skuName = body.get("skuName") != null ? String.valueOf(body.get("skuName")) : "";
|
||||
double amount = parseDouble(body.get("amount"), 1.8);
|
||||
int quantity = parseInt(body.get("quantity"), 1);
|
||||
int batchSize = parseInt(body.get("batchSize"), 15);
|
||||
|
||||
String idOrUrl = skuId != null && !skuId.trim().isEmpty() ? skuId : materialUrl;
|
||||
if (idOrUrl == null || idOrUrl.trim().isEmpty()) {
|
||||
return error("skuId or materialUrl is required");
|
||||
}
|
||||
if (amount <= 0 || quantity <= 0) {
|
||||
return error("amount and quantity must be positive");
|
||||
}
|
||||
if (batchSize <= 0 || batchSize > 100) {
|
||||
return error("batchSize must be between 1 and 100");
|
||||
}
|
||||
|
||||
logger.info("批量创建礼金券请求 - idOrUrl={}, amount={}, quantity={}, batchSize={}, owner={}, skuName={}",
|
||||
idOrUrl, amount, quantity, batchSize, owner, skuName);
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> results = jdProductService.batchCreateGiftCouponsWithLinks(
|
||||
idOrUrl, amount, quantity, batchSize, owner, skuName);
|
||||
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
for (Map<String, Object> result : results) {
|
||||
if (Boolean.TRUE.equals(result.get("success"))) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("results", results);
|
||||
resp.put("total", batchSize);
|
||||
resp.put("successCount", successCount);
|
||||
resp.put("failCount", failCount);
|
||||
|
||||
logger.info("批量创建礼金券完成 - 总数={}, 成功={}, 失败={}", batchSize, successCount, failCount);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
logger.error("batchCreateGiftCoupons error", e);
|
||||
return error("batchCreateGiftCoupons failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动清理Redis中超过93天的旧数据
|
||||
* 请求参数:{ skey }
|
||||
* 返回:{ message, success }
|
||||
* 注意:请将skey放在请求Body中(JSON格式),不是Query参数
|
||||
*/
|
||||
@PostMapping("/cleanRedisData")
|
||||
public Object cleanRedisData(@RequestBody(required = false) Map<String, Object> body) {
|
||||
// 兼容处理:如果body为空,返回友好提示
|
||||
if (body == null || body.isEmpty()) {
|
||||
JSONObject tips = new JSONObject();
|
||||
tips.put("error", "请求Body不能为空");
|
||||
tips.put("tip", "请在Postman的Body标签中选择raw/JSON格式,并输入: {\"skey\": \"your_skey_here\"}");
|
||||
return tips;
|
||||
}
|
||||
|
||||
String skey = body.get("skey") != null ? String.valueOf(body.get("skey")) : null;
|
||||
if (checkSkey(skey)) {
|
||||
return error("invalid skey");
|
||||
}
|
||||
try {
|
||||
logger.info("手动触发Redis清理任务");
|
||||
jdScheduleJob.manualCleanOldRedisData();
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.put("success", true);
|
||||
resp.put("message", "Redis清理任务已执行完成,详情请查看日志");
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
logger.error("cleanRedisData error", e);
|
||||
return error("cleanRedisData failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static JSONObject error(String msg) {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("error", msg);
|
||||
|
||||
@@ -15,6 +15,7 @@ public class JDOrder {
|
||||
|
||||
private String remark; // 单据备注(如日期编号)
|
||||
private String distributionMark; // 分销标记
|
||||
private String thirdPartyOrderNo; // 第三方单号
|
||||
private String modelNumber; // 型号
|
||||
private String link; // 链接
|
||||
private Double paymentAmount; // 下单付款金额
|
||||
|
||||
67
src/main/java/cn/van/business/model/pl/ImageConversion.java
Normal file
67
src/main/java/cn/van/business/model/pl/ImageConversion.java
Normal file
@@ -0,0 +1,67 @@
|
||||
package cn.van.business.model.pl;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 图片转换记录实体类
|
||||
* 用于记录webp格式图片转换为jpg格式的映射关系
|
||||
*
|
||||
* @author System
|
||||
* @version 1.0
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "image_conversions", indexes = {
|
||||
@Index(name = "idx_original_url", columnList = "original_url"),
|
||||
@Index(name = "idx_converted_url", columnList = "converted_url")
|
||||
})
|
||||
@Data
|
||||
public class ImageConversion {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 原始webp图片URL
|
||||
*/
|
||||
@Column(name = "original_url", nullable = false, length = 2048, unique = true)
|
||||
private String originalUrl;
|
||||
|
||||
/**
|
||||
* 转换后的jpg图片URL或本地路径
|
||||
*/
|
||||
@Column(name = "converted_url", nullable = false, length = 2048)
|
||||
private String convertedUrl;
|
||||
|
||||
/**
|
||||
* 转换时间
|
||||
*/
|
||||
@Column(name = "converted_at", nullable = false)
|
||||
private LocalDateTime convertedAt;
|
||||
|
||||
/**
|
||||
* 文件大小(字节)
|
||||
*/
|
||||
@Column(name = "file_size")
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
if (convertedAt == null) {
|
||||
convertedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,12 @@ public class SuperAdmin {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Integer isActive = 1;
|
||||
|
||||
/**
|
||||
* 接收人(企业微信用户ID,多个用逗号分隔)
|
||||
*/
|
||||
@Column(name = "touser", length = 500)
|
||||
private String touser;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package cn.van.business.mq;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.van.business.util.WxtsUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.google.common.util.concurrent.RateLimiter;
|
||||
import org.apache.rocketmq.spring.annotation.ConsumeMode;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
@@ -13,11 +10,6 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static cn.van.business.util.WXUtil.WX_BASE_URL;
|
||||
|
||||
/**
|
||||
* @author Leo
|
||||
* @version 1.0
|
||||
@@ -46,35 +38,35 @@ public class MessageConsumerService implements RocketMQListener<JSONObject> {
|
||||
public void onMessage(JSONObject message) {
|
||||
try {
|
||||
logger.info("消费消息:{}", message);
|
||||
//logger.info("[RateLimiter] 开始处理消息,当前时间:{}", System.currentTimeMillis());
|
||||
//rateLimiter.acquire();
|
||||
//logger.info("[RateLimiter] 获得令牌,当前时间:{}", System.currentTimeMillis());
|
||||
|
||||
// 解析消息类型和数据
|
||||
//String type = message.getString("type");
|
||||
JSONObject data = message.getJSONObject("data");
|
||||
//
|
||||
//if (data == null) {
|
||||
// logger.error("消息数据为空:{}", message);
|
||||
// return;
|
||||
//}
|
||||
//
|
||||
String wxid = data.getString("wxid");
|
||||
//if (wxid == null || wxid.isEmpty()) {
|
||||
// logger.error("消息缺少wxid字段:{}", message);
|
||||
// return;
|
||||
//}
|
||||
|
||||
// 根据消息类型调用不同的wxts接口
|
||||
|
||||
// 发送文本消息
|
||||
String content = data.getString("msg");
|
||||
Integer msgType = data.getInteger("msgType");
|
||||
String fromWxid = data.getString("fromWxid");
|
||||
Boolean hiddenTime = data.getBoolean("hiddenTime");
|
||||
String touser = data.getString("touser"); // 获取接收人参数
|
||||
|
||||
wxtsUtil.sendWxTextMessage(wxid, content, msgType, fromWxid, hiddenTime, touser);
|
||||
|
||||
|
||||
//logger.debug("构造完成的消息结构:{}", requestBody.toJSONString());
|
||||
|
||||
// 4. 发送请求(保持原有)
|
||||
String responseStr;
|
||||
|
||||
// 修改onMessage方法中的HTTP请求部分
|
||||
responseStr = HttpRequest.post(WX_BASE_URL).header("Content-Type", "application/json; charset=UTF-8") // 明确指定编码
|
||||
.body(message.toJSONString().getBytes(StandardCharsets.UTF_8)) // 显式转为UTF-8字节
|
||||
.execute().charset("UTF-8") // 强制响应体使用UTF-8解码
|
||||
.body();
|
||||
// ... [保持原有响应处理逻辑]
|
||||
if (ObjectUtil.isNotEmpty(responseStr)) {
|
||||
JSONObject response = JSONObject.parseObject(responseStr);
|
||||
logger.info("消息成功发送并得到响应:{}", response);
|
||||
if (response.getInteger("code") != 200) {
|
||||
// TODO: 如果需要处理错误,您可以在这里添加逻辑
|
||||
wxtsUtil.sendNotify("消息发送失败: " + responseStr);
|
||||
throw new RuntimeException("消息发送失败: " + responseStr);
|
||||
}
|
||||
//logger.info("消息成功发送并得到响应:{}", response);
|
||||
} else {
|
||||
wxtsUtil.sendNotify("消息发送失败,没有收到响应");
|
||||
throw new RuntimeException("消息发送失败,没有收到响应");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("消息处理失败,原始消息:{}", message, e);
|
||||
wxtsUtil.sendNotify("系统异常:" + e.getMessage());
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.van.business.repository;
|
||||
|
||||
import cn.van.business.model.pl.ImageConversion;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 图片转换记录Repository
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Repository
|
||||
public interface ImageConversionRepository extends JpaRepository<ImageConversion, Long> {
|
||||
|
||||
/**
|
||||
* 根据原始URL查找转换记录
|
||||
*
|
||||
* @param originalUrl 原始webp图片URL
|
||||
* @return 转换记录
|
||||
*/
|
||||
Optional<ImageConversion> findByOriginalUrl(String originalUrl);
|
||||
|
||||
/**
|
||||
* 检查是否已存在转换记录
|
||||
*
|
||||
* @param originalUrl 原始webp图片URL
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByOriginalUrl(String originalUrl);
|
||||
}
|
||||
|
||||
401
src/main/java/cn/van/business/service/ImageConvertService.java
Normal file
401
src/main/java/cn/van/business/service/ImageConvertService.java
Normal file
@@ -0,0 +1,401 @@
|
||||
package cn.van.business.service;
|
||||
|
||||
import cn.van.business.model.pl.ImageConversion;
|
||||
import cn.van.business.repository.ImageConversionRepository;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import net.coobird.thumbnailator.Thumbnails;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* WebP图片IO支持工具类
|
||||
* 尝试使用可用的方式读取webp图片
|
||||
*/
|
||||
class WebPImageIO {
|
||||
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(WebPImageIO.class);
|
||||
private static volatile boolean webpSupported = false;
|
||||
private static volatile boolean checked = false;
|
||||
|
||||
/**
|
||||
* 检查是否支持webp格式
|
||||
*/
|
||||
static synchronized boolean isWebPSupported() {
|
||||
if (checked) {
|
||||
return webpSupported;
|
||||
}
|
||||
|
||||
try {
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("webp");
|
||||
webpSupported = readers.hasNext();
|
||||
if (webpSupported) {
|
||||
log.info("WebP图片格式支持已启用");
|
||||
} else {
|
||||
log.warn("未检测到WebP图片格式支持,webp图片转换将被跳过");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("检查WebP支持时出错: {}", e.getMessage());
|
||||
webpSupported = false;
|
||||
}
|
||||
|
||||
checked = true;
|
||||
return webpSupported;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片转换服务类
|
||||
* 负责将webp格式的图片转换为jpg格式,并缓存转换结果
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImageConvertService {
|
||||
|
||||
@Autowired
|
||||
private ImageConversionRepository imageConversionRepository;
|
||||
|
||||
/**
|
||||
* 图片存储根目录,默认使用系统临时目录
|
||||
*/
|
||||
@Value("${image.convert.storage-path:${java.io.tmpdir}/comment-images}")
|
||||
private String storagePath;
|
||||
|
||||
/**
|
||||
* 图片访问基础URL,用于生成转换后图片的访问地址
|
||||
* 如果为空,则返回本地文件路径
|
||||
*/
|
||||
@Value("${image.convert.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 转换图片URL列表,将webp格式转换为jpg
|
||||
*
|
||||
* @param imageUrls 原始图片URL列表
|
||||
* @return 转换后的图片URL列表(webp已转换为jpg,其他格式保持不变)
|
||||
*/
|
||||
public List<String> convertImageUrls(List<String> imageUrls) {
|
||||
if (imageUrls == null || imageUrls.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
log.info("开始转换图片URL列表,共{}张图片", imageUrls.size());
|
||||
List<String> convertedUrls = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
|
||||
for (String imageUrl : imageUrls) {
|
||||
if (StrUtil.isBlank(imageUrl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.debug("处理图片URL: {}", imageUrl);
|
||||
try {
|
||||
String convertedUrl = convertImageUrl(imageUrl);
|
||||
if (!convertedUrl.equals(imageUrl)) {
|
||||
successCount++;
|
||||
log.debug("图片转换成功: {} -> {}", imageUrl, convertedUrl);
|
||||
} else {
|
||||
skipCount++;
|
||||
log.debug("图片无需转换(非webp格式): {}", imageUrl);
|
||||
}
|
||||
convertedUrls.add(convertedUrl);
|
||||
} catch (Exception e) {
|
||||
// 转换失败时使用原URL,不中断流程
|
||||
skipCount++;
|
||||
log.warn("图片转换失败,使用原URL: {}. 错误: {}", imageUrl, e.getMessage());
|
||||
convertedUrls.add(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("图片URL转换完成,共{}张,成功转换{}张,跳过/失败{}张",
|
||||
imageUrls.size(), successCount, skipCount);
|
||||
return convertedUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换单个图片URL
|
||||
*
|
||||
* @param originalUrl 原始图片URL
|
||||
* @return 转换后的图片URL(如果不需要转换则返回原URL)
|
||||
* @throws Exception 转换过程中的异常(调用方会捕获并返回原URL)
|
||||
*/
|
||||
private String convertImageUrl(String originalUrl) throws Exception {
|
||||
if (StrUtil.isBlank(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// 规范化URL:处理协议相对URL(//开头)
|
||||
String normalizedUrl = normalizeUrl(originalUrl);
|
||||
|
||||
// 检查是否为webp格式
|
||||
if (!isWebpFormat(normalizedUrl)) {
|
||||
return originalUrl; // 返回原URL,保持一致性
|
||||
}
|
||||
|
||||
// 检查系统是否支持webp转换
|
||||
if (!WebPImageIO.isWebPSupported()) {
|
||||
log.warn("系统不支持webp格式,跳过转换: {}", normalizedUrl);
|
||||
throw new IOException("系统不支持webp格式转换");
|
||||
}
|
||||
|
||||
// 使用规范化后的URL进行缓存查询和转换
|
||||
// 检查是否已转换(使用规范化URL作为key)
|
||||
Optional<ImageConversion> existing = imageConversionRepository.findByOriginalUrl(normalizedUrl);
|
||||
if (existing.isPresent()) {
|
||||
ImageConversion conversion = existing.get();
|
||||
log.debug("使用缓存的转换结果: {} -> {}", normalizedUrl, conversion.getConvertedUrl());
|
||||
return conversion.getConvertedUrl();
|
||||
}
|
||||
|
||||
// 执行转换(使用规范化URL)
|
||||
log.info("开始转换webp图片: {}", normalizedUrl);
|
||||
String convertedUrl = performConversion(normalizedUrl);
|
||||
log.info("图片转换成功: {} -> {}", normalizedUrl, convertedUrl);
|
||||
return convertedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化URL:处理协议相对URL等特殊情况
|
||||
*
|
||||
* @param url 原始URL
|
||||
* @return 规范化后的URL
|
||||
*/
|
||||
private String normalizeUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 清理特殊字符(如零宽字符)
|
||||
String cleanUrl = url.trim().replaceAll("[\\u200B-\\u200D\\uFEFF]", "");
|
||||
|
||||
// 处理协议相对URL(//开头)
|
||||
if (cleanUrl.startsWith("//")) {
|
||||
cleanUrl = "https:" + cleanUrl;
|
||||
log.debug("转换协议相对URL: {} -> {}", url, cleanUrl);
|
||||
}
|
||||
|
||||
return cleanUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查URL是否为webp格式
|
||||
*
|
||||
* @param url 图片URL
|
||||
* @return 是否为webp格式
|
||||
*/
|
||||
private boolean isWebpFormat(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 清理URL中的特殊字符(如零宽字符)
|
||||
String cleanUrl = url.trim().replaceAll("[\\u200B-\\u200D\\uFEFF]", "");
|
||||
|
||||
// 检查URL中是否包含.webp扩展名(不区分大小写)
|
||||
String lowerUrl = cleanUrl.toLowerCase();
|
||||
// 检查URL参数或路径中是否包含webp
|
||||
boolean isWebp = lowerUrl.contains(".webp") ||
|
||||
lowerUrl.contains("format=webp") ||
|
||||
lowerUrl.contains("?webp") ||
|
||||
lowerUrl.contains("&webp");
|
||||
|
||||
if (isWebp) {
|
||||
log.debug("检测到webp格式图片: {}", cleanUrl);
|
||||
}
|
||||
|
||||
return isWebp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行图片转换
|
||||
*
|
||||
* @param originalUrl 原始webp图片URL
|
||||
* @return 转换后的jpg图片URL或路径
|
||||
*/
|
||||
private String performConversion(String originalUrl) throws IOException {
|
||||
// 确保存储目录存在
|
||||
Path storageDir = Paths.get(storagePath);
|
||||
if (!Files.exists(storageDir)) {
|
||||
Files.createDirectories(storageDir);
|
||||
}
|
||||
|
||||
// 生成转换后的文件名(基于原URL的MD5)
|
||||
String fileName = generateFileName(originalUrl);
|
||||
Path outputPath = storageDir.resolve(fileName);
|
||||
|
||||
// 如果转换后的文件已存在,直接返回
|
||||
if (Files.exists(outputPath)) {
|
||||
String convertedUrl = generateConvertedUrl(fileName);
|
||||
// 如果数据库中没有记录,保存记录
|
||||
if (!imageConversionRepository.existsByOriginalUrl(originalUrl)) {
|
||||
saveConversionRecord(originalUrl, convertedUrl, Files.size(outputPath));
|
||||
}
|
||||
return convertedUrl;
|
||||
}
|
||||
|
||||
// 下载原始图片
|
||||
byte[] imageData = downloadImage(originalUrl);
|
||||
if (imageData == null || imageData.length == 0) {
|
||||
throw new IOException("下载图片失败或图片数据为空: " + originalUrl);
|
||||
}
|
||||
|
||||
// 转换图片格式
|
||||
BufferedImage bufferedImage = null;
|
||||
try {
|
||||
// 检查是否支持webp格式
|
||||
boolean webpSupported = WebPImageIO.isWebPSupported();
|
||||
|
||||
if (webpSupported) {
|
||||
// 如果支持webp,尝试使用ImageIO读取
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) {
|
||||
bufferedImage = ImageIO.read(bais);
|
||||
}
|
||||
|
||||
// 如果ImageIO无法读取,尝试使用WebP特定的读取器
|
||||
if (bufferedImage == null) {
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("webp");
|
||||
if (readers.hasNext()) {
|
||||
ImageReader reader = readers.next();
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData);
|
||||
ImageInputStream iis = ImageIO.createImageInputStream(bais)) {
|
||||
reader.setInput(iis);
|
||||
bufferedImage = reader.read(0);
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果不支持webp,尝试使用Thumbnailator转换
|
||||
// 注意:Thumbnailator内部也使用ImageIO,所以通常也无法处理webp
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) {
|
||||
bufferedImage = Thumbnails.of(bais)
|
||||
.scale(1.0)
|
||||
.asBufferedImage();
|
||||
} catch (Exception e) {
|
||||
log.debug("Thumbnailator无法读取webp图片: {}", e.getMessage());
|
||||
// 如果无法转换,抛出异常,后续会返回原URL
|
||||
throw new IOException("当前系统不支持webp格式转换。图片将保持原格式返回。", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (bufferedImage == null) {
|
||||
throw new IOException("无法读取webp图片格式。当前系统不支持webp格式转换。");
|
||||
}
|
||||
|
||||
// 如果是RGBA格式,转换为RGB
|
||||
if (bufferedImage.getType() == BufferedImage.TYPE_4BYTE_ABGR ||
|
||||
bufferedImage.getType() == BufferedImage.TYPE_INT_ARGB) {
|
||||
BufferedImage rgbImage = new BufferedImage(
|
||||
bufferedImage.getWidth(),
|
||||
bufferedImage.getHeight(),
|
||||
BufferedImage.TYPE_INT_RGB
|
||||
);
|
||||
rgbImage.createGraphics().drawImage(bufferedImage, 0, 0, null);
|
||||
bufferedImage = rgbImage;
|
||||
}
|
||||
|
||||
// 保存为jpg格式
|
||||
boolean success = ImageIO.write(bufferedImage, "jpg", outputPath.toFile());
|
||||
if (!success) {
|
||||
throw new IOException("无法写入jpg格式");
|
||||
}
|
||||
|
||||
String convertedUrl = generateConvertedUrl(fileName);
|
||||
long fileSize = Files.size(outputPath);
|
||||
|
||||
// 保存转换记录
|
||||
saveConversionRecord(originalUrl, convertedUrl, fileSize);
|
||||
|
||||
log.info("图片转换完成: {} -> {} (大小: {} bytes)", originalUrl, convertedUrl, fileSize);
|
||||
return convertedUrl;
|
||||
|
||||
} finally {
|
||||
if (bufferedImage != null) {
|
||||
bufferedImage.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片
|
||||
*
|
||||
* @param url 图片URL
|
||||
* @return 图片字节数组
|
||||
*/
|
||||
private byte[] downloadImage(String url) {
|
||||
try {
|
||||
return HttpUtil.downloadBytes(url);
|
||||
} catch (Exception e) {
|
||||
log.error("下载图片失败: {}, 错误: {}", url, e.getMessage());
|
||||
throw new RuntimeException("下载图片失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名(基于原URL的MD5)
|
||||
*
|
||||
* @param originalUrl 原始URL
|
||||
* @return 文件名(不含扩展名)
|
||||
*/
|
||||
private String generateFileName(String originalUrl) {
|
||||
// 使用URL的MD5作为文件名,避免重复和特殊字符问题
|
||||
String md5 = cn.hutool.crypto.digest.DigestUtil.md5Hex(originalUrl);
|
||||
return md5 + ".jpg";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成转换后的URL
|
||||
*
|
||||
* @param fileName 文件名
|
||||
* @return 转换后的URL或本地路径
|
||||
*/
|
||||
private String generateConvertedUrl(String fileName) {
|
||||
if (StrUtil.isNotBlank(baseUrl)) {
|
||||
// 如果配置了baseUrl,返回HTTP访问地址
|
||||
return baseUrl.endsWith("/") ? baseUrl + fileName : baseUrl + "/" + fileName;
|
||||
} else {
|
||||
// 否则返回本地文件路径
|
||||
return Paths.get(storagePath, fileName).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存转换记录
|
||||
*
|
||||
* @param originalUrl 原始URL
|
||||
* @param convertedUrl 转换后的URL
|
||||
* @param fileSize 文件大小
|
||||
*/
|
||||
private void saveConversionRecord(String originalUrl, String convertedUrl, long fileSize) {
|
||||
ImageConversion conversion = new ImageConversion();
|
||||
conversion.setOriginalUrl(originalUrl);
|
||||
conversion.setConvertedUrl(convertedUrl);
|
||||
conversion.setFileSize(fileSize);
|
||||
conversion.setConvertedAt(LocalDateTime.now());
|
||||
|
||||
try {
|
||||
imageConversionRepository.save(conversion);
|
||||
} catch (Exception e) {
|
||||
log.warn("保存转换记录失败: {}, 错误: {}", originalUrl, e.getMessage());
|
||||
// 不抛出异常,因为转换本身已成功
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
433
src/main/java/cn/van/business/service/MarketingImageService.java
Normal file
433
src/main/java/cn/van/business/service/MarketingImageService.java
Normal file
@@ -0,0 +1,433 @@
|
||||
package cn.van.business.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.van.business.util.ds.DeepSeekClientUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.coobird.thumbnailator.Thumbnails;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 营销图片合成服务
|
||||
* 用于生成小红书等平台的营销对比图
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MarketingImageService {
|
||||
|
||||
@Autowired
|
||||
private DeepSeekClientUtil deepSeekClientUtil;
|
||||
|
||||
// 输出图片尺寸
|
||||
private static final int OUTPUT_WIDTH = 1080;
|
||||
private static final int OUTPUT_HEIGHT = 1080;
|
||||
|
||||
// 字体配置(支持回退)
|
||||
private static final String[] FONT_NAMES = {"Microsoft YaHei", "SimHei", "Arial", Font.SANS_SERIF}; // 字体优先级
|
||||
private static final int ORIGINAL_PRICE_FONT_SIZE = 36; // 官网价字体大小
|
||||
private static final int FINAL_PRICE_FONT_SIZE = 72; // 到手价字体大小
|
||||
private static final int PRODUCT_NAME_FONT_SIZE = 32; // 商品名称字体大小
|
||||
|
||||
/**
|
||||
* 获取可用字体
|
||||
*/
|
||||
private Font getAvailableFont(int style, int size) {
|
||||
GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
|
||||
String[] availableFonts = ge.getAvailableFontFamilyNames();
|
||||
|
||||
for (String fontName : FONT_NAMES) {
|
||||
for (String available : availableFonts) {
|
||||
if (available.equals(fontName)) {
|
||||
return new Font(fontName, style, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果都不可用,使用默认字体
|
||||
return new Font(Font.SANS_SERIF, style, size);
|
||||
}
|
||||
|
||||
// 颜色配置
|
||||
private static final Color ORIGINAL_PRICE_COLOR = new Color(153, 153, 153); // 灰色 #999999
|
||||
private static final Color FINAL_PRICE_COLOR = new Color(255, 0, 0); // 红色 #FF0000
|
||||
private static final Color PRODUCT_NAME_COLOR = new Color(51, 51, 51); // 深灰色 #333333
|
||||
private static final Color BACKGROUND_COLOR = Color.WHITE; // 背景色
|
||||
|
||||
/**
|
||||
* 生成营销图片
|
||||
*
|
||||
* @param productImageUrl 商品主图URL
|
||||
* @param originalPrice 官网价
|
||||
* @param finalPrice 到手价
|
||||
* @param productName 商品名称(可选,如果为空则使用AI提取)
|
||||
* @return Base64编码的图片
|
||||
*/
|
||||
public String generateMarketingImage(String productImageUrl, Double originalPrice, Double finalPrice, String productName) {
|
||||
try {
|
||||
log.info("开始生成营销图片: productImageUrl={}, originalPrice={}, finalPrice={}, productName={}",
|
||||
productImageUrl, originalPrice, finalPrice, productName);
|
||||
|
||||
// 1. 加载商品主图
|
||||
BufferedImage productImage = loadProductImage(productImageUrl);
|
||||
if (productImage == null) {
|
||||
throw new IOException("无法加载商品主图: " + productImageUrl);
|
||||
}
|
||||
|
||||
// 2. 提取商品标题关键部分(如果未提供)
|
||||
String keyProductName = productName;
|
||||
if (StrUtil.isBlank(keyProductName)) {
|
||||
// 如果未提供商品名称,则无法提取,留空
|
||||
keyProductName = "";
|
||||
} else {
|
||||
// 如果提供了完整商品名称,提取关键部分
|
||||
keyProductName = extractKeyProductName(keyProductName);
|
||||
}
|
||||
|
||||
// 3. 创建画布
|
||||
BufferedImage canvas = new BufferedImage(OUTPUT_WIDTH, OUTPUT_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = canvas.createGraphics();
|
||||
|
||||
// 设置抗锯齿
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
|
||||
// 4. 绘制背景
|
||||
g2d.setColor(BACKGROUND_COLOR);
|
||||
g2d.fillRect(0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT);
|
||||
|
||||
// 5. 缩放并绘制商品主图(居中,保持比例)
|
||||
int productImageSize = 800; // 商品图尺寸
|
||||
int productImageX = (OUTPUT_WIDTH - productImageSize) / 2;
|
||||
int productImageY = 80; // 顶部留白
|
||||
|
||||
BufferedImage scaledProductImage = Thumbnails.of(productImage)
|
||||
.size(productImageSize, productImageSize)
|
||||
.asBufferedImage();
|
||||
|
||||
g2d.drawImage(scaledProductImage, productImageX, productImageY, null);
|
||||
|
||||
// 6. 绘制商品名称(如果有)
|
||||
int textStartY = productImageY + productImageSize + 40;
|
||||
if (StrUtil.isNotBlank(keyProductName)) {
|
||||
drawProductName(g2d, keyProductName, textStartY);
|
||||
textStartY += 60; // 增加间距
|
||||
}
|
||||
|
||||
// 7. 绘制官网价(带删除线,在上方)
|
||||
int originalPriceY = textStartY + 80;
|
||||
drawOriginalPrice(g2d, originalPrice, originalPriceY);
|
||||
|
||||
// 8. 绘制向下箭头
|
||||
int arrowY = originalPriceY + 60;
|
||||
drawDownArrow(g2d, arrowY);
|
||||
|
||||
// 9. 绘制到手价(大红色,在下方)
|
||||
int finalPriceY = arrowY + 80;
|
||||
drawFinalPrice(g2d, finalPrice, finalPriceY);
|
||||
|
||||
// 10. 绘制爆炸贴图装饰(右下角)
|
||||
drawExplosionDecoration(g2d);
|
||||
|
||||
g2d.dispose();
|
||||
|
||||
// 11. 转换为Base64
|
||||
String base64Image = imageToBase64(canvas, "jpg");
|
||||
log.info("营销图片生成成功");
|
||||
return base64Image;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成营销图片失败", e);
|
||||
throw new RuntimeException("生成营销图片失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载商品主图
|
||||
*/
|
||||
private BufferedImage loadProductImage(String imageUrl) throws IOException {
|
||||
try {
|
||||
byte[] imageData = HttpUtil.downloadBytes(imageUrl);
|
||||
if (imageData == null || imageData.length == 0) {
|
||||
throw new IOException("下载图片失败或图片数据为空");
|
||||
}
|
||||
|
||||
try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) {
|
||||
return ImageIO.read(bais);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("加载商品主图失败: {}", imageUrl, e);
|
||||
throw new IOException("加载商品主图失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取商品标题关键部分(使用AI)
|
||||
*
|
||||
* @param fullProductName 完整商品名称
|
||||
* @return 提取的关键部分
|
||||
*/
|
||||
public String extractKeyProductName(String fullProductName) {
|
||||
if (StrUtil.isBlank(fullProductName)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用DeepSeek提取商品标题关键部分
|
||||
String prompt = String.format(
|
||||
"请从以下商品标题中提取最关键的3-8个字作为核心卖点,只返回提取的关键词,不要其他内容:\n%s",
|
||||
fullProductName
|
||||
);
|
||||
|
||||
String extracted = deepSeekClientUtil.getDeepSeekResponse(prompt);
|
||||
if (StrUtil.isNotBlank(extracted)) {
|
||||
// 清理可能的换行和多余空格
|
||||
extracted = extracted.trim().replaceAll("\\s+", "");
|
||||
// 限制长度
|
||||
if (extracted.length() > 12) {
|
||||
extracted = extracted.substring(0, 12);
|
||||
}
|
||||
log.info("提取商品标题关键部分成功: {} -> {}", fullProductName, extracted);
|
||||
return extracted;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("使用AI提取商品标题关键部分失败,使用简单截取: {}", fullProductName, e);
|
||||
}
|
||||
|
||||
// 降级方案:简单截取前部分
|
||||
return simpleExtractKeyName(fullProductName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单提取商品名称关键部分(降级方案)
|
||||
*/
|
||||
private String simpleExtractKeyName(String fullName) {
|
||||
if (StrUtil.isBlank(fullName)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 移除常见的规格信息(如XL、175/96A等)
|
||||
String cleaned = fullName
|
||||
.replaceAll("\\s*XL|L|M|S|XXL\\s*", "")
|
||||
.replaceAll("\\s*\\d+/\\d+[A-Z]?\\s*", "")
|
||||
.replaceAll("\\s*【.*?】\\s*", "")
|
||||
.replaceAll("\\s*\\(.*?\\)\\s*", "");
|
||||
|
||||
// 提取前10-15个字符
|
||||
if (cleaned.length() > 15) {
|
||||
cleaned = cleaned.substring(0, 15);
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制商品名称
|
||||
*/
|
||||
private void drawProductName(Graphics2D g2d, String productName, int y) {
|
||||
Font font = getAvailableFont(Font.BOLD, PRODUCT_NAME_FONT_SIZE);
|
||||
g2d.setFont(font);
|
||||
g2d.setColor(PRODUCT_NAME_COLOR);
|
||||
|
||||
// 计算文字宽度,如果太长则截断
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
String displayName = productName;
|
||||
int maxWidth = OUTPUT_WIDTH - 80; // 左右各留40px
|
||||
|
||||
if (fm.stringWidth(displayName) > maxWidth) {
|
||||
// 截断并添加省略号
|
||||
while (fm.stringWidth(displayName + "...") > maxWidth && displayName.length() > 0) {
|
||||
displayName = displayName.substring(0, displayName.length() - 1);
|
||||
}
|
||||
displayName += "...";
|
||||
}
|
||||
|
||||
int textWidth = fm.stringWidth(displayName);
|
||||
int x = (OUTPUT_WIDTH - textWidth) / 2; // 居中
|
||||
|
||||
g2d.drawString(displayName, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制官网价(带删除线)
|
||||
*/
|
||||
private void drawOriginalPrice(Graphics2D g2d, Double originalPrice, int y) {
|
||||
Font font = getAvailableFont(Font.BOLD, ORIGINAL_PRICE_FONT_SIZE);
|
||||
g2d.setFont(font);
|
||||
g2d.setColor(ORIGINAL_PRICE_COLOR);
|
||||
|
||||
String priceText = "官网价:¥" + String.format("%.0f", originalPrice);
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
int textWidth = fm.stringWidth(priceText);
|
||||
int x = (OUTPUT_WIDTH - textWidth) / 2; // 居中
|
||||
|
||||
// 绘制文字
|
||||
g2d.drawString(priceText, x, y);
|
||||
|
||||
// 绘制删除线
|
||||
int lineY = y - fm.getAscent() / 2;
|
||||
g2d.setStroke(new BasicStroke(3.0f)); // 3px粗的删除线
|
||||
g2d.drawLine(x, lineY, x + textWidth, lineY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制向下箭头
|
||||
*/
|
||||
private void drawDownArrow(Graphics2D g2d, int y) {
|
||||
int centerX = OUTPUT_WIDTH / 2;
|
||||
int arrowSize = 40;
|
||||
|
||||
g2d.setColor(new Color(200, 200, 200)); // 浅灰色箭头
|
||||
g2d.setStroke(new BasicStroke(3.0f));
|
||||
|
||||
// 绘制竖线
|
||||
g2d.drawLine(centerX, y, centerX, y + arrowSize);
|
||||
|
||||
// 绘制箭头(向下)
|
||||
int[] xPoints = {centerX, centerX - 15, centerX + 15};
|
||||
int[] yPoints = {y + arrowSize, y + arrowSize - 20, y + arrowSize - 20};
|
||||
g2d.fillPolygon(xPoints, yPoints, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制到手价(大红色)
|
||||
*/
|
||||
private void drawFinalPrice(Graphics2D g2d, Double finalPrice, int y) {
|
||||
Font font = getAvailableFont(Font.BOLD, FINAL_PRICE_FONT_SIZE);
|
||||
g2d.setFont(font);
|
||||
g2d.setColor(FINAL_PRICE_COLOR);
|
||||
|
||||
String priceText = "到手价:¥" + String.format("%.0f", finalPrice);
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
int textWidth = fm.stringWidth(priceText);
|
||||
int x = (OUTPUT_WIDTH - textWidth) / 2; // 居中
|
||||
|
||||
g2d.drawString(priceText, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制爆炸贴图装饰(右下角)
|
||||
*/
|
||||
private void drawExplosionDecoration(Graphics2D g2d) {
|
||||
// 绘制简单的爆炸形状(星形)
|
||||
int centerX = OUTPUT_WIDTH - 120;
|
||||
int centerY = OUTPUT_HEIGHT - 120;
|
||||
int radius = 50;
|
||||
|
||||
g2d.setColor(new Color(255, 200, 0)); // 金黄色
|
||||
g2d.setStroke(new BasicStroke(4.0f));
|
||||
|
||||
// 绘制星形爆炸效果
|
||||
int points = 8;
|
||||
int[] xPoints = new int[points * 2];
|
||||
int[] yPoints = new int[points * 2];
|
||||
|
||||
for (int i = 0; i < points * 2; i++) {
|
||||
double angle = Math.PI * i / points;
|
||||
int r = (i % 2 == 0) ? radius : radius / 2;
|
||||
xPoints[i] = (int) (centerX + r * Math.cos(angle));
|
||||
yPoints[i] = (int) (centerY + r * Math.sin(angle));
|
||||
}
|
||||
|
||||
g2d.fillPolygon(xPoints, yPoints, points * 2);
|
||||
|
||||
// 绘制内部圆形
|
||||
g2d.setColor(new Color(255, 100, 0)); // 橙红色
|
||||
g2d.fillOval(centerX - radius / 2, centerY - radius / 2, radius, radius);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将BufferedImage转换为Base64字符串
|
||||
*/
|
||||
private String imageToBase64(BufferedImage image, String format) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, format, baos);
|
||||
byte[] imageBytes = baos.toByteArray();
|
||||
return "data:image/" + format + ";base64," + Base64.getEncoder().encodeToString(imageBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成营销图片
|
||||
*
|
||||
* @param requests 批量请求列表
|
||||
* @return 结果列表,每个元素包含base64图片
|
||||
*/
|
||||
public Map<String, Object> batchGenerateMarketingImages(java.util.List<Map<String, Object>> requests) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
java.util.List<Map<String, Object>> results = new java.util.ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
for (int i = 0; i < requests.size(); i++) {
|
||||
Map<String, Object> request = requests.get(i);
|
||||
Map<String, Object> itemResult = new HashMap<>();
|
||||
|
||||
try {
|
||||
String productImageUrl = (String) request.get("productImageUrl");
|
||||
Double originalPrice = getDoubleValue(request.get("originalPrice"));
|
||||
Double finalPrice = getDoubleValue(request.get("finalPrice"));
|
||||
String productName = (String) request.get("productName");
|
||||
|
||||
if (productImageUrl == null || originalPrice == null || finalPrice == null) {
|
||||
throw new IllegalArgumentException("缺少必要参数: productImageUrl, originalPrice, finalPrice");
|
||||
}
|
||||
|
||||
String base64Image = generateMarketingImage(productImageUrl, originalPrice, finalPrice, productName);
|
||||
|
||||
itemResult.put("success", true);
|
||||
itemResult.put("imageBase64", base64Image);
|
||||
itemResult.put("index", i);
|
||||
successCount++;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("批量生成第{}张图片失败", i, e);
|
||||
itemResult.put("success", false);
|
||||
itemResult.put("error", e.getMessage());
|
||||
itemResult.put("index", i);
|
||||
failCount++;
|
||||
}
|
||||
|
||||
results.add(itemResult);
|
||||
}
|
||||
|
||||
result.put("results", results);
|
||||
result.put("total", requests.size());
|
||||
result.put("successCount", successCount);
|
||||
result.put("failCount", failCount);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取Double值
|
||||
*/
|
||||
private Double getDoubleValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Double) {
|
||||
return (Double) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).doubleValue();
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(value.toString());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
347
src/main/java/cn/van/business/service/SocialMediaService.java
Normal file
347
src/main/java/cn/van/business/service/SocialMediaService.java
Normal file
@@ -0,0 +1,347 @@
|
||||
package cn.van.business.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.van.business.util.ds.DeepSeekClientUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 小红书/抖音内容生成服务
|
||||
* 提供关键词提取、文案生成等功能
|
||||
*
|
||||
* @author System
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SocialMediaService {
|
||||
|
||||
@Autowired
|
||||
private DeepSeekClientUtil deepSeekClientUtil;
|
||||
|
||||
@Autowired
|
||||
private MarketingImageService marketingImageService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private StringRedisTemplate redisTemplate;
|
||||
|
||||
// Redis Key 前缀
|
||||
private static final String REDIS_KEY_PREFIX = "social_media:prompt:";
|
||||
|
||||
// 默认提示词模板
|
||||
private static final String DEFAULT_KEYWORDS_PROMPT =
|
||||
"请从以下商品标题中提取3-5个最核心的关键词,这些关键词要能突出商品的核心卖点和特色。\n" +
|
||||
"要求:\n" +
|
||||
"1. 每个关键词2-4个字\n" +
|
||||
"2. 关键词要能吸引小红书/抖音用户\n" +
|
||||
"3. 用逗号分隔,只返回关键词,不要其他内容\n" +
|
||||
"商品标题:%s";
|
||||
|
||||
private static final String DEFAULT_CONTENT_PROMPT_XHS =
|
||||
"请为小红书平台生成一篇商品推广文案,要求:\n" +
|
||||
"1. 风格:真实、种草、有温度\n" +
|
||||
"2. 开头:用emoji或感叹句吸引注意\n" +
|
||||
"3. 内容:突出商品亮点、使用场景、性价比\n" +
|
||||
"4. 结尾:引导行动(如:快冲、闭眼入等)\n" +
|
||||
"5. 长度:150-300字\n" +
|
||||
"6. 适当使用emoji和换行\n" +
|
||||
"\n商品信息:\n" +
|
||||
"商品名称:%s\n" +
|
||||
"%s" + // 价格信息
|
||||
"%s" + // 关键词
|
||||
"\n请直接生成文案内容,不要添加其他说明:";
|
||||
|
||||
private static final String DEFAULT_CONTENT_PROMPT_DOUYIN =
|
||||
"请为抖音平台生成一篇商品推广文案,要求:\n" +
|
||||
"1. 风格:直接、有冲击力、吸引眼球\n" +
|
||||
"2. 开头:用疑问句或对比句抓住注意力\n" +
|
||||
"3. 内容:强调价格优势、限时优惠、稀缺性\n" +
|
||||
"4. 结尾:制造紧迫感,引导立即行动\n" +
|
||||
"5. 长度:100-200字\n" +
|
||||
"6. 使用短句,节奏感强\n" +
|
||||
"\n商品信息:\n" +
|
||||
"商品名称:%s\n" +
|
||||
"%s" + // 价格信息
|
||||
"%s" + // 关键词
|
||||
"\n请直接生成文案内容,不要添加其他说明:";
|
||||
|
||||
private static final String DEFAULT_CONTENT_PROMPT_BOTH =
|
||||
"请生成一篇适合小红书和抖音平台的商品推广文案,要求:\n" +
|
||||
"1. 风格:真实、有吸引力\n" +
|
||||
"2. 突出商品亮点和价格优势\n" +
|
||||
"3. 长度:150-250字\n" +
|
||||
"\n商品信息:\n" +
|
||||
"商品名称:%s\n" +
|
||||
"%s" + // 价格信息
|
||||
"%s" + // 关键词
|
||||
"\n请直接生成文案内容,不要添加其他说明:";
|
||||
|
||||
/**
|
||||
* 提取商品标题关键词
|
||||
*
|
||||
* @param productName 商品名称
|
||||
* @return 关键词列表
|
||||
*/
|
||||
public Map<String, Object> extractKeywords(String productName) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (StrUtil.isBlank(productName)) {
|
||||
result.put("success", false);
|
||||
result.put("error", "商品名称不能为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// 从 Redis 读取提示词模板,如果没有则使用默认模板
|
||||
String promptTemplate = getPromptTemplate("keywords", DEFAULT_KEYWORDS_PROMPT);
|
||||
String prompt = String.format(promptTemplate, productName);
|
||||
|
||||
String response = deepSeekClientUtil.getDeepSeekResponse(prompt);
|
||||
|
||||
if (StrUtil.isNotBlank(response)) {
|
||||
// 解析关键词
|
||||
String[] keywords = response.trim()
|
||||
.replaceAll("[,,]", ",")
|
||||
.split(",");
|
||||
|
||||
List<String> keywordList = new ArrayList<>();
|
||||
for (String keyword : keywords) {
|
||||
String cleaned = keyword.trim();
|
||||
if (StrUtil.isNotBlank(cleaned) && cleaned.length() <= 6) {
|
||||
keywordList.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
// 限制数量
|
||||
if (keywordList.size() > 5) {
|
||||
keywordList = keywordList.subList(0, 5);
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
result.put("keywords", keywordList);
|
||||
result.put("keywordsText", String.join("、", keywordList));
|
||||
log.info("提取关键词成功: {} -> {}", productName, keywordList);
|
||||
} else {
|
||||
throw new Exception("AI返回结果为空");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("提取关键词失败", e);
|
||||
result.put("success", false);
|
||||
result.put("error", "提取关键词失败: " + e.getMessage());
|
||||
// 降级方案:简单提取
|
||||
result.put("keywords", simpleExtractKeywords(productName));
|
||||
result.put("keywordsText", String.join("、", simpleExtractKeywords(productName)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小红书/抖音文案
|
||||
*
|
||||
* @param productName 商品名称
|
||||
* @param originalPrice 原价
|
||||
* @param finalPrice 到手价
|
||||
* @param keywords 关键词(可选)
|
||||
* @param style 文案风格:xhs(小红书)、douyin(抖音)、both(通用)
|
||||
* @return 生成的文案
|
||||
*/
|
||||
public Map<String, Object> generateContent(String productName, Double originalPrice,
|
||||
Double finalPrice, String keywords, String style) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (StrUtil.isBlank(productName)) {
|
||||
result.put("success", false);
|
||||
result.put("error", "商品名称不能为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建价格信息
|
||||
StringBuilder priceInfo = new StringBuilder();
|
||||
if (originalPrice != null && originalPrice > 0) {
|
||||
priceInfo.append("原价:¥").append(String.format("%.0f", originalPrice)).append("\n");
|
||||
}
|
||||
if (finalPrice != null && finalPrice > 0) {
|
||||
priceInfo.append("到手价:¥").append(String.format("%.0f", finalPrice)).append("\n");
|
||||
}
|
||||
|
||||
// 构建关键词信息
|
||||
String keywordsInfo = "";
|
||||
if (StrUtil.isNotBlank(keywords)) {
|
||||
keywordsInfo = "关键词:" + keywords + "\n";
|
||||
}
|
||||
|
||||
// 从 Redis 读取提示词模板,如果没有则使用默认模板
|
||||
String promptTemplate;
|
||||
if ("xhs".equals(style)) {
|
||||
promptTemplate = getPromptTemplate("content:xhs", DEFAULT_CONTENT_PROMPT_XHS);
|
||||
} else if ("douyin".equals(style)) {
|
||||
promptTemplate = getPromptTemplate("content:douyin", DEFAULT_CONTENT_PROMPT_DOUYIN);
|
||||
} else {
|
||||
promptTemplate = getPromptTemplate("content:both", DEFAULT_CONTENT_PROMPT_BOTH);
|
||||
}
|
||||
|
||||
String prompt = String.format(promptTemplate, productName, priceInfo.toString(), keywordsInfo);
|
||||
|
||||
String content = deepSeekClientUtil.getDeepSeekResponse(prompt.toString());
|
||||
|
||||
if (StrUtil.isNotBlank(content)) {
|
||||
result.put("success", true);
|
||||
result.put("content", content.trim());
|
||||
log.info("生成文案成功: {}", productName);
|
||||
} else {
|
||||
throw new Exception("AI返回结果为空");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成文案失败", e);
|
||||
result.put("success", false);
|
||||
result.put("error", "生成文案失败: " + e.getMessage());
|
||||
// 降级方案:生成简单文案
|
||||
result.put("content", generateSimpleContent(productName, originalPrice, finalPrice));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键生成完整内容(关键词 + 文案 + 图片)
|
||||
*
|
||||
* @param productImageUrl 商品主图URL
|
||||
* @param productName 商品名称
|
||||
* @param originalPrice 原价
|
||||
* @param finalPrice 到手价
|
||||
* @param style 文案风格
|
||||
* @return 完整内容
|
||||
*/
|
||||
public Map<String, Object> generateCompleteContent(String productImageUrl, String productName,
|
||||
Double originalPrice, Double finalPrice, String style) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 1. 提取关键词
|
||||
Map<String, Object> keywordResult = extractKeywords(productName);
|
||||
List<String> keywords = (List<String>) keywordResult.get("keywords");
|
||||
String keywordsText = (String) keywordResult.get("keywordsText");
|
||||
|
||||
// 2. 生成文案
|
||||
Map<String, Object> contentResult = generateContent(
|
||||
productName, originalPrice, finalPrice, keywordsText, style
|
||||
);
|
||||
String content = (String) contentResult.get("content");
|
||||
|
||||
// 3. 生成营销图片
|
||||
String imageBase64 = null;
|
||||
if (StrUtil.isNotBlank(productImageUrl)) {
|
||||
try {
|
||||
// 使用提取的关键词作为商品名称显示
|
||||
String displayName = keywords != null && !keywords.isEmpty()
|
||||
? keywords.get(0)
|
||||
: productName;
|
||||
imageBase64 = marketingImageService.generateMarketingImage(
|
||||
productImageUrl, originalPrice, finalPrice, displayName
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("生成营销图片失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
result.put("keywords", keywords);
|
||||
result.put("keywordsText", keywordsText);
|
||||
result.put("content", content);
|
||||
result.put("imageBase64", imageBase64);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("生成完整内容失败", e);
|
||||
result.put("success", false);
|
||||
result.put("error", "生成完整内容失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单提取关键词(降级方案)
|
||||
*/
|
||||
private List<String> simpleExtractKeywords(String productName) {
|
||||
List<String> keywords = new ArrayList<>();
|
||||
|
||||
// 移除常见规格信息
|
||||
String cleaned = productName
|
||||
.replaceAll("\\s*XL|L|M|S|XXL\\s*", "")
|
||||
.replaceAll("\\s*\\d+/\\d+[A-Z]?\\s*", "")
|
||||
.replaceAll("\\s*【.*?】\\s*", "")
|
||||
.replaceAll("\\s*\\(.*?\\)\\s*", "");
|
||||
|
||||
// 提取前几个词
|
||||
String[] words = cleaned.split("\\s+");
|
||||
for (String word : words) {
|
||||
if (word.length() >= 2 && word.length() <= 6 && keywords.size() < 5) {
|
||||
keywords.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
return keywords;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 获取提示词模板,如果没有则返回默认模板
|
||||
*
|
||||
* @param templateKey 模板键名(如:keywords, content:xhs, content:douyin, content:both)
|
||||
* @param defaultTemplate 默认模板
|
||||
* @return 提示词模板
|
||||
*/
|
||||
private String getPromptTemplate(String templateKey, String defaultTemplate) {
|
||||
if (redisTemplate == null) {
|
||||
log.debug("Redis未配置,使用默认模板: {}", templateKey);
|
||||
return defaultTemplate;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = REDIS_KEY_PREFIX + templateKey;
|
||||
String template = redisTemplate.opsForValue().get(redisKey);
|
||||
if (StrUtil.isNotBlank(template)) {
|
||||
log.debug("从Redis读取模板: {}", templateKey);
|
||||
return template;
|
||||
} else {
|
||||
log.debug("Redis中未找到模板,使用默认模板: {}", templateKey);
|
||||
return defaultTemplate;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("读取Redis模板失败,使用默认模板: {}", templateKey, e);
|
||||
return defaultTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成简单文案(降级方案)
|
||||
*/
|
||||
private String generateSimpleContent(String productName, Double originalPrice, Double finalPrice) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
content.append("🔥 ").append(productName).append("\n\n");
|
||||
|
||||
if (originalPrice != null && finalPrice != null && originalPrice > finalPrice) {
|
||||
content.append("💰 原价:¥").append(String.format("%.0f", originalPrice)).append("\n");
|
||||
content.append("💸 到手价:¥").append(String.format("%.0f", finalPrice)).append("\n");
|
||||
double discount = ((originalPrice - finalPrice) / originalPrice) * 100;
|
||||
content.append("✨ 立省:¥").append(String.format("%.0f", originalPrice - finalPrice))
|
||||
.append("(").append(String.format("%.0f", discount)).append("%)\n\n");
|
||||
}
|
||||
|
||||
content.append("💡 超值好物,不容错过!\n");
|
||||
content.append("🎁 限时优惠,先到先得!");
|
||||
|
||||
return content.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,16 +32,21 @@ import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static cn.van.business.util.JDUtil.*;
|
||||
|
||||
/**
|
||||
* 京东商品服务类,抽取来源于JDUtil
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class JDProductService {
|
||||
|
||||
private static final String LPF_APP_KEY_WZ = "98e21c89ae5610240ec3f5f575f86a59";
|
||||
private static final String LPF_SECRET_KEY_WZ = "3dcb6b23a1104639ac433fd07adb6dfb";
|
||||
// 自己的98e21c89ae5610240ec3f5f575f86a59
|
||||
private static final String LPF_APP_KEY_WZ = "34407d6cae6d43eca740370b8e12b01e";
|
||||
// 自己的3dcb6b23a1104639ac433fd07adb6dfb
|
||||
private static final String LPF_SECRET_KEY_WZ = "ad4966e1df3348a185fe6b33aa679a69";
|
||||
private static final String SERVER_URL = "https://api.jd.com/routerjson";
|
||||
private static final String ACCESS_TOKEN = "";
|
||||
private static final Logger logger = LoggerFactory.getLogger(JDProductService.class);
|
||||
@@ -59,6 +64,13 @@ public class JDProductService {
|
||||
this.xbMessageItemRepository = xbMessageItemRepository;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成转链和方案的方法(JSON数组格式)
|
||||
*
|
||||
* @param message 方案内容,包含商品链接
|
||||
* @return 处理后的方案,以标准JSON数组格式返回,每个商品及其文案为一个独立对象
|
||||
*/
|
||||
public synchronized JSONArray generatePromotionContentAsJsonArray(String message) {
|
||||
JSONArray resultArray = new JSONArray();
|
||||
List<String> urls = extractUJDUrls(message);
|
||||
@@ -74,17 +86,31 @@ public class JDProductService {
|
||||
for (String url : urls) {
|
||||
try {
|
||||
String format = dateFormat.format(new Date());
|
||||
GoodsQueryResult productInfo = queryProductInfoByUJDUrl(url);
|
||||
String originalUrlInText = url;
|
||||
String normalizedUrl = normalizeJdUrl(originalUrlInText);
|
||||
if (normalizedUrl == null) {
|
||||
log.warn("检测到的链接无法识别为合法京东链接,跳过处理: {}", originalUrlInText);
|
||||
JSONObject errorObj = new JSONObject();
|
||||
errorObj.put("url", originalUrlInText);
|
||||
errorObj.put("error", "链接格式不支持或识别失败");
|
||||
resultArray.add(errorObj);
|
||||
continue;
|
||||
}
|
||||
|
||||
GoodsQueryResult productInfo = queryProductInfoByUJDUrl(normalizedUrl);
|
||||
if (productInfo == null || productInfo.getCode() != 200 || productInfo.getData() == null || productInfo.getData().length == 0) {
|
||||
JSONObject errorObj = new JSONObject();
|
||||
errorObj.put("url", url);
|
||||
errorObj.put("url", originalUrlInText);
|
||||
errorObj.put("error", "链接查询失败");
|
||||
resultArray.add(errorObj);
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONObject productObj = new JSONObject();
|
||||
productObj.put("url", url);
|
||||
productObj.put("originalUrl", originalUrlInText);
|
||||
productObj.put("normalizedUrl", normalizedUrl);
|
||||
|
||||
// 商品基本信息
|
||||
productObj.put("materialUrl", productInfo.getData()[0].getMaterialUrl());
|
||||
productObj.put("oriItemId", productInfo.getData()[0].getOriItemId());
|
||||
productObj.put("owner", productInfo.getData()[0].getOwner());
|
||||
@@ -94,6 +120,7 @@ public class JDProductService {
|
||||
String cleanSkuName = productInfo.getData()[0].getSkuName().replaceAll("以旧|政府|换新|领取|国家|补贴|15%|20%|国补|立减|【|】", "");
|
||||
productObj.put("cleanSkuName", cleanSkuName);
|
||||
productObj.put("spuid", String.valueOf(productInfo.getData()[0].getSpuid()));
|
||||
productObj.put("skuId", String.valueOf(productInfo.getData()[0].getSkuId()));
|
||||
productObj.put("commission", String.valueOf(productInfo.getData()[0].getCommissionInfo().getCommission()));
|
||||
productObj.put("commissionShare", String.valueOf(productInfo.getData()[0].getCommissionInfo().getCommissionShare()));
|
||||
if (productInfo.getData()[0].getPriceInfo() != null && productInfo.getData()[0].getPriceInfo().getPrice() != null) {
|
||||
@@ -109,7 +136,31 @@ public class JDProductService {
|
||||
}
|
||||
productObj.put("images", imageArray);
|
||||
|
||||
// 生成转链后的短链
|
||||
try {
|
||||
String shortUrl = transfer(normalizedUrl, null);
|
||||
String effectiveUrl = normalizedUrl;
|
||||
if (shortUrl != null && !shortUrl.isEmpty()) {
|
||||
productObj.put("shortUrl", shortUrl);
|
||||
productObj.put("transferSuccess", true);
|
||||
effectiveUrl = shortUrl;
|
||||
} else {
|
||||
productObj.put("shortUrl", normalizedUrl); // 如果转链失败,使用归一化后的链接
|
||||
productObj.put("transferSuccess", false);
|
||||
log.warn("转链失败,使用原链接: {}", normalizedUrl);
|
||||
}
|
||||
productObj.put("effectiveUrl", effectiveUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("生成转链时发生异常: {}", normalizedUrl, e);
|
||||
productObj.put("shortUrl", normalizedUrl); // 转链异常时使用原链接
|
||||
productObj.put("transferSuccess", false);
|
||||
productObj.put("transferError", e.getMessage());
|
||||
productObj.put("effectiveUrl", normalizedUrl);
|
||||
}
|
||||
|
||||
// 文案信息
|
||||
JSONArray wenanArray = new JSONArray();
|
||||
|
||||
String title = "";
|
||||
try {
|
||||
if (!message.equals(url)) {
|
||||
@@ -126,6 +177,7 @@ public class JDProductService {
|
||||
log.error("文案首行异常", e);
|
||||
}
|
||||
|
||||
// 生成各种文案
|
||||
JSONObject wenan1 = new JSONObject();
|
||||
wenan1.put("type", "标价到手-方案1");
|
||||
wenan1.put("content", "(标价到手) " + title + cleanSkuName + "\n" + WENAN_FANAN_LQD.replaceAll("更新", format + "更新"));
|
||||
@@ -146,9 +198,29 @@ public class JDProductService {
|
||||
wenan4.put("content", "【教你下单】 " + title + cleanSkuName + "\n" + WENAN_FANAN_BX.replaceAll("信息更新日期:", "信息更新日期:" + format));
|
||||
wenanArray.add(wenan4);
|
||||
|
||||
|
||||
JSONObject wenan5 = new JSONObject();
|
||||
wenan5.put("type", "羽绒服专属-标价到手"); // type明确产品类型+下单方式,与其他方案区分
|
||||
wenan5.put("content", "(羽绒服专属) " + title + cleanSkuName + "\n" + WENAN_YURONGFU.replaceAll("更新", format + "更新"));
|
||||
wenanArray.add(wenan5);
|
||||
|
||||
productObj.put("wenan", wenanArray);
|
||||
|
||||
// 添加通用文案 - 使用转链后的短链替换原始链接
|
||||
JSONObject commonWenan = new JSONObject();
|
||||
commonWenan.put("type", "通用文案");
|
||||
commonWenan.put("content", format + FANAN_COMMON + message);
|
||||
// 将原始消息中的链接替换为转链后的短链
|
||||
String targetUrl = productObj.getString("effectiveUrl");
|
||||
String normalizedForReplace = productObj.getString("normalizedUrl");
|
||||
String messageWithShortUrl = message;
|
||||
if (targetUrl != null) {
|
||||
String replaced = message.replace(originalUrlInText, targetUrl);
|
||||
if (replaced.equals(message) && normalizedForReplace != null) {
|
||||
replaced = replaced.replace(normalizedForReplace, targetUrl);
|
||||
}
|
||||
messageWithShortUrl = replaced;
|
||||
}
|
||||
commonWenan.put("content", format + FANAN_COMMON + messageWithShortUrl);
|
||||
wenanArray.add(commonWenan);
|
||||
|
||||
productObj.put("wenan", wenanArray);
|
||||
@@ -162,9 +234,17 @@ public class JDProductService {
|
||||
resultArray.add(errorObj);
|
||||
}
|
||||
}
|
||||
|
||||
return resultArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商品信息
|
||||
*
|
||||
* @param uJDUrl 京东商品链接
|
||||
* @return 商品查询结果
|
||||
* @throws Exception 查询异常
|
||||
*/
|
||||
public GoodsQueryResult queryProductInfoByUJDUrl(String uJDUrl) throws Exception {
|
||||
UnionOpenGoodsQueryResponse response = getUnionOpenGoodsQueryRequest(uJDUrl);
|
||||
if (response == null || response.getQueryResult() == null) return null;
|
||||
@@ -173,8 +253,16 @@ public class JDProductService {
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用京东开放平台接口查询商品信息
|
||||
*
|
||||
* @param uJDUrl 京东商品链接
|
||||
* @return 京东商品查询响应
|
||||
* @throws Exception 查询异常
|
||||
*/
|
||||
public UnionOpenGoodsQueryResponse getUnionOpenGoodsQueryRequest(String uJDUrl) throws Exception {
|
||||
JdClient client = new DefaultJdClient(SERVER_URL, ACCESS_TOKEN, LPF_APP_KEY_WZ, LPF_SECRET_KEY_WZ);
|
||||
|
||||
UnionOpenGoodsQueryRequest request = new UnionOpenGoodsQueryRequest();
|
||||
GoodsReq goodsReq = new GoodsReq();
|
||||
goodsReq.setKeyword(uJDUrl);
|
||||
@@ -195,6 +283,7 @@ public class JDProductService {
|
||||
owner = (owner != null && !owner.isEmpty()) ? owner : "g";
|
||||
|
||||
JdClient client = new DefaultJdClient(SERVER_URL, ACCESS_TOKEN, LPF_APP_KEY_WZ, LPF_SECRET_KEY_WZ);
|
||||
|
||||
UnionOpenCouponGiftGetRequest request = new UnionOpenCouponGiftGetRequest();
|
||||
CreateGiftCouponReq couponReq = new CreateGiftCouponReq();
|
||||
couponReq.setSkuMaterialId(skuId);
|
||||
@@ -232,12 +321,74 @@ public class JDProductService {
|
||||
log.debug("礼金创建成功:giftKey={}", giftKey);
|
||||
return giftKey;
|
||||
}
|
||||
log.error("礼金创建失败:code={}, msg={}", response != null ? response.getCode() : "null", response != null ? response.getMsg() : "null");
|
||||
return null;
|
||||
// 详细记录失败信息
|
||||
String errorCode = response != null ? response.getCode() : "null";
|
||||
String errorMsg = response != null ? response.getMsg() : "null";
|
||||
Integer resultCode = response != null && response.getGetResult() != null ? response.getGetResult().getCode() : null;
|
||||
|
||||
// 尝试解析response.getMsg()中的详细错误信息(JSON格式)
|
||||
String detailErrorMsg = errorMsg;
|
||||
Integer detailCode = resultCode;
|
||||
boolean parsedDetail = false;
|
||||
|
||||
try {
|
||||
if (errorMsg != null && errorMsg.startsWith("{") && errorMsg.contains("message")) {
|
||||
// 解析外层JSON
|
||||
JSONObject outerJson = JSON.parseObject(errorMsg);
|
||||
if (outerJson.containsKey("jd_union_open_coupon_gift_get_responce")) {
|
||||
JSONObject innerObj = outerJson.getJSONObject("jd_union_open_coupon_gift_get_responce");
|
||||
if (innerObj.containsKey("getResult")) {
|
||||
String getResultStr = innerObj.getString("getResult");
|
||||
// getResult是一个JSON字符串,需要再次解析
|
||||
if (getResultStr != null && getResultStr.startsWith("{")) {
|
||||
JSONObject resultJson = JSON.parseObject(getResultStr);
|
||||
if (resultJson.containsKey("message")) {
|
||||
detailErrorMsg = resultJson.getString("message");
|
||||
Integer parsedDetailCode = resultJson.getInteger("code");
|
||||
if (parsedDetailCode != null) {
|
||||
detailCode = parsedDetailCode;
|
||||
}
|
||||
parsedDetail = true;
|
||||
log.error("礼金创建失败 - 京东API错误: code={}, message={}, SKU={}, owner={}, amount={}, quantity={}",
|
||||
detailCode, detailErrorMsg, skuId, owner, amount, quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception parseEx) {
|
||||
// JSON解析失败,使用原始错误信息
|
||||
log.warn("解析错误信息失败,使用原始信息: {}", errorMsg);
|
||||
}
|
||||
|
||||
// 记录日志并抛出包含详细错误信息的异常
|
||||
if (!parsedDetail) {
|
||||
log.error("礼金创建失败 - response.code={}, response.msg={}, result.code={}, SKU={}, owner={}, amount={}, quantity={}",
|
||||
errorCode, errorMsg, resultCode, skuId, owner, amount, quantity);
|
||||
}
|
||||
|
||||
// 构造错误消息
|
||||
String finalErrorMsg;
|
||||
if (parsedDetail) {
|
||||
finalErrorMsg = String.format("礼金创建失败:%s (错误码:%d)", detailErrorMsg, detailCode);
|
||||
} else {
|
||||
finalErrorMsg = String.format("礼金创建失败:京东API返回错误 (response.code=%s, result.code=%s, msg=%s)",
|
||||
errorCode, resultCode != null ? resultCode : "null", detailErrorMsg);
|
||||
}
|
||||
|
||||
throw new Exception(finalErrorMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转链接口:通过商品链接、领券链接、活动链接获取普通推广链接或优惠券二合一推广链接
|
||||
*
|
||||
* @param url 原始链接
|
||||
* @param giftCouponKey 礼金Key(可选)
|
||||
* @return 转换后的短链接
|
||||
*/
|
||||
public String transfer(String url, String giftCouponKey) {
|
||||
JdClient client = new DefaultJdClient(SERVER_URL, ACCESS_TOKEN, LPF_APP_KEY_WZ, LPF_SECRET_KEY_WZ);
|
||||
|
||||
UnionOpenPromotionBysubunionidGetRequest request = new UnionOpenPromotionBysubunionidGetRequest();
|
||||
PromotionCodeReq promotionCodeReq = new PromotionCodeReq();
|
||||
promotionCodeReq.setSceneId(1);
|
||||
@@ -245,6 +396,7 @@ public class JDProductService {
|
||||
if (giftCouponKey != null && !giftCouponKey.isEmpty()) {
|
||||
promotionCodeReq.setGiftCouponKey(giftCouponKey);
|
||||
}
|
||||
|
||||
request.setPromotionCodeReq(promotionCodeReq);
|
||||
request.setVersion("1.0");
|
||||
|
||||
@@ -260,6 +412,14 @@ public class JDProductService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将礼金信息写入Redis
|
||||
*
|
||||
* @param skuId 商品SKU ID
|
||||
* @param giftKey 礼金Key
|
||||
* @param skuName 商品名称
|
||||
* @param owner 商品所有者
|
||||
*/
|
||||
public void saveGiftCouponToRedis(String skuId, String giftKey, String skuName, String owner) {
|
||||
String key = "gift_coupon:" + skuId;
|
||||
String hashKey = giftKey;
|
||||
@@ -271,9 +431,16 @@ public class JDProductService {
|
||||
data.put("owner", owner);
|
||||
data.put("expireTime", expireTime.format(DateTimeFormatter.ISO_DATE_TIME));
|
||||
|
||||
// 存入 Redis Hash
|
||||
redisTemplate.opsForHash().put(key, hashKey, JSON.toJSONString(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取商品价格信息
|
||||
*
|
||||
* @param productInfo 商品查询结果
|
||||
* @return 包含价格信息的Map
|
||||
*/
|
||||
public Map<String, Object> extractPriceInfo(GoodsQueryResult productInfo) {
|
||||
Map<String, Object> priceMap = new HashMap<>();
|
||||
if (productInfo == null || productInfo.getData() == null || productInfo.getData().length == 0) {
|
||||
@@ -315,4 +482,168 @@ public class JDProductService {
|
||||
return errorMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量创建礼金券并生成包含礼金的推广链接
|
||||
*
|
||||
* @param skuId 商品SKU ID或materialUrl
|
||||
* @param amount 礼金金额(单位:元)
|
||||
* @param quantity 每个礼金券的数量
|
||||
* @param batchSize 批量创建的个数(默认20)
|
||||
* @param owner 商品类型(g=自营,pop=POP)
|
||||
* @param skuName 商品名称
|
||||
* @return 批量创建结果列表,包含giftCouponKey和shortURL
|
||||
*/
|
||||
public List<Map<String, Object>> batchCreateGiftCouponsWithLinks(String skuId, double amount, int quantity, int batchSize, String owner, String skuName) {
|
||||
log.info("开始批量创建礼金券 - SKU={}, 金额={}元, 数量={}, 批次大小={}, Owner={}", skuId, amount, quantity, batchSize, owner);
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
for (int i = 0; i < batchSize; i++) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("index", i + 1);
|
||||
result.put("success", false);
|
||||
|
||||
try {
|
||||
// 创建礼金券
|
||||
String giftCouponKey = createGiftCoupon(skuId, amount, quantity, owner, skuName);
|
||||
|
||||
if (giftCouponKey == null || giftCouponKey.trim().isEmpty()) {
|
||||
log.error("批量创建礼金券失败 [{}/{}] - giftCouponKey为空", i + 1, batchSize);
|
||||
result.put("error", "礼金创建失败,giftCouponKey为空");
|
||||
result.put("giftCouponKey", null);
|
||||
result.put("shortURL", null);
|
||||
failCount++;
|
||||
} else {
|
||||
log.info("批量创建礼金券成功 [{}/{}] - giftCouponKey={}", i + 1, batchSize, giftCouponKey);
|
||||
|
||||
// 保存到Redis
|
||||
try {
|
||||
saveGiftCouponToRedis(skuId, giftCouponKey, skuName, owner);
|
||||
} catch (Exception e) {
|
||||
log.warn("保存礼金到Redis失败,但礼金创建成功 - giftCouponKey={}, error={}", giftCouponKey, e.getMessage());
|
||||
}
|
||||
|
||||
// 生成包含礼金的推广链接
|
||||
String shortURL = null;
|
||||
try {
|
||||
// 使用materialUrl或skuId作为原始链接
|
||||
String originalUrl = skuId;
|
||||
// transfer方法支持SKU ID或materialUrl直接传入
|
||||
shortURL = transfer(originalUrl, giftCouponKey);
|
||||
|
||||
if (shortURL == null || shortURL.trim().isEmpty()) {
|
||||
log.warn("生成推广链接失败 - giftCouponKey={}, 礼金创建成功但转链失败", giftCouponKey);
|
||||
} else {
|
||||
log.info("生成推广链接成功 [{}/{}] - giftCouponKey={}, shortURL={}", i + 1, batchSize, giftCouponKey, shortURL);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("生成推广链接异常 - giftCouponKey={}, error={}", giftCouponKey, e.getMessage(), e);
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
result.put("giftCouponKey", giftCouponKey);
|
||||
result.put("shortURL", shortURL);
|
||||
successCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("批量创建礼金券异常 [{}/{}] - error={}", i + 1, batchSize, e.getMessage(), e);
|
||||
result.put("error", "创建异常: " + e.getMessage());
|
||||
result.put("giftCouponKey", null);
|
||||
result.put("shortURL", null);
|
||||
failCount++;
|
||||
}
|
||||
|
||||
results.add(result);
|
||||
|
||||
// 避免请求过快,每创建一张礼金后稍作延迟
|
||||
if (i < batchSize - 1) {
|
||||
try {
|
||||
Thread.sleep(100); // 延迟100ms
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("批量创建礼金券延迟被中断");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("批量创建礼金券完成 - 总数={}, 成功={}, 失败={}", batchSize, successCount, failCount);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static final Pattern UJD_LINK_PATTERN = Pattern.compile("^https?://u\\.jd\\.com/[A-Za-z0-9]+[A-Za-z0-9_-]*$", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern JINGFEN_LINK_PATTERN = Pattern.compile("^https?://jingfen\\.jd\\.com/detail/[A-Za-z0-9]+\\.html$", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern TRAILING_SYMBOLS_PATTERN = Pattern.compile("[))】>》。,;;!!??“”\"'、…—\\s]+$");
|
||||
|
||||
private static String normalizeJdUrl(String rawUrl) {
|
||||
if (rawUrl == null || rawUrl.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = rawUrl.trim();
|
||||
|
||||
// 截断常见中文/英文括号后的内容
|
||||
int cutoffIndex = findCutoffIndex(trimmed);
|
||||
if (cutoffIndex > -1) {
|
||||
trimmed = trimmed.substring(0, cutoffIndex);
|
||||
}
|
||||
|
||||
// 去掉末尾的标点符号
|
||||
trimmed = TRAILING_SYMBOLS_PATTERN.matcher(trimmed).replaceAll("");
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||
trimmed = "https://" + trimmed;
|
||||
}
|
||||
|
||||
if (UJD_LINK_PATTERN.matcher(trimmed).matches() || JINGFEN_LINK_PATTERN.matcher(trimmed).matches()) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// 针对 u.jd.com 链接,尝试进一步截断到第一个不合法字符
|
||||
if (trimmed.contains("u.jd.com/")) {
|
||||
int schemeEnd = trimmed.indexOf("u.jd.com/") + "u.jd.com/".length();
|
||||
StringBuilder sb = new StringBuilder(trimmed.substring(0, schemeEnd));
|
||||
for (int i = schemeEnd; i < trimmed.length(); i++) {
|
||||
char c = trimmed.charAt(i);
|
||||
if (isAllowedShortLinkChar(c)) {
|
||||
sb.append(c);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
String candidate = sb.toString();
|
||||
if (UJD_LINK_PATTERN.matcher(candidate).matches()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int findCutoffIndex(String text) {
|
||||
char[] stopChars = new char[]{'(', '(', '[', '【', '<', '《', '「', '『'};
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (Character.isWhitespace(c)) {
|
||||
return i;
|
||||
}
|
||||
for (char stopChar : stopChars) {
|
||||
if (c == stopChar) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean isAllowedShortLinkChar(char c) {
|
||||
return (c >= 'A' && c <= 'Z')
|
||||
|| (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '-' || c == '_' || c == '.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -70,6 +73,12 @@ public class JDScheduleJob {
|
||||
@Value("${isRunning.jd}")
|
||||
private String isRunning_jd;
|
||||
|
||||
@Value("${jarvis.server.comment.base-url:http://192.168.8.6:5000}")
|
||||
private String commentBaseUrl;
|
||||
|
||||
@Value("${jarvis.server.comment.fetch-path:/fetch_comments}")
|
||||
private String commentFetchPath;
|
||||
|
||||
// 构造函数中注入StringRedisTemplate
|
||||
@Autowired
|
||||
public JDScheduleJob(WXUtil wxUtil, StringRedisTemplate redisTemplate, OrderRowRepository orderRowRepository, OrderUtil orderUtil, JDUtil jdUtil, CommentRepository commentRepository) {
|
||||
@@ -458,7 +467,7 @@ public class JDScheduleJob {
|
||||
return client.execute(request);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 8-20 * * ?") // 每天从 8:00 到 20:00,每小时执行一次
|
||||
//@Scheduled(cron = "0 0 8-20 * * ?") // 每天从 8:00 到 20:00,每小时执行一次
|
||||
public void fetchPL() {
|
||||
logger.info("开始执行fetchPL任务");
|
||||
// 设置每天最多执行 3 次
|
||||
@@ -513,7 +522,7 @@ public class JDScheduleJob {
|
||||
|
||||
|
||||
try {
|
||||
String fetchUrl = "http://192.168.8.6:5000/fetch_comments?product_id=" + product_id;
|
||||
String fetchUrl = commentBaseUrl + commentFetchPath + "?product_id=" + product_id;
|
||||
// 用hutool发起post请求
|
||||
HttpResponse response = HttpRequest.post(fetchUrl).timeout(60000).execute();
|
||||
|
||||
@@ -586,4 +595,130 @@ public class JDScheduleJob {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清理三个月前的Redis hash数据
|
||||
* 修复了时间解析异常的问题
|
||||
*/
|
||||
@Scheduled(cron = "0 45 11 * * ?") // 每月1日的凌晨3点执行
|
||||
public void cleanOldRedisHashData() {
|
||||
try {
|
||||
// 获取三个月前的时间
|
||||
LocalDateTime threeMonthsAgo = LocalDateTime.now().minusMonths(3);
|
||||
|
||||
// 获取所有以JD_REFRESH_TAG开头的键
|
||||
Set<String> keys = redisTemplate.keys(JD_REFRESH_TAG + "*");
|
||||
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
for (String key : keys) {
|
||||
try {
|
||||
// 提取时间部分,处理两种格式:
|
||||
// 1. jd:refresh:tag:hash值:2025-02-02 16:00:00
|
||||
// 2. jd:refresh:tag:2024-11-30 09:26:00
|
||||
String timePart;
|
||||
|
||||
// 使用正则表达式统一提取时间部分(避免lastIndexOf在时间字符串中找到错误的冒号)
|
||||
String timePattern = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}";
|
||||
Pattern pattern = Pattern.compile(timePattern);
|
||||
Matcher matcher = pattern.matcher(key);
|
||||
if (matcher.find()) {
|
||||
timePart = matcher.group();
|
||||
} else {
|
||||
logger.warn("无法识别Redis键格式:{}", key);
|
||||
continue;
|
||||
}
|
||||
|
||||
LocalDateTime time;
|
||||
try {
|
||||
// 解析为完整的时间格式 yyyy-MM-dd HH:mm:ss
|
||||
time = LocalDateTime.parse(timePart, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
} catch (DateTimeParseException e) {
|
||||
logger.warn("无法解析Redis键时间:{},时间部分:{}", key, timePart);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否在三个月前
|
||||
if (time.isBefore(threeMonthsAgo)) {
|
||||
redisTemplate.delete(key);
|
||||
logger.info("已删除过期的Redis键:{}", key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("解析Redis键时间失败:{}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("清理Redis hash数据时发生错误", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理tag:hash:时间 格式的Redis键(按小时),删除93天前的数据
|
||||
* 可以手动调用或定时执行
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
|
||||
public void cleanOldTagRedisData() {
|
||||
try {
|
||||
// 获取93天前的时间
|
||||
LocalDateTime ninetyThreeDaysAgo = LocalDateTime.now().minusDays(93);
|
||||
int deletedCount = 0;
|
||||
|
||||
logger.info("开始清理93天前的tag键数据,截止时间:{}", ninetyThreeDaysAgo.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
|
||||
// 获取所有以"tag:"开头的键
|
||||
Set<String> tagKeys = redisTemplate.keys("tag:*");
|
||||
|
||||
if (tagKeys != null && !tagKeys.isEmpty()) {
|
||||
logger.info("找到 {} 个tag相关的键", tagKeys.size());
|
||||
|
||||
for (String key : tagKeys) {
|
||||
try {
|
||||
// 处理格式:tag:hash值:YYYY-MM-DD HH
|
||||
// 例如:tag:01381d95e4936f1f3fe643bba2171894:2025-01-12 00
|
||||
if (key.matches("tag:[a-f0-9]{32}:[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}")) {
|
||||
// 提取时间部分(最后一个冒号之后)
|
||||
String timePart = key.substring(key.lastIndexOf(":") + 1);
|
||||
|
||||
LocalDateTime time;
|
||||
try {
|
||||
// 解析为小时级别的时间格式 yyyy-MM-dd HH
|
||||
time = LocalDateTime.parse(timePart + ":00:00", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
} catch (DateTimeParseException e) {
|
||||
logger.warn("无法解析Redis键时间:{},时间部分:{}", key, timePart);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否在93天前
|
||||
if (time.isBefore(ninetyThreeDaysAgo)) {
|
||||
redisTemplate.delete(key);
|
||||
deletedCount++;
|
||||
if (deletedCount % 100 == 0) {
|
||||
logger.info("已删除 {} 个过期的tag键", deletedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("解析Redis tag键时间失败:{}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("tag键清理完成,共删除 {} 个过期键", deletedCount);
|
||||
} else {
|
||||
logger.info("未找到tag相关的键");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("清理tag Redis数据时发生错误", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行清理方法(通过接口调用)
|
||||
* 清理所有超过93天的tag键和jd:refresh:tag键
|
||||
*/
|
||||
public void manualCleanOldRedisData() {
|
||||
logger.info("=== 手动触发Redis键清理 ===");
|
||||
cleanOldTagRedisData();
|
||||
cleanOldRedisHashData();
|
||||
logger.info("=== Redis键清理完成 ===");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
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.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -78,8 +79,10 @@ public class JDUtil {
|
||||
*/
|
||||
|
||||
// van论坛
|
||||
private static final String LPF_APP_KEY_WZ = "98e21c89ae5610240ec3f5f575f86a59";
|
||||
private static final String LPF_SECRET_KEY_WZ = "3dcb6b23a1104639ac433fd07adb6dfb";
|
||||
// 自己的98e21c89ae5610240ec3f5f575f86a59
|
||||
private static final String LPF_APP_KEY_WZ = "34407d6cae6d43eca740370b8e12b01e";
|
||||
// 自己的3dcb6b23a1104639ac433fd07adb6dfb
|
||||
private static final String LPF_SECRET_KEY_WZ = "ad4966e1df3348a185fe6b33aa679a69";
|
||||
|
||||
private static final String SERVER_URL = "https://api.jd.com/routerjson";
|
||||
//accessToken
|
||||
@@ -93,8 +96,8 @@ public class JDUtil {
|
||||
|
||||
private static final String COMMENT_TEMPLATES_DS = "我需要为我的商品模拟一些商品评论。你协助我生成2条不同的评价内容,京东商品评价的风格,每条评价100字左右,要基于原来的评论稍作修改,不要更换产品类型,只需要好评。不需要太浮夸,也不要太像ai生成,尽量模拟真实客户评价,不要提到以旧换新和国家补贴,只要回复我评论的内容就可以。这个是给你参考的其他真实用户的评论:";
|
||||
private static final long TIMEOUT_MINUTES = 30;
|
||||
public static final String WENAN_FANAN_LQD = "提供方法自己下单\n" + "全程都是自己的账号下单\n" + "标价就是下单的到手价\n" + "本人有耐心,会一步一步提供教程\n" + "以后有什么质量问题都是用自己的账号走京东售后\n" + "\n" + "更新\n" + "\n" + "用你自己的账号下单\n" + "官方店铺 提供方法自己下单\n" + "————————————————————\n" + "同行可长久合作,可提供神级家电线报\n" + "\n" + "配合家电线报可以自己下单,不用找代购和代下,订单和利润都掌握在自己手中。\n" + "\n" + "一次入会永久使用,包含家电帮,雷神价,韭菜帮,河南&湖南帮,各种暗号帮后返等内部独家家电线报\n" + "\n" + "JD采销采购不定时发放独家优惠券\n" + "\n" + "基本上你能看到的京东家电低价都是从这些渠道里面出来。\n" + "\n" + "2025年家电项目新方向,配合家电线报下单,秒省1K+。";
|
||||
public static final String WENAN_FANAN_HG = "\n手把手教你实现超值下单,无需依赖他人!全程使用个人专属账号操作,所见即所得,页面标价即为最终到手价。\n别担心操作难题,我会全程贴心指导,每一步都细致讲解,助你轻松下单。\n后续若出现任何质量问题,凭借个人账号就能直接对接JD官方售后,售后无忧。\n\n" + "更新\n" + "\n采用自主账号下单模式,官方店铺商品随心购,专业方法全程提供!\n" + "————————————————————\n" + "诚邀同行建立长期合作关系,海量独家家电优惠线报倾囊相授!\n" + "借助这些优质家电线报,无需寻求代购代下服务,自己就能轻松下单,订单信息与收益牢牢掌握在手中。\n" + "一次加入,终身受益!涵盖家电帮、雷神价、韭菜帮、河南 & 湖南帮等众多渠道,还有各类暗号帮后返等内部专属家电优惠信息一网打尽。\n" + "JD采销团队会不定时发放独家隐藏优惠券,市面上那些令人心动的JD家电低价好物,大多都源自这些渠道!\n" + "2025 年家电选购新趋势,依托线报下单,轻松省下千元开支,开启超值购物之旅!";
|
||||
public static final String WENAN_FANAN_LQD = "提供方法自己下单\n" + "全程都是自己的账号下单\n" + "标价就是下单的到手价\n" + "本人有耐心,会一步一步提供教程\n" + "以后有什么质量问题都是用自己的账号走冬京售后\n" + "\n" + "更新\n" + "\n" + "用你自己的账号下单\n" + "官方店铺 提供方法自己下单\n" + "————————————————————\n" + "同行可长久合作,可提供神级家电线报\n" + "\n" + "配合家电线报可以自己下单,不用找代购和代下,订单和利润都掌握在自己手中。\n" + "\n" + "一次入会永久使用,包含家电帮,雷神价,韭菜帮,河南&湖南帮,各种暗号帮后返等内部独家家电线报\n" + "\n" + "冬J京采销采购不定时发放独家优惠券\n" + "\n" + "基本上你能看到的冬京家电低价都是从这些渠道里面出来。\n" + "\n" + "2025年家电项目新方向,配合家电线报下单,秒省1K+。";
|
||||
public static final String WENAN_FANAN_HG = "\n手把手教你实现超值下单,无需依赖他人!全程使用个人专属账号操作,所见即所得,页面标价即为最终到手价。\n别担心操作难题,我会全程贴心指导,每一步都细致讲解,助你轻松下单。\n后续若出现任何质量问题,凭借个人账号就能直接对接冬J京官方售后,售后无忧。\n\n" + "更新\n" + "\n采用自主账号下单模式,官方店铺商品随心购,专业方法全程提供!\n" + "————————————————————\n" + "诚邀同行建立长期合作关系,海量独家家电优惠线报倾囊相授!\n" + "借助这些优质家电线报,无需寻求代购代下服务,自己就能轻松下单,订单信息与收益牢牢掌握在手中。\n" + "一次加入,终身受益!涵盖家电帮、雷神价、韭菜帮、河南 & 湖南帮等众多渠道,还有各类暗号帮后返等内部专属家电优惠信息一网打尽。\n" + "冬J京采销团队会不定时发放独家隐藏优惠券,市面上那些令人心动的冬J京家电低价好物,大多都源自这些渠道!\n" + "2025 年家电选购新趋势,依托线报下单,轻松省下千元开支,开启超值购物之旅!";
|
||||
public static final String WENAN_ZCXS = """
|
||||
|
||||
购买后,两小时内出库,物流会电话联系您,同时生成京东官方安装单。送装一体,无需担心。
|
||||
@@ -107,7 +110,23 @@ public class JDUtil {
|
||||
5:全国联保,全国统一安装标准。支持官方 400,服务号查询,假一赔十。
|
||||
""";
|
||||
public static final String WENAN_FANAN_BX = "本人提供免费指导下单服务,一台也是团购价,细心指导\n" + "\n" + "【质量】官旗下单,包正的\n" + "【物流】您自己账户可跟踪,24小时发货\n" + "【售后】您自己账户直接联系,无忧售后\n" + "【安装】专业人员安装,全程无需您操心\n" + "【价格】标价就是到手价,骑共享单车去酒吧,该省省该花花\n" + "【服务】手把手教您下单,有问题随时咨询\n" + "【体验】所有服务都是官旗提供,价格有内部渠道优惠,同品质更优惠!\n" + "\n" + "信息更新日期:\n" + "\n" + "捡漏价格不定时有变动,优惠不等人,发「省份+型号」免费咨询当日最低价!";
|
||||
public static final String FANAN_COMMON = "\n1 文案复制到微,点击领券,到J东APP结算\n" + "2 换新可直接代消单,不用提供回收\n " + "3 独家虹包 https://u.jd.com/raa0eI4 至高可领256188 \n";
|
||||
public static final String WENAN_YURONGFU = "坦博尔正品羽绒服!大额优惠券直接送!立省几百\n" +
|
||||
"品牌官方授权渠道直发,比旗舰自营店到手价更低,用你自己的账号下单更放心~\n" +
|
||||
"款色码全任你选(仅限店内展示款式),下单前必询库存!拒绝盲拍哦~\n" +
|
||||
"先确认货号、颜色、尺码,再拍不踩雷,建议先去实体店试穿合身,避免后续麻烦呀\n" +
|
||||
"到手价 = 页面标价 + 6 元代拍费(划重点:不是所有款都二百多,以页面标价为准)\n" +
|
||||
"叠加专属优惠券后,比自己直接买省不少,福利不等人!\n" +
|
||||
"正品保障拉满!假一罚十,支持任何渠道验货无忧\n" +
|
||||
"后续有质量问题,直接用自己的账号走官方售后,售后有保障\n" +
|
||||
"目前仅店内展示款式可拍,暂时没有额外款式补充哈\n" +
|
||||
"粉丝优先回复处理,官方渠道直发,代拍不退代拍费,望理解~\n" +
|
||||
"\n" +
|
||||
"更新\n" + // 日期替换标记,代码自动替换为“yyyyMMdd更新”
|
||||
"\n" ;
|
||||
|
||||
|
||||
public static final String FANAN_COMMON = "\n 文案复制到微x,点击领券,把商品加到J东,去APP结算才能显示折扣补贴\n";
|
||||
|
||||
|
||||
/**
|
||||
* 内部单号:
|
||||
@@ -124,10 +143,16 @@ public class JDUtil {
|
||||
* <p>
|
||||
* 订单号:
|
||||
*/
|
||||
private static final String WENAN_D = "单:\n" + "{单号} \n备注:{单的备注}\n" + "分销标记:{分销标记}\n" + "型号:\n" + "{型号}" + "\n" + "链接:\n" + "{链接}" + "\n" + "下单付款:\n" + "\n" + "后返金额:\n" + "\n" + "地址:\n" + "{地址}" + "\n" + "物流链接:\n" + "\n" + "订单号:\n" + "\n" + "下单人:\n" + "\n";
|
||||
private static final String WENAN_D = "单:\n" + "{单号} \n" + "分销标记:{分销标记}\n" + "第三方单号:\n" + "—————————\n" + "下单链接(必须用这个):\n" + "{链接}\n" + "下单地址(注意带分机):\n" + "{地址}\n" + "—————————\n" + "型号:{型号}\n" + "\n" + "下单人(需填):\n" + "\n" + "下单付款(注意核对):\n" + "\n" + "后返金额(注意核对):\n" + "\n" + "订单号(需填):\n" + "\n" + "物流链接(需填):\n" + "\n" + "备注(下单号码有变动/没法带分机号的写这里):\n" + "{单的备注}\n" + "—————————\n" + "京粉实际价格:不用填";
|
||||
|
||||
final WXUtil wxUtil;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
@Value("${jarvis.server.comment.base-url:http://192.168.8.6:5000}")
|
||||
private String commentBaseUrl;
|
||||
|
||||
@Value("${jarvis.server.comment.fetch-path:/fetch_comments}")
|
||||
private String commentFetchPath;
|
||||
private final OrderRowRepository orderRowRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final JDOrderRepository jdOrderRepository;
|
||||
@@ -166,21 +191,20 @@ public class JDUtil {
|
||||
|
||||
private void handleProductWithJF() {
|
||||
/**
|
||||
* 130
|
||||
* https://u.jd.com/Y6ZKmwN
|
||||
* 130B
|
||||
* https://u.jd.com/YGZKHZS
|
||||
* 150
|
||||
* https://u.jd.com/YDZK5rD
|
||||
* 180
|
||||
* https://u.jd.com/YDZKmb2
|
||||
* 92dpro
|
||||
* https://u.jd.com/YgZKViD*/
|
||||
productWithJF.put("ZQD130F-EB130", "https://u.jd.com/Y6ZKmwN");
|
||||
productWithJF.put("ZQD130F-EB130B", "https://u.jd.com/YGZKHZS");
|
||||
productWithJF.put("ZQD150F-EB150", " https://u.jd.com/YDZK5rD");
|
||||
productWithJF.put("ZQD180F-EB200", "https://u.jd.com/YDZKmb2");
|
||||
productWithJF.put("CXW-298-IQ92DPRO", "https://u.jd.com/Y1AMT2l");
|
||||
130
|
||||
https://u.jd.com/Sa6OgtP
|
||||
130B
|
||||
https://u.jd.com/Sa6OSY6
|
||||
150
|
||||
https://u.jd.com/Sg6Orka
|
||||
180
|
||||
https://u.jd.com/Sa6ODs5
|
||||
*/
|
||||
productWithJF.put("ZQD130F-EB130", "https://u.jd.com/Sa6OgtP");
|
||||
productWithJF.put("ZQD130F-EB130B", "https://u.jd.com/Sa6OSY6");
|
||||
productWithJF.put("ZQD150F-EB150", "https://u.jd.com/Sg6Orka");
|
||||
productWithJF.put("ZQD180F-EB200", "https://u.jd.com/Sa6ODs5");
|
||||
productWithJF.put("CXW-298-IQ92DPRO", "https://u.jd.com/SO1nC85");
|
||||
}
|
||||
|
||||
private List<OrderRow> filterOrdersByDate(List<OrderRow> orderRows, int daysBack) {
|
||||
@@ -2029,6 +2053,118 @@ public class JDUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供给对外API使用:根据商品类型选择一条评论
|
||||
* 优先返回未使用的京东评论;若无则尝试淘宝映射;仍无则从已使用中随机一条(京东和淘宝随机选择)。
|
||||
* 返回的 Comment 可能不是持久化实体(如淘宝生成),调用方需自行处理 images 解析。
|
||||
*/
|
||||
public synchronized cn.van.business.model.pl.Comment selectCommentForProductType(String productType) {
|
||||
try {
|
||||
// 加载型号映射
|
||||
getProductTypeMap();
|
||||
String productId = productTypeMap.get(productType);
|
||||
if (productId == null || productId.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 本地京东评论(优先未使用)
|
||||
java.util.List<cn.van.business.model.pl.Comment> availableComments = commentRepository
|
||||
.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(productId, 1);
|
||||
java.util.List<cn.van.business.model.pl.Comment> usedComments = commentRepository
|
||||
.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(productId, 0);
|
||||
|
||||
if (availableComments != null && !availableComments.isEmpty()) {
|
||||
java.util.Collections.shuffle(availableComments);
|
||||
cn.van.business.model.pl.Comment chosen = availableComments.get(0);
|
||||
// 标记为已使用
|
||||
try {
|
||||
if (chosen.getIsUse() == null || chosen.getIsUse() == 0) {
|
||||
chosen.setIsUse(1);
|
||||
commentRepository.save(chosen);
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// 无可用京东评论时,尝试淘宝映射生成(未使用的淘宝评论)
|
||||
cn.van.business.model.pl.Comment taobao = null;
|
||||
try {
|
||||
taobao = generateTaobaoComment(productType);
|
||||
if (taobao != null) {
|
||||
return taobao; // 注意:此处返回的对象未必持久化
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
|
||||
// 兜底:当京东和淘宝的未使用评论都用完了,在京东已使用和淘宝已使用之间随机选择
|
||||
java.util.List<cn.van.business.model.pl.Comment> allUsedComments = new java.util.ArrayList<>();
|
||||
|
||||
// 添加已使用的京东评论
|
||||
if (usedComments != null && !usedComments.isEmpty()) {
|
||||
allUsedComments.addAll(usedComments);
|
||||
}
|
||||
|
||||
// 添加已使用的淘宝评论
|
||||
try {
|
||||
getProductTypeMapForTB();
|
||||
String taobaoProductId = productTypeMapTB.getOrDefault(productId, productId);
|
||||
java.util.List<TaobaoComment> usedTbComments = taobaoCommentRepository
|
||||
.findByProductIdAndIsUseNotAndPictureUrlsIsNotNull(taobaoProductId, 0);
|
||||
|
||||
if (usedTbComments != null && !usedTbComments.isEmpty()) {
|
||||
// 将淘宝评论转换为京东评论格式
|
||||
for (TaobaoComment tbComment : usedTbComments) {
|
||||
cn.van.business.model.pl.Comment converted = new cn.van.business.model.pl.Comment();
|
||||
converted.setCommentText(tbComment.getCommentText());
|
||||
String pictureUrls = tbComment.getPictureUrls();
|
||||
if (pictureUrls != null) {
|
||||
pictureUrls = pictureUrls.replace("//img.", "https://img.");
|
||||
}
|
||||
converted.setPictureUrls(pictureUrls);
|
||||
converted.setCommentId(tbComment.getCommentId());
|
||||
converted.setProductId(productId);
|
||||
converted.setUserName(tbComment.getUserName());
|
||||
converted.setCreatedAt(tbComment.getCreatedAt());
|
||||
allUsedComments.add(converted);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("获取已使用的淘宝评论失败", e);
|
||||
}
|
||||
|
||||
// 从所有已使用的评论(京东+淘宝)中随机选择一条
|
||||
if (!allUsedComments.isEmpty()) {
|
||||
java.util.Collections.shuffle(allUsedComments);
|
||||
return allUsedComments.get(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("selectCommentForProductType error", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具:将数据库中的 pictureUrls 解析为列表。
|
||||
* 支持 JSON 数组或以逗号/空白分隔的纯文本。
|
||||
*/
|
||||
public static java.util.List<String> parsePictureUrlsToList(String raw) {
|
||||
if (raw == null || raw.trim().isEmpty()) return java.util.Collections.emptyList();
|
||||
try {
|
||||
Object parsed = com.alibaba.fastjson2.JSON.parse(raw);
|
||||
if (parsed instanceof com.alibaba.fastjson2.JSONArray) {
|
||||
com.alibaba.fastjson2.JSONArray ja = (com.alibaba.fastjson2.JSONArray) parsed;
|
||||
java.util.List<String> list = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < ja.size(); i++) {
|
||||
Object v = ja.get(i);
|
||||
if (v != null) list.add(String.valueOf(v));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
return java.util.Arrays.stream(raw.split("[\n,\t ]+"))
|
||||
.filter(s -> s != null && !s.trim().isEmpty())
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据商品类型生成评论内容,并优先使用京东评论,若无则使用淘宝评论
|
||||
@@ -2101,7 +2237,7 @@ public class JDUtil {
|
||||
*/
|
||||
if (!isTb) {
|
||||
try {
|
||||
String fetchUrl = "http://192.168.8.6:5000/fetch_comments?product_id=" + product_id;
|
||||
String fetchUrl = commentBaseUrl + commentFetchPath + "?product_id=" + product_id;
|
||||
HttpResponse response = HttpRequest.post(fetchUrl).timeout(1000 * 60).execute();
|
||||
|
||||
logger.info("fetchUrl: {}", fetchUrl);
|
||||
@@ -2531,30 +2667,51 @@ public class JDUtil {
|
||||
// 生成当前日期
|
||||
JDOrder jdOrder = parseOrderFromText(input.trim().replace("元", ""));
|
||||
|
||||
if (Util.isAnyEmpty(jdOrder.getOrderId(), jdOrder.getBuyer(), jdOrder.getOrderTime(), jdOrder.getPaymentAmount(), jdOrder.getRebateAmount(), jdOrder.getAddress(), jdOrder.getLogisticsLink(), jdOrder.getModelNumber(), jdOrder.getLink(), jdOrder.getOrderId(), jdOrder.getBuyer())) {
|
||||
// 警告缺少的字段getOrderId
|
||||
// 验证必填字段(根据新模板格式)
|
||||
// 单号存储在remark字段中,订单号存储在orderId字段中
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (Util.isEmpty(jdOrder.getOrderId())) {
|
||||
boolean hasError = false;
|
||||
|
||||
if (Util.isEmpty(jdOrder.getRemark())) {
|
||||
sb.append("单号\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getBuyer())) {
|
||||
sb.append("下单人\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getPaymentAmount())) {
|
||||
sb.append("下单价格\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getRebateAmount())) {
|
||||
sb.append("后返金额\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getLogisticsLink())) {
|
||||
sb.append("物流链接\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getAddress())) {
|
||||
sb.append("收货地址\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getModelNumber())) {
|
||||
sb.append("型号\n");
|
||||
hasError = true;
|
||||
}
|
||||
if (Util.isEmpty(jdOrder.getOrderTime())) {
|
||||
sb.append("下单时间\n");
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
// 添加调试信息:打印解析到的字段值
|
||||
logger.warn("录单解析失败,解析到的字段值: remark={}, buyer={}, paymentAmount={}, rebateAmount={}, logisticsLink={}, address={}, modelNumber={}, orderTime={}",
|
||||
jdOrder.getRemark(), jdOrder.getBuyer(), jdOrder.getPaymentAmount(),
|
||||
jdOrder.getRebateAmount(), jdOrder.getLogisticsLink(), jdOrder.getAddress(),
|
||||
jdOrder.getModelNumber(), jdOrder.getOrderTime());
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n缺少表单字段 \n" + sb, 1, fromWxid, true);
|
||||
}
|
||||
@@ -2563,9 +2720,9 @@ public class JDUtil {
|
||||
List<JDOrder> byAddress = jdOrderRepository.findByAddressOrderByOrderTimeDesc(jdOrder.getAddress());
|
||||
if (!byAddress.isEmpty()) {
|
||||
int count = byAddress.size();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(DateUtil.dateToStr(byAddress.get(0).getOrderTime(), "yyyy-MM-dd HH:mm:ss"));
|
||||
wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n收货地址重复,请确认 !!! \n 此地址共" + count + "个订单,最近的订单时间:" + sb, 1, fromWxid, true);
|
||||
StringBuilder addressSb = new StringBuilder();
|
||||
addressSb.append(DateUtil.dateToStr(byAddress.get(0).getOrderTime(), "yyyy-MM-dd HH:mm:ss"));
|
||||
wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n收货地址重复,请确认 !!! \n 此地址共" + count + "个订单,最近的订单时间:" + addressSb, 1, fromWxid, true);
|
||||
}
|
||||
JDOrder byRemark = jdOrderRepository.findByRemark(jdOrder.getRemark());
|
||||
String info;
|
||||
@@ -2579,7 +2736,7 @@ public class JDUtil {
|
||||
}
|
||||
jdOrderRepository.save(jdOrder);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
StringBuilder orderInfoSb = new StringBuilder();
|
||||
// 单号 下单日期 型号 内部订单号 地址 物流 外派给谁 后返金额 谁的单 下单价格
|
||||
String distributionMark = jdOrder.getDistributionMark();
|
||||
String distributionMark2 = "";
|
||||
@@ -2590,14 +2747,14 @@ public class JDUtil {
|
||||
distributionMark2 = "凡";
|
||||
}
|
||||
}
|
||||
sb.append(jdOrder.getRemark()).append("\t").append(jdOrder.getOrderId()).append("\t").append(DateUtil.dateToStr(jdOrder.getOrderTime(), "yyyy-MM-dd")).append("\t").append(jdOrder.getModelNumber()).append("\t").append(jdOrder.getAddress()).append("\t").append(jdOrder.getLogisticsLink()).append("\t\t").append(jdOrder.getBuyer()).append("\t").append(jdOrder.getPaymentAmount()).append("\t").append(jdOrder.getRebateAmount()).append("\t").append(distributionMark2);
|
||||
orderInfoSb.append(jdOrder.getRemark()).append("\t").append(jdOrder.getOrderId()).append("\t").append(DateUtil.dateToStr(jdOrder.getOrderTime(), "yyyy-MM-dd")).append("\t").append(jdOrder.getModelNumber()).append("\t").append(jdOrder.getAddress()).append("\t").append(jdOrder.getLogisticsLink()).append("\t\t").append(jdOrder.getBuyer()).append("\t").append(jdOrder.getPaymentAmount()).append("\t").append(jdOrder.getRebateAmount()).append("\t").append(distributionMark2);
|
||||
|
||||
logger.info("订单信息:{}", sb);
|
||||
logger.info("订单信息:{}", orderInfoSb);
|
||||
if (fromWxid.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
wxUtil.sendTextMessage(fromWxid, info, 1, null, true);
|
||||
wxUtil.sendTextMessage(fromWxid, sb.toString(), 1, null, true);
|
||||
wxUtil.sendTextMessage(fromWxid, orderInfoSb.toString(), 1, null, true);
|
||||
} else if (input.startsWith("TF")) {
|
||||
/*
|
||||
ZQD130F-EB130 1 张林 17530176250 湖北省 武汉市 东西湖区 径河街道 径河街道临空港小区二期 8栋2单元2204联系15783450649转6316
|
||||
@@ -2749,58 +2906,89 @@ public class JDUtil {
|
||||
|
||||
|
||||
public JDOrder parseOrderFromText(String input) {
|
||||
// 清理多余的空白字符
|
||||
input = input.replaceAll("\\s+", " ").trim();
|
||||
|
||||
// 不清理空白字符,保持原始格式以便正确解析多行字段
|
||||
Map<String, String> fields = new HashMap<>();
|
||||
|
||||
// 定义正则表达式提取各个字段
|
||||
extractField(input, fields, "单:", "备注:");
|
||||
extractField(input, fields, "备注:", "分销标记:");
|
||||
extractField(input, fields, "分销标记:", "型号:");
|
||||
extractField(input, fields, "型号:", "链接:");
|
||||
extractField(input, fields, "链接:", "下单付款:");
|
||||
extractField(input, fields, "下单付款:", "后返金额:");
|
||||
extractField(input, fields, "后返金额:", "地址:");
|
||||
extractField(input, fields, "地址:", "物流链接:");
|
||||
extractField(input, fields, "物流链接:", "订单号:");
|
||||
extractField(input, fields, "订单号:", "下单人:");
|
||||
// 适配新模板格式的字段提取
|
||||
// 按顺序提取各个字段,支持分隔线"—————————"作为字段分隔符
|
||||
extractFieldWithSeparator(input, fields, "单:", "分销标记:");
|
||||
extractFieldWithSeparator(input, fields, "分销标记:", "第三方单号:");
|
||||
extractFieldWithSeparator(input, fields, "第三方单号:", "下单链接(必须用这个):");
|
||||
extractFieldWithSeparator(input, fields, "下单链接(必须用这个):", "下单地址(注意带分机):");
|
||||
extractFieldWithSeparator(input, fields, "下单地址(注意带分机):", "型号:");
|
||||
extractFieldWithSeparator(input, fields, "型号:", "下单人(需填):");
|
||||
extractFieldWithSeparator(input, fields, "下单人(需填):", "下单付款(注意核对):");
|
||||
extractFieldWithSeparator(input, fields, "下单付款(注意核对):", "后返金额(注意核对):");
|
||||
extractFieldWithSeparator(input, fields, "后返金额(注意核对):", "订单号(需填):");
|
||||
extractFieldWithSeparator(input, fields, "订单号(需填):", "物流链接(需填):");
|
||||
extractFieldWithSeparator(input, fields, "物流链接(需填):", "备注(下单号码有变动/没法带分机号的写这里):");
|
||||
|
||||
// 手动提取"下单人"
|
||||
Pattern buyerPattern = Pattern.compile("下单人:\\s*(.*?)\\s*(?=单:|\\Z)", Pattern.DOTALL);
|
||||
Matcher buyerMatcher = buyerPattern.matcher(input);
|
||||
if (buyerMatcher.find()) {
|
||||
fields.put("下单人", buyerMatcher.group(1).trim());
|
||||
// 手动提取"备注"字段(最后一个字段,可能后面有分隔线或"京粉实际价格")
|
||||
Pattern remarkPattern = Pattern.compile("备注(下单号码有变动/没法带分机号的写这里):[\\s\\n]*([\\s\\S]*?)[\\s\\n]*(?=—————————|京粉实际价格|\\Z)", Pattern.DOTALL);
|
||||
Matcher remarkMatcher = remarkPattern.matcher(input);
|
||||
if (remarkMatcher.find()) {
|
||||
String remarkValue = remarkMatcher.group(1);
|
||||
if (remarkValue != null) {
|
||||
remarkValue = remarkValue.trim();
|
||||
// 去掉可能的分隔线
|
||||
remarkValue = remarkValue.replaceAll("^—————————[\\s\\n]*", "").trim();
|
||||
if (!remarkValue.isEmpty()) {
|
||||
fields.put("备注", remarkValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调试:打印提取到的字段
|
||||
logger.info("解析到的字段: {}", fields);
|
||||
|
||||
// 构建 JDOrder 对象
|
||||
JDOrder order = new JDOrder();
|
||||
order.setRemark(fields.getOrDefault("单", null));
|
||||
// 单号(格式:2025-12-13 006)
|
||||
String orderNo = fields.getOrDefault("单", "").trim();
|
||||
order.setRemark(orderNo);
|
||||
order.setDistributionMark(fields.getOrDefault("分销标记", null));
|
||||
order.setModelNumber(fields.getOrDefault("型号", null));
|
||||
order.setLink(fields.getOrDefault("链接", null));
|
||||
// 下单链接(必须用这个)
|
||||
order.setLink(fields.getOrDefault("下单链接(必须用这个)", null));
|
||||
// 备注字段
|
||||
order.setStatus(fields.getOrDefault("备注", null));
|
||||
// 第三方单号
|
||||
String thirdPartyOrderNo = fields.getOrDefault("第三方单号", null);
|
||||
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
|
||||
order.setThirdPartyOrderNo(thirdPartyOrderNo.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
order.setPaymentAmount(Double.parseDouble(fields.getOrDefault("下单付款", "0")));
|
||||
String paymentStr = fields.getOrDefault("下单付款(注意核对)", "0").trim();
|
||||
if (!paymentStr.isEmpty() && !paymentStr.equals("0")) {
|
||||
order.setPaymentAmount(Double.parseDouble(paymentStr));
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
|
||||
try {
|
||||
order.setRebateAmount(Double.parseDouble(fields.getOrDefault("后返金额", "0")));
|
||||
String rebateStr = fields.getOrDefault("后返金额(注意核对)", "0").trim();
|
||||
if (!rebateStr.isEmpty() && !rebateStr.equals("0")) {
|
||||
order.setRebateAmount(Double.parseDouble(rebateStr));
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
|
||||
order.setAddress(fields.getOrDefault("地址", null));
|
||||
order.setLogisticsLink(extractFirstUrl(fields.getOrDefault("物流链接", "")));
|
||||
order.setOrderId(fields.getOrDefault("订单号", null));
|
||||
order.setBuyer(fields.getOrDefault("下单人", null));
|
||||
// 下单地址(注意带分机)
|
||||
order.setAddress(fields.getOrDefault("下单地址(注意带分机)", null));
|
||||
order.setLogisticsLink(extractFirstUrl(fields.getOrDefault("物流链接(需填)", "")));
|
||||
order.setOrderId(fields.getOrDefault("订单号(需填)", null));
|
||||
order.setBuyer(fields.getOrDefault("下单人(需填)", null));
|
||||
|
||||
// 设置下单时间,格式为 yyyy-MM-dd HH:mm:ss
|
||||
try {
|
||||
String dateStr = fields.getOrDefault("单", "").split(" ")[0];
|
||||
if (!orderNo.isEmpty()) {
|
||||
String dateStr = orderNo.split("\\s+")[0]; // 从"2025-12-13 006"中提取日期部分
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
||||
order.setOrderTime(sdf.parse(dateStr));
|
||||
} else {
|
||||
order.setOrderTime(new Date());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
order.setOrderTime(new Date());
|
||||
}
|
||||
@@ -2808,6 +2996,48 @@ public class JDUtil {
|
||||
return order;
|
||||
}
|
||||
|
||||
// 提取字段的方法(支持分隔线)
|
||||
private void extractFieldWithSeparator(String input, Map<String, String> map, String startKeyword, String endKeyword) {
|
||||
try {
|
||||
// 构建正则表达式,匹配从startKeyword到endKeyword或分隔线之间的内容
|
||||
String escapedStart = Pattern.quote(startKeyword);
|
||||
String escapedEnd = Pattern.quote(endKeyword);
|
||||
|
||||
// 匹配模式:startKeyword + 可选的空白和换行 + 字段值(非贪婪,支持多行) + 后面是换行+endKeyword或换行+分隔线或结束
|
||||
// 使用[\s\S]来匹配包括换行在内的所有字符
|
||||
// 改进:更精确地匹配字段值,确保能捕获到换行后的内容
|
||||
String patternStr = escapedStart + "[\\s\\n]*([\\s\\S]*?)(?=\\s*\\n?\\s*" + escapedEnd + "|\\s*\\n?\\s*—————————|\\Z)";
|
||||
Pattern pattern = Pattern.compile(patternStr, Pattern.DOTALL);
|
||||
Matcher matcher = pattern.matcher(input);
|
||||
|
||||
if (matcher.find()) {
|
||||
String value = matcher.group(1);
|
||||
if (value != null) {
|
||||
// 去掉开头和结尾的空白字符和换行
|
||||
value = value.trim();
|
||||
// 去掉可能的分隔线(在值的前后)
|
||||
value = value.replaceAll("^—————————[\\s\\n]*", "").replaceAll("[\\s\\n]*—————————$", "").trim();
|
||||
// 去掉字段标签本身(如果被匹配到了)
|
||||
value = value.replaceAll("^" + escapedStart, "").trim();
|
||||
// 保存字段值,使用去掉冒号的key
|
||||
String key = startKeyword.replace(":", "").trim();
|
||||
if (!value.isEmpty()) {
|
||||
map.put(key, value);
|
||||
logger.debug("成功提取字段: {} = {}", key, value.length() > 50 ? value.substring(0, 50) + "..." : value);
|
||||
} else {
|
||||
logger.debug("字段值为空: {}", key);
|
||||
}
|
||||
} else {
|
||||
logger.debug("未找到字段值: startKeyword={}", startKeyword);
|
||||
}
|
||||
} else {
|
||||
logger.debug("正则未匹配: startKeyword={}, endKeyword={}", startKeyword, endKeyword);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("提取字段失败: startKeyword={}, endKeyword={}, error={}", startKeyword, endKeyword, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取字段的方法
|
||||
private void extractField(String input, Map<String, String> map, String startKeyword, String endKeyword) {
|
||||
Pattern pattern = Pattern.compile(startKeyword + "\\s*(.*?)\\s*(?=" + endKeyword + "|\\Z)", Pattern.DOTALL);
|
||||
|
||||
@@ -68,9 +68,13 @@ public class OrderUtil {
|
||||
if (!isAutoFlush || !lastValidCode.equals(newValidCode)) {
|
||||
String content = getFormattedOrderInfo(orderRow);
|
||||
String wxId = getWxidFromJdid(orderRow.getUnionId().toString());
|
||||
// 根据unionId获取接收人列表
|
||||
String unionIdStr = orderRow.getUnionId().toString();
|
||||
String touser = WXUtil.getTouserByUnionId(unionIdStr);
|
||||
logger.info("京粉订单推送 - unionId={}, wxId={}, touser={}", unionIdStr, wxId, touser);
|
||||
|
||||
if (Util.isNotEmpty(wxId)) {
|
||||
wxUtil.sendTextMessage(wxId, content, 1, wxId, true);
|
||||
wxUtil.sendTextMessage(wxId, content, 1, wxId, true, touser);
|
||||
// 不是已完成,不是违规的才发送
|
||||
if (newValidCode != 17 && newValidCode != 25 && newValidCode != 26 && newValidCode != 27 && newValidCode != 28) {
|
||||
// 发送今日统计信息
|
||||
@@ -98,13 +102,15 @@ public class OrderUtil {
|
||||
}
|
||||
if (shouldNotify) {
|
||||
String wxId = getWxidFromJdid(orderRow.getUnionId().toString());
|
||||
// 根据unionId获取接收人列表
|
||||
String touser = WXUtil.getTouserByUnionId(orderRow.getUnionId().toString());
|
||||
if (Util.isNotEmpty(wxId)) {
|
||||
String content = getFormattedOrderInfoForJB(orderRow);
|
||||
String alertMsg = "[爱心] 价保/赔付 : " + newProPriceAmount + " [爱心] \n" + content;
|
||||
|
||||
try {
|
||||
// 先发送通知
|
||||
wxUtil.sendTextMessage(wxId, alertMsg, 1, wxId, true);
|
||||
wxUtil.sendTextMessage(wxId, alertMsg, 1, wxId, true, touser);
|
||||
|
||||
// 通知成功后更新Redis,格式为 "金额:true"
|
||||
if (!isAutoFlushJB) {
|
||||
@@ -176,6 +182,8 @@ public class OrderUtil {
|
||||
if (!orderRowList.isEmpty()) {
|
||||
int i = 1;
|
||||
String wxId = getWxidFromJdid(orderRowList.get(0).getUnionId().toString());
|
||||
// 根据unionId获取接收人列表
|
||||
String touser = WXUtil.getTouserByUnionId(orderRowList.get(0).getUnionId().toString());
|
||||
StringBuilder content = new StringBuilder();
|
||||
content.append("批量订单:\n\r ").append(" 共 ").append(orderRowList.size()).append("单 \r");
|
||||
List<OrderRow> filterList = orderRowList.stream().filter(orderRow -> orderRow.getValidCode() != 2 && orderRow.getValidCode() != 3).toList();
|
||||
@@ -187,7 +195,7 @@ public class OrderUtil {
|
||||
|
||||
}
|
||||
if (Util.isNotEmpty(wxId)) {
|
||||
wxUtil.sendTextMessage(wxId, content.toString(), 1, wxId, false);
|
||||
wxUtil.sendTextMessage(wxId, content.toString(), 1, wxId, false, touser);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -247,6 +255,8 @@ public class OrderUtil {
|
||||
if (!orderRowList.isEmpty()) {
|
||||
int i = 1;
|
||||
String wxId = getWxidFromJdid(orderRowList.get(0).getUnionId().toString());
|
||||
// 根据unionId获取接收人列表
|
||||
String touser = WXUtil.getTouserByUnionId(orderRowList.get(0).getUnionId().toString());
|
||||
StringBuilder content = new StringBuilder();
|
||||
content.append("批量订单:\n\r ").append(" 共 ").append(orderRowList.size()).append("单 \r");
|
||||
List<OrderRow> filterList = orderRowList.stream().filter(orderRow -> orderRow.getValidCode() != 2 && orderRow.getValidCode() != 3).toList();
|
||||
@@ -258,7 +268,7 @@ public class OrderUtil {
|
||||
|
||||
}
|
||||
if (Util.isNotEmpty(wxId)) {
|
||||
wxUtil.sendTextMessage(wxId, content.toString(), 1, wxId, false);
|
||||
wxUtil.sendTextMessage(wxId, content.toString(), 1, wxId, false, touser);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.springframework.stereotype.Component;
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* @author Leo
|
||||
@@ -35,7 +34,11 @@ public class WXUtil {
|
||||
|
||||
public static final String default_super_admin_wxid = "wxid_ytpc72mdoskt22";
|
||||
private static final Logger logger = LoggerFactory.getLogger(WXUtil.class);
|
||||
public static String default_bot_wxid = "wxid_kr145nk7l0an31";
|
||||
//大号
|
||||
//public static String default_bot_wxid = "wxid_kr145nk7l0an31";
|
||||
|
||||
//小号
|
||||
public static String default_bot_wxid = "wxid_cfmrk2upjtf322";
|
||||
|
||||
public static Map<String, SuperAdmin> super_admins = new HashMap<>();
|
||||
public static Map<String, String> jdidToWxidMap = new HashMap<>();
|
||||
@@ -130,6 +133,33 @@ public class WXUtil {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据unionId获取SuperAdmin的接收人列表
|
||||
* @param unionId 联盟ID
|
||||
* @return 接收人列表(企业微信用户ID,多个用逗号分隔),如果未配置则返回null
|
||||
*/
|
||||
public static String getTouserByUnionId(String unionId) {
|
||||
if (unionId == null || unionId.trim().isEmpty()) {
|
||||
logger.debug("getTouserByUnionId: unionId为空");
|
||||
return null;
|
||||
}
|
||||
logger.debug("getTouserByUnionId: 查找unionId={}, super_admins数量={}", unionId, super_admins.size());
|
||||
for (SuperAdmin admin : super_admins.values()) {
|
||||
if (unionId.equals(admin.getUnionId())) {
|
||||
String touser = admin.getTouser();
|
||||
logger.debug("getTouserByUnionId: 找到匹配的SuperAdmin, unionId={}, name={}, touser={}",
|
||||
admin.getUnionId(), admin.getName(), touser);
|
||||
if (touser != null && !touser.trim().isEmpty()) {
|
||||
return touser.trim();
|
||||
} else {
|
||||
logger.debug("getTouserByUnionId: SuperAdmin的touser字段为空或未配置");
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.debug("getTouserByUnionId: 未找到匹配的SuperAdmin, unionId={}", unionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public static List<String> splitStringByLength(String input, int length) {
|
||||
List<String> result = new ArrayList<>();
|
||||
@@ -155,7 +185,8 @@ public class WXUtil {
|
||||
jdidToWxidMap.put(superAdmin.getUnionId(), superAdmin.getWxid());
|
||||
jdidToRemarkMap.put(superAdmin.getUnionId(), superAdmin.getName());
|
||||
}
|
||||
logger.info("超级管理员:{} {}", superAdmin.getName(), superAdmin.getWxid());
|
||||
logger.info("超级管理员:{} {}, unionId={}, touser={}",
|
||||
superAdmin.getName(), superAdmin.getWxid(), superAdmin.getUnionId(), superAdmin.getTouser());
|
||||
}
|
||||
|
||||
/* 内部管理群 */
|
||||
@@ -215,6 +246,10 @@ public class WXUtil {
|
||||
}
|
||||
|
||||
public void sendTextMessage(String wxid, String content, Integer msgType, String fromwxid, Boolean hiddenTime) {
|
||||
sendTextMessage(wxid, content, msgType, fromwxid, hiddenTime, null);
|
||||
}
|
||||
|
||||
public void sendTextMessage(String wxid, String content, Integer msgType, String fromwxid, Boolean hiddenTime, String touser) {
|
||||
// 全部打印
|
||||
//logger.info("发送文本消息 msgType: {} wxid: {} fromwxid: {} content: {}", msgType, wxid, fromwxid, content);
|
||||
// 先在content顶部插入时间戳
|
||||
@@ -258,6 +293,13 @@ public class WXUtil {
|
||||
}*/
|
||||
data.put("msg", string);
|
||||
data.put("wxid", wxid);
|
||||
data.put("msgType", msgType);
|
||||
data.put("fromWxid", fromwxid);
|
||||
data.put("hiddenTime", hiddenTime);
|
||||
// 如果提供了接收人列表,添加到数据中
|
||||
if (touser != null && !touser.trim().isEmpty()) {
|
||||
data.put("touser", touser.trim());
|
||||
}
|
||||
wxReqDate.setData(data);
|
||||
// wxReqDate 转成 JSONObject
|
||||
JSONObject message = JSON.parseObject(JSON.toJSONString(wxReqDate));
|
||||
@@ -349,7 +391,7 @@ public class WXUtil {
|
||||
return wxReqDate;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 * * * * ?")
|
||||
//@Scheduled(cron = "0 * * * * ?")
|
||||
public void checkWxStatus() {
|
||||
WxReqDate wxReqDate = createWxReqData(WXReqType.GET_WX_STATUS);
|
||||
JSONObject data = new JSONObject();
|
||||
|
||||
@@ -5,6 +5,7 @@ import cn.hutool.http.HttpResponse;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -21,6 +22,8 @@ public class WxtsUtil {
|
||||
public static final String TOKEN = "super_token_b62190c26";
|
||||
private static final String SERVER_URL = "https://wxts.van333.cn";
|
||||
|
||||
@Value("${jarvis.server.host:127.0.0.1}")
|
||||
private String serverHost;
|
||||
|
||||
public void sendNotify(String content) {
|
||||
try {
|
||||
@@ -28,10 +31,10 @@ public class WxtsUtil {
|
||||
HashMap<String, Object> paramMap = new HashMap<>();
|
||||
paramMap.put("title", "JD机器人微信推送");
|
||||
content = content.replaceAll("\\n", "<br>");
|
||||
String common = "192.168.8.88 (微信机器人), 信息 : ";
|
||||
String common = serverHost + " (微信机器人), 信息 : ";
|
||||
content = common + content + "<br><br>";
|
||||
paramMap.put("text", content);
|
||||
HttpResponse execute = HttpRequest.post(url).header("vanToken", TOKEN).header("source", "XZJ_UBUNTU").body(JSON.toJSONString(paramMap)).execute();
|
||||
HttpRequest.post(url).header("vanToken", TOKEN).header("source", "XZJ_UBUNTU").body(JSON.toJSONString(paramMap)).execute();
|
||||
//logger.info("企业微信推送结果:{}", execute);
|
||||
} catch (Exception e) {
|
||||
logger.error("企业微信推送失败:{}", e.getMessage());
|
||||
@@ -49,4 +52,85 @@ public class WxtsUtil {
|
||||
// - 触发短信通知
|
||||
sendNotify(formattedMsg); // 复用原有通知方法
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信文本消息到wxts接口
|
||||
* @param wxid 接收者微信ID
|
||||
* @param content 消息内容
|
||||
* @param msgType 消息类型
|
||||
* @param fromWxid 发送者微信ID
|
||||
* @param hiddenTime 是否隐藏时间戳
|
||||
*/
|
||||
public void sendWxTextMessage(String wxid, String content, Integer msgType, String fromWxid, Boolean hiddenTime) {
|
||||
sendWxTextMessage(wxid, content, msgType, fromWxid, hiddenTime, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信文本消息到wxts接口(带接收人参数)
|
||||
* @param wxid 接收者微信ID
|
||||
* @param content 消息内容
|
||||
* @param msgType 消息类型
|
||||
* @param fromWxid 发送者微信ID
|
||||
* @param hiddenTime 是否隐藏时间戳
|
||||
* @param touser 接收人列表(企业微信用户ID,多个用逗号分隔)
|
||||
*/
|
||||
public void sendWxTextMessage(String wxid, String content, Integer msgType, String fromWxid, Boolean hiddenTime, String touser) {
|
||||
try {
|
||||
String url = SERVER_URL + "/wx/send/jd";
|
||||
HashMap<String, Object> paramMap = new HashMap<>();
|
||||
paramMap.put("text", content);
|
||||
|
||||
// 如果提供了接收人列表,添加到参数中
|
||||
if (touser != null && !touser.trim().isEmpty()) {
|
||||
paramMap.put("touser", touser.trim());
|
||||
logger.info("企业微信推送设置接收人 - 接收人: {}", touser);
|
||||
}
|
||||
|
||||
HttpResponse execute = HttpRequest.post(url)
|
||||
.header("vanToken", TOKEN)
|
||||
.header("source", "XZJ_UBUNTU")
|
||||
.body(JSON.toJSONString(paramMap))
|
||||
.execute();
|
||||
|
||||
if (execute.getStatus() == 200) {
|
||||
logger.info("微信文本消息发送成功:wxid={}, content={}, touser={}", wxid, content, touser);
|
||||
} else {
|
||||
logger.error("微信文本消息发送失败:status={}, response={}", execute.getStatus(), execute.body());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("微信文本消息发送异常:{}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信图片消息到wxts接口
|
||||
* @param wxid 接收者微信ID
|
||||
* @param imagePath 图片路径
|
||||
*/
|
||||
public void sendWxImageMessage(String wxid, String imagePath) {
|
||||
try {
|
||||
String url = SERVER_URL + "/send/jd";
|
||||
HashMap<String, Object> paramMap = new HashMap<>();
|
||||
paramMap.put("wxid", wxid);
|
||||
paramMap.put("imagePath", imagePath);
|
||||
|
||||
// 提取文件名
|
||||
String[] split = imagePath.split("/");
|
||||
paramMap.put("fileName", split[split.length - 1]);
|
||||
|
||||
HttpResponse execute = HttpRequest.post(url)
|
||||
.header("vanToken", TOKEN)
|
||||
.header("source", "XZJ_UBUNTU")
|
||||
.body(JSON.toJSONString(paramMap))
|
||||
.execute();
|
||||
|
||||
if (execute.getStatus() == 200) {
|
||||
logger.info("微信图片消息发送成功:wxid={}, imagePath={}", wxid, imagePath);
|
||||
} else {
|
||||
logger.error("微信图片消息发送失败:status={}, response={}", execute.getStatus(), execute.body());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("微信图片消息发送异常:{}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.Method;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -28,8 +29,12 @@ public class GPTClientUtil {
|
||||
private static final String GPT_API_URL = "https://api.openai.com/v1/chat/completions"; // GPT API 地址
|
||||
private static final String GPT_API_KEY = "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"; // 替换为你的 GPT API 密钥
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper(); // Jackson JSON 解析器
|
||||
private static final String PROXY_HOST = "192.168.8.9"; // 本地代理地址
|
||||
private static final int PROXY_PORT = 1070; // 本地代理端口
|
||||
|
||||
@Value("${jarvis.server.proxy.host:192.168.8.9}")
|
||||
private String proxyHost; // 代理地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
|
||||
@Value("${jarvis.server.proxy.port:1070}")
|
||||
private int proxyPort; // 代理端口
|
||||
|
||||
/**
|
||||
* 调用 GPT API 并返回第一次回复的文本内容
|
||||
@@ -59,7 +64,7 @@ public class GPTClientUtil {
|
||||
String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
// 3. 使用 Hutool HTTP 发送请求,设置代理
|
||||
Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(PROXY_HOST, PROXY_PORT));
|
||||
Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));
|
||||
HttpRequest request = HttpRequest.of(GPT_API_URL)
|
||||
.method(Method.POST)
|
||||
.header("Content-Type", "application/json")
|
||||
|
||||
@@ -49,11 +49,35 @@ logging:
|
||||
level:
|
||||
cn.van: debug
|
||||
org.springframework: warn
|
||||
# 服务地址配置(用于服务器迁移)
|
||||
jarvis:
|
||||
# 服务器基础地址(如果所有服务都在同一台服务器,可以使用127.0.0.1)
|
||||
# 开发环境:根据实际情况配置
|
||||
server:
|
||||
host: 192.168.8.88
|
||||
# RocketMQ Name Server地址
|
||||
rocketmq:
|
||||
name-server: 192.168.8.88:39876
|
||||
# 微信机器人服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
wx:
|
||||
base-url: http://192.168.8.6:7777
|
||||
api-path: /qianxun/httpapi
|
||||
# 评论接口服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
comment:
|
||||
base-url: http://192.168.8.6:5000
|
||||
fetch-path: /fetch_comments
|
||||
# 青龙面板服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
qinglong:
|
||||
base-url: http://134.175.126.60:35700
|
||||
# 代理服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
proxy:
|
||||
host: 192.168.8.9
|
||||
port: 1070
|
||||
config:
|
||||
WX_BASE_URL: http://192.168.8.6:7777/qianxun/httpapi?wxid=wxid_kr145nk7l0an31
|
||||
QL_BASE_URL: http://134.175.126.60:35700
|
||||
rocketmq:
|
||||
name-server: 192.168.8.88:39876 # RocketMQ Name Server 地址
|
||||
name-server: ${jarvis.server.rocketmq.name-server} # RocketMQ Name Server 地址
|
||||
producer:
|
||||
group: wx_producer # 生产者组名
|
||||
send-msg-timeout: 1000 # 发送消息超时时间
|
||||
|
||||
@@ -8,7 +8,7 @@ spring:
|
||||
#数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://192.168.8.88:3306/jd?characterEncoding=utf-8&useSSL=true&serverTimezone=GMT%2B8
|
||||
url: jdbc:mysql://${jarvis.server.host}:3306/jd?characterEncoding=utf-8&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: root
|
||||
password: mysql_7sjTXH
|
||||
hikari:
|
||||
@@ -38,7 +38,7 @@ spring:
|
||||
basename: i18n/messages
|
||||
data:
|
||||
redis:
|
||||
host: 192.168.8.88
|
||||
host: ${jarvis.server.host}
|
||||
password: redis_6PZ52S
|
||||
timeout: 1800000
|
||||
port: 6379
|
||||
@@ -49,11 +49,35 @@ logging:
|
||||
level:
|
||||
cn.van: debug
|
||||
org.springframework: warn
|
||||
# 服务地址配置(用于服务器迁移)
|
||||
jarvis:
|
||||
# 服务器基础地址(如果所有服务都在同一台服务器,可以使用127.0.0.1)
|
||||
# 生产环境:192.168.8.88 或 127.0.0.1
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
# RocketMQ Name Server地址
|
||||
rocketmq:
|
||||
name-server: 127.0.0.1:9876
|
||||
# 微信机器人服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
wx:
|
||||
base-url: http://192.168.8.6:7777
|
||||
api-path: /qianxun/httpapi
|
||||
# 评论接口服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
comment:
|
||||
base-url: http://192.168.8.6:5000
|
||||
fetch-path: /fetch_comments
|
||||
# 青龙面板服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
qinglong:
|
||||
base-url: http://134.175.126.60:35700
|
||||
# 代理服务地址(注意:此服务不在同一台服务器,需要单独配置)
|
||||
proxy:
|
||||
host: 192.168.8.9
|
||||
port: 1070
|
||||
config:
|
||||
WX_BASE_URL: http://192.168.8.6:7777/qianxun/httpapi?wxid=wxid_kr145nk7l0an31
|
||||
WX_BASE_URL: http://192.168.8.6:7777/qianxun/httpapi?wxid=wxid_cfmrk2upjtf322 #wxid_kr145nk7l0an31大号
|
||||
QL_BASE_URL: http://134.175.126.60:35700
|
||||
rocketmq:
|
||||
name-server: 192.168.8.88:9876 # RocketMQ Name Server 地址
|
||||
name-server: ${jarvis.server.rocketmq.name-server} # RocketMQ Name Server 地址
|
||||
producer:
|
||||
group: wx_producer # 生产者组名
|
||||
send-msg-timeout: 1000 # 发送消息超时时间
|
||||
|
||||
@@ -57,8 +57,14 @@ logging:
|
||||
org.springframework.web: WARN
|
||||
org.apache.http: WARN
|
||||
com.zaxxer.hikari: ERROR
|
||||
# 服务地址配置(用于服务器迁移)
|
||||
jarvis:
|
||||
server:
|
||||
host: 192.168.8.88
|
||||
rocketmq:
|
||||
name-server: 192.168.8.88:9876 # RocketMQ Name Server 地址
|
||||
name-server: 192.168.8.88:9876
|
||||
rocketmq:
|
||||
name-server: ${jarvis.server.rocketmq.name-server:192.168.8.88:9876} # RocketMQ Name Server 地址
|
||||
producer:
|
||||
group: wx_producer # 生产者组名
|
||||
send-msg-timeout: 1000 # 发送消息超时时间
|
||||
@@ -88,3 +94,14 @@ resilience4j.ratelimiter:
|
||||
timeoutDuration: 0 # 立即失败模式
|
||||
registerHealthIndicator: true
|
||||
|
||||
# 图片转换配置
|
||||
image:
|
||||
convert:
|
||||
# 图片存储路径(转换后的jpg图片存储目录)
|
||||
storage-path: ${user.home}/comment-images
|
||||
# 图片访问基础URL(如果配置,转换后的图片将通过此URL访问)
|
||||
# 例如: http://your-domain.com/images 或 http://localhost:6666/images
|
||||
# 如果为空,则返回本地文件路径
|
||||
# 建议配置为:http://your-domain:6666/images (使用ImageController提供HTTP访问)
|
||||
base-url:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user