Compare commits

...

66 Commits

Author SHA1 Message Date
Leo
791a19839a 1 2026-01-17 22:41:25 +08:00
Leo
88ae4affa4 1 2026-01-17 22:31:45 +08:00
Leo
3dabd23dd7 1 2026-01-16 18:12:18 +08:00
Leo
7c7076f4ef 1 2025-12-22 22:56:17 +08:00
Leo
a2f32dc7c4 1 2025-12-14 15:43:00 +08:00
Leo
ad1caa64b8 1 2025-12-14 14:45:41 +08:00
Leo
8bd5ffc53d 1 2025-12-14 14:32:34 +08:00
Leo
f08fc60857 1 2025-12-14 14:20:16 +08:00
Leo
f541962b21 1 2025-12-14 14:07:10 +08:00
Leo
fcc934f8e5 1 2025-12-14 14:02:06 +08:00
Leo
8572faf072 1 2025-12-14 13:43:26 +08:00
Leo
d78e77530b 1 2025-12-14 00:00:45 +08:00
Leo
f89ed66bcc 1 2025-12-08 15:27:52 +08:00
Leo
a893f3cd61 1 2025-12-05 23:02:02 +08:00
Leo
c2be15e3f5 1 2025-12-05 23:01:41 +08:00
Leo
8445b500ae 1 2025-12-05 22:35:51 +08:00
Leo
ee67d1ae8f Merge branch 'master' of https://git.van333.cn/CC/Jarvis_java 2025-12-04 15:47:42 +08:00
Leo
a20e92d7bf 1 2025-12-04 15:47:39 +08:00
69d1d91f5e 1 2025-12-04 14:48:18 +08:00
Leo
570fcb0b93 1 2025-11-29 23:39:37 +08:00
Leo
7fda3da9ed 1 2025-11-29 22:47:41 +08:00
Leo
e7687c8909 1 2025-11-29 22:35:06 +08:00
Leo
8e12076225 1 2025-11-10 21:49:45 +08:00
Leo
5b48727fb2 1 2025-11-10 18:43:27 +08:00
Leo
bb6c907cda 1 2025-11-10 18:43:18 +08:00
Leo
bdd33581f1 1 2025-11-09 00:00:41 +08:00
Leo
ef358cc6b3 1 2025-11-08 15:25:43 +08:00
Leo
e76c6d4451 羽绒服 2025-11-08 02:18:35 +08:00
31ecfa6a2f 1 2025-11-03 19:49:12 +08:00
127a5b71c6 1 2025-11-03 19:44:22 +08:00
1872908dae 1 2025-11-03 16:03:34 +08:00
efdb727e48 1 2025-11-03 15:46:21 +08:00
4af64b58d6 1 2025-11-03 15:38:07 +08:00
424cf37260 1 2025-11-03 15:30:33 +08:00
47fd91b948 1 2025-11-03 15:29:21 +08:00
89b37907e7 1 2025-11-03 15:26:52 +08:00
6b36f0ee52 1 2025-11-03 11:54:11 +08:00
5f75603532 1 2025-10-31 22:25:09 +08:00
a2d011fb01 1 2025-10-31 22:04:01 +08:00
1a6ddce3f0 1 2025-10-31 16:58:23 +08:00
a82ff0d39f 1 2025-10-28 00:33:26 +08:00
1c9c9cfa06 1 2025-10-28 00:18:31 +08:00
8905ce179c 1 2025-10-22 19:58:26 +08:00
2114f5d0f6 1 2025-10-08 17:16:25 +08:00
4010910846 1 2025-10-08 17:12:25 +08:00
bd9b0f9384 1 2025-10-03 11:53:31 +08:00
雷欧(林平凡)
b9de9ed7f4 1 2025-09-25 16:06:37 +08:00
雷欧(林平凡)
fd940bbd66 1 2025-09-25 16:03:14 +08:00
雷欧(林平凡)
473e305bb7 1 2025-09-09 11:39:09 +08:00
雷欧(林平凡)
c1fbe3bb4b 1 2025-09-09 11:16:52 +08:00
雷欧(林平凡)
41083d4519 1 2025-09-09 10:45:04 +08:00
595642677f 1 2025-09-07 17:35:35 +08:00
d74af8a07f 1 2025-09-07 17:25:16 +08:00
6e68591991 1 2025-09-06 16:17:37 +08:00
雷欧(林平凡)
5543b5bcde 1 2025-09-04 18:00:43 +08:00
c3a23bf6fa 1 2025-09-02 19:17:21 +08:00
b7528dc077 1 2025-08-31 15:25:36 +08:00
3883cd76b4 1 2025-08-31 15:08:06 +08:00
49320e35ed 1 2025-08-28 01:15:34 +08:00
a95956a73e 1 2025-08-28 01:09:36 +08:00
b983219502 1 2025-08-28 01:07:09 +08:00
cbf1600497 1 2025-08-28 01:03:53 +08:00
雷欧(林平凡)
cce7ffad00 1 2025-08-22 11:47:45 +08:00
雷欧(林平凡)
841c4a6a5a Merge remote-tracking branch '群晖/master' 2025-08-22 11:47:17 +08:00
雷欧(林平凡)
e736ad9a96 1 2025-08-22 11:45:59 +08:00
d582014f24 1 2025-08-21 19:55:14 +08:00
33 changed files with 4558 additions and 388 deletions

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ build/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
/logs/app.log /logs/app.log
/logs/

115
Redis清理说明.md Normal file
View 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天前的数据。

View 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
View 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 "✅ 已设置执行权限"

View File

@@ -1,228 +1,9 @@
2025-07-13 18:24:00 [main] INFO cn.van.Application - Starting Application using Java 17.0.14 with PID 44180 (D:\code\jd\target\classes started by CC in D:\code\jd) 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-07-13 18:24:00 [main] DEBUG cn.van.Application - Running with Spring Boot v3.1.5, Spring v6.0.13 2025-11-03 15:29:34 [main] DEBUG cn.van.Application - Running with Spring Boot v3.1.5, Spring v6.0.13
2025-07-13 18:24:00 [main] INFO cn.van.Application - The following 1 profile is active: "dev" 2025-11-03 15:29:34 [main] INFO cn.van.Application - The following 1 profile is active: "dev"
2025-07-13 18:24:02 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-6666"] 2025-11-03 15:29:37 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-6666"]
2025-07-13 18:24:02 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat] 2025-11-03 15:29:37 [main] INFO o.a.catalina.core.StandardService - Starting service [Tomcat]
2025-07-13 18:24:02 [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.catalina.core.StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.15]
2025-07-13 18:24:02 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext 2025-11-03 15:29:37 [main] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
2025-07-13 18:24:09 [main] INFO o.a.r.s.a.RocketMQAutoConfiguration - a producer (wx_producer) init on namesrv 192.168.8.88:39876 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-07-13 18:24:13 [main] ERROR o.a.r.spring.core.RocketMQTemplate - syncSend failed. destination:wx-message, message:GenericMessage [payload={"data":{"msg":"[ 18:24:07 2025-07-13 ] \r\nJarvis 更新完成 [亲亲][亲亲][亲亲] ","wxid":"wxid_ytpc72mdoskt22"},"type":"sendText2"}, headers={TAGS=wx, id=1c782993-2b30-94a7-e131-7e2f9727d2f6, timestamp=1752402251367}], detail exception info: 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)
java.lang.IllegalStateException: org.apache.rocketmq.remoting.exception.RemotingConnectException: connect to null failed
at org.apache.rocketmq.client.impl.factory.MQClientInstance.updateTopicRouteInfoFromNameServer(MQClientInstance.java:843)
at org.apache.rocketmq.client.impl.factory.MQClientInstance.updateTopicRouteInfoFromNameServer(MQClientInstance.java:574)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.tryToFindTopicPublishInfo(DefaultMQProducerImpl.java:887)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.sendDefaultImpl(DefaultMQProducerImpl.java:745)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.send(DefaultMQProducerImpl.java:1564)
at org.apache.rocketmq.client.producer.DefaultMQProducer.send(DefaultMQProducer.java:475)
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:687)
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:487)
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:475)
at org.apache.rocketmq.spring.core.RocketMQTemplate.doSend(RocketMQTemplate.java:1142)
at org.apache.rocketmq.spring.core.RocketMQTemplate.doSend(RocketMQTemplate.java:61)
at org.springframework.messaging.core.AbstractMessageSendingTemplate.send(AbstractMessageSendingTemplate.java:109)
at cn.van.business.mq.MessageProducerService.sendMessage(MessageProducerService.java:54)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:343)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:699)
at cn.van.business.mq.MessageProducerService$$SpringCGLIB$$0.sendMessage(<generated>)
at cn.van.business.util.WXUtil.sendTextMessage(WXUtil.java:327)
at cn.van.business.util.WXUtil.initSuperAdmins(WXUtil.java:243)
at cn.van.business.util.WXUtil.<init>(WXUtil.java:100)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:500)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:481)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:211)
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:110)
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:309)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1189)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:560)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337)
at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:910)
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:788)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1189)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:560)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337)
at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:910)
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:788)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1189)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:560)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:764)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:747)
at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:145)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:492)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1416)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:597)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:973)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:950)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:616)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:738)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:440)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:316)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1306)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1295)
at cn.van.Application.main(Application.java:31)
Caused by: org.apache.rocketmq.remoting.exception.RemotingConnectException: connect to null failed
at org.apache.rocketmq.remoting.netty.NettyRemotingClient.invokeSync(NettyRemotingClient.java:572)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.getTopicRouteInfoFromNameServer(MQClientAPIImpl.java:2050)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.getTopicRouteInfoFromNameServer(MQClientAPIImpl.java:2041)
at org.apache.rocketmq.client.impl.factory.MQClientInstance.updateTopicRouteInfoFromNameServer(MQClientInstance.java:782)
... 91 common frames omitted
2025-07-13 18:24:13 [main] WARN o.s.b.w.s.c.AnnotationConfigServletWebServerApplicationContext - Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'WXListener': Unsatisfied dependency expressed through field 'wxMessageConsumer': Error creating bean with name 'wxMessageConsumer' defined in file [D:\code\jd\target\classes\cn\van\business\util\WxMessageConsumer.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'otherUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\OtherUtil.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'WXUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\WXUtil.class]: Failed to instantiate [cn.van.business.util.WXUtil]: Constructor threw exception
2025-07-13 18:24:14 [main] INFO o.a.catalina.core.StandardService - Stopping service [Tomcat]
2025-07-13 18:24:14 [main] ERROR o.s.boot.SpringApplication - Application run failed
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'WXListener': Unsatisfied dependency expressed through field 'wxMessageConsumer': Error creating bean with name 'wxMessageConsumer' defined in file [D:\code\jd\target\classes\cn\van\business\util\WxMessageConsumer.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'otherUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\OtherUtil.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'WXUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\WXUtil.class]: Failed to instantiate [cn.van.business.util.WXUtil]: Constructor threw exception
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:767)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:747)
at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:145)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:492)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1416)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:597)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:973)
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:950)
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:616)
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146)
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:738)
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:440)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:316)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1306)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1295)
at cn.van.Application.main(Application.java:31)
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'wxMessageConsumer' defined in file [D:\code\jd\target\classes\cn\van\business\util\WxMessageConsumer.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'otherUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\OtherUtil.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'WXUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\WXUtil.class]: Failed to instantiate [cn.van.business.util.WXUtil]: Constructor threw exception
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:801)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1189)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:560)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:764)
... 20 common frames omitted
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'otherUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\OtherUtil.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'WXUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\WXUtil.class]: Failed to instantiate [cn.van.business.util.WXUtil]: Constructor threw exception
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:801)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1189)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:560)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337)
at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:910)
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:788)
... 33 common frames omitted
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'WXUtil' defined in file [D:\code\jd\target\classes\cn\van\business\util\WXUtil.class]: Failed to instantiate [cn.van.business.util.WXUtil]: Constructor threw exception
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:309)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1189)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:560)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:520)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1417)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1337)
at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:910)
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:788)
... 47 common frames omitted
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [cn.van.business.util.WXUtil]: Constructor threw exception
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:224)
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:110)
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:318)
... 61 common frames omitted
Caused by: org.springframework.messaging.MessagingException: org.apache.rocketmq.remoting.exception.RemotingConnectException: connect to null failed
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:695)
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:487)
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:475)
at org.apache.rocketmq.spring.core.RocketMQTemplate.doSend(RocketMQTemplate.java:1142)
at org.apache.rocketmq.spring.core.RocketMQTemplate.doSend(RocketMQTemplate.java:61)
at org.springframework.messaging.core.AbstractMessageSendingTemplate.send(AbstractMessageSendingTemplate.java:109)
at cn.van.business.mq.MessageProducerService.sendMessage(MessageProducerService.java:54)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:343)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:699)
at cn.van.business.mq.MessageProducerService$$SpringCGLIB$$0.sendMessage(<generated>)
at cn.van.business.util.WXUtil.sendTextMessage(WXUtil.java:327)
at cn.van.business.util.WXUtil.initSuperAdmins(WXUtil.java:243)
at cn.van.business.util.WXUtil.<init>(WXUtil.java:100)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:500)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:481)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:211)
... 63 common frames omitted
Caused by: java.lang.IllegalStateException: org.apache.rocketmq.remoting.exception.RemotingConnectException: connect to null failed
at org.apache.rocketmq.client.impl.factory.MQClientInstance.updateTopicRouteInfoFromNameServer(MQClientInstance.java:843)
at org.apache.rocketmq.client.impl.factory.MQClientInstance.updateTopicRouteInfoFromNameServer(MQClientInstance.java:574)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.tryToFindTopicPublishInfo(DefaultMQProducerImpl.java:887)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.sendDefaultImpl(DefaultMQProducerImpl.java:745)
at org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl.send(DefaultMQProducerImpl.java:1564)
at org.apache.rocketmq.client.producer.DefaultMQProducer.send(DefaultMQProducer.java:475)
at org.apache.rocketmq.spring.core.RocketMQTemplate.syncSend(RocketMQTemplate.java:687)
... 85 common frames omitted
Caused by: org.apache.rocketmq.remoting.exception.RemotingConnectException: connect to null failed
at org.apache.rocketmq.remoting.netty.NettyRemotingClient.invokeSync(NettyRemotingClient.java:572)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.getTopicRouteInfoFromNameServer(MQClientAPIImpl.java:2050)
at org.apache.rocketmq.client.impl.MQClientAPIImpl.getTopicRouteInfoFromNameServer(MQClientAPIImpl.java:2041)
at org.apache.rocketmq.client.impl.factory.MQClientInstance.updateTopicRouteInfoFromNameServer(MQClientInstance.java:782)
... 91 common frames omitted

85
manage_service.sh Normal file
View 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

18
pom.xml
View File

@@ -113,6 +113,12 @@
<artifactId>httpclient</artifactId> <artifactId>httpclient</artifactId>
<version>4.5.13</version> <version>4.5.13</version>
</dependency> </dependency>
<!-- 图片处理库 Thumbnailator -->
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -158,5 +164,17 @@
<name>RocketMQ Repository</name> <name>RocketMQ Repository</name>
<url>https://repo1.maven.org/maven2/org/apache/rocketmq/</url> <url>https://repo1.maven.org/maven2/org/apache/rocketmq/</url>
</repository> </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> </repositories>
</project> </project>

284
setup_centos.sh Normal file
View 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
View 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
# 安装 FlaskAPI 服务需要)
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
View 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='图片转换记录表';

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -1,6 +1,13 @@
package cn.van.business.controller.jd; 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.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.JSONArray;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -10,6 +17,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
import java.util.*;
import java.util.Random;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/jd") @RequestMapping("/jd")
@@ -24,10 +34,20 @@ public class JDInnerController {
} }
private final JDProductService jdProductService; 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 @Autowired
public JDInnerController(JDProductService jdProductService) { public JDInnerController(JDProductService jdProductService, JDUtil jdUtil, JDScheduleJob jdScheduleJob, CommentRepository commentRepository, TaobaoCommentRepository taobaoCommentRepository, ImageConvertService imageConvertService) {
this.jdProductService = jdProductService; this.jdProductService = jdProductService;
this.jdUtil = jdUtil;
this.jdScheduleJob = jdScheduleJob;
this.commentRepository = commentRepository;
this.taobaoCommentRepository = taobaoCommentRepository;
this.imageConvertService = imageConvertService;
} }
@PostMapping("/generatePromotionContent") @PostMapping("/generatePromotionContent")
@@ -45,6 +65,357 @@ public class JDInnerController {
return arr; 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") @PostMapping("/createGiftCoupon")
public Object createGiftCoupon(@RequestBody Map<String, Object> body) { public Object createGiftCoupon(@RequestBody Map<String, Object> body) {
String skey = body.get("skey") != null ? String.valueOf(body.get("skey")) : null; String skey = body.get("skey") != null ? String.valueOf(body.get("skey")) : null;
@@ -68,12 +439,21 @@ public class JDInnerController {
try { try {
String giftKey = jdProductService.createGiftCoupon(idOrUrl, amount, quantity, owner, skuName); 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(); JSONObject resp = new JSONObject();
resp.put("giftCouponKey", giftKey); resp.put("giftCouponKey", giftKey);
// 可选:入库/缓存
if (giftKey != null) {
jdProductService.saveGiftCouponToRedis(idOrUrl, giftKey, skuName, owner);
}
return resp; return resp;
} catch (Exception e) { } catch (Exception e) {
logger.error("createGiftCoupon error", 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) { private static JSONObject error(String msg) {
JSONObject o = new JSONObject(); JSONObject o = new JSONObject();
o.put("error", msg); o.put("error", msg);

View File

@@ -15,6 +15,7 @@ public class JDOrder {
private String remark; // 单据备注(如日期编号) private String remark; // 单据备注(如日期编号)
private String distributionMark; // 分销标记 private String distributionMark; // 分销标记
private String thirdPartyOrderNo; // 第三方单号
private String modelNumber; // 型号 private String modelNumber; // 型号
private String link; // 链接 private String link; // 链接
private Double paymentAmount; // 下单付款金额 private Double paymentAmount; // 下单付款金额

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

View File

@@ -61,6 +61,12 @@ public class SuperAdmin {
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
private Integer isActive = 1; private Integer isActive = 1;
/**
* 接收人企业微信用户ID多个用逗号分隔
*/
@Column(name = "touser", length = 500)
private String touser;
/** /**
* 创建时间 * 创建时间
*/ */

View File

@@ -1,10 +1,7 @@
package cn.van.business.mq; package cn.van.business.mq;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.http.HttpRequest;
import cn.van.business.util.WxtsUtil; import cn.van.business.util.WxtsUtil;
import com.alibaba.fastjson2.JSONObject; 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.ConsumeMode;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener; 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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; 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 * @author Leo
* @version 1.0 * @version 1.0
@@ -46,35 +38,35 @@ public class MessageConsumerService implements RocketMQListener<JSONObject> {
public void onMessage(JSONObject message) { public void onMessage(JSONObject message) {
try { try {
logger.info("消费消息:{}", message); 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) { } catch (Exception e) {
logger.error("消息处理失败,原始消息:{}", message, e); logger.error("消息处理失败,原始消息:{}", message, e);
wxtsUtil.sendNotify("系统异常:" + e.getMessage()); wxtsUtil.sendNotify("系统异常:" + e.getMessage());

View File

@@ -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);
}

View 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());
// 不抛出异常,因为转换本身已成功
}
}
}

View 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;
}
}
}

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

View File

@@ -32,7 +32,7 @@ import java.text.SimpleDateFormat;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit; import java.util.regex.Pattern;
import static cn.van.business.util.JDUtil.*; import static cn.van.business.util.JDUtil.*;
@@ -43,8 +43,10 @@ import static cn.van.business.util.JDUtil.*;
@Slf4j @Slf4j
public class JDProductService { public class JDProductService {
private static final String LPF_APP_KEY_WZ = "98e21c89ae5610240ec3f5f575f86a59"; // 自己的98e21c89ae5610240ec3f5f575f86a59
private static final String LPF_SECRET_KEY_WZ = "3dcb6b23a1104639ac433fd07adb6dfb"; 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 SERVER_URL = "https://api.jd.com/routerjson";
private static final String ACCESS_TOKEN = ""; private static final String ACCESS_TOKEN = "";
private static final Logger logger = LoggerFactory.getLogger(JDProductService.class); private static final Logger logger = LoggerFactory.getLogger(JDProductService.class);
@@ -84,17 +86,29 @@ public class JDProductService {
for (String url : urls) { for (String url : urls) {
try { try {
String format = dateFormat.format(new Date()); 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) { if (productInfo == null || productInfo.getCode() != 200 || productInfo.getData() == null || productInfo.getData().length == 0) {
JSONObject errorObj = new JSONObject(); JSONObject errorObj = new JSONObject();
errorObj.put("url", url); errorObj.put("url", originalUrlInText);
errorObj.put("error", "链接查询失败"); errorObj.put("error", "链接查询失败");
resultArray.add(errorObj); resultArray.add(errorObj);
continue; continue;
} }
JSONObject productObj = new JSONObject(); JSONObject productObj = new JSONObject();
productObj.put("originalUrl", url); productObj.put("originalUrl", originalUrlInText);
productObj.put("normalizedUrl", normalizedUrl);
// 商品基本信息 // 商品基本信息
productObj.put("materialUrl", productInfo.getData()[0].getMaterialUrl()); productObj.put("materialUrl", productInfo.getData()[0].getMaterialUrl());
@@ -106,6 +120,7 @@ public class JDProductService {
String cleanSkuName = productInfo.getData()[0].getSkuName().replaceAll("以旧|政府|换新|领取|国家|补贴|15%|20%|国补|立减|【|】", ""); String cleanSkuName = productInfo.getData()[0].getSkuName().replaceAll("以旧|政府|换新|领取|国家|补贴|15%|20%|国补|立减|【|】", "");
productObj.put("cleanSkuName", cleanSkuName); productObj.put("cleanSkuName", cleanSkuName);
productObj.put("spuid", String.valueOf(productInfo.getData()[0].getSpuid())); 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("commission", String.valueOf(productInfo.getData()[0].getCommissionInfo().getCommission()));
productObj.put("commissionShare", String.valueOf(productInfo.getData()[0].getCommissionInfo().getCommissionShare())); productObj.put("commissionShare", String.valueOf(productInfo.getData()[0].getCommissionInfo().getCommissionShare()));
if (productInfo.getData()[0].getPriceInfo() != null && productInfo.getData()[0].getPriceInfo().getPrice() != null) { if (productInfo.getData()[0].getPriceInfo() != null && productInfo.getData()[0].getPriceInfo().getPrice() != null) {
@@ -123,22 +138,24 @@ public class JDProductService {
// 生成转链后的短链 // 生成转链后的短链
try { try {
String shortUrl = transfer(url, null); String shortUrl = transfer(normalizedUrl, null);
String effectiveUrl = normalizedUrl;
if (shortUrl != null && !shortUrl.isEmpty()) { if (shortUrl != null && !shortUrl.isEmpty()) {
productObj.put("shortUrl", shortUrl); productObj.put("shortUrl", shortUrl);
productObj.put("transferSuccess", true); productObj.put("transferSuccess", true);
// 将短链替换原始链接,用于后续文案生成 effectiveUrl = shortUrl;
url = shortUrl;
} else { } else {
productObj.put("shortUrl", url); // 如果转链失败,使用链接 productObj.put("shortUrl", normalizedUrl); // 如果转链失败,使用归一化后的链接
productObj.put("transferSuccess", false); productObj.put("transferSuccess", false);
log.warn("转链失败,使用原链接: {}", url); log.warn("转链失败,使用原链接: {}", normalizedUrl);
} }
productObj.put("effectiveUrl", effectiveUrl);
} catch (Exception e) { } catch (Exception e) {
log.error("生成转链时发生异常: {}", url, e); log.error("生成转链时发生异常: {}", normalizedUrl, e);
productObj.put("shortUrl", url); // 转链异常时使用原链接 productObj.put("shortUrl", normalizedUrl); // 转链异常时使用原链接
productObj.put("transferSuccess", false); productObj.put("transferSuccess", false);
productObj.put("transferError", e.getMessage()); productObj.put("transferError", e.getMessage());
productObj.put("effectiveUrl", normalizedUrl);
} }
// 文案信息 // 文案信息
@@ -181,13 +198,28 @@ public class JDProductService {
wenan4.put("content", "【教你下单】 " + title + cleanSkuName + "\n" + WENAN_FANAN_BX.replaceAll("信息更新日期:", "信息更新日期:" + format)); wenan4.put("content", "【教你下单】 " + title + cleanSkuName + "\n" + WENAN_FANAN_BX.replaceAll("信息更新日期:", "信息更新日期:" + format));
wenanArray.add(wenan4); 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); productObj.put("wenan", wenanArray);
// 添加通用文案 - 使用转链后的短链替换原始链接 // 添加通用文案 - 使用转链后的短链替换原始链接
JSONObject commonWenan = new JSONObject(); JSONObject commonWenan = new JSONObject();
commonWenan.put("type", "通用文案"); commonWenan.put("type", "通用文案");
// 将原始消息中的链接替换为转链后的短链 // 将原始消息中的链接替换为转链后的短链
String messageWithShortUrl = message.replace(productObj.getString("originalUrl"), url); 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); commonWenan.put("content", format + FANAN_COMMON + messageWithShortUrl);
wenanArray.add(commonWenan); wenanArray.add(commonWenan);
@@ -289,8 +321,62 @@ public class JDProductService {
log.debug("礼金创建成功giftKey={}", giftKey); log.debug("礼金创建成功giftKey={}", giftKey);
return 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);
} }
/** /**
@@ -396,4 +482,168 @@ public class JDProductService {
return errorMap; 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 == '.';
}
} }

View File

@@ -32,7 +32,10 @@ import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -69,6 +72,12 @@ public class JDScheduleJob {
@Getter @Getter
@Value("${isRunning.jd}") @Value("${isRunning.jd}")
private String 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 // 构造函数中注入StringRedisTemplate
@Autowired @Autowired
@@ -458,7 +467,7 @@ public class JDScheduleJob {
return client.execute(request); 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() { public void fetchPL() {
logger.info("开始执行fetchPL任务"); logger.info("开始执行fetchPL任务");
// 设置每天最多执行 3 次 // 设置每天最多执行 3 次
@@ -513,7 +522,7 @@ public class JDScheduleJob {
try { 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请求 // 用hutool发起post请求
HttpResponse response = HttpRequest.post(fetchUrl).timeout(60000).execute(); 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键清理完成 ===");
}
} }

View File

@@ -35,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -78,8 +79,10 @@ public class JDUtil {
*/ */
// van论坛 // van论坛
private static final String LPF_APP_KEY_WZ = "98e21c89ae5610240ec3f5f575f86a59"; // 自己的98e21c89ae5610240ec3f5f575f86a59
private static final String LPF_SECRET_KEY_WZ = "3dcb6b23a1104639ac433fd07adb6dfb"; 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 SERVER_URL = "https://api.jd.com/routerjson";
//accessToken //accessToken
@@ -93,8 +96,8 @@ public class JDUtil {
private static final String COMMENT_TEMPLATES_DS = "我需要为我的商品模拟一些商品评论。你协助我生成2条不同的评价内容京东商品评价的风格每条评价100字左右要基于原来的评论稍作修改不要更换产品类型只需要好评。不需要太浮夸也不要太像ai生成尽量模拟真实客户评价不要提到以旧换新和国家补贴只要回复我评论的内容就可以。这个是给你参考的其他真实用户的评论"; private static final String COMMENT_TEMPLATES_DS = "我需要为我的商品模拟一些商品评论。你协助我生成2条不同的评价内容京东商品评价的风格每条评价100字左右要基于原来的评论稍作修改不要更换产品类型只需要好评。不需要太浮夸也不要太像ai生成尽量模拟真实客户评价不要提到以旧换新和国家补贴只要回复我评论的内容就可以。这个是给你参考的其他真实用户的评论";
private static final long TIMEOUT_MINUTES = 30; 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_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后续若出现任何质量问题凭借个人账号就能直接对接JD官方售后,售后无忧。\n\n" + "更新\n" + "\n采用自主账号下单模式官方店铺商品随心购专业方法全程提供\n" + "————————————————————\n" + "诚邀同行建立长期合作关系,海量独家家电优惠线报倾囊相授!\n" + "借助这些优质家电线报,无需寻求代购代下服务,自己就能轻松下单,订单信息与收益牢牢掌握在手中。\n" + "一次加入,终身受益!涵盖家电帮、雷神价、韭菜帮、河南 & 湖南帮等众多渠道,还有各类暗号帮后返等内部专属家电优惠信息一网打尽。\n" + "JD采销团队会不定时发放独家隐藏优惠券,市面上那些令人心动的JD家电低价好物,大多都源自这些渠道!\n" + "2025 年家电选购新趋势,依托线报下单,轻松省下千元开支,开启超值购物之旅!"; 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 = """ public static final String WENAN_ZCXS = """
购买后,两小时内出库,物流会电话联系您,同时生成京东官方安装单。送装一体,无需担心。 购买后,两小时内出库,物流会电话联系您,同时生成京东官方安装单。送装一体,无需担心。
@@ -107,7 +110,23 @@ public class JDUtil {
5:全国联保,全国统一安装标准。支持官方 400服务号查询假一赔十。 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 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> * <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; final WXUtil wxUtil;
private final StringRedisTemplate redisTemplate; 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 OrderRowRepository orderRowRepository;
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final JDOrderRepository jdOrderRepository; private final JDOrderRepository jdOrderRepository;
@@ -166,21 +191,20 @@ public class JDUtil {
private void handleProductWithJF() { private void handleProductWithJF() {
/** /**
* 130 130
* https://u.jd.com/Y6ZKmwN https://u.jd.com/Sa6OgtP
* 130B 130B
* https://u.jd.com/YGZKHZS https://u.jd.com/Sa6OSY6
* 150 150
* https://u.jd.com/YDZK5rD https://u.jd.com/Sg6Orka
* 180 180
* https://u.jd.com/YDZKmb2 https://u.jd.com/Sa6ODs5
* 92dpro */
* https://u.jd.com/YgZKViD*/ productWithJF.put("ZQD130F-EB130", "https://u.jd.com/Sa6OgtP");
productWithJF.put("ZQD130F-EB130", "https://u.jd.com/Y6ZKmwN"); productWithJF.put("ZQD130F-EB130B", "https://u.jd.com/Sa6OSY6");
productWithJF.put("ZQD130F-EB130B", "https://u.jd.com/YGZKHZS"); productWithJF.put("ZQD150F-EB150", "https://u.jd.com/Sg6Orka");
productWithJF.put("ZQD150F-EB150", " https://u.jd.com/YDZK5rD"); productWithJF.put("ZQD180F-EB200", "https://u.jd.com/Sa6ODs5");
productWithJF.put("ZQD180F-EB200", "https://u.jd.com/YDZKmb2"); productWithJF.put("CXW-298-IQ92DPRO", "https://u.jd.com/SO1nC85");
productWithJF.put("CXW-298-IQ92DPRO", "https://u.jd.com/Y1AMT2l");
} }
private List<OrderRow> filterOrdersByDate(List<OrderRow> orderRows, int daysBack) { 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) { if (!isTb) {
try { 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(); HttpResponse response = HttpRequest.post(fetchUrl).timeout(1000 * 60).execute();
logger.info("fetchUrl: {}", fetchUrl); logger.info("fetchUrl: {}", fetchUrl);
@@ -2531,30 +2667,51 @@ public class JDUtil {
// 生成当前日期 // 生成当前日期
JDOrder jdOrder = parseOrderFromText(input.trim().replace("", "")); 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(); StringBuilder sb = new StringBuilder();
if (Util.isEmpty(jdOrder.getOrderId())) { boolean hasError = false;
sb.append("单号\n");
} if (Util.isEmpty(jdOrder.getRemark())) {
if (Util.isEmpty(jdOrder.getBuyer())) { sb.append("单号\n");
sb.append("下单人\n"); hasError = true;
} }
if (Util.isEmpty(jdOrder.getPaymentAmount())) { if (Util.isEmpty(jdOrder.getBuyer())) {
sb.append("下单价格\n"); sb.append("下单\n");
} hasError = true;
if (Util.isEmpty(jdOrder.getRebateAmount())) { }
sb.append("后返金额\n"); if (Util.isEmpty(jdOrder.getPaymentAmount())) {
} sb.append("下单价格\n");
if (Util.isEmpty(jdOrder.getLogisticsLink())) { hasError = true;
sb.append("物流链接\n"); }
} if (Util.isEmpty(jdOrder.getRebateAmount())) {
if (Util.isEmpty(jdOrder.getAddress())) { sb.append("后返金额\n");
sb.append("收货地址\n"); hasError = true;
} }
if (Util.isEmpty(jdOrder.getModelNumber())) { if (Util.isEmpty(jdOrder.getLogisticsLink())) {
sb.append("型号\n"); 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++) { for (int i = 0; i < 3; i++) {
wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n缺少表单字段 \n" + sb, 1, fromWxid, true); wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n缺少表单字段 \n" + sb, 1, fromWxid, true);
} }
@@ -2563,9 +2720,9 @@ public class JDUtil {
List<JDOrder> byAddress = jdOrderRepository.findByAddressOrderByOrderTimeDesc(jdOrder.getAddress()); List<JDOrder> byAddress = jdOrderRepository.findByAddressOrderByOrderTimeDesc(jdOrder.getAddress());
if (!byAddress.isEmpty()) { if (!byAddress.isEmpty()) {
int count = byAddress.size(); int count = byAddress.size();
StringBuilder sb = new StringBuilder(); StringBuilder addressSb = new StringBuilder();
sb.append(DateUtil.dateToStr(byAddress.get(0).getOrderTime(), "yyyy-MM-dd HH:mm:ss")); addressSb.append(DateUtil.dateToStr(byAddress.get(0).getOrderTime(), "yyyy-MM-dd HH:mm:ss"));
wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n收货地址重复请确认 !!! \n 此地址共" + count + "个订单,最近的订单时间:" + sb, 1, fromWxid, true); wxUtil.sendTextMessage(fromWxid, "[炸弹] [炸弹] [炸弹] 录单警告!!! \n收货地址重复请确认 !!! \n 此地址共" + count + "个订单,最近的订单时间:" + addressSb, 1, fromWxid, true);
} }
JDOrder byRemark = jdOrderRepository.findByRemark(jdOrder.getRemark()); JDOrder byRemark = jdOrderRepository.findByRemark(jdOrder.getRemark());
String info; String info;
@@ -2579,7 +2736,7 @@ public class JDUtil {
} }
jdOrderRepository.save(jdOrder); jdOrderRepository.save(jdOrder);
StringBuilder sb = new StringBuilder(); StringBuilder orderInfoSb = new StringBuilder();
// 单号 下单日期 型号 内部订单号 地址 物流 外派给谁 后返金额 谁的单 下单价格 // 单号 下单日期 型号 内部订单号 地址 物流 外派给谁 后返金额 谁的单 下单价格
String distributionMark = jdOrder.getDistributionMark(); String distributionMark = jdOrder.getDistributionMark();
String distributionMark2 = ""; String distributionMark2 = "";
@@ -2590,14 +2747,14 @@ public class JDUtil {
distributionMark2 = ""; 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()) { if (fromWxid.isEmpty()) {
return; return;
} }
wxUtil.sendTextMessage(fromWxid, info, 1, null, true); 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")) { } else if (input.startsWith("TF")) {
/* /*
ZQD130F-EB130 1 张林 17530176250 湖北省 武汉市 东西湖区 径河街道 径河街道临空港小区二期 8栋2单元2204联系15783450649转6316 ZQD130F-EB130 1 张林 17530176250 湖北省 武汉市 东西湖区 径河街道 径河街道临空港小区二期 8栋2单元2204联系15783450649转6316
@@ -2749,58 +2906,89 @@ public class JDUtil {
public JDOrder parseOrderFromText(String input) { public JDOrder parseOrderFromText(String input) {
// 清理多余的空白字符 // 清理空白字符,保持原始格式以便正确解析多行字段
input = input.replaceAll("\\s+", " ").trim();
Map<String, String> fields = new HashMap<>(); Map<String, String> fields = new HashMap<>();
// 定义正则表达式提取各个字段 // 适配新模板格式的字段提取
extractField(input, fields, "单:", "备注:"); // 按顺序提取各个字段,支持分隔线"—————————"作为字段分隔符
extractField(input, fields, "备注", "分销标记:"); extractFieldWithSeparator(input, fields, "", "分销标记:");
extractField(input, fields, "分销标记:", "号:"); extractFieldWithSeparator(input, fields, "分销标记:", "第三方单号:");
extractField(input, fields, "号:", "链接"); extractFieldWithSeparator(input, fields, "第三方单号:", "下单链接(必须用这个)");
extractField(input, fields, "链接", "下单付款"); extractFieldWithSeparator(input, fields, "下单链接(必须用这个)", "下单地址(注意带分机)");
extractField(input, fields, "下单付款", "后返金额"); extractFieldWithSeparator(input, fields, "下单地址(注意带分机)", "型号");
extractField(input, fields, "后返金额", "地址"); extractFieldWithSeparator(input, fields, "型号", "下单人(需填)");
extractField(input, fields, "地址", "物流链接"); extractFieldWithSeparator(input, fields, "下单人(需填)", "下单付款(注意核对)");
extractField(input, fields, "物流链接", "订单号"); extractFieldWithSeparator(input, fields, "下单付款(注意核对)", "后返金额(注意核对)");
extractField(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()) { Pattern remarkPattern = Pattern.compile("备注(下单号码有变动/没法带分机号的写这里):[\\s\\n]*([\\s\\S]*?)[\\s\\n]*(?=—————————|京粉实际价格|\\Z)", Pattern.DOTALL);
fields.put("下单人", buyerMatcher.group(1).trim()); 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 对象
JDOrder order = new 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.setDistributionMark(fields.getOrDefault("分销标记", null));
order.setModelNumber(fields.getOrDefault("型号", null)); order.setModelNumber(fields.getOrDefault("型号", null));
order.setLink(fields.getOrDefault("链接", null)); // 下单链接(必须用这个)
order.setLink(fields.getOrDefault("下单链接(必须用这个)", null));
// 备注字段
order.setStatus(fields.getOrDefault("备注", null)); order.setStatus(fields.getOrDefault("备注", null));
// 第三方单号
String thirdPartyOrderNo = fields.getOrDefault("第三方单号", null);
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
order.setThirdPartyOrderNo(thirdPartyOrderNo.trim());
}
try { 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) { } catch (NumberFormatException ignored) {
} }
try { 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) { } catch (NumberFormatException ignored) {
} }
order.setAddress(fields.getOrDefault("地址", null)); // 下单地址(注意带分机)
order.setLogisticsLink(extractFirstUrl(fields.getOrDefault("物流链接", ""))); order.setAddress(fields.getOrDefault("下单地址(注意带分机)", null));
order.setOrderId(fields.getOrDefault("订单号", null)); order.setLogisticsLink(extractFirstUrl(fields.getOrDefault("物流链接(需填)", "")));
order.setBuyer(fields.getOrDefault("下单人", null)); order.setOrderId(fields.getOrDefault("订单号(需填)", null));
order.setBuyer(fields.getOrDefault("下单人(需填)", null));
// 设置下单时间,格式为 yyyy-MM-dd HH:mm:ss // 设置下单时间,格式为 yyyy-MM-dd HH:mm:ss
try { try {
String dateStr = fields.getOrDefault("", "").split(" ")[0]; if (!orderNo.isEmpty()) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); String dateStr = orderNo.split("\\s+")[0]; // 从"2025-12-13 006"中提取日期部分
order.setOrderTime(sdf.parse(dateStr)); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
order.setOrderTime(sdf.parse(dateStr));
} else {
order.setOrderTime(new Date());
}
} catch (Exception e) { } catch (Exception e) {
order.setOrderTime(new Date()); order.setOrderTime(new Date());
} }
@@ -2808,6 +2996,48 @@ public class JDUtil {
return order; 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) { private void extractField(String input, Map<String, String> map, String startKeyword, String endKeyword) {
Pattern pattern = Pattern.compile(startKeyword + "\\s*(.*?)\\s*(?=" + endKeyword + "|\\Z)", Pattern.DOTALL); Pattern pattern = Pattern.compile(startKeyword + "\\s*(.*?)\\s*(?=" + endKeyword + "|\\Z)", Pattern.DOTALL);

View File

@@ -68,9 +68,13 @@ public class OrderUtil {
if (!isAutoFlush || !lastValidCode.equals(newValidCode)) { if (!isAutoFlush || !lastValidCode.equals(newValidCode)) {
String content = getFormattedOrderInfo(orderRow); String content = getFormattedOrderInfo(orderRow);
String wxId = getWxidFromJdid(orderRow.getUnionId().toString()); 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)) { 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) { if (newValidCode != 17 && newValidCode != 25 && newValidCode != 26 && newValidCode != 27 && newValidCode != 28) {
// 发送今日统计信息 // 发送今日统计信息
@@ -98,13 +102,15 @@ public class OrderUtil {
} }
if (shouldNotify) { if (shouldNotify) {
String wxId = getWxidFromJdid(orderRow.getUnionId().toString()); String wxId = getWxidFromJdid(orderRow.getUnionId().toString());
// 根据unionId获取接收人列表
String touser = WXUtil.getTouserByUnionId(orderRow.getUnionId().toString());
if (Util.isNotEmpty(wxId)) { if (Util.isNotEmpty(wxId)) {
String content = getFormattedOrderInfoForJB(orderRow); String content = getFormattedOrderInfoForJB(orderRow);
String alertMsg = "[爱心] 价保/赔付 " + newProPriceAmount + " [爱心] \n" + content; String alertMsg = "[爱心] 价保/赔付 " + newProPriceAmount + " [爱心] \n" + content;
try { try {
// 先发送通知 // 先发送通知
wxUtil.sendTextMessage(wxId, alertMsg, 1, wxId, true); wxUtil.sendTextMessage(wxId, alertMsg, 1, wxId, true, touser);
// 通知成功后更新Redis格式为 "金额:true" // 通知成功后更新Redis格式为 "金额:true"
if (!isAutoFlushJB) { if (!isAutoFlushJB) {
@@ -176,6 +182,8 @@ public class OrderUtil {
if (!orderRowList.isEmpty()) { if (!orderRowList.isEmpty()) {
int i = 1; int i = 1;
String wxId = getWxidFromJdid(orderRowList.get(0).getUnionId().toString()); String wxId = getWxidFromJdid(orderRowList.get(0).getUnionId().toString());
// 根据unionId获取接收人列表
String touser = WXUtil.getTouserByUnionId(orderRowList.get(0).getUnionId().toString());
StringBuilder content = new StringBuilder(); StringBuilder content = new StringBuilder();
content.append("批量订单:\n\r ").append("").append(orderRowList.size()).append("\r"); content.append("批量订单:\n\r ").append("").append(orderRowList.size()).append("\r");
List<OrderRow> filterList = orderRowList.stream().filter(orderRow -> orderRow.getValidCode() != 2 && orderRow.getValidCode() != 3).toList(); 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)) { 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()) { if (!orderRowList.isEmpty()) {
int i = 1; int i = 1;
String wxId = getWxidFromJdid(orderRowList.get(0).getUnionId().toString()); String wxId = getWxidFromJdid(orderRowList.get(0).getUnionId().toString());
// 根据unionId获取接收人列表
String touser = WXUtil.getTouserByUnionId(orderRowList.get(0).getUnionId().toString());
StringBuilder content = new StringBuilder(); StringBuilder content = new StringBuilder();
content.append("批量订单:\n\r ").append("").append(orderRowList.size()).append("\r"); content.append("批量订单:\n\r ").append("").append(orderRowList.size()).append("\r");
List<OrderRow> filterList = orderRowList.stream().filter(orderRow -> orderRow.getValidCode() != 2 && orderRow.getValidCode() != 3).toList(); 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)) { if (Util.isNotEmpty(wxId)) {
wxUtil.sendTextMessage(wxId, content.toString(), 1, wxId, false); wxUtil.sendTextMessage(wxId, content.toString(), 1, wxId, false, touser);
} }
} }

View File

@@ -22,7 +22,6 @@ import org.springframework.stereotype.Component;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
/** /**
* @author Leo * @author Leo
@@ -35,7 +34,11 @@ public class WXUtil {
public static final String default_super_admin_wxid = "wxid_ytpc72mdoskt22"; public static final String default_super_admin_wxid = "wxid_ytpc72mdoskt22";
private static final Logger logger = LoggerFactory.getLogger(WXUtil.class); 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, SuperAdmin> super_admins = new HashMap<>();
public static Map<String, String> jdidToWxidMap = new HashMap<>(); public static Map<String, String> jdidToWxidMap = new HashMap<>();
@@ -130,6 +133,33 @@ public class WXUtil {
return result; 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) { public static List<String> splitStringByLength(String input, int length) {
List<String> result = new ArrayList<>(); List<String> result = new ArrayList<>();
@@ -155,7 +185,8 @@ public class WXUtil {
jdidToWxidMap.put(superAdmin.getUnionId(), superAdmin.getWxid()); jdidToWxidMap.put(superAdmin.getUnionId(), superAdmin.getWxid());
jdidToRemarkMap.put(superAdmin.getUnionId(), superAdmin.getName()); 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) { 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); //logger.info("发送文本消息 msgType: {} wxid: {} fromwxid: {} content: {}", msgType, wxid, fromwxid, content);
// 先在content顶部插入时间戳 // 先在content顶部插入时间戳
@@ -258,6 +293,13 @@ public class WXUtil {
}*/ }*/
data.put("msg", string); data.put("msg", string);
data.put("wxid", wxid); 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.setData(data);
// wxReqDate 转成 JSONObject // wxReqDate 转成 JSONObject
JSONObject message = JSON.parseObject(JSON.toJSONString(wxReqDate)); JSONObject message = JSON.parseObject(JSON.toJSONString(wxReqDate));
@@ -349,7 +391,7 @@ public class WXUtil {
return wxReqDate; return wxReqDate;
} }
@Scheduled(cron = "0 * * * * ?") //@Scheduled(cron = "0 * * * * ?")
public void checkWxStatus() { public void checkWxStatus() {
WxReqDate wxReqDate = createWxReqData(WXReqType.GET_WX_STATUS); WxReqDate wxReqDate = createWxReqData(WXReqType.GET_WX_STATUS);
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();

View File

@@ -5,6 +5,7 @@ import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap; import java.util.HashMap;
@@ -21,6 +22,8 @@ public class WxtsUtil {
public static final String TOKEN = "super_token_b62190c26"; public static final String TOKEN = "super_token_b62190c26";
private static final String SERVER_URL = "https://wxts.van333.cn"; 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) { public void sendNotify(String content) {
try { try {
@@ -28,10 +31,10 @@ public class WxtsUtil {
HashMap<String, Object> paramMap = new HashMap<>(); HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("title", "JD机器人微信推送"); paramMap.put("title", "JD机器人微信推送");
content = content.replaceAll("\\n", "<br>"); content = content.replaceAll("\\n", "<br>");
String common = "192.168.8.88 (微信机器人), 信息 : "; String common = serverHost + " (微信机器人), 信息 : ";
content = common + content + "<br><br>"; content = common + content + "<br><br>";
paramMap.put("text", content); 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); //logger.info("企业微信推送结果:{}", execute);
} catch (Exception e) { } catch (Exception e) {
logger.error("企业微信推送失败:{}", e.getMessage()); logger.error("企业微信推送失败:{}", e.getMessage());
@@ -49,4 +52,85 @@ public class WxtsUtil {
// - 触发短信通知 // - 触发短信通知
sendNotify(formattedMsg); // 复用原有通知方法 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);
}
}
} }

View File

@@ -12,6 +12,7 @@ import cn.hutool.http.HttpResponse;
import cn.hutool.http.Method; import cn.hutool.http.Method;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException; 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_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 String GPT_API_KEY = "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"; // 替换为你的 GPT API 密钥
private static final ObjectMapper objectMapper = new ObjectMapper(); // Jackson JSON 解析器 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 并返回第一次回复的文本内容 * 调用 GPT API 并返回第一次回复的文本内容
@@ -59,7 +64,7 @@ public class GPTClientUtil {
String jsonBody = objectMapper.writeValueAsString(requestBody); String jsonBody = objectMapper.writeValueAsString(requestBody);
// 3. 使用 Hutool HTTP 发送请求,设置代理 // 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) HttpRequest request = HttpRequest.of(GPT_API_URL)
.method(Method.POST) .method(Method.POST)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")

View File

@@ -49,11 +49,35 @@ logging:
level: level:
cn.van: debug cn.van: debug
org.springframework: warn 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: 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_kr145nk7l0an31
QL_BASE_URL: http://134.175.126.60:35700 QL_BASE_URL: http://134.175.126.60:35700
rocketmq: rocketmq:
name-server: 192.168.8.88:39876 # RocketMQ Name Server 地址 name-server: ${jarvis.server.rocketmq.name-server} # RocketMQ Name Server 地址
producer: producer:
group: wx_producer # 生产者组名 group: wx_producer # 生产者组名
send-msg-timeout: 1000 # 发送消息超时时间 send-msg-timeout: 1000 # 发送消息超时时间

View File

@@ -8,7 +8,7 @@ spring:
#数据源配置 #数据源配置
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver 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 username: root
password: mysql_7sjTXH password: mysql_7sjTXH
hikari: hikari:
@@ -38,7 +38,7 @@ spring:
basename: i18n/messages basename: i18n/messages
data: data:
redis: redis:
host: 192.168.8.88 host: ${jarvis.server.host}
password: redis_6PZ52S password: redis_6PZ52S
timeout: 1800000 timeout: 1800000
port: 6379 port: 6379
@@ -49,11 +49,35 @@ logging:
level: level:
cn.van: debug cn.van: debug
org.springframework: warn 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: 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 QL_BASE_URL: http://134.175.126.60:35700
rocketmq: rocketmq:
name-server: 192.168.8.88:9876 # RocketMQ Name Server 地址 name-server: ${jarvis.server.rocketmq.name-server} # RocketMQ Name Server 地址
producer: producer:
group: wx_producer # 生产者组名 group: wx_producer # 生产者组名
send-msg-timeout: 1000 # 发送消息超时时间 send-msg-timeout: 1000 # 发送消息超时时间

View File

@@ -57,8 +57,14 @@ logging:
org.springframework.web: WARN org.springframework.web: WARN
org.apache.http: WARN org.apache.http: WARN
com.zaxxer.hikari: ERROR com.zaxxer.hikari: ERROR
# 服务地址配置(用于服务器迁移)
jarvis:
server:
host: 192.168.8.88
rocketmq:
name-server: 192.168.8.88:9876
rocketmq: rocketmq:
name-server: 192.168.8.88:9876 # RocketMQ Name Server 地址 name-server: ${jarvis.server.rocketmq.name-server:192.168.8.88:9876} # RocketMQ Name Server 地址
producer: producer:
group: wx_producer # 生产者组名 group: wx_producer # 生产者组名
send-msg-timeout: 1000 # 发送消息超时时间 send-msg-timeout: 1000 # 发送消息超时时间
@@ -88,3 +94,14 @@ resilience4j.ratelimiter:
timeoutDuration: 0 # 立即失败模式 timeoutDuration: 0 # 立即失败模式
registerHealthIndicator: true 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: