Compare commits

..

169 Commits

Author SHA1 Message Date
Leo
72ff30567b 1 2026-01-26 22:31:46 +08:00
Leo
caa36c4966 1 2026-01-19 21:22:55 +08:00
Leo
269e8e48a7 1 2026-01-19 20:53:46 +08:00
Leo
dde274acba 1 2026-01-17 23:52:56 +08:00
Leo
0297c6e131 1 2026-01-17 23:47:19 +08:00
Leo
6394658a70 1 2026-01-17 23:40:10 +08:00
Leo
dafd63a9ec 1 2026-01-17 13:43:46 +08:00
Leo
5a8e1198cf 1 2026-01-17 13:40:48 +08:00
Leo
6257d816e9 1 2026-01-17 13:23:32 +08:00
Leo
3336eeb6aa 1 2026-01-16 18:07:24 +08:00
Leo
18f541fdf7 1 2026-01-16 18:02:54 +08:00
Leo
9cb5e6a488 1 2026-01-15 22:00:09 +08:00
Leo
2ce45e5ccc 1 2026-01-15 21:49:38 +08:00
Leo
c837917be3 1 2026-01-15 21:07:53 +08:00
Leo
4379277a08 WPS365 2026-01-15 21:03:42 +08:00
Leo
ff9ab96833 WPS365 2026-01-15 20:51:40 +08:00
Leo
2b74f77419 WPS365 2026-01-15 20:48:39 +08:00
Leo
76500642eb WPS365 2026-01-15 20:42:07 +08:00
Leo
a41c9ceaf9 1 2026-01-15 20:38:45 +08:00
Leo
7f7bec8d29 1 2026-01-15 20:37:49 +08:00
Leo
8802e68106 1 2026-01-15 20:21:13 +08:00
Leo
2fb283c3f3 1 2026-01-15 20:13:15 +08:00
Leo
f044417d8d 1 2026-01-15 16:22:00 +08:00
Leo
ba1e025326 1 2026-01-15 16:13:16 +08:00
Leo
61f66d90b4 1 2026-01-15 16:12:42 +08:00
Leo
e93e7cec46 1 2026-01-15 16:10:41 +08:00
Leo
d5df4108a6 1 2026-01-15 16:02:04 +08:00
Leo
c58adef068 1 2026-01-15 15:28:10 +08:00
Leo
b43daf2965 1 2026-01-14 22:55:36 +08:00
Leo
ab062b3b5a 1 2026-01-13 23:05:20 +08:00
Leo
01b19602b6 1 2026-01-09 20:08:13 +08:00
Leo
01e5312ccc 1 2026-01-07 15:15:55 +08:00
Leo
00f0c38672 1 2026-01-06 22:52:24 +08:00
Leo
ba6f250914 1 2026-01-06 22:07:52 +08:00
Leo
fb00ecbcb6 1 2026-01-06 19:06:37 +08:00
Leo
9483ebf1f5 1 2026-01-06 18:51:38 +08:00
Leo
440df7d538 1 2026-01-06 18:50:24 +08:00
Leo
2fe78ec192 1 2026-01-06 18:44:16 +08:00
Leo
ccf8298e17 1 2026-01-06 18:22:11 +08:00
Leo
46d2a209c0 1 2026-01-06 15:56:27 +08:00
Leo
9935c6c07e 1 2026-01-06 15:51:56 +08:00
Leo
23c59a5e52 1 2026-01-06 01:03:53 +08:00
Leo
0556f19e97 1 2026-01-06 00:41:09 +08:00
Leo
74d3579e1e 1 2026-01-06 00:37:57 +08:00
Leo
160c97eb5b 1 2026-01-06 00:10:37 +08:00
Leo
407640ff96 1 2026-01-05 22:13:46 +08:00
Leo
81203488c8 1 2026-01-05 22:02:15 +08:00
Leo
e9747e6af2 1 2026-01-03 12:20:09 +08:00
Leo
4ba1f6a572 1 2025-12-24 14:29:23 +08:00
Leo
d6ab231534 1 2025-12-24 14:22:37 +08:00
Leo
dfa6109788 1 2025-12-23 18:46:26 +08:00
Leo
c56b911db0 1 2025-12-23 18:18:06 +08:00
Leo
d98711f06a 1 2025-12-23 15:59:05 +08:00
Leo
40dd64482c 1 2025-12-22 22:56:19 +08:00
Leo
77dcc149c3 1 2025-12-14 16:35:20 +08:00
Leo
1a14830dac 1 2025-12-14 15:39:26 +08:00
Leo
30ca39a4b6 1 2025-12-14 15:04:50 +08:00
Leo
237f0c88ad 1 2025-12-14 14:56:06 +08:00
Leo
2fd371f2f4 1 2025-12-14 14:50:49 +08:00
Leo
c661e921df 1 2025-12-14 14:50:38 +08:00
Leo
f4c07859e4 1 2025-12-14 13:43:30 +08:00
Leo
d1c374ca99 Merge branch 'master' of https://git.van333.cn/CC/ruoyi-java 2025-12-14 00:00:54 +08:00
Leo
317ab03c7c 1 2025-12-14 00:00:49 +08:00
b86c4bea88 1 2025-12-13 13:48:42 +08:00
Leo
9a8c7b1039 1 2025-12-08 14:42:44 +08:00
Leo
632b9f7eb1 1 2025-12-06 17:08:38 +08:00
Leo
eb53915bcd 1 2025-12-05 22:48:22 +08:00
Leo
4dd3e9dd70 1 2025-12-05 22:35:55 +08:00
Leo
9206824efb 1 2025-12-05 22:20:17 +08:00
Leo
2524461ff4 1 2025-12-05 22:16:25 +08:00
Leo
7581cc02a9 1 2025-12-02 17:39:27 +08:00
Leo
1dc91a6bb0 1 2025-12-02 01:41:51 +08:00
Leo
6b3c2b17c8 1 2025-11-29 23:39:40 +08:00
Leo
e890b18e3e 1 2025-11-29 23:28:53 +08:00
Leo
9b2b770e29 1 2025-11-26 15:01:30 +08:00
Leo
047575ea42 1 2025-11-25 21:27:15 +08:00
Leo
702463b856 1 2025-11-25 18:56:37 +08:00
Leo
3aa3da8ade 1 2025-11-24 19:02:07 +08:00
Leo
20861d270a 1 2025-11-24 18:55:02 +08:00
Leo
e7c991ed9c 1 2025-11-21 23:26:29 +08:00
Leo
2ead103faa 1 2025-11-20 23:38:04 +08:00
Leo
c541beb413 1 2025-11-19 22:29:52 +08:00
Leo
083bcca270 1 2025-11-19 16:02:30 +08:00
Leo
35dcb20e4a 1 2025-11-19 15:58:40 +08:00
Leo
7648b934ed 1 2025-11-16 00:28:52 +08:00
Leo
01f0be6198 1 2025-11-16 00:12:07 +08:00
Leo
276fb49354 1 2025-11-15 23:59:36 +08:00
Leo
4f917dce10 1 2025-11-15 23:45:41 +08:00
Leo
98b56ab11b 1 2025-11-15 17:48:17 +08:00
Leo
b495431b7e 1 2025-11-15 17:42:56 +08:00
Leo
7f4b0dd986 1 2025-11-15 17:39:42 +08:00
Leo
79c5bf266f 1 2025-11-15 17:33:03 +08:00
Leo
04156492a6 1 2025-11-15 15:15:09 +08:00
Leo
f578b9b2c9 1 2025-11-15 15:08:02 +08:00
Leo
6b07fa1d75 1 2025-11-15 11:26:01 +08:00
Leo
978da7042d 1 2025-11-15 01:45:20 +08:00
Leo
66ac54ca70 1 2025-11-15 00:45:52 +08:00
Leo
026c6bf2a3 1 2025-11-14 23:55:59 +08:00
Leo
2b0587d4e1 1 2025-11-14 23:48:19 +08:00
Leo
0880628c93 1 2025-11-14 23:43:41 +08:00
Leo
2e59f49677 1 2025-11-14 23:42:26 +08:00
Leo
a54c8cc0cd 1 2025-11-14 00:13:18 +08:00
Leo
8a23c4d3f7 1 2025-11-14 00:02:40 +08:00
Leo
b8981ffc98 1 2025-11-13 23:55:25 +08:00
Leo
9e69230948 1 2025-11-13 23:54:14 +08:00
Leo
64ce923631 1 2025-11-13 23:51:44 +08:00
Leo
2cd3a0a798 1 2025-11-13 23:38:30 +08:00
Leo
8889791a83 1 2025-11-13 16:08:45 +08:00
Leo
e184c7926f 1 2025-11-13 11:38:26 +08:00
Leo
d73c7b6560 1 2025-11-11 18:18:19 +08:00
Leo
9d8f2ded0c 1 2025-11-11 14:13:11 +08:00
Leo
7294748ae9 1 2025-11-11 14:06:34 +08:00
Leo
142b395dbe 1 2025-11-11 12:41:21 +08:00
Leo
c8b15275a4 Revert "1"
This reverts commit e79e7081ee.
2025-11-11 12:30:45 +08:00
Leo
a61003fb7c 1 2025-11-11 00:42:05 +08:00
Leo
939d03e192 1 2025-11-11 00:24:09 +08:00
Leo
e2facc3099 1 2025-11-10 23:32:04 +08:00
Leo
af68b529b0 1 2025-11-10 21:21:01 +08:00
Leo
185483dace 1 2025-11-10 21:02:44 +08:00
Leo
e79e7081ee 1 2025-11-10 19:07:24 +08:00
Leo
3176e45057 1 2025-11-10 18:55:03 +08:00
Leo
72b3458ef9 1 2025-11-09 23:54:38 +08:00
Leo
00149dc198 1 2025-11-09 16:00:45 +08:00
Leo
10020e6d52 1 2025-11-09 15:59:42 +08:00
Leo
c0908690b4 1 2025-11-09 00:46:10 +08:00
Leo
70ea908c23 1 2025-11-09 00:43:36 +08:00
Leo
18d2fb8dee 1 2025-11-09 00:00:44 +08:00
Leo
a8c948e958 1 2025-11-08 15:46:40 +08:00
Leo
654a496478 1 2025-11-08 15:42:19 +08:00
Leo
79082adf8c 1 2025-11-08 15:33:06 +08:00
Leo
287bf75d77 1 2025-11-08 15:25:48 +08:00
8ba4c4e383 1 2025-11-07 21:24:20 +08:00
0b5f054286 1 2025-11-07 21:09:11 +08:00
652824b84a 1 2025-11-07 16:11:17 +08:00
d1a1100064 1 2025-11-07 15:59:52 +08:00
4430351e69 1 2025-11-07 15:54:16 +08:00
7b7f8de2de 1 2025-11-07 15:48:43 +08:00
ea29e2c551 1 2025-11-07 14:50:40 +08:00
3a71725d23 1 2025-11-07 14:45:33 +08:00
2409c8c819 1 2025-11-07 14:40:42 +08:00
b2b18972d7 1 2025-11-07 13:45:58 +08:00
a61dac3c57 1 2025-11-07 13:42:53 +08:00
e868566b2d 1 2025-11-07 01:29:13 +08:00
92d4338bb5 1 2025-11-07 01:23:40 +08:00
8b8b6d8797 1 2025-11-06 22:17:57 +08:00
8d409157c5 1 2025-11-06 22:16:47 +08:00
42c6c8bc23 1 2025-11-06 22:10:09 +08:00
6653c2ca03 1 2025-11-06 21:54:10 +08:00
3bf02de147 1 2025-11-06 20:32:16 +08:00
e865220a50 1 2025-11-06 20:18:15 +08:00
b532aa1b84 1 2025-11-06 20:02:07 +08:00
8f68b7a4d5 1 2025-11-06 19:36:24 +08:00
714fce242f 1 2025-11-06 19:23:39 +08:00
0607182140 1 2025-11-06 19:22:55 +08:00
6c67c76cdf 1 2025-11-06 19:19:33 +08:00
d65aa1add4 1 2025-11-06 18:51:25 +08:00
a92f122926 1 2025-11-06 18:43:15 +08:00
26918a7ed2 1 2025-11-06 18:31:59 +08:00
f1c5d22e03 1 2025-11-06 18:17:43 +08:00
5b54146a4a 1 2025-11-06 18:08:57 +08:00
639d5c2c86 1 2025-11-06 17:56:26 +08:00
99d64022dd 1 2025-11-06 17:53:16 +08:00
6768fa5061 1 2025-11-06 17:39:20 +08:00
bd61ef108c 1 2025-11-06 17:23:34 +08:00
3970cbbbe6 1 2025-11-06 16:34:22 +08:00
5f25910a4b 1 2025-11-06 16:18:46 +08:00
041d47e9ba 1 2025-11-06 16:04:24 +08:00
c99088ff57 1 2025-11-06 15:32:57 +08:00
1570468d13 1 2025-11-06 15:25:10 +08:00
100 changed files with 16998 additions and 817 deletions

View File

@@ -0,0 +1,119 @@
# WPS365 回调地址配置说明
## 错误信息
如果遇到以下错误:
```json
{
"code": 40000001,
"msg": "invalid_request",
"debug": {
"desc": "The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls."
}
}
```
**原因**`redirect_uri` 参数与WPS365开放平台配置的回调地址不一致。
## 解决方案
### 1. 检查当前配置的回调地址
查看 `application.yml` 中的配置:
```yaml
wps365:
redirect-uri: https://jarvis.van333.cn/wps365-callback
```
### 2. 在WPS365开放平台配置回调地址
1. **登录WPS365开放平台**
- 访问https://open.wps.cn/
- 进入你的应用管理页面
2. **找到"安全设置"或"回调配置"**
- 通常在"开发配置" > "安全设置" 或 "事件与回调" 中
3. **配置授权回调地址**
- 回调地址必须与配置文件中的 `redirect-uri` **完全一致**
- 包括:
- ✅ 协议:`https://`(不能是 `http://`
- ✅ 域名:`jarvis.van333.cn`(不能有 `www` 或其他前缀)
- ✅ 路径:`/wps365-callback`(不能有多余的斜杠)
- ✅ 端口如果使用默认端口443不需要写端口号
4. **正确的配置示例**
```
https://jarvis.van333.cn/wps365-callback
```
5. **错误的配置示例(会导致错误)**
```
❌ http://jarvis.van333.cn/wps365-callback (协议错误)
❌ https://www.jarvis.van333.cn/wps365-callback (域名不一致)
❌ https://jarvis.van333.cn/wps365-callback/ (末尾多斜杠)
❌ https://jarvis.van333.cn:443/wps365-callback (端口号多余)
❌ https://jarvis.van333.cn/jarvis/wps365/oauth/callback (路径不一致)
```
### 3. 验证回调地址
配置完成后WPS365平台会发送一个challenge验证请求到你的回调地址。确保
- ✅ 回调接口支持GET和POST请求
- ✅ 能正确处理challenge参数并返回
- ✅ 接口可以正常访问(不被防火墙拦截)
### 4. 常见问题排查
#### 问题1配置了但验证失败
- 检查回调接口是否支持POST请求
- 检查是否能正确处理challenge参数
- 查看后端日志确认是否收到challenge请求
#### 问题2授权时提示redirect_uri不匹配
- 检查WPS365平台配置的回调地址
- 检查配置文件中的redirect-uri
- 确保两者完全一致(包括大小写、斜杠等)
#### 问题3回调地址访问失败
- 检查nginx配置是否正确代理
- 检查前端白名单是否已配置
- 检查域名DNS解析是否正常
### 5. 调试步骤
1. **查看后端日志**
```
生成授权URL: https://openapi.wps.cn/oauth2/auth?client_id=xxx&redirect_uri=...
使用回调地址: https://jarvis.van333.cn/wps365-callback
```
2. **复制日志中的回调地址**
- 确保WPS365平台配置的地址与日志中的地址完全一致
3. **测试回调地址**
- 直接在浏览器访问:`https://jarvis.van333.cn/wps365-callback`
- 应该能看到授权页面或提示信息
4. **重新授权**
- 修改配置后,重新点击"立即授权"
- 如果还是失败检查WPS365平台的配置
## 配置检查清单
- [ ] WPS365开放平台已配置回调地址
- [ ] 回调地址与配置文件中的redirect-uri完全一致
- [ ] 回调接口支持GET和POST请求
- [ ] 回调接口能正确处理challenge验证
- [ ] 前端白名单已配置 `/wps365-callback`
- [ ] nginx已配置 `/wps365-callback` 路由
- [ ] 域名DNS解析正常
- [ ] SSL证书有效
## 注意事项
1. **回调地址必须使用HTTPS**(生产环境)
2. **路径不能有末尾斜杠**(除非是根路径)
3. **域名必须完全匹配**不能有www前缀等
4. **配置后需要等待几分钟生效**WPS365平台可能有缓存

View File

@@ -0,0 +1,259 @@
# WPS365 授权错误排查指南
## 常见错误类型
### 1. invalid_request (40000001) - redirect_uri不匹配
```json
{
"code": 40000001,
"msg": "invalid_request",
"debug": {
"desc": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls."
}
}
```
**错误含义**redirect_uri参数值与WPS365平台配置的回调地址不一致
### 2. invalid_scope (40000005) - scope权限无效 ⚠️
```json
{
"code": 40000005,
"msg": "invalid_scope",
"debug": {
"desc": "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'file.read,ksheet.read,user.info'."
}
}
```
**错误含义**请求的scope权限格式不正确或者应用未申请这些权限
## 错误含义总览
授权错误可能由以下原因导致:
1. **缺少必需参数** - 授权请求中缺少某个必需的参数
2. **参数值无效** - 某个参数的值格式不正确
3. **参数重复** - 某个参数在请求中出现了多次
4. **redirect_uri不匹配** - redirect_uri参数值与WPS365平台配置的回调地址不一致
5. **scope无效** - scope权限格式不正确或未申请
## 排查步骤
### 1. 查看后端日志
查看后端日志中的授权URL和参数清单例如
```
生成授权URL: https://openapi.wps.cn/oauth2/auth?client_id=xxx&redirect_uri=...
📋 授权请求参数清单:
- client_id: AK20260114NNQJKV
- redirect_uri: https://jarvis.van333.cn/wps365-callback
- response_type: code
- scope: file.read,ksheet.read,user.info
- state: xxxxx
```
### 2. 检查参数名是否正确
**问题**WPS365可能使用 `app_id` 而不是标准的 `client_id`
**解决方案**
- 如果使用 `client_id` 报错,尝试改为 `app_id`
- 查看WPS365官方文档确认正确的参数名
**当前代码使用**`client_id`标准OAuth2参数名
### 3. 检查必需参数是否齐全
授权请求必须包含以下参数:
| 参数名 | 是否必需 | 说明 | 当前值 |
|--------|---------|------|--------|
| `client_id``app_id` | ✅ 必需 | 应用ID | 从配置读取 |
| `redirect_uri` | ✅ 必需 | 回调地址 | 从配置读取 |
| `response_type` | ✅ 必需 | 固定值 `code` | `code` |
| `scope` | ✅ 必需 | 权限范围 | `file.read,ksheet.read,user.info` |
| `state` | ⚠️ 推荐 | 防CSRF攻击 | 自动生成 |
### 4. 检查redirect_uri是否匹配
这是最常见的错误原因。必须确保:
1. **协议一致**:必须是 `https://`(不能是 `http://`
2. **域名一致**:必须是 `jarvis.van333.cn`(不能有 `www` 前缀)
3. **路径一致**:必须是 `/wps365-callback`(不能有末尾斜杠)
4. **端口一致**使用默认443端口不需要写端口号
**在WPS365平台配置的回调地址必须与日志中的redirect_uri完全一致**
### 5. 检查scope权限是否已申请重要
**如果遇到 `invalid_scope` 错误,请按以下步骤排查:**
#### 5.1 检查WPS365平台后台的scope格式
1. 登录WPS365开放平台
2. 进入"开发配置" > "权限管理"
3. 查看已申请的权限列表,**注意权限的格式**
- 必须以 `kso.` 开头,如:`kso.file.read``kso.file.readwrite`
- 不是 `file.read``ksheet.read`(这些格式不存在)
- 根据官方文档https://open.wps.cn/documents/app-integration-dev/wps365/server/
#### 5.2 检查scope分隔符
**根据WPS365官方文档必须使用英文逗号分隔**(不是空格):
| 分隔符 | 示例 | 说明 |
|--------|------|------|
| **逗号(正确)** | `kso.file.readwrite,kso.doclib.readwrite` | ✅ WPS365官方要求 |
| **空格(错误)** | `kso.file.readwrite kso.doclib.readwrite` | ❌ 会导致invalid_scope |
| **逗号+空格** | `kso.file.readwrite, kso.doclib.readwrite` | ⚠️ 可能支持,但不推荐 |
**重要**WPS365官方文档明确要求使用英文逗号 `,` 分隔,不能使用空格。
#### 5.3 在配置文件中指定scope
`application.yml` 中添加 `scope` 配置,**必须使用英文逗号分隔**
```yaml
wps365:
# 根据WPS365平台后台"权限管理"中显示的实际权限名称配置
# 使用英文逗号分隔WPS365官方要求
# 权限名称必须以 kso. 开头
scope: kso.file.readwrite
```
多个权限示例:
```yaml
wps365:
# 多个权限用英文逗号分隔,不能有空格
# 权限名称必须以 kso. 开头
scope: kso.file.read,kso.file.readwrite
```
#### 5.4 确认权限已申请且名称正确
**关键步骤**
1. **登录WPS365开放平台**
- 访问https://open.wps.cn/
- 进入你的应用管理页面
2. **查看已申请的权限**
- 进入"开发配置" > "权限管理"
- 查看已申请权限的**准确名称**(注意大小写、分隔符、命名空间)
3. **对比权限名称**
- 权限名称必须与后台显示的**完全一致**
- 例如:如果后台显示的是 `kso.doclib.readwrite`,不能写成 `kso.doclib.readWrite``kso_doclib_readwrite`
4. **常见权限名称示例**(仅供参考,以平台后台实际显示为准):
- `kso.doclib.readwrite` - 文档库读写权限
- `kso.doclib.read` - 文档库读取权限
- `ksheet.read` - 在线表格读取权限(如果支持)
**重要提示**
- ⚠️ `file.read``ksheet.read``user.info` 这些权限名称可能不存在
- ✅ 必须查看WPS365平台后台实际显示的权限名称
- ✅ 权限名称必须完全匹配(包括大小写、分隔符、命名空间)
- ✅ 必须使用英文逗号分隔多个权限
### 6. 尝试修改参数名
如果确认所有参数都正确,但仍然报错,可能是参数名问题:
**测试方案1**:将 `client_id` 改为 `app_id`
修改 `WPS365OAuthServiceImpl.java` 中的代码:
```java
// 原代码
authUrl.append("?client_id=").append(appId);
// 改为
authUrl.append("?app_id=").append(appId);
```
**测试方案2**同时提供两个参数如果WPS365支持
```java
authUrl.append("?app_id=").append(appId);
authUrl.append("&client_id=").append(appId);
```
### 7. 检查URL编码
确保 `redirect_uri` 参数正确进行了URL编码
- 空格应该编码为 `%20`
- 斜杠 `/` 应该编码为 `%2F`
- 冒号 `:` 应该编码为 `%3A`
查看日志中的"编码后"值,确认编码是否正确。
### 8. 验证WPS365平台配置
登录WPS365开放平台检查
1. **应用IDAppId** 是否与配置文件中的 `app-id` 一致
2. **回调地址配置** 是否与日志中的 `redirect_uri` 完全一致
3. **权限配置** 是否已申请所需的scope权限
4. **应用状态** 是否为"已上线"或"测试中"
## 常见问题
### Q1: 参数名应该用 `client_id` 还是 `app_id`
**A**: 根据WPS365官方文档确认。标准OAuth2使用 `client_id`,但某些平台可能使用 `app_id`。如果 `client_id` 不工作,尝试 `app_id`
### Q2: 为什么redirect_uri明明配置了还是报错
**A**: 最常见的原因是:
- 平台配置的回调地址与代码中的不完全一致(多了/少了斜杠、协议不同等)
- 平台配置未保存或未生效
- 使用了错误的配置环境(开发/生产)
### Q3: scope权限在哪里申请
**A**: 在WPS365开放平台的"开发配置" > "权限管理"中申请。
### Q4: 如何确认参数是否正确?
**A**: 查看后端日志会打印完整的授权URL和参数清单。对比WPS365平台配置确保完全一致。
### Q5: 遇到 `invalid_scope` 错误怎么办? ⚠️
**A**: 这是scope格式或权限问题按以下步骤排查
1. **查看WPS365平台后台的权限格式**
- 进入"开发配置" > "权限管理"
- 查看已申请权限的**确切格式**(包括分隔符、大小写)
2. **尝试不同的scope格式**
- 空格分隔:`file.read ksheet.read user.info`(当前默认)
- 逗号分隔:`file.read,ksheet.read,user.info`
-`application.yml` 中配置 `scope` 参数进行测试
3. **确认权限已申请且已审核通过**
- 权限必须处于"已通过"或"可用"状态
- 如果权限未申请,需要先申请并等待审核
4. **查看后端日志中的scope值**
- 日志会显示实际使用的scope格式
- 对比WPS365平台显示的格式确保完全一致
## 调试建议
1. **启用DEBUG日志**:在 `application.yml` 中设置日志级别为DEBUG
2. **查看完整授权URL**复制日志中的授权URL在浏览器中访问查看具体错误
3. **对比官方文档**查看WPS365官方OAuth文档确认参数名和格式
4. **联系WPS365技术支持**:如果所有参数都正确但仍报错,可能是平台问题
## 下一步
如果按照以上步骤排查后仍然报错,请提供:
1. 后端日志中的完整授权URL和参数清单
2. WPS365平台配置的回调地址截图
3. WPS365平台的应用配置截图隐藏敏感信息

View File

@@ -0,0 +1,271 @@
# WPS365 读取在线表格内容配置指南
## 概述
本指南详细说明如何配置WPS365应用以实现读取自己创建的在线表格内容。
## 一、WPS365开放平台配置步骤
### 1. 应用基础配置
1. **登录WPS365开放平台**
- 访问https://open.wps.cn/
- 使用WPS账号登录
2. **创建应用**
- 进入"应用管理"
- 点击"创建应用"
- 填写应用名称Jarvis同步WPS
- 选择应用类型:**服务端应用**
3. **获取应用凭证**
- 记录 `AppID`应用ID
- 记录 `AppKey`(应用密钥,注意保密)
### 2. 配置回调地址
1. **进入"开发配置" > "事件与回调"**
2. **配置回调地址**
- 回调地址:`https://jarvis.van333.cn/wps365-callback`
- 点击"验证"按钮,确保验证通过
- 如果验证失败,检查:
- 回调接口是否支持GET和POST请求
- 是否能正确处理challenge参数
- 前端白名单是否已配置
### 3. 配置权限(关键步骤)
#### 3.1 进入权限管理
1. 在左侧导航栏选择 **"开发配置" > "权限管理"**
2. 查看当前应用的权限列表
#### 3.2 添加必要权限
需要添加以下权限才能读取在线表格内容:
**基础权限:**
-`file.read` - 读取文件权限
-`file.readwrite` - 读取和写入文件权限如果只需要读取可以只选read
-`user.info` - 获取用户信息权限
**在线表格KSheet权限**
-`ksheet:read` - 读取在线表格数据
-`ksheet:write` - 写入在线表格数据(如果需要编辑功能)
**权限说明:**
- 这些权限决定了你的应用能访问哪些资源
- 用户授权时,会看到这些权限的说明
- 只有用户同意授权后,应用才能访问对应的资源
#### 3.3 权限申请流程
1. 在权限管理页面,点击"添加权限"
2. 搜索并选择上述权限
3. 提交权限申请(部分权限可能需要审核)
4. 等待审核通过
### 4. 应用能力配置(可选)
根据图片显示WPS365开放平台提供了多种应用能力
**协作与会话:**
- WPS协作机器人
- 工作台小组件
- WPS协作网页应用
**多维表格:**
- 记录卡片插件
- 字段插件
- 视图插件
- 仪表盘插件
- 数据连接器插件
- 自动化插件
**注意:**
- 如果只是读取在线表格内容,通常不需要开启这些插件能力
- 这些能力主要用于扩展表格功能,不是读取数据的必要条件
- 读取数据主要依赖API权限而不是这些插件能力
## 二、后端配置
### 1. 更新配置文件
`application-dev.yml` 中添加配置:
```yaml
wps365:
# 应用ID从WPS365开放平台获取
app-id: YOUR_APP_ID
# 应用密钥从WPS365开放平台获取
app-key: YOUR_APP_KEY
# 授权回调地址
redirect-uri: https://jarvis.van333.cn/wps365-callback
# API基础地址
api-base-url: https://open.wps.cn/api/v1
# OAuth授权地址
oauth-url: https://open.wps.cn/oauth2/v1/authorize
# 获取Token地址
token-url: https://open.wps.cn/oauth2/v1/token
# 刷新Token地址
refresh-token-url: https://open.wps.cn/oauth2/v1/token
```
### 2. 确认授权范围Scope
当前代码中使用的授权范围:
```java
scope=file.readwrite,user.info
```
如果需要更细粒度的权限控制,可以修改为:
```java
scope=file.read,ksheet:read,user.info // 只读权限
// 或
scope=file.readwrite,ksheet:readwrite,user.info // 读写权限
```
## 三、使用流程
### 1. 用户授权
1. 调用 `/jarvis/wps365/authUrl` 获取授权URL
2. 用户访问授权URL同意授权
3. WPS365回调到 `/wps365-callback`自动保存Token
### 2. 获取文件列表
```javascript
// 调用接口获取文件列表
GET /jarvis/wps365/files?userId=xxx&page=1&pageSize=20
```
### 3. 获取工作表列表
```javascript
// 获取指定文件的工作表列表
GET /jarvis/wps365/sheets?userId=xxx&fileToken=xxx
```
### 4. 读取单元格数据
```javascript
// 读取指定范围的单元格数据
GET /jarvis/wps365/readCells?userId=xxx&fileToken=xxx&sheetIdx=0&range=A1:Z100
```
**参数说明:**
- `userId`: 用户ID授权后获取
- `fileToken`: 文件Token从文件列表中获取
- `sheetIdx`: 工作表索引从0开始
- `range`: 单元格范围A1:Z100可选不填则读取整个工作表
## 四、常见问题
### Q1: 提示"权限不足"或"无权限访问"
**解决方案:**
1. 检查WPS365开放平台的权限配置
2. 确认已添加 `file.read``ksheet:read` 权限
3. 重新授权删除旧Token重新获取授权
### Q2: 无法读取自己创建的表格
**可能原因:**
1. 文件Token不正确
2. 工作表索引错误
3. 用户对文件没有读取权限(即使是自己创建的,也需要授权给应用)
**解决方案:**
1. 确认文件Token是从文件列表中正确获取的
2. 使用 `/jarvis/wps365/sheets` 接口确认工作表索引
3. 确保用户已授权应用访问该文件
### Q3: 读取返回空数据
**可能原因:**
1. 指定的单元格范围没有数据
2. 工作表索引错误
3. API返回格式与预期不符
**解决方案:**
1. 尝试读取更大的范围A1:Z1000
2. 检查工作表索引是否正确
3. 查看后端日志确认API返回的具体内容
## 五、API接口说明
### 读取单元格数据接口
**接口地址:** `GET /jarvis/wps365/readCells`
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| userId | String | 是 | 用户ID |
| fileToken | String | 是 | 文件Token |
| sheetIdx | Integer | 否 | 工作表索引默认0 |
| range | String | 否 | 单元格范围A1:B10 |
**返回示例:**
```json
{
"code": 200,
"msg": "读取单元格数据成功",
"data": {
"values": [
["列1", "列2", "列3"],
["数据1", "数据2", "数据3"],
["数据4", "数据5", "数据6"]
]
}
}
```
## 六、完整示例
### 前端调用示例
```javascript
import {
getWPS365AuthUrl,
getWPS365FileList,
getWPS365SheetList,
readWPS365Cells
} from '@/api/jarvis/wps365'
// 1. 获取授权URL
const authResponse = await getWPS365AuthUrl()
window.open(authResponse.data, '_blank')
// 2. 等待授权完成后,获取文件列表
const fileListResponse = await getWPS365FileList({
userId: 'your_user_id',
page: 1,
pageSize: 20
})
// 3. 选择要读取的文件,获取工作表列表
const fileToken = fileListResponse.data.files[0].file_token
const sheetListResponse = await getWPS365SheetList('your_user_id', fileToken)
// 4. 读取第一个工作表的数据
const dataResponse = await readWPS365Cells({
userId: 'your_user_id',
fileToken: fileToken,
sheetIdx: 0,
range: 'A1:Z100'
})
console.log('表格数据:', dataResponse.data.values)
```
## 七、注意事项
1. **权限范围**确保在WPS365开放平台配置了正确的权限
2. **文件Token**使用fileToken而不是fileId来访问文件
3. **工作表索引**工作表索引从0开始不是从1开始
4. **单元格范围**:范围格式为 `A1:B10`,不区分大小写
5. **Token有效期**Token会过期需要定期刷新或重新授权
6. **数据量限制**:一次读取的数据量不要太大,建议分批读取

View File

@@ -0,0 +1,270 @@
# WPS365 在线表格API集成使用说明
## 概述
本功能实现了WPS365在线表格KSheet的完整API集成支持用户授权、文档查看和编辑等功能。
## 功能特性
1. **OAuth用户授权**支持WPS365标准的OAuth 2.0授权流程
2. **Token管理**自动保存和管理访问令牌支持Token刷新
3. **文件管理**:获取文件列表、文件信息
4. **工作表管理**:获取工作表列表、创建数据表
5. **单元格编辑**:支持读取和更新单元格数据,支持批量更新
## 配置说明
### 1. 在WPS365开放平台注册应用
1. 访问 [WPS365开放平台](https://open.wps.cn/)
2. 注册成为服务商并创建应用
3. 获取 `app_id``app_key`
4. 配置授权回调地址(需要与配置文件中的 `redirect-uri` 一致)
### 1.1 配置应用权限(重要!)
在WPS365开放平台的应用配置中需要确保开启以下权限
#### 基础权限
- **文件读取权限** (`file.read``file.readwrite`)
- **用户信息权限** (`user.info`)
#### 在线表格KSheet相关权限
根据图片中的应用能力配置页面,如果需要读取在线表格内容,建议:
1. **进入"应用能力"配置页面**
- 在左侧导航栏选择"应用能力" > "应用能力"
2. **确保相关能力已开启**
- 虽然图片中显示的是"多维表格"插件相关能力,但读取在线表格内容主要依赖:
- **文件读取权限**(在"权限管理"中配置)
- **API调用权限**确保应用有调用OpenAPI的权限
3. **权限管理配置**
- 进入"开发配置" > "权限管理"
- 确保添加了以下权限:
- `ksheet:read` - 读取在线表格数据
- `ksheet:write` - 写入在线表格数据(如果需要编辑)
- `file:read` - 读取文件
- `file:write` - 写入文件(如果需要编辑)
4. **事件与回调配置**
- 进入"开发配置" > "事件与回调"
- 配置回调地址:`https://your-domain.com/wps365-callback`
- 确保回调地址验证通过支持GET和POST请求
### 2. 更新配置文件
编辑 `application-dev.yml`或对应的环境配置文件添加WPS365配置
```yaml
wps365:
# 应用ID从WPS365开放平台获取
app-id: YOUR_APP_ID
# 应用密钥从WPS365开放平台获取
app-key: YOUR_APP_KEY
# 授权回调地址需要在WPS365开放平台配置
# 注意:使用 /wps365-callback 路径,避免前端路由拦截
redirect-uri: https://your-domain.com/wps365-callback
# API基础地址一般不需要修改
api-base-url: https://openapi.wps.cn/api/v1
# OAuth授权地址一般不需要修改
oauth-url: https://openapi.wps.cn/oauth2/auth
# 获取Token地址一般不需要修改
token-url: https://openapi.wps.cn/oauth2/token
# 刷新Token地址一般不需要修改
refresh-token-url: https://openapi.wps.cn/oauth2/token
```
**重要提示**
- 回调地址使用 `/wps365-callback` 而不是 `/jarvis/wps365/oauth/callback`
- 这样可以避免前端路由拦截确保OAuth回调能正常访问
- 在WPS365开放平台配置时只需配置域名`jarvis.van333.cn`回调路径会自动使用配置中的完整URL
## API接口说明
### 1. 获取授权URL
**接口**: `GET /jarvis/wps365/authUrl`
**参数**:
- `state` (可选): 状态参数用于防止CSRF攻击
**返回**: 授权URL用户需要访问此URL完成授权
**示例**:
```javascript
// 前端调用
import { getWPS365AuthUrl } from '@/api/jarvis/wps365'
const response = await getWPS365AuthUrl()
const authUrl = response.data
// 打开授权页面
window.open(authUrl, '_blank')
```
### 2. OAuth回调处理
**接口**: `GET /wps365-callback`
**参数**:
- `code`: 授权码由WPS365回调时自动传入
- `state`: 状态参数(可选)
- `error`: 错误码(授权失败时)
- `error_description`: 错误描述(授权失败时)
**说明**:
- 此接口处理WPS365的授权回调自动获取并保存Token
- 返回HTML页面显示授权结果
- 使用 `/wps365-callback` 路径避免前端路由拦截
- 授权成功后会显示成功页面3秒后自动关闭窗口
### 3. 获取Token状态
**接口**: `GET /jarvis/wps365/tokenStatus`
**参数**:
- `userId`: 用户ID
**返回**: Token状态信息是否授权、是否有效等
### 4. 刷新Token
**接口**: `POST /jarvis/wps365/refreshToken`
**请求体**:
```json
{
"refreshToken": "refresh_token_value"
}
```
### 5. 获取用户信息
**接口**: `GET /jarvis/wps365/userInfo`
**参数**:
- `userId`: 用户ID
### 6. 获取文件列表
**接口**: `GET /jarvis/wps365/files`
**参数**:
- `userId`: 用户ID
- `page`: 页码默认1
- `pageSize`: 每页数量默认20
### 7. 获取工作表列表
**接口**: `GET /jarvis/wps365/sheets`
**参数**:
- `userId`: 用户ID
- `fileToken`: 文件token
### 8. 读取单元格数据
**接口**: `GET /jarvis/wps365/readCells`
**参数**:
- `userId`: 用户ID
- `fileToken`: 文件token
- `sheetIdx`: 工作表索引从0开始
- `range`: 单元格范围A1:B10可选
### 9. 更新单元格数据
**接口**: `POST /jarvis/wps365/updateCells`
**请求体**:
```json
{
"userId": "user_id",
"fileToken": "file_token",
"sheetIdx": 0,
"range": "A1:B2",
"values": [
["值1", "值2"],
["值3", "值4"]
]
}
```
### 10. 批量更新单元格数据
**接口**: `POST /jarvis/wps365/batchUpdateCells`
**请求体**:
```json
{
"userId": "user_id",
"fileToken": "file_token",
"sheetIdx": 0,
"updates": [
{
"range": "A1:B2",
"values": [["值1", "值2"], ["值3", "值4"]]
},
{
"range": "C1:D2",
"values": [["值5", "值6"], ["值7", "值8"]]
}
]
}
```
## 使用流程
### 1. 用户授权流程
1. 前端调用 `/jarvis/wps365/authUrl` 获取授权URL
2. 在新窗口打开授权URL用户完成授权
3. WPS365会回调到配置的 `redirect-uri`,自动处理授权码
4. 系统自动获取并保存Token到Redis
### 2. 编辑文档流程
1. 调用 `/jarvis/wps365/files` 获取文件列表
2. 选择要编辑的文件,调用 `/jarvis/wps365/sheets` 获取工作表列表
3. 调用 `/jarvis/wps365/readCells` 读取现有数据
4. 修改数据后,调用 `/jarvis/wps365/updateCells` 更新数据
### 3. Token刷新
- Token过期前系统会自动尝试刷新
- 也可以手动调用 `/jarvis/wps365/refreshToken` 刷新Token
## 注意事项
1. **用户权限**:只有文档的所有者或被授予编辑权限的用户才能编辑文档
2. **Token管理**Token存储在Redis中有效期30天。过期后需要重新授权
3. **API限制**注意WPS365的API调用频率限制
4. **文件Token**WPS365使用 `file_token` 而不是文件ID需要从文件列表中获取
## 前端页面
访问 `/jarvis/wps365` 可以打开WPS365管理页面包含
- 授权状态显示
- 用户信息查看
- 文件列表浏览
- 在线编辑表格功能
## 错误处理
- **未授权**:返回错误提示,引导用户完成授权
- **Token过期**自动尝试刷新Token刷新失败则提示重新授权
- **权限不足**返回错误码10003提示用户没有编辑权限
## 技术实现
- **后端**Spring Boot + RedisToken存储
- **前端**Vue.js + Element UI
- **HTTP客户端**HttpURLConnection不使用代理直接连接
## 参考文档
- [WPS365开放平台文档](https://open.wps.cn/)
- [用户授权流程](https://open.wps.cn/documents/app-integration-dev/wps365/server/certification-authorization/user-authorization/flow)
- [KSheet API文档](https://developer.kdocs.cn/server/ksheet/)

View File

@@ -0,0 +1,261 @@
# 从Status字段提取手机号码功能说明
## 功能概述
在批量同步物流链接到腾讯文档时,系统会自动从订单的 `status` 字段中提取手机号码,并写入到腾讯文档的"下单电话"列。
> **注意:** 手机号码存储在 `status` 字段,不是 `remark` 字段。
## 实现原理
### 1. 列识别
系统在读取表头时,会自动识别以下列名:
- `下单电话`
- `电话`
- `手机`
任何包含以上关键词的列都会被识别为"下单电话"列。
### 2. 手机号码提取
从订单的 `status`(状态)字段中提取手机号码:
**提取规则:**
- 自动移除status字段中的空格、横线、括号等分隔符
- 使用正则表达式匹配11位手机号码1开头的11位数字
- 支持格式示例:
```
138 0013 8000
138-0013-8000
(138)00138000
13800138000
```
**正则表达式:** `1[3-9]\d{9}`
**匹配规则:**
- 第1位必须是 `1`
- 第2位必须是 `3-9`
- 后面9位是任意数字 `0-9`
### 3. 写入逻辑
在批量同步时,如果同时满足以下条件,会写入手机号码:
1. ✅ 表头中识别到了"下单电话"列
2. ✅ 从订单备注中成功提取到手机号码
3. ✅ 订单有物流链接需要同步
写入时使用 `batchUpdate` API一次性更新多个字段
1. 物流单号(超链接类型)
2. **下单电话(普通文本)** ← 新增
3. 是否安排(值为"2"
4. 标记当前日期格式251106
## 代码实现
### 关键方法
#### `extractPhoneFromRemark(String remark)`
```java
/**
* 从备注中提取手机号码
* 支持11位手机号码可能包含空格、横线等分隔符
*
* @param remark 备注信息
* @return 提取到的手机号码如果没有则返回null
*/
private String extractPhoneFromRemark(String remark) {
if (remark == null || remark.trim().isEmpty()) {
return null;
}
// 移除所有空格、横线、括号等分隔符
String cleanedRemark = remark.replaceAll("[\\s\\-\\(\\)\\[\\]\\【\\】]", "");
// 匹配11位手机号码1开头的11位数字
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("1[3-9]\\d{9}");
java.util.regex.Matcher matcher = pattern.matcher(cleanedRemark);
if (matcher.find()) {
String phone = matcher.group();
log.debug("从备注中提取到手机号码: {}", phone);
return phone;
}
return null;
}
```
### 使用位置
**`TencentDocController.fillLogisticsByOrderNo`** - 批量同步方法
1. **列识别阶段**第1003-1008行
```java
// 识别"下单电话"列(可选)
if (phoneColumn == null && (cellValueTrim.contains("下单电话")
|| cellValueTrim.contains("电话")
|| cellValueTrim.contains("手机"))) {
phoneColumn = i;
log.info("✓ 识别到 '下单电话' 列:第 {} 列(索引{}", i + 1, i);
}
```
2. **数据处理阶段**第1188-1195行
```java
// 从status字段中提取手机号码
String phone = null;
if (phoneColumn != null) {
phone = extractPhoneFromRemark(order.getStatus());
if (phone != null) {
log.info("✓ 从status字段提取手机号码 - 单号: {}, 手机号: {}", orderNo, phone);
}
}
```
3. **构建更新请求**第1201-1205行
```java
// 如果找到手机号码,也添加到更新中
if (phone != null && phoneColumn != null) {
update.put("phone", phone);
update.put("phoneColumn", phoneColumn);
}
```
4. **写入腾讯文档**第1294-1300行
```java
// 2. 更新"下单电话"列(如果存在且提取到了手机号码)
String phone = update.getString("phone");
Integer phoneCol = update.getInteger("phoneColumn");
if (phone != null && phoneCol != null) {
requests.add(buildUpdateCellRequest(sheetId, row - 1, phoneCol, phone, false));
log.info("✓ 准备写入手机号码 - 单号: {}, 手机号: {}, 行: {}, 列: {}",
expectedOrderNo, phone, row, phoneCol);
}
```
## 日志输出
### 成功提取手机号码
```
✓ 从status字段提取手机号码 - 单号: JY2025110601, 手机号: 13800138000
✓ 准备写入手机号码 - 单号: JY2025110601, 手机号: 13800138000, 行: 3, 列: 6
✓ 写入成功 - 行: 3, 单号: JY2025110601, 物流链接: https://3.cn/xxx, 手机号: 13800138000
```
### 未找到手机号码
```
找到订单物流链接 - 单号: JY2025110602, 物流链接: https://3.cn/xxx, 手机号: 无, 行号: 4, 已推送: 否
✓ 写入成功 - 行: 4, 单号: JY2025110602, 物流链接: https://3.cn/xxx
```
### 未识别到"下单电话"列
```
未找到'下单电话'列,将跳过该字段的更新
列位置识别完成 - 单号: 2, 物流单号: 12, 是否安排: null, 标记: 14, 下单电话: null
```
## 使用示例
### 订单Status字段示例
| Status内容 | 提取结果 | 说明 |
|---------|---------|------|
| `联系电话138 0013 8000` | `13800138000` | ✅ 成功提取 |
| `手机号138-0013-8000` | `13800138000` | ✅ 成功提取 |
| `电话:(138)00138000` | `13800138000` | ✅ 成功提取 |
| `13800138000 张三` | `13800138000` | ✅ 成功提取 |
| `17703916233` | `17703916233` | ✅ 成功提取 |
| `无电话` | `null` | ❌ 未提取到 |
| `12345678901` | `null` | ❌ 不符合规则不是1开头 |
| `1280013800` | `null` | ❌ 不符合规则第2位不是3-9 |
### 腾讯文档表头示例
支持的表头名称(包含即可识别):
- ✅ `下单电话`
- ✅ `电话`
- ✅ `手机`
- ✅ `手机号`
- ✅ `联系电话`
- ✅ `客户电话`
## 兼容性
### 向后兼容
- ✅ 如果表头中**没有**"下单电话"列,功能自动跳过,不影响其他字段的同步
- ✅ 如果订单status字段中**没有**手机号码,功能自动跳过,不影响其他字段的同步
- ✅ 不影响现有的物流链接、是否安排、标记字段的同步
### 可选性
此功能是**完全可选**的:
1. 不需要在表头中添加"下单电话"列
2. 订单status字段可以不包含手机号码
3. 功能会自动识别和适配
## 测试建议
### 测试场景
1. **正常场景**
- 表头包含"下单电话"列
- 订单status字段包含手机号码
- 预期:手机号码正确写入
2. **无电话列场景**
- 表头不包含"下单电话"列
- 预期:其他字段正常同步,手机号码跳过
3. **无手机号场景**
- 表头包含"下单电话"列
- 订单status字段不包含手机号码
- 预期:其他字段正常同步,手机号码列为空
4. **多种格式场景**
- 测试不同格式的手机号码(带空格、横线、括号等)
- 预期:都能正确提取
### 测试步骤
1. 在腾讯文档表头添加"下单电话"列(或"电话"、"手机"
2. 确保订单的status字段包含手机号码例如17703916233
3. 点击"批量同步物流"按钮
4. 查看后端日志,确认提取和写入成功
5. 查看腾讯文档,确认手机号码正确显示在"下单电话"列
## 未来优化建议
1. **支持更多手机号格式**
- 国际号码(+86
- 固定电话(区号+号码)
2. **支持多个手机号**
- 从备注中提取多个手机号码
- 用逗号或分号分隔
3. **手机号验证**
- 验证号码段有效性135、138等
- 提示无效号码
4. **手机号脱敏**
- 日志中对手机号进行脱敏显示
- 如138****8000
## 相关文件
- **Controller**: `TencentDocController.java`
- **方法**: `fillLogisticsByOrderNo()`, `extractPhoneFromRemark()`
- **API**: 腾讯文档 `batchUpdate` API
---
**最后更新时间**: 2025-11-06
**版本**: v1.0

View File

@@ -0,0 +1,324 @@
# 如何查看同步进度和操作日志
## 您的三个问题解答
### 1⃣ startRow被更新了吗
**答:是的,每次同步都会更新!**
更新逻辑在代码中:
```java
// 更新 Redis 中的进度
redisCache.setCacheObject(redisKey, currentMaxRow, 30, TimeUnit.DAYS);
```
**但是**:前端配置页面**不会自动刷新**
您需要:
1. **关闭配置对话框**
2. **重新打开配置**
3. 就能看到最新的进度了
---
### 2⃣ 更新状态是真实的吗?
**答:是真实的!**
数据存储位置:
- **Redis Key**: `tendoc:progress:fileId:sheetId`
- **过期时间**: 30天
- **存储内容**: 当前处理到的最大行号
您可以通过以下方式验证:
```bash
# 在Redis中查看
redis-cli
> get "tendoc:progress:DTUFydU9FTkRLbEN6:BB08J2"
```
---
### 3⃣ 同步日志在哪里查看?
**答:操作日志记录在数据库中!**
#### 📊 方法1直接查询数据库
```sql
-- 查看最近50条操作日志
SELECT
id,
operation_type,
order_no,
target_row,
operation_status,
error_message,
operator,
create_time
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
ORDER BY create_time DESC
LIMIT 50;
-- 查看成功的操作
SELECT COUNT(*) as 成功数量
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
AND operation_status = 'SUCCESS'
AND DATE(create_time) = CURDATE();
-- 查看失败的操作
SELECT
order_no,
target_row,
error_message,
create_time
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
AND operation_status = 'FAILED'
ORDER BY create_time DESC;
-- 查看跳过的操作
SELECT COUNT(*) as 跳过数量
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
AND operation_status = 'SKIPPED'
AND DATE(create_time) = CURDATE();
```
#### 📊 方法2通过API查看已添加
**接口1查询操作日志列表**
```
GET /jarvis-api/jarvis/tendoc/operationLogs?fileId=DTUFydU9FTkRLbEN6
```
**接口2查询最近N条日志**
```
GET /jarvis-api/jarvis/tendoc/recentLogs?fileId=DTUFydU9FTkRLbEN6&limit=50
```
**返回数据示例:**
```json
{
"code": 200,
"msg": "操作成功",
"data": [
{
"id": 1,
"fileId": "DTUFydU9FTkRLbEN6",
"sheetId": "BB08J2",
"operationType": "BATCH_SYNC",
"orderNo": "JY202511061595",
"targetRow": 2575,
"logisticsLink": "https://3.cn/-2urt1U5",
"operationStatus": "SUCCESS",
"errorMessage": null,
"operator": "admin",
"createTime": "2025-11-06 22:03:30"
}
]
}
```
---
## 🔍 详细的同步进度说明
### 进度更新规则
代码中的进度更新逻辑:
1. **有数据填充成功**
```
currentMaxRow = endRow (本次处理的结束行)
nextStartRow = currentMaxRow - 100 (回溯100行防止遗漏)
```
2. **本次无数据填充,但跳跃不大**
```
currentMaxRow = endRow
nextStartRow = currentMaxRow - 100
```
3. **本次无数据填充,且跳跃过大**
```
不更新Redis进度
nextStartRow = effectiveStartRow (配置的起始行)
```
### 为什么前端不自动刷新?
因为配置对话框是**静态的**,它在打开时读取一次配置,之后不会主动刷新。
**解决方案:**
- 关闭配置对话框
- 重新打开
- 或者点击"刷新"按钮(如果有)
---
## 📈 如何判断同步是否正常?
### 1. 查看后端日志
```
grep "批量填充物流链接完成" ruoyi-admin.log | tail -10
```
应该看到类似:
```
批量填充物流链接完成 - 成功: 15, 跳过: 178, 错误: 7
本次填充成功 15 条,更新进度到第 2699 行,下次从第 2599 行开始
```
### 2. 查看数据库日志统计
```sql
-- 今天的统计
SELECT
operation_status,
COUNT(*) as 数量
FROM tencent_doc_operation_log
WHERE file_id = 'DTUFydU9FTkRLbEN6'
AND DATE(create_time) = CURDATE()
GROUP BY operation_status;
```
应该看到:
```
operation_status | 数量
----------------|------
SUCCESS | 150
SKIPPED | 500
FAILED | 10
```
### 3. 检查Redis中的进度
```bash
redis-cli
> get "tendoc:progress:DTUFydU9FTkRLbEN6:BB08J2"
"2699"
```
这个数字应该随着同步而增长。
---
## 🎯 快速诊断问题
### 问题A进度没有更新
**可能原因:**
1. Redis连接失败
2. 同步过程中出现异常
3. 没有成功填充任何数据
**排查方法:**
```bash
# 1. 检查Redis
redis-cli ping
# 2. 查看后端日志
tail -f ruoyi-admin.log | grep "tendoc:progress"
# 3. 查看数据库日志
SELECT * FROM tencent_doc_operation_log
ORDER BY create_time DESC LIMIT 10;
```
### 问题B日志中全是SKIPPED
**可能原因:**
1. 所有订单都已经推送过了(`tencent_doc_pushed = 1`
2. 或者腾讯文档中的物流链接列都已经有值了
**解决方法:**
```sql
-- 检查订单的推送状态
SELECT
tencent_doc_pushed,
COUNT(*) as 数量
FROM jd_order
WHERE distribution_mark = 'H-TF'
GROUP BY tencent_doc_pushed;
-- 重置推送状态(慎用!)
UPDATE jd_order
SET tencent_doc_pushed = 0,
tencent_doc_push_time = NULL
WHERE distribution_mark = 'H-TF'
AND tencent_doc_pushed = 1;
```
### 问题C有ERROR日志
**排查步骤:**
```sql
-- 查看错误详情
SELECT
order_no,
target_row,
error_message,
create_time
FROM tencent_doc_operation_log
WHERE operation_status = 'FAILED'
ORDER BY create_time DESC
LIMIT 20;
```
常见错误:
- `未找到订单` - 数据库中不存在该订单
- `订单物流链接为空` - 订单还没有物流信息
- `API调用失败` - 腾讯文档API异常
---
## 🔧 添加前端日志查看功能(可选)
如果您想在前端直接查看日志,我可以帮您添加一个"查看操作日志"对话框。
需要:
1. 在配置页面添加"查看日志"按钮
2. 创建日志查看对话框组件
3. 调用上面的API接口展示数据
是否需要?请告知!
---
## 📊 日志表结构
```sql
CREATE TABLE `tencent_doc_operation_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`file_id` varchar(100) COMMENT '文件ID',
`sheet_id` varchar(100) COMMENT '工作表ID',
`operation_type` varchar(50) COMMENT '操作类型',
`order_no` varchar(100) COMMENT '订单号',
`target_row` int COMMENT '目标行号',
`logistics_link` varchar(500) COMMENT '物流链接',
`operation_status` varchar(50) COMMENT '操作状态',
`error_message` text COMMENT '错误信息',
`operator` varchar(100) COMMENT '操作人',
`create_time` datetime COMMENT '创建时间',
`remark` varchar(500) COMMENT '备注',
PRIMARY KEY (`id`),
KEY `idx_file_id` (`file_id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_create_time` (`create_time`)
) COMMENT='腾讯文档操作日志表';
```
---
**总结**
1. ✅ startRow **有更新**存储在Redis中
2. ✅ 更新状态是**真实的**
3. ✅ 日志在 `tencent_doc_operation_log` 表中可通过SQL或API查询
4. ❓ 前端配置页面需要**手动刷新**(关闭重开)才能看到最新进度
如需添加前端日志查看功能,请告知!

View File

@@ -0,0 +1,201 @@
# 腾讯文档延迟推送配置说明
## 📋 功能说明
H-TF订单录单后**不立即推送**到腾讯文档,而是采用**智能延迟推送机制**
1. 录单完成 → 触发10分钟倒计时
2. 10分钟内有新录单 → 重置倒计时
3. 10分钟内无新录单 → 自动执行推送
4. 推送执行中有新录单 → 推送完成后重新倒计时
## ⚙️ 配置文件
`application.yml` 中添加配置:
```yaml
# 腾讯文档延迟推送配置
tencent:
doc:
delayed:
push:
# 延迟时间分钟默认10分钟
minutes: 10
```
## 🎯 工作原理
### 1. Redis存储
- **倒计时结束时间**: `tendoc:delayed_push:next_time`
- **推送执行锁**: `tendoc:delayed_push:lock`
- **新订单标记**: `tendoc:delayed_push:new_order_flag`
### 2. 定时任务
- 每30秒检查一次是否到期
- 到期后自动执行推送
### 3. 防并发机制
- 使用Redis分布式锁
- 确保同一时间只有一个推送任务在执行
### 4. 智能重试
- 推送执行期间有新录单 → 推送完成后自动重新开始倒计时
## 📊 API接口待实现
### 查询倒计时状态
```
GET /jarvis-api/jarvis/tendoc/delayedPushStatus
```
**响应示例:**
```json
{
"code": 200,
"data": {
"hasPending": true,
"remainingSeconds": 300,
"nextPushTime": "2025-11-06 23:10:00",
"isPushing": false
}
}
```
### 立即执行推送
```
POST /jarvis-api/jarvis/tendoc/executeDelayedPushNow
```
### 取消待推送任务
```
POST /jarvis-api/jarvis/tendoc/cancelDelayedPush
```
## 🔍 日志输出
### 触发延迟推送
```
✓ H-TF订单已触发延迟推送 - 单号: 2025110601, 第三方单号: JY202511061595
触发延迟推送10分钟后执行23:10:00
```
### 倒计时检查
```
距离下次推送还有 300 秒
```
### 执行推送
```
倒计时结束,开始执行推送
✓ 获取推送锁成功
开始执行批量同步...
批量同步调用完成,响应码: 200
✓ 推送执行完成
✓ 释放推送锁
```
### 推送期间有新录单
```
推送执行中,标记有新订单,推送完成后将重新开始倒计时
...
推送期间有新订单,重新开始倒计时
触发延迟推送10分钟后执行23:20:00
```
## 🎯 使用场景
### 场景1连续录单
```
23:00:00 - 录单1 → 触发倒计时23:10:00执行
23:02:00 - 录单2 → 重置倒计时23:12:00执行
23:05:00 - 录单3 → 重置倒计时23:15:00执行
23:15:00 - 10分钟无新录单→ 自动推送
```
### 场景2推送执行中有新录单
```
23:00:00 - 录单1 → 触发倒计时23:10:00执行
23:10:00 - 开始推送预计需要2分钟
23:11:00 - 录单2 → 标记有新订单
23:12:00 - 推送完成 → 检测到标记 → 重新触发倒计时23:22:00执行
```
### 场景3手动触发推送
```
23:00:00 - 录单1 → 触发倒计时23:10:00执行
23:05:00 - 手动点击"批量同步物流" → 立即执行推送
23:05:05 - 推送完成 → 清除倒计时
23:06:00 - 录单2 → 重新触发倒计时23:16:00执行
```
## ⚠️ 注意事项
1. **延迟时间建议**:
- 录单频率高设置5-10分钟
- 录单频率低设置10-15分钟
2. **服务器重启**:
- 倒计时存储在Redis中
- 服务器重启后倒计时会继续Redis数据保留
3. **推送失败**:
- 推送失败不会自动重试
- 需要手动点击"批量同步物流"
4. **并发安全**:
- 使用Redis分布式锁
- 多台服务器部署时也能正确工作
## 🔧 故障排查
### 问题1倒计时不触发
**检查步骤:**
1. 确认Service已正常启动
2. 查看日志中是否有"延迟推送服务已启动"
3. 检查Redis连接是否正常
**解决方法:**
```bash
# 查看Redis中的倒计时
redis-cli
> get "tendoc:delayed_push:next_time"
```
### 问题2推送不执行
**检查步骤:**
1. 查看日志中是否有"倒计时结束,开始执行推送"
2. 检查是否有错误日志
3. 查看Redis锁状态
**解决方法:**
```bash
# 查看锁状态
redis-cli
> get "tendoc:delayed_push:lock"
# 如果有锁但长时间未释放,手动删除
> del "tendoc:delayed_push:lock"
```
### 问题3倒计时一直重置
**原因:** 录单频率太高,倒计时不断被重置
**解决方法:**
- 减少延迟时间如改为5分钟
- 或手动触发推送
---
**最后更新**: 2025-11-06
**版本**: v1.0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,237 @@
# 手机号码功能详细排查指南
## ✅ 已确认信息
- **表头列名**: `下单电话` (严格匹配)
- **数据字段**: `status` 订单的status字段存储手机号码
- **示例数据**: `17703916233`
## 🔍 增强的日志输出
现在代码中已经添加了详细的DEBUG和INFO日志重新编译部署后您会看到以下完整的日志链路
### 1⃣ 表头列识别日志
```log
开始识别表头列,共 26 列
列 0 内容: [日期]
列 1 内容: [公司]
列 2 内容: [单号]
✓ 识别到 '单号' 列:第 3 列索引2
列 3 内容: [型号]
列 4 内容: [数量]
列 5 内容: [姓名]
列 6 内容: [下单电话]
✓ 识别到 '下单电话' 列:第 7 列索引6列名: [下单电话]
...
表头列识别完成
列位置识别完成 - 单号: 2, 物流单号: 12, 是否安排: null, 标记: 14, 下单电话: 6
```
**✅ 关键检查点:**
- 必须看到 `✓ 识别到 '下单电话' 列` 这一行
- 最后一行 `下单电话: 6` **不能是null**
### 2⃣ 手机号提取日志
```log
准备从status字段提取手机号 - 单号: JY202511061595, status内容: [17703916233]
原始文本: [17703916233]
清理后文本: [17703916233]
成功提取手机号码: [17703916233] <- 原文本: [17703916233]
✓ 从status字段提取手机号码 - 单号: JY202511061595, status: [17703916233], 手机号: 17703916233
```
**✅ 关键检查点:**
- 必须看到 `成功提取手机号码` 这一行
- status内容不能为空
### 3⃣ 准备写入日志
```log
✓ 准备写入手机号码 - 单号: JY202511061595, 手机号: 17703916233, 行: 2575, 列: 6
```
### 4⃣ batchUpdate请求体日志
```json
批量更新表格 - 请求体: {
"requests":[
{"updateRangeRequest":{"sheetId":"BB08J2","gridData":{"startRow":2574,"startColumn":12,
"rows":[{"values":[{"cellValue":{"link":{"url":"https://3.cn/xxx","text":"https://3.cn/xxx"}}}]}]}}},
{"updateRangeRequest":{"sheetId":"BB08J2","gridData":{"startRow":2574,"startColumn":6, <-- 手机号列
"rows":[{"values":[{"cellValue":{"text":"17703916233"}}]}]}}},
{"updateRangeRequest":{"sheetId":"BB08J2","gridData":{"startRow":2574,"startColumn":14,
"rows":[{"values":[{"cellValue":{"text":"251106"}}]}]}}}
]
}
```
**✅ 关键检查点:**
- `requests` 数组应该有 **3个元素**(物流、手机号、标记)
- 其中一个 `startColumn` 应该是 **6**如果下单电话在第7列
### 5⃣ 写入成功日志
```log
✓ 写入成功 - 行: 2575, 单号: JY202511061595, 物流链接: https://3.cn/xxx, 手机号: 17703916233
```
## 🎯 部署和测试步骤
### 步骤1: 重新编译后端
```bash
cd d:\code\RuoYi-Vue-master\ruoyi-java
mvn clean package -DskipTests
```
### 步骤2: 重启后端服务
确保服务完全重启,加载了新的代码。
### 步骤3: 启用DEBUG日志可选推荐
如果想看到更详细的日志,修改 `application.yml``logback.xml`
```yaml
logging:
level:
com.ruoyi.web.controller.jarvis.TencentDocController: DEBUG
```
### 步骤4: 执行批量同步
1. 打开订单列表页面
2. 点击"批量同步物流"按钮
3. 确认同步
### 步骤5: 查看日志
查看后端日志,按照上面的 5⃣ 个关键节点逐一检查。
## 🐛 问题排查流程
### 问题A: 没有识别到"下单电话"列
**症状:**
```log
列位置识别完成 - 单号: 2, 物流单号: 12, 是否安排: null, 标记: 14, 下单电话: null
```
**排查:**
1. 查看前面的 `列 X 内容: [XXX]` 日志,找到所有列名
2. 确认是否真的有一列叫 "下单电话"
3. 检查列名是否有额外的空格或特殊字符
4. 如果列名是 "电话" 或 "手机",也应该能识别
**解决:**
- 如果列名不匹配,在腾讯文档中将该列重命名为 "下单电话"
- 或者修改代码,添加更多匹配规则
### 问题B: 识别到列但没有提取到手机号
**症状:**
```log
✓ 识别到 '下单电话' 列:第 7 列索引6列名: [下单电话]
...
phoneColumn为null跳过手机号提取 - 单号: JY202511061595
```
**这个不应该发生!** 如果识别到了列,`phoneColumn` 就不应该是null。
**排查:**
- 检查是否有异常日志
- 可能是代码逻辑问题
### 问题C: status字段为空或不包含手机号
**症状:**
```log
准备从status字段提取手机号 - 单号: JY202511061595, status内容: []
准备从status字段提取手机号 - 单号: JY202511061595, status内容: [其他内容,没有手机号]
未找到匹配的手机号码,文本: [其他内容]
```
**排查:**
1. 确认订单的status字段确实存储了手机号码
2. 检查数据库中该订单的status值
3. 可能某些订单的status字段不包含手机号
**解决:**
- 确保所有需要同步的订单其status字段都包含11位手机号码
- 如果status字段用于其他用途可能需要调整数据结构
### 问题D: 提取成功但请求体中没有手机号字段
**症状:**
```log
✓ 从status字段提取手机号码 - 单号: JY202511061595, status: [17703916233], 手机号: 17703916233
...
批量更新表格 - 请求体: {"requests":[...]} <-- 只有2个updateRangeRequest
```
**排查:**
- 查看 `✓ 准备写入手机号码` 日志是否存在
- 检查代码逻辑update对象是否正确构建
### 问题E: 写入请求发送但腾讯文档没有显示
**症状:**
日志显示写入成功,但腾讯文档中"下单电话"列仍然为空。
**排查:**
1. 检查API响应是否真的返回 `updatedCells: 1`
2. 刷新腾讯文档页面
3. 检查列索引是否正确(可能写到了其他列)
4. 检查该列是否有格式限制或保护
## 📊 完整日志示例(成功场景)
```log
22:03:29.150 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - 开始读取表头 - 行号: 2, range: A2:Z2
22:03:29.259 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - 开始识别表头列,共 26 列
22:03:29.259 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 列 0 内容: [日期]
22:03:29.259 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 列 1 内容: [公司]
22:03:29.259 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 列 2 内容: [单号]
22:03:29.259 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - ✓ 识别到 '单号' 列:第 3 列索引2
22:03:29.259 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 列 6 内容: [下单电话]
22:03:29.259 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - ✓ 识别到 '下单电话' 列:第 7 列索引6列名: [下单电话]
22:03:29.259 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - 表头列识别完成
22:03:29.259 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - 列位置识别完成 - 单号: 2, 物流单号: 12, 是否安排: null, 标记: 14, 下单电话: 6
... (读取数据行) ...
22:03:29.500 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 准备从status字段提取手机号 - 单号: JY202511061595, status内容: [17703916233]
22:03:29.500 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 原始文本: [17703916233]
22:03:29.500 [http-nio-30313-exec-10] DEBUG c.r.w.c.j.TencentDocController - 清理后文本: [17703916233]
22:03:29.500 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - 成功提取手机号码: [17703916233] <- 原文本: [17703916233]
22:03:29.500 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - ✓ 从status字段提取手机号码 - 单号: JY202511061595, status: [17703916233], 手机号: 17703916233
22:03:29.500 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - 找到订单物流链接 - 单号: JY202511061595, 物流链接: https://3.cn/xxx, 手机号: 17703916233, 行号: 2575, 已推送: 否
... (批量写入) ...
22:03:29.700 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - ✓ 准备写入手机号码 - 单号: JY202511061595, 手机号: 17703916233, 行: 2575, 列: 6
22:03:29.700 [http-nio-30313-exec-10] DEBUG c.r.j.u.TencentDocApiUtil - 批量更新表格 - 请求体: {"requests":[...3个updateRangeRequest...]}
22:03:30.284 [http-nio-30313-exec-10] INFO c.r.w.c.j.TencentDocController - ✓ 写入成功 - 行: 2575, 单号: JY202511061595, 物流链接: https://3.cn/xxx, 手机号: 17703916233
```
## 🎯 下一步行动
1. **重新编译部署**
2. **执行一次批量同步**
3. **复制完整日志**(从"开始识别表头列"到"写入成功"的所有日志)
4. **提供给我分析**
如果还有问题,请提供:
- ✅ 完整的表头识别日志(包括所有 `列 X 内容` 的DEBUG日志
- ✅ 手机号提取相关的所有日志
- ✅ batchUpdate请求体的完整JSON
- ✅ 腾讯文档表头的截图
---
**最后更新**: 2025-11-06 22:30
**版本**: v2.0 - 增强日志版

View File

@@ -0,0 +1,274 @@
# 智能状态同步机制 - 详细说明
## 📖 背景
在实际使用中,腾讯文档的物流链接可能通过多种方式填写:
1. **系统推送**:通过"推送物流"按钮自动填写
2. **手动填写**:用户直接在文档中手动填写
3. **外部导入**从Excel等外部文件导入
4. **协同编辑**:团队成员直接编辑文档
如果没有智能同步机制,会导致:
- ❌ 订单状态显示"未推送",但文档中已有值
- ❌ 批量同步时重复查询这些订单
- ❌ 增加数据库查询负担
- ❌ 状态不一致,影响业务判断
## ✨ 智能同步机制
### 核心思路
**以腾讯文档的实际状态为准,自动同步到订单系统**
```
文档是最终展示层(实际填写状态)
订单系统是管理层(推送状态记录)
文档有值 + 订单未标记 = 状态不一致
智能同步:自动更新订单状态
```
## 🔄 工作流程
### 场景1系统推送正常流程
```
用户点击"推送物流"
1. 检查订单状态:未推送 ✅
2. 检查文档物流列:无值 ✅
写入物流链接到文档
更新订单状态为"已推送"
记录操作日志SUCCESS
```
**结果**:订单状态 ✅ 已推送 | 文档状态 ✅ 有值
---
### 场景2手动填写后首次批量同步智能同步触发
```
某人手动在文档中填写物流链接
订单状态仍为"未推送"(因为是手动填写)
批量同步开始
1. 读取文档数据
2. 发现某行物流列已有值
3. 查询该单号对应的订单
检测到状态不一致:
- 订单状态:未推送 ❌
- 文档状态:有值 ✅
【智能同步触发】
自动更新订单状态为"已推送"
记录同步日志SKIPPED文档中已有值已同步订单状态
```
**结果**:订单状态 ✅ 已推送 | 文档状态 ✅ 有值
---
### 场景3手动填写后再次批量同步无需同步
```
批量同步开始
1. 读取文档数据
2. 发现某行物流列已有值
3. 查询该单号对应的订单
检测到状态一致:
- 订单状态:已推送 ✅(上次已同步)
- 文档状态:有值 ✅
直接跳过(无需同步)
```
**结果**:订单状态 ✅ 已推送 | 文档状态 ✅ 有值
---
### 场景4用户尝试重复推送拒绝
```
用户点击"推送物流"
1. 检查订单状态:已推送 ❌
拒绝推送
返回错误提示:
"该订单已推送到腾讯文档推送时间2025-11-06 12:30:00请勿重复操作"
```
**结果**:请求被拒绝,订单和文档状态保持不变
## 📊 状态同步矩阵
| 订单状态 | 文档物流列 | 用户操作 | 系统行为 | 最终状态 |
|---------|-----------|---------|---------|---------|
| 未推送 | 无值 | 单个推送 | ✅ 写入物流链接,更新订单状态 | 已推送 + 有值 |
| 未推送 | 无值 | 批量同步 | ✅ 写入物流链接,更新订单状态 | 已推送 + 有值 |
| 未推送 | **有值** | 单个推送 | ❌ 拒绝(文档已有值) | 未推送 + 有值 |
| 未推送 | **有值** | 批量同步 | ✅ **智能同步订单状态** | **已推送 + 有值** |
| 已推送 | 有值 | 单个推送 | ❌ 拒绝(订单已推送) | 已推送 + 有值 |
| 已推送 | 有值 | 批量同步 | ✅ 跳过(订单已推送) | 已推送 + 有值 |
| 已推送 | 无值 | 单个推送 | ❌ 拒绝(订单已推送) | 已推送 + 无值 |
| 已推送 | 无值 | 批量同步 | ✅ 跳过(订单已推送) | 已推送 + 无值 |
**重点场景**第4行 - 未推送 + 有值 + 批量同步 = **智能同步**
## 🎯 核心优势
### 1. 自动化
- ✅ 无需人工干预
- ✅ 批量同步时自动检测
- ✅ 自动修正状态不一致
### 2. 高效性
- ✅ 同步后下次批量同步会跳过
- ✅ 减少数据库查询
- ✅ 减少不必要的状态检查
### 3. 可追溯
- ✅ 记录同步操作日志
- ✅ 标记同步原因:"文档中已有物流链接(可能手动填写)"
- ✅ 便于审计和问题排查
### 4. 兼容性
- ✅ 兼容手动填写
- ✅ 兼容外部导入
- ✅ 兼容协同编辑
- ✅ 兼容各种数据来源
## 📝 代码实现(简化版)
```java
// 批量同步时,检查文档物流列
String existingLogisticsLink = row.getString(logisticsLinkColumn);
if (existingLogisticsLink != null && !existingLogisticsLink.trim().isEmpty()) {
// 文档中已有物流链接,检查订单状态
JDOrder existingOrder = jdOrderService.selectJDOrderByThirdPartyOrderNo(orderNo);
if (existingOrder != null &&
(existingOrder.getTencentDocPushed() == null ||
existingOrder.getTencentDocPushed() == 0)) {
// 状态不一致,触发智能同步
existingOrder.setTencentDocPushed(1);
existingOrder.setTencentDocPushTime(new Date());
jdOrderService.updateJDOrder(existingOrder);
log.info("✓ 同步订单状态 - 单号: {}, 行号: {}, 原因: 文档中已有物流链接(可能手动填写)",
orderNo, excelRow);
// 记录同步日志
logOperation(fileId, sheetId, "BATCH_SYNC", orderNo, excelRow,
existingLogisticsLink, "SKIPPED", "文档中已有物流链接,已同步订单状态");
}
skippedCount++; // 跳过写入
continue;
}
```
## 🔍 日志示例
### 智能同步触发
```
2025-11-06 14:30:15 INFO - 批量同步开始 - 范围第3-202行
2025-11-06 14:30:16 INFO - 发现物流列已有值 - 单号: JY2025110329041, 行号: 123
2025-11-06 14:30:16 INFO - 检测到状态不一致 - 订单状态: 未推送, 文档状态: 有值
2025-11-06 14:30:16 INFO - ✓ 同步订单状态 - 单号: JY2025110329041, 行号: 123, 原因: 文档中已有物流链接(可能手动填写)
2025-11-06 14:30:16 INFO - 记录同步日志 - 操作类型: BATCH_SYNC, 状态: SKIPPED
```
### 操作日志表记录
```sql
INSERT INTO tencent_doc_operation_log (
file_id, sheet_id, operation_type, order_no, target_row,
logistics_link, operation_status, error_message, operator, create_time
) VALUES (
'DUW50RUprWXh2TGJK', 'BB08J2', 'BATCH_SYNC', 'JY2025110329041', 123,
'https://3.cn/2ume-Ak1', 'SKIPPED', '文档中已有物流链接,已同步订单状态',
'admin', '2025-11-06 14:30:16'
);
```
## 🛠️ 排查与维护
### 查询智能同步记录
```sql
-- 查询所有智能同步操作
SELECT order_no, target_row, logistics_link, create_time, operator
FROM tencent_doc_operation_log
WHERE operation_status = 'SKIPPED'
AND error_message LIKE '%文档中已有物流链接,已同步订单状态%'
ORDER BY create_time DESC;
```
### 查询状态不一致的订单理论上应该为0
```sql
-- 如果有记录,说明智能同步未触发或失败
SELECT o.third_party_order_no, o.tencent_doc_pushed, o.logistics_link
FROM jd_order o
WHERE o.logistics_link IS NOT NULL
AND o.logistics_link != ''
AND (o.tencent_doc_pushed IS NULL OR o.tencent_doc_pushed = 0);
```
### 手动修正状态不一致
```sql
-- 如果发现状态不一致,可以手动修正
UPDATE jd_order
SET tencent_doc_pushed = 1,
tencent_doc_push_time = NOW()
WHERE logistics_link IS NOT NULL
AND logistics_link != ''
AND (tencent_doc_pushed IS NULL OR tencent_doc_pushed = 0);
```
## ✅ 最佳实践
1. **定期批量同步**
- 建议每天运行一次批量同步
- 自动修正所有状态不一致
2. **监控同步日志**
- 定期检查 `SKIPPED` 状态的日志
- 分析手动填写的频率和模式
3. **培训团队成员**
- 告知团队手动填写会被系统自动同步
- 建议优先使用系统推送功能
4. **备份重要数据**
- 定期备份腾讯文档
- 定期备份订单数据库
## 🎉 总结
智能状态同步机制确保了:
-**订单状态****文档实际状态** 始终保持一致
- ✅ 兼容多种数据来源(系统推送、手动填写、外部导入)
- ✅ 减少重复查询,提高系统效率
- ✅ 所有同步操作可追溯,便于审计
**这是一个真正智能的、自适应的状态管理机制!** 🚀

View File

@@ -0,0 +1,431 @@
# 紧急修复:重复写入问题
## ❌ 严重问题
用户反馈:**完全重复写入了**
## 🔍 根本原因分析
### 问题1数据解析器无法识别超链接类型最严重
**错误代码** (`TencentDocDataParser.java` 第120-122行)
```java
private static String extractCellText(JSONObject cell) {
// ...
// ❌ 只提取 text 字段link类型提取不到
String text = cellValue.getString("text");
return text != null ? text : "";
}
```
**单元格类型**
- 普通文本:`cellValue = {"text": "xxx"}` ✅ 能提取
- **超链接**`cellValue = {"link": {"url": "xxx", "text": "xxx"}}`**提取失败!**
**后果**
1. 读取腾讯文档时,物流列(超链接类型)被解析为空字符串 `""`
2. 系统检查:`existingLogisticsLink == ""` → 认为物流列为空
3. 系统认为可以写入
4. **再次写入同一订单****重复写入!**
5. 文档中出现两行相同数据!
**验证**
```
第一次写入:
- 物流列为空 ✅
- 写入物流链接(超链接类型)✅
- 文档中有1行 ✅
第二次批量同步:
- 读取数据,物流列被解析为 "" ❌
- 系统认为物流列为空 ❌
- 又写入了一次 ❌
- 文档中有2行
```
### 问题2订单状态更新使用旧对象
**错误代码**
```java
// 批量同步中在数据收集阶段保存order对象
update.put("order", order); // ❌ 保存的是旧对象
// 写入成功后,使用旧对象更新状态
JDOrder orderToUpdate = (JDOrder) update.get("order"); // ❌ 使用旧对象
orderToUpdate.setTencentDocPushed(1);
jdOrderService.updateJDOrder(orderToUpdate); // ❌ 可能失败或覆盖其他字段
```
**问题**
1. 从数据收集到写入成功,可能间隔数秒甚至数十秒
2. 这期间订单可能被其他操作修改(如状态变更、金额更新等)
3. 使用旧对象更新会:
- ❌ 覆盖其他字段的最新值
- ❌ 可能因为乐观锁或版本号失败
- ❌ 导致 `tencentDocPushed` 字段更新失败
**后果**
- 订单状态未更新为"已推送"
- 下次批量同步时,系统认为订单未推送
- **再次写入同一订单** → **重复写入!**
### 问题2没有检查更新结果
**错误代码**
```java
jdOrderService.updateJDOrder(orderToUpdate); // ❌ 没有检查返回值
log.info("✓ 订单推送状态已更新"); // ❌ 假设成功
```
**问题**
- `updateJDOrder` 返回受影响的行数
- 如果返回0说明更新失败
- 但代码没有检查,误以为更新成功
- 订单状态实际未更新 → **下次重复写入**
### 问题3智能同步也存在同样问题
智能同步虽然查询是实时的,但也没有检查更新结果。
## ✅ 修复方案
### 修复1增强数据解析器支持超链接类型最关键
**新代码** (`TencentDocDataParser.java`)
```java
private static String extractCellText(JSONObject cell) {
// ...
JSONObject cellValue = cell.getJSONObject("cellValue");
// ✅ 优先级1检查link字段超链接类型
JSONObject link = cellValue.getJSONObject("link");
if (link != null) {
String linkText = link.getString("text");
if (linkText != null && !linkText.isEmpty()) {
return linkText; // ✅ 能正确提取超链接文本
}
String linkUrl = link.getString("url");
if (linkUrl != null && !linkUrl.isEmpty()) {
return linkUrl; // ✅ 或返回url
}
}
// ✅ 优先级2检查text字段普通文本
String text = cellValue.getString("text");
if (text != null) {
return text;
}
// ✅ 优先级3支持number、bool等其他类型
// ...
return "";
}
```
**修复效果**
```
第二次批量同步(修复后):
- 读取数据,物流列被解析为 "https://3.cn/xxx" ✅
- 系统检查existingLogisticsLink != "" ✅
- 跳过写入 ✅
- 文档仍然只有1行 ✅
```
### 修复2重新查询订单关键
```java
// ✅ 写入成功后,重新查询订单,确保数据最新
JDOrder orderToUpdate = jdOrderService.selectJDOrderByThirdPartyOrderNo(expectedOrderNo);
if (orderToUpdate != null) {
orderToUpdate.setTencentDocPushed(1);
orderToUpdate.setTencentDocPushTime(new Date());
int updateResult = jdOrderService.updateJDOrder(orderToUpdate);
// ✅ 检查更新结果
if (updateResult > 0) {
log.info("✓ 订单推送状态已更新");
} else {
log.warn("⚠️ 订单推送状态更新返回0可能未更新");
}
}
```
### 修复2移除不必要的order对象保存
```java
// ❌ 旧代码
update.put("order", order); // 不再需要
// ✅ 新代码
// 不保存order对象写入成功后重新查询
```
### 修复3增强日志
```java
// ✅ 详细日志,便于排查
log.info("✓ 订单推送状态已更新 - 单号: {}, updateResult: {}", orderNo, updateResult);
log.warn("⚠️ 订单推送状态更新返回0 - 单号: {}, 可能未更新", orderNo);
log.error("❌ 更新订单推送状态失败 - 单号: {}", orderNo, e);
```
## 📊 修复前后对比
### 场景批量同步100个订单
#### 修复前有bug
```
1. 读取100行数据
2. 收集100个订单对象保存到updates
3. 开始写入10秒后
4. 写入第1个订单成功
5. 使用10秒前的旧对象更新状态
6. 更新失败(对象已过期)或覆盖其他字段
7. 订单状态仍为"未推送"
8. 写入第2个订单...
...
下次批量同步:
1. 读取数据发现第1个订单"未推送"
2. 再次写入第1个订单 ❌ 重复写入!
3. 再次写入第2个订单 ❌ 重复写入!
...
```
#### 修复后(正确)
```
1. 读取100行数据
2. 收集100个订单号只保存必要信息
3. 开始写入
4. 写入第1个订单成功
5. 重新查询第1个订单最新数据
6. 更新状态成功 ✅
7. 检查updateResult > 0 ✅
8. 订单状态更新为"已推送"
9. 写入第2个订单...
...
下次批量同步:
1. 读取数据发现第1个订单"已推送"
2. 跳过第1个订单 ✅ 不重复!
3. 跳过第2个订单 ✅ 不重复!
...
```
## 🔧 修复的文件
### 核心修复(最重要)
1. **`TencentDocDataParser.java`** ⭐⭐⭐⭐⭐
- 行110-160`extractCellText` 方法
- **增加超链接类型支持**
- 修复数据解析bug彻底解决重复写入
### 次要修复
2. **`TencentDocController.java`**
- 行1258-1276批量同步中的订单状态更新逻辑
- 行1098-1120智能同步中的状态更新逻辑
- 行1150-1157移除不必要的order对象保存
## ✅ 验证步骤
### Step 1: 清空测试数据
```sql
-- 重置所有订单的推送状态
UPDATE jd_order
SET tencent_doc_pushed = 0,
tencent_doc_push_time = NULL
WHERE distribution_mark = 'H-TF';
-- 清空操作日志
TRUNCATE TABLE tencent_doc_operation_log;
```
### Step 2: 第一次批量同步
```bash
# 预期写入10个订单所有订单状态更新为"已推送"
# 检查日志:
grep "✓ 订单推送状态已更新" application.log | wc -l # 应该是10
grep "⚠️ 订单推送状态更新返回0" application.log | wc -l # 应该是0
```
### Step 3: 检查订单状态
```sql
-- 应该有10个订单已推送
SELECT COUNT(*) FROM jd_order
WHERE distribution_mark = 'H-TF'
AND tencent_doc_pushed = 1; -- 应该返回10
```
### Step 4: 第二次批量同步
```bash
# 预期:跳过所有已推送的订单,不重复写入
# 检查日志:
grep "跳过已推送订单" application.log | wc -l # 应该是10
```
### Step 5: 检查腾讯文档
- 每个订单应该只出现一次
- **不应该有重复的物流链接**
### Step 6: 检查操作日志
```sql
-- 每个订单应该只有1条SUCCESS记录
SELECT order_no, COUNT(*) as count
FROM tencent_doc_operation_log
WHERE operation_status = 'SUCCESS'
GROUP BY order_no
HAVING COUNT(*) > 1; -- 应该返回0行
```
## 🚨 紧急部署
### 1. 先执行SQL必须
```bash
mysql -u root -p your_database < doc/订单表添加腾讯文档推送标记.sql
mysql -u root -p your_database < doc/腾讯文档操作日志表.sql
```
### 2. 重新编译
```bash
cd d:\code\RuoYi-Vue-master\ruoyi-java
mvn clean package -DskipTests
```
### 3. 立即重启服务
```bash
# 停止旧服务
# 部署新war/jar
# 启动新服务
```
### 4. 观察日志
```bash
tail -f application.log | grep -E "(✓|⚠️|❌)"
```
## 📝 监控要点
### 正常日志(修复后)
```
✓ 写入成功 - 行: 123, 单号: JY2025110329041, 物流链接: xxx
✓ 订单推送状态已更新 - 单号: JY2025110329041, updateResult: 1
```
### 异常日志(需要关注)
```
⚠️ 订单推送状态更新返回0 - 单号: JY2025110329041, 可能未更新
→ 检查数据库连接、订单是否存在
❌ 更新订单推送状态失败 - 单号: JY2025110329041
→ 检查异常堆栈,可能是数据库锁、约束等问题
```
## 💡 预防措施
### 1. 数据库层面
```sql
-- 添加唯一索引,防止重复单号(如果适用)
CREATE UNIQUE INDEX uk_third_party_order_no
ON jd_order(third_party_order_no);
-- 添加检查约束
ALTER TABLE jd_order
ADD CONSTRAINT ck_tencent_doc_pushed
CHECK (tencent_doc_pushed IN (0, 1));
```
### 2. 应用层面
- ✅ 始终重新查询订单再更新
- ✅ 检查更新结果
- ✅ 记录详细日志
- ✅ 定期检查操作日志表
### 3. 监控告警
```sql
-- 每小时检查是否有订单被重复写入
SELECT order_no, COUNT(*) as write_count
FROM tencent_doc_operation_log
WHERE operation_status = 'SUCCESS'
AND create_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)
GROUP BY order_no
HAVING COUNT(*) > 1;
-- 如果有结果,发送告警
```
## 🎯 总结
### 根本原因(按重要性排序)
#### 1⃣ 数据解析器无法识别超链接(最严重!)⭐⭐⭐⭐⭐
**问题**`TencentDocDataParser.extractCellText()` 只提取 `cellValue.text`,对于超链接类型 `cellValue.link` 提取失败
**后果**
- 读取数据时,物流列(超链接)被解析为空字符串
- 系统误认为物流列为空
- **重复写入同一订单!**
- **文档中出现多行相同数据!**
#### 2⃣ 使用旧订单对象更新状态(严重)
**问题**:批量同步时保存旧订单对象,写入成功后使用旧对象更新状态
**后果**
- 状态更新可能失败
- 订单状态未更新为"已推送"
- 下次批量同步时重复处理
### 解决方案
#### 核心修复(必须)
**增强数据解析器支持超链接类型**
- 优先检查 `link` 字段
- 再检查 `text` 字段
- 支持 `number``bool` 等其他类型
#### 辅助修复(建议)
✅ 重新查询订单再更新状态
✅ 检查更新结果
✅ 详细日志
### 重要性
这是一个**数据完整性严重问题**,必须立即修复!
**如果不修复**
- ❌ 每次批量同步都会重复写入
- ❌ 文档中数据越来越多
- ❌ 用户无法使用批量同步功能
- ❌ 手动填写的数据也会被重复写入
**修复后**
- ✅ 正确识别物流列已有值
- ✅ 跳过已有数据的行
- ✅ 不再重复写入
- ✅ 文档数据保持唯一
---
**修复完成后,请按照验证步骤仔细测试!特别要测试超链接类型的单元格!**

View File

@@ -0,0 +1,299 @@
# 腾讯文档倒计时和批量推送记录功能说明
## 功能概述
本次更新实现了腾讯文档自动推送的倒计时监控和批量推送记录管理功能,主要包括:
1. **批量推送记录**:记录每次批量推送的详细信息,包括成功数、失败数、耗时等
2. **操作日志关联**每条操作日志都关联到对应的批次ID方便追踪
3. **倒计时监控**:实时显示自动推送倒计时,支持手动触发和取消
4. **推送历史查看**:可查看历史推送记录,展开查看每条记录的详细操作日志
## 数据库变更
### 1. 新建批量推送记录表
```sql
-- 执行SQL文件
source sql/tencent_doc_batch_push_record.sql;
```
主要字段:
- `batch_id`批次IDUUID
- `file_id``sheet_id`文档和工作表ID
- `push_type`推送类型AUTO-自动推送MANUAL-手动推送)
- `trigger_source`触发来源DELAYED_TIMER-延迟定时器USER-用户手动)
- `start_time``end_time`:推送开始和结束时间
- `duration_ms`:推送耗时(毫秒)
- `start_row``end_row`:推送的行范围
- `success_count``skip_count``error_count`:成功、跳过、错误数量
- `status`状态RUNNING-执行中SUCCESS-成功PARTIAL-部分成功FAILED-失败)
### 2. 修改操作日志表
`tencent_doc_operation_log` 表添加 `batch_id` 字段,用于关联批量推送记录。
```sql
ALTER TABLE `tencent_doc_operation_log`
ADD COLUMN `batch_id` varchar(64) DEFAULT NULL COMMENT '批次ID关联批量推送记录' AFTER `id`,
ADD KEY `idx_batch_id` (`batch_id`);
```
## 后端更新
### 1. 新增实体类和Mapper
- **TencentDocBatchPushRecord.java**:批量推送记录实体
- **TencentDocBatchPushRecordMapper.java**批量推送记录Mapper接口
- **TencentDocBatchPushRecordMapper.xml**MyBatis映射文件
### 2. 新增Service层
- **ITencentDocBatchPushService.java**:批量推送服务接口
- **TencentDocBatchPushServiceImpl.java**:批量推送服务实现
主要方法:
- `createBatchPushRecord`:创建批量推送记录
- `updateBatchPushRecord`:更新批量推送记录
- `getBatchPushRecord`:查询单条记录(含操作日志)
- `getBatchPushRecordListWithLogs`:查询记录列表(含操作日志)
- `getLastSuccessRecord`:查询最后一次成功的推送记录
- `getPushStatusAndCountdown`:获取推送状态和倒计时信息
### 3. 修改延迟推送服务
- **TencentDocDelayedPushServiceImpl.java**
- 在执行批量推送前创建批量推送记录
- 调用API时传递批次ID
- 推送失败时更新记录状态
### 4. 新增Controller API
**TencentDocController.java** 新增接口:
| 接口 | 方法 | 说明 |
|------|------|------|
| `/jarvis/tendoc/batchPushRecords` | GET | 获取批量推送记录列表 |
| `/jarvis/tendoc/batchPushRecord/{batchId}` | GET | 获取批量推送记录详情 |
| `/jarvis/tendoc/pushStatus` | GET | 获取推送状态和倒计时信息 |
| `/jarvis/tendoc/triggerPushNow` | POST | 手动触发立即推送 |
| `/jarvis/tendoc/cancelPendingPush` | POST | 取消待推送任务 |
## 前端更新
### 1. API接口封装
**tendoc.js** 新增方法:
```javascript
// 获取批量推送记录列表
getBatchPushRecords(params)
// 获取批量推送记录详情
getBatchPushRecordDetail(batchId)
// 获取推送状态和倒计时信息
getPushStatus()
// 手动触发立即推送
triggerPushNow()
// 取消待推送任务
cancelPendingPush()
```
### 2. 新增推送监控组件
**TencentDocPushMonitor.vue**
功能特性:
- ✅ 实时倒计时显示(分:秒格式)
- ✅ 推送状态监控(等待推送中/无待推送任务)
- ✅ 手动触发立即推送
- ✅ 取消待推送任务
- ✅ 查看推送历史记录
- ✅ 时间轴展示推送记录
- ✅ 展开查看每条记录的操作日志
- ✅ 自动刷新每30秒
- ✅ 倒计时自动更新(每秒)
### 3. 集成到订单列表
**orderList.vue** 更新:
- 新增"推送监控"按钮
- 导入并注册 `TencentDocPushMonitor` 组件
- 添加 `showPushMonitor` 状态控制
## 使用指南
### 1. 打开推送监控
在订单列表页面,点击"推送监控"按钮即可打开监控对话框。
### 2. 查看倒计时
对话框顶部显示当前倒计时状态:
- **有待推送任务**:显示剩余时间(分:秒)
- **无待推送任务**:显示"00:00"
### 3. 手动操作
- **立即推送**:点击后立即执行批量推送,无需等待倒计时结束
- **取消推送**:取消当前待推送任务,倒计时清零
- **刷新状态**:手动刷新当前状态
### 4. 查看推送历史
对话框下方以时间轴形式展示推送记录:
- 绿色:推送成功
- 黄色:部分成功
- 红色:推送失败
- 蓝色:正在执行
点击记录可展开查看详细信息:
- 结果消息
- 错误信息(如果有)
- 操作日志列表(每条订单的详细操作记录)
### 5. 查看操作日志
展开推送记录后,可以看到该批次的所有操作日志,包括:
- 订单号
- 操作类型
- 目标行
- 物流链接
- 操作状态
- 错误信息(如果有)
## 数据流程
### 1. 录单触发
```
用户录单H-TF订单
触发延迟推送服务
设置10分钟倒计时
10分钟内有新录单 → 重置倒计时
10分钟到期 → 执行批量推送
```
### 2. 批量推送流程
```
创建批量推送记录状态RUNNING
调用批量同步API传递batchId
每条订单操作都关联batchId
推送完成后更新批量推送记录
├─ 状态SUCCESS/PARTIAL/FAILED
├─ 成功/跳过/错误数量
├─ 结果消息
└─ 错误信息(如果有)
```
### 3. 前端监控流程
```
打开推送监控对话框
加载推送状态(倒计时)
加载推送记录列表
每秒更新倒计时显示
每30秒自动刷新状态
展开记录查看操作日志
```
## 技术要点
### 1. 倒计时同步
- 后端Redis存储 `scheduledTime`(推送执行时间戳)
- 前端每秒计算 `remainingSeconds = (scheduledTime - now) / 1000`
- 服务端和客户端同步倒计时,避免误差
### 2. 批次ID生成
使用UUID生成唯一批次ID
```java
String batchId = UUID.randomUUID().toString().replace("-", "");
```
### 3. 日志关联
操作日志表添加 `batch_id` 字段,通过此字段关联:
- 一次批量推送 → 一条批量推送记录
- 一次批量推送 → 多条操作日志(每条订单一条)
### 4. 状态管理
批量推送记录的状态转换:
```
RUNNING → SUCCESS (全部成功)
RUNNING → PARTIAL (部分成功)
RUNNING → FAILED (全部失败)
```
### 5. 自动刷新
组件实现两个定时器:
- **countdownTimer**:每秒更新倒计时显示
- **refreshTimer**每30秒刷新状态和记录列表
## 注意事项
1. **数据库迁移**部署前必须执行SQL脚本创建新表和字段
2. **Redis配置**确保Redis正常运行用于存储倒计时信息
3. **时间同步**:确保服务器时间准确,避免倒计时误差
4. **性能考虑**:批量推送记录会持续增长,建议定期清理历史记录
5. **并发控制**:延迟推送服务使用分布式锁,防止并发推送
## 常见问题
### Q1倒计时不准确
**A**:检查服务器时间是否准确,确保服务器时区设置正确。
### Q2推送记录看不到操作日志
**A**:确保 `batch_id` 字段已正确添加到操作日志表,并且在插入日志时传递了 `batchId`
### Q3手动触发推送没反应
**A**
1. 检查后端日志是否有错误
2. 确认腾讯文档配置是否完整
3. 检查网络连接和API权限
### Q4倒计时显示00:00但标记为"等待推送中"
**A**:可能是倒计时刚结束,正在执行推送。刷新状态即可更新。
## 后续优化建议
1. **推送记录分页**:当记录很多时,实现分页加载
2. **日志导出**支持导出推送记录和操作日志为Excel
3. **推送统计**:添加推送成功率、平均耗时等统计图表
4. **告警通知**:推送失败时发送邮件或钉钉通知
5. **历史清理**:实现定时任务自动清理过期记录
6. **性能监控**:记录每次推送的性能指标,优化慢查询
## 版本信息
- **版本**v1.0.0
- **更新日期**2025-11-07
- **开发者**AI Assistant
- **适用系统**若依管理系统RuoYi-Vue
---
如有问题,请查看日志文件或联系技术支持。

View File

@@ -0,0 +1,20 @@
-- 腾讯文档操作日志表
CREATE TABLE `tencent_doc_operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`file_id` varchar(100) DEFAULT NULL COMMENT '文档ID',
`sheet_id` varchar(100) DEFAULT NULL COMMENT '工作表ID',
`operation_type` varchar(50) DEFAULT NULL COMMENT '操作类型WRITE_SINGLE单个写入、BATCH_SYNC批量同步',
`order_no` varchar(100) DEFAULT NULL COMMENT '订单单号',
`target_row` int(11) DEFAULT NULL COMMENT '目标行号',
`logistics_link` varchar(500) DEFAULT NULL COMMENT '写入的物流链接',
`operation_status` varchar(20) DEFAULT NULL COMMENT '操作状态SUCCESS成功、FAILED失败、SKIPPED跳过',
`error_message` text COMMENT '错误信息',
`operator` varchar(100) DEFAULT NULL COMMENT '操作人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_create_time` (`create_time`),
KEY `idx_file_sheet` (`file_id`, `sheet_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯文档操作日志表';

View File

@@ -0,0 +1,130 @@
# 腾讯文档物流链接填充 - 严格模式
## 🔒 核心安全机制
### 1. **分布式锁**
- 使用Redis分布式锁防止并发写入
- 锁的粒度:`文档ID:工作表ID:订单单号`
- 锁超时时间30秒
- 确保同一订单同一时刻只能有一个请求处理
### 2. **操作日志记录**
- 所有操作都会记录到数据库表 `tencent_doc_operation_log`
- 记录内容包括:
- 文档ID、工作表ID
- 操作类型WRITE_SINGLE / BATCH_SYNC
- 订单单号、目标行号
- 物流链接
- 操作状态SUCCESS / FAILED / SKIPPED
- 错误信息
- 操作人、操作时间
### 3. **写入前验证**
在写入之前,会进行以下验证:
1. **再次读取目标行** - 防止行数据在查找和写入之间发生变化
2. **验证单号匹配** - 确保单号仍然在预期的行
3. **验证物流列为空** - 如果已有物流链接,则拒绝写入,防止覆盖
### 4. **录单不再自动触发**
- **旧行为**:录单时如果分销标识是 `H-TF`,自动写入腾讯文档
- **新行为**:录单时不再自动写入,必须通过订单列表手动触发
- **原因**:防止并发写入和数据覆盖,需要人工确认
## 📋 操作流程
### 单个订单填充物流链接
1. 在订单列表找到目标订单
2. 点击"推送物流"按钮(或类似按钮)
3. 系统会:
- 获取分布式锁
- 读取表头识别列位置
- 查找订单单号所在行
- 验证单号和物流列
- 写入物流链接
- 记录操作日志
- 释放锁
### 批量同步物流链接
1. 点击"批量同步"按钮
2. 系统会自动:
- 读取表格数据
- 根据单号查询订单系统
- 逐个写入(每个都有锁保护)
- 记录所有操作日志
## 🛡️ 安全保障
### 防止数据覆盖
- ✅ 分布式锁防止并发写入
- ✅ 写入前验证单号匹配
- ✅ 写入前检查物流列是否为空
- ✅ 如果物流列已有值,拒绝写入并提示
### 操作可追溯
- ✅ 所有操作都记录到数据库
- ✅ 记录操作人、操作时间
- ✅ 记录成功/失败/跳过状态
- ✅ 记录错误原因
## 📊 数据库表结构
```sql
CREATE TABLE `tencent_doc_operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`file_id` varchar(100) DEFAULT NULL COMMENT '文档ID',
`sheet_id` varchar(100) DEFAULT NULL COMMENT '工作表ID',
`operation_type` varchar(50) DEFAULT NULL COMMENT '操作类型',
`order_no` varchar(100) DEFAULT NULL COMMENT '订单单号',
`target_row` int(11) DEFAULT NULL COMMENT '目标行号',
`logistics_link` varchar(500) DEFAULT NULL COMMENT '写入的物流链接',
`operation_status` varchar(20) DEFAULT NULL COMMENT '操作状态',
`error_message` text COMMENT '错误信息',
`operator` varchar(100) DEFAULT NULL COMMENT '操作人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_create_time` (`create_time`),
KEY `idx_file_sheet` (`file_id`, `sheet_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯文档操作日志表';
```
## ⚠️ 注意事项
1. **必须先执行SQL** - 请先执行 `doc/腾讯文档操作日志表.sql` 创建日志表
2. **Redis必须可用** - 分布式锁依赖Redis
3. **手动触发** - 录单后需要手动点击按钮推送到腾讯文档
4. **物流列非空则跳过** - 如果物流列已有值,会拒绝写入并提示
## 🔍 日志查询示例
### 查询某个订单的操作历史
```sql
SELECT * FROM tencent_doc_operation_log
WHERE order_no = 'JY2025110329041'
ORDER BY create_time DESC;
```
### 查询失败的操作
```sql
SELECT * FROM tencent_doc_operation_log
WHERE operation_status = 'FAILED'
ORDER BY create_time DESC
LIMIT 100;
```
### 查询被跳过的操作(物流已存在)
```sql
SELECT * FROM tencent_doc_operation_log
WHERE operation_status = 'SKIPPED'
AND error_message LIKE '%物流链接列已有值%'
ORDER BY create_time DESC;
```
## 📞 技术支持
如遇到问题,请检查:
1. 操作日志表 `tencent_doc_operation_log`
2. 应用日志中的 `TencentDocController` 相关日志
3. Redis是否正常运行

View File

@@ -0,0 +1,395 @@
# 腾讯文档防重复写入 - 完整解决方案
## 🎯 问题背景
**原问题**:物流链接被重复写入到腾讯文档,导致同一订单的物流信息出现多次。
**根本原因**
1. ❌ 没有持久化的推送状态标记
2. ❌ 用户可以多次点击推送按钮
3. ❌ 锁释放后仍可再次推送
4. ❌ 录单时自动推送,无人工确认
## ✅ 完整解决方案
### 核心机制(五重防护)
#### 🛡️ 第一重:订单表状态标记(持久化)
-`jd_order` 表添加两个字段:
- `tencent_doc_pushed`0-未推送1-已推送)
- `tencent_doc_push_time`(推送时间)
- 推送成功后立即更新订单状态
- 再次推送前先检查状态,已推送则拒绝
#### 🔄 第五重:智能状态同步(新增)
- **批量同步时检测文档已有值**
- 如果文档中已有物流链接(可能手动填写)
- 但订单状态为"未推送"
- **自动同步订单状态为"已推送"**
- 保持订单状态与文档状态一致
#### 🔒 第二重Redis分布式锁防并发
- 锁的粒度:`文档ID:工作表ID:订单单号`
- 30秒超时自动释放
- 同一订单同一时刻只能有一个请求处理
#### ✅ 第三重:写入前验证(防覆盖)
每次写入前都会:
1. 再次读取目标行
2. 验证单号是否匹配
3. 检查物流列是否为空
4. 任何一项不通过都拒绝写入
#### 📊 第四重:操作日志记录(可追溯)
- 所有操作记录到 `tencent_doc_operation_log`
- 记录成功/失败/跳过状态
- 记录操作人和时间
- 可以查询历史操作
### 录单行为变更
**旧行为**(已禁用):
```java
// 录单时如果是H-TF自动写入腾讯文档
if ("H-TF".equals(order.getDistributionMark())) {
asyncWriteToTencentDoc(order);
}
```
**新行为**
- ✅ 录单时**不再自动推送**
- ✅ 必须在订单列表**手动点击按钮**推送
- ✅ 推送前人工确认,避免误操作
## 🔄 智能状态同步机制
### 为什么需要智能同步?
在实际使用中,可能出现以下情况:
1. **手动填写**:有人直接在腾讯文档中手动填写了物流链接
2. **外部导入**:从其他系统导入数据到腾讯文档
3. **状态不一致**:订单状态显示"未推送",但文档中已有值
### 智能同步的工作流程
```
批量同步读取腾讯文档
发现某行的物流列已有值
查询该订单的推送状态
如果订单状态为"未推送"
自动更新为"已推送"
记录同步日志
下次批量同步时就会跳过这个订单
```
### 同步效果
| 场景 | 订单状态 | 文档状态 | 系统行为 |
|------|---------|---------|---------|
| 正常推送 | 未推送 | 无值 | ✅ 写入物流链接,更新状态 |
| 手动填写后首次同步 | 未推送 | 有值 | ✅ **自动同步状态**,跳过写入 |
| 手动填写后再次同步 | 已推送 | 有值 | ✅ 跳过(订单状态已同步) |
| 重复推送尝试 | 已推送 | 有值 | ✅ 拒绝(订单已推送) |
### 日志示例
```
INFO - ✓ 同步订单状态 - 单号: JY2025110329041, 行号: 123, 原因: 文档中已有物流链接(可能手动填写)
INFO - 记录同步日志 - 操作类型: BATCH_SYNC, 状态: SKIPPED, 错误信息: 文档中已有物流链接,已同步订单状态
```
## 📋 使用流程
### 1. 首次推送
1. 在订单列表找到目标订单
2. 点击"推送物流"按钮
3. 系统检查:
- ✅ 订单未推送过 → 执行推送
- ✅ 推送成功 → 更新订单状态为"已推送"
- ✅ 返回成功提示
### 2. 再次推送(默认拒绝)
1. 再次点击"推送物流"按钮
2. 系统检查:
- ❌ 订单已推送 → 拒绝推送
- 📝 提示:"该订单已推送到腾讯文档推送时间2025-11-06 12:30:00请勿重复操作"
### 3. 强制重新推送(特殊情况)
如果需要重新推送(例如腾讯文档被误删),可以:
- 前端传递参数:`forceRePush: true`
- 系统会忽略"已推送"状态,重新执行推送
## 🔧 部署步骤
### Step 1: 执行SQL脚本必须
```bash
# 1. 添加订单表字段
mysql -u root -p your_database < doc/订单表添加腾讯文档推送标记.sql
# 2. 创建操作日志表
mysql -u root -p your_database < doc/腾讯文档操作日志表.sql
```
### Step 2: 重新编译部署
```bash
cd d:\code\RuoYi-Vue-master\ruoyi-java
mvn clean package -DskipTests
```
### Step 3: 重启服务
```bash
# 停止旧服务,启动新服务
```
## 🛡️ 安全保障
### 防止重复推送
| 机制 | 说明 | 效果 |
|------|------|------|
| 订单状态标记 | 持久化到数据库 | ✅ 永久防止重复(除非强制) |
| 智能状态同步 | 自动同步文档状态到订单 | ✅ 处理手动填写场景 |
| 分布式锁 | Redis锁30秒超时 | ✅ 防止并发冲突 |
| 写入前验证 | 验证单号和物流列 | ✅ 防止写错行或覆盖 |
| 操作日志 | 记录所有操作 | ✅ 可追溯,可审计 |
### 防止覆盖已有数据
- ✅ 验证物流列是否为空
- ✅ 如果已有值,拒绝写入
- ✅ 返回错误提示:"该订单物流链接已存在xxx"
### 防止并发冲突
- ✅ Redis分布式锁
- ✅ 同一订单同时只能有一个请求处理
- ✅ 锁冲突时返回:"该订单正在处理中,请稍后再试"
## 📊 数据库表结构
### 订单表新增字段
```sql
ALTER TABLE jd_order
ADD COLUMN `tencent_doc_pushed` tinyint(1) DEFAULT 0 COMMENT '是否已推送到腾讯文档0-未推送1-已推送)',
ADD COLUMN `tencent_doc_push_time` datetime DEFAULT NULL COMMENT '推送到腾讯文档的时间';
CREATE INDEX idx_tencent_doc_pushed ON jd_order(tencent_doc_pushed, distribution_mark);
```
### 操作日志表
```sql
CREATE TABLE `tencent_doc_operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`file_id` varchar(100) DEFAULT NULL,
`sheet_id` varchar(100) DEFAULT NULL,
`operation_type` varchar(50) DEFAULT NULL COMMENT 'WRITE_SINGLE / BATCH_SYNC',
`order_no` varchar(100) DEFAULT NULL,
`target_row` int(11) DEFAULT NULL,
`logistics_link` varchar(500) DEFAULT NULL,
`operation_status` varchar(20) DEFAULT NULL COMMENT 'SUCCESS / FAILED / SKIPPED',
`error_message` text,
`operator` varchar(100) DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`remark` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_create_time` (`create_time`),
KEY `idx_file_sheet` (`file_id`, `sheet_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## 🔍 常见问题排查
### Q1: 订单明明没推送过,为什么提示"已推送"
**排查方法**
```sql
-- 查询订单的推送状态
SELECT third_party_order_no, tencent_doc_pushed, tencent_doc_push_time
FROM jd_order
WHERE third_party_order_no = 'JY2025110329041';
```
**解决方法**
```sql
-- 如果确认是误标记,可以手动重置
UPDATE jd_order
SET tencent_doc_pushed = 0, tencent_doc_push_time = NULL
WHERE third_party_order_no = 'JY2025110329041';
```
### Q2: 如何查看某个订单的推送历史?
```sql
-- 查询操作日志
SELECT * FROM tencent_doc_operation_log
WHERE order_no = 'JY2025110329041'
ORDER BY create_time DESC;
```
### Q3: 如何批量重置推送状态?
```sql
-- 谨慎操作!只在确认需要重新推送时使用
UPDATE jd_order
SET tencent_doc_pushed = 0, tencent_doc_push_time = NULL
WHERE distribution_mark = 'H-TF'
AND tencent_doc_pushed = 1;
```
### Q4: 如何查看最近失败的推送?
```sql
SELECT order_no, error_message, create_time, operator
FROM tencent_doc_operation_log
WHERE operation_status = 'FAILED'
AND create_time > DATE_SUB(NOW(), INTERVAL 1 DAY)
ORDER BY create_time DESC;
```
## 📞 前端对接说明
### API参数
```javascript
// 基本推送(默认,如果已推送则拒绝)
{
"thirdPartyOrderNo": "JY2025110329041",
"logisticsLink": "https://3.cn/2ume-Ak1"
}
// 强制推送(忽略已推送状态)
{
"thirdPartyOrderNo": "JY2025110329041",
"logisticsLink": "https://3.cn/2ume-Ak1",
"forceRePush": true // 特殊情况使用
}
```
### 返回结果
```javascript
// 成功
{
"code": 200,
"msg": "物流链接填充成功",
"data": {
"thirdPartyOrderNo": "JY2025110329041",
"logisticsLink": "https://3.cn/2ume-Ak1",
"row": 123,
"column": 12,
"pushed": true,
"pushTime": "2025-11-06 12:30:00"
}
}
// 失败(已推送)
{
"code": 500,
"msg": "该订单已推送到腾讯文档推送时间2025-11-06 12:30:00请勿重复操作如需重新推送请使用强制推送功能。"
}
```
### 前端按钮建议
```javascript
// 推送按钮应该:
1. 根据订单的 tencentDocPushed 状态显示不同文本
- 未推送显示"推送物流"
- 已推送显示"已推送"置灰或隐藏
2. 提供"强制推送"选项需二次确认
this.$confirm('该订单已推送,确定要重新推送吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 调用APIforceRePush: true
});
3. 防止快速重复点击前端防抖
methods: {
handlePush: _.debounce(function() {
// 调用API
}, 1000, { leading: true, trailing: false })
}
```
## ✅ 验证测试
### 测试场景 1首次推送
1. 选择一个未推送的订单(`tencent_doc_pushed = 0`
2. 点击"推送物流"
3. **预期**:推送成功,订单状态更新为"已推送"
### 测试场景 2重复推送默认拒绝
1. 选择一个已推送的订单(`tencent_doc_pushed = 1`
2. 点击"推送物流"
3. **预期**:拒绝推送,提示"已推送"
### 测试场景 3强制推送
1. 选择一个已推送的订单
2. 勾选"强制推送"选项
3. 点击"推送物流"
4. **预期**:推送成功,更新推送时间
### 测试场景 4并发推送
1. 同一订单,同时点击两次"推送物流"按钮
2. **预期**:只有一个请求成功,另一个提示"正在处理中"
### 测试场景 5物流列非空
1. 手动在腾讯文档中填写物流链接
2. 点击"推送物流"
3. **预期**:拒绝推送,提示"物流链接已存在"
## 🎯 总结
### 彻底解决重复写入的核心
1. **持久化状态**(最关键)
- 订单表增加 `tencent_doc_pushed` 字段
- 推送成功后立即更新
- 再次推送前先检查状态
2. **智能状态同步**(新增核心功能)
- 批量同步时检测文档已有值
- 自动同步订单状态为"已推送"
- 处理手动填写、外部导入等场景
- 保持订单状态与文档状态一致
3. **分布式锁**
- 防止并发冲突
- 同一订单同时只能一个请求处理
4. **写入前验证**
- 验证单号匹配
- 验证物流列为空
- 防止写错行或覆盖
5. **操作日志**
- 所有操作可追溯
- 便于问题排查和审计
6. **录单不再自动触发**
- 必须手动点击按钮
- 人工确认,避免误操作
### 防护等级:⭐⭐⭐⭐⭐(最高)
现在即使:
- ✅ 用户多次点击 → 拒绝重复推送
- ✅ 并发请求 → 分布式锁防护
- ✅ 误操作 → 已推送则拒绝
-**别人手动填写文档****智能同步状态**
- ✅ 外部数据导入 → 自动检测并同步
**彻底解决所有重复写入场景!** 🎉

View File

@@ -0,0 +1,8 @@
-- 给订单表添加腾讯文档推送标记字段
ALTER TABLE jd_order
ADD COLUMN `tencent_doc_pushed` tinyint(1) DEFAULT 0 COMMENT '是否已推送到腾讯文档0-未推送1-已推送)' AFTER `logistics_link`,
ADD COLUMN `tencent_doc_push_time` datetime DEFAULT NULL COMMENT '推送到腾讯文档的时间' AFTER `tencent_doc_pushed`;
-- 添加索引,方便查询未推送的订单
CREATE INDEX idx_tencent_doc_pushed ON jd_order(tencent_doc_pushed, distribution_mark);

View File

@@ -16,6 +16,7 @@ import com.ruoyi.erp.request.ProductCategoryListQueryRequest;
import com.ruoyi.erp.request.ProductPropertyListQueryRequest;
import com.ruoyi.erp.request.AuthorizeListQueryRequest;
import com.ruoyi.erp.request.ProductPublishRequest;
import com.ruoyi.erp.request.ProductDownShelfRequest;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -550,6 +551,217 @@ public class ProductController extends BaseController {
public String getAppid() { return appid; }
public void setAppid(String appid) { this.appid = appid; }
}
/**
* 下架商品(单个)
*/
@PostMapping("/downShelf")
public R<?> downShelf(@RequestBody @Validated DownShelfRequest req) {
try {
ERPAccount account = resolveAccount(req.getAppid());
ProductDownShelfRequest downShelfRequest = new ProductDownShelfRequest(account);
downShelfRequest.setProductId(req.getProductId());
String resp = downShelfRequest.getResponseBody();
JSONObject jo = JSONObject.parseObject(resp);
return R.ok(jo);
} catch (Exception e) {
log.error("下架商品失败: productId={}", req.getProductId(), e);
return R.fail("下架失败: " + e.getMessage());
}
}
/**
* 批量上架商品
*/
@PostMapping("/batchPublish")
public R<?> batchPublish(@RequestBody @Validated BatchPublishRequest req) {
try {
ERPAccount account = resolveAccount(req.getAppid());
List<Long> productIds = req.getProductIds();
if (productIds == null || productIds.isEmpty()) {
return R.fail("商品ID列表不能为空");
}
if (req.getUserName() == null || req.getUserName().isEmpty()) {
return R.fail("闲鱼会员名不能为空");
}
List<HashMap<String, Object>> results = new ArrayList<>();
int successCount = 0;
int failCount = 0;
for (Long productId : productIds) {
HashMap<String, Object> result = new HashMap<>();
result.put("productId", productId);
try {
ProductPublishRequest publishRequest = new ProductPublishRequest(account);
publishRequest.setProductId(productId);
publishRequest.setUserName(req.getUserName());
if (req.getSpecifyPublishTime() != null) {
publishRequest.setSpecifyPublishTime(req.getSpecifyPublishTime());
}
String resp = publishRequest.getResponseBody();
JSONObject jo = JSONObject.parseObject(resp);
if (jo != null && jo.getInteger("code") != null && jo.getInteger("code") == 0) {
result.put("success", true);
result.put("msg", "上架成功");
result.put("response", jo);
successCount++;
} else {
result.put("success", false);
result.put("msg", jo != null ? jo.getString("msg") : "上架失败");
result.put("response", jo);
failCount++;
}
} catch (Exception e) {
log.error("批量上架商品失败: productId={}", productId, e);
result.put("success", false);
result.put("msg", "上架异常: " + e.getMessage());
result.put("response", null);
failCount++;
}
results.add(result);
}
HashMap<String, Object> summary = new HashMap<>();
summary.put("total", productIds.size());
summary.put("success", successCount);
summary.put("fail", failCount);
summary.put("results", results);
JSONObject response = new JSONObject();
response.put("code", failCount == 0 ? 0 : 500);
response.put("msg", failCount == 0 ? "全部上架成功" : String.format("成功: %d, 失败: %d", successCount, failCount));
response.put("data", summary);
return R.ok(response);
} catch (Exception e) {
log.error("批量上架商品异常", e);
return R.fail("批量上架失败: " + e.getMessage());
}
}
/**
* 批量下架商品
*/
@PostMapping("/batchDownShelf")
public R<?> batchDownShelf(@RequestBody @Validated BatchDownShelfRequest req) {
try {
ERPAccount account = resolveAccount(req.getAppid());
List<Long> productIds = req.getProductIds();
if (productIds == null || productIds.isEmpty()) {
return R.fail("商品ID列表不能为空");
}
List<HashMap<String, Object>> results = new ArrayList<>();
int successCount = 0;
int failCount = 0;
for (Long productId : productIds) {
HashMap<String, Object> result = new HashMap<>();
result.put("productId", productId);
try {
ProductDownShelfRequest downShelfRequest = new ProductDownShelfRequest(account);
downShelfRequest.setProductId(productId);
String resp = downShelfRequest.getResponseBody();
JSONObject jo = JSONObject.parseObject(resp);
if (jo != null && jo.getInteger("code") != null && jo.getInteger("code") == 0) {
result.put("success", true);
result.put("msg", "下架成功");
result.put("response", jo);
successCount++;
} else {
result.put("success", false);
result.put("msg", jo != null ? jo.getString("msg") : "下架失败");
result.put("response", jo);
failCount++;
}
} catch (Exception e) {
log.error("批量下架商品失败: productId={}", productId, e);
result.put("success", false);
result.put("msg", "下架异常: " + e.getMessage());
result.put("response", null);
failCount++;
}
results.add(result);
}
HashMap<String, Object> summary = new HashMap<>();
summary.put("total", productIds.size());
summary.put("success", successCount);
summary.put("fail", failCount);
summary.put("results", results);
JSONObject response = new JSONObject();
response.put("code", failCount == 0 ? 0 : 500);
response.put("msg", failCount == 0 ? "全部下架成功" : String.format("成功: %d, 失败: %d", successCount, failCount));
response.put("data", summary);
return R.ok(response);
} catch (Exception e) {
log.error("批量下架商品异常", e);
return R.fail("批量下架失败: " + e.getMessage());
}
}
/**
* 下架请求体
*/
public static class DownShelfRequest {
@NotNull
private Long productId;
private String appid;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public String getAppid() { return appid; }
public void setAppid(String appid) { this.appid = appid; }
}
/**
* 批量上架请求体
*/
public static class BatchPublishRequest {
@NotNull
@Size(min = 1, message = "商品ID列表不能为空")
private List<Long> productIds;
@NotBlank(message = "闲鱼会员名不能为空")
private String userName;
private String specifyPublishTime;
private String appid;
public List<Long> getProductIds() { return productIds; }
public void setProductIds(List<Long> productIds) { this.productIds = productIds; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getSpecifyPublishTime() { return specifyPublishTime; }
public void setSpecifyPublishTime(String specifyPublishTime) { this.specifyPublishTime = specifyPublishTime; }
public String getAppid() { return appid; }
public void setAppid(String appid) { this.appid = appid; }
}
/**
* 批量下架请求体
*/
public static class BatchDownShelfRequest {
@NotNull
@Size(min = 1, message = "商品ID列表不能为空")
private List<Long> productIds;
private String appid;
public List<Long> getProductIds() { return productIds; }
public void setProductIds(List<Long> productIds) { this.productIds = productIds; }
public String getAppid() { return appid; }
public void setAppid(String appid) { this.appid = appid; }
}
}

View File

@@ -0,0 +1,137 @@
package com.ruoyi.web.controller.jarvis;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.jarvis.domain.Comment;
import com.ruoyi.jarvis.domain.dto.CommentApiStatistics;
import com.ruoyi.jarvis.domain.dto.CommentStatistics;
import com.ruoyi.jarvis.service.ICommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
/**
* 评论管理 Controller
*/
@RestController
@RequestMapping("/jarvis/comment")
public class CommentController extends BaseController {
@Autowired
private ICommentService commentService;
/**
* 查询京东评论列表
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:list')")
@GetMapping("/jd/list")
public TableDataInfo list(Comment comment) {
startPage();
List<Comment> list = commentService.selectCommentList(comment);
return getDataTable(list);
}
/**
* 导出京东评论列表
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:export')")
@Log(title = "京东评论", businessType = BusinessType.EXPORT)
@PostMapping("/jd/export")
public void export(HttpServletResponse response, Comment comment) {
List<Comment> list = commentService.selectCommentList(comment);
ExcelUtil<Comment> util = new ExcelUtil<Comment>(Comment.class);
util.exportExcel(response, list, "京东评论数据");
}
/**
* 获取京东评论详细信息
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:query')")
@GetMapping("/jd/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id) {
return success(commentService.selectCommentById(id));
}
/**
* 修改评论使用状态
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:edit')")
@Log(title = "评论管理", businessType = BusinessType.UPDATE)
@PutMapping("/jd")
public AjaxResult edit(@RequestBody Comment comment) {
return toAjax(commentService.updateCommentIsUse(comment));
}
/**
* 删除评论
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:remove')")
@Log(title = "评论管理", businessType = BusinessType.DELETE)
@DeleteMapping("/jd/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(commentService.deleteCommentByIds(ids));
}
/**
* 重置评论使用状态按商品ID
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:edit')")
@Log(title = "评论管理", businessType = BusinessType.UPDATE)
@PutMapping("/jd/reset/{productId}")
public AjaxResult resetByProductId(@PathVariable String productId) {
return toAjax(commentService.resetCommentIsUseByProductId(productId));
}
/**
* 获取评论统计信息
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:list')")
@GetMapping("/statistics")
public AjaxResult getStatistics(@RequestParam(required = false) String source) {
List<CommentStatistics> statistics = commentService.getCommentStatistics(source);
return success(statistics);
}
/**
* 获取接口调用统计
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:list')")
@GetMapping("/api/statistics")
public AjaxResult getApiStatistics(
@RequestParam(required = false) String apiType,
@RequestParam(required = false) String productType,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
List<CommentApiStatistics> statistics = commentService.getApiStatistics(apiType, productType, startDate, endDate);
return success(statistics);
}
/**
* 获取Redis产品类型映射京东
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:list')")
@GetMapping("/redis/jd/map")
public AjaxResult getJdProductTypeMap() {
Map<String, String> map = commentService.getJdProductTypeMap();
return success(map);
}
/**
* 获取Redis产品类型映射淘宝
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:list')")
@GetMapping("/redis/tb/map")
public AjaxResult getTbProductTypeMap() {
Map<String, String> map = commentService.getTbProductTypeMap();
return success(map);
}
}

View File

@@ -0,0 +1,234 @@
package com.ruoyi.web.controller.jarvis;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.domain.ErpProduct;
import com.ruoyi.jarvis.service.IErpProductService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 闲鱼商品Controller
*
* @author ruoyi
* @date 2024-01-01
*/
@RestController
@RequestMapping("/jarvis/erpProduct")
public class ErpProductController extends BaseController
{
@Autowired
private IErpProductService erpProductService;
/**
* 查询闲鱼商品列表
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:list')")
@GetMapping("/list")
public TableDataInfo list(ErpProduct erpProduct)
{
startPage();
List<ErpProduct> list = erpProductService.selectErpProductList(erpProduct);
return getDataTable(list);
}
/**
* 导出闲鱼商品列表
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:export')")
@Log(title = "闲鱼商品", businessType = BusinessType.EXPORT)
@GetMapping("/export")
public AjaxResult export(ErpProduct erpProduct)
{
List<ErpProduct> list = erpProductService.selectErpProductList(erpProduct);
ExcelUtil<ErpProduct> util = new ExcelUtil<ErpProduct>(ErpProduct.class);
return util.exportExcel(list, "闲鱼商品数据");
}
/**
* 获取闲鱼商品详细信息
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(erpProductService.selectErpProductById(id));
}
/**
* 新增闲鱼商品
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:add')")
@Log(title = "闲鱼商品", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody ErpProduct erpProduct)
{
return toAjax(erpProductService.insertErpProduct(erpProduct));
}
/**
* 修改闲鱼商品
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:edit')")
@Log(title = "闲鱼商品", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody ErpProduct erpProduct)
{
return toAjax(erpProductService.updateErpProduct(erpProduct));
}
/**
* 删除闲鱼商品
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:remove')")
@Log(title = "闲鱼商品", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(erpProductService.deleteErpProductByIds(ids));
}
/**
* 从闲鱼ERP拉取商品列表并保存单页保留用于兼容
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:pull')")
@Log(title = "拉取闲鱼商品", businessType = BusinessType.INSERT)
@PostMapping("/pull")
public AjaxResult pullProductList(
@RequestParam(required = false) String appid,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "50") Integer pageSize,
@RequestParam(required = false) Integer productStatus)
{
try {
int count = erpProductService.pullAndSaveProductList(appid, pageNo, pageSize, productStatus);
if (count > 0) {
return success("成功拉取并保存 " + count + " 个商品");
} else {
String statusText = getStatusText(productStatus);
String message = "拉取完成,但没有获取到商品数据";
if (productStatus != null) {
message += "(筛选条件:状态=" + statusText + "";
}
message += "。建议:使用全量同步功能自动遍历所有页码";
return success(message);
}
} catch (Exception e) {
return error("拉取商品列表失败: " + e.getMessage());
}
}
/**
* 全量同步商品(自动遍历所有页码,同步更新和删除)
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:pull')")
@Log(title = "全量同步闲鱼商品", businessType = BusinessType.UPDATE)
@PostMapping("/syncAll")
public AjaxResult syncAllProducts(
@RequestParam(required = false) String appid,
@RequestParam(required = false) Integer productStatus)
{
try {
IErpProductService.SyncResult result = erpProductService.syncAllProducts(appid, productStatus);
return success(result.getMessage());
} catch (Exception e) {
return error("全量同步失败: " + e.getMessage());
}
}
/**
* 获取状态文本(用于提示信息)
*/
private String getStatusText(Integer status) {
if (status == null) {
return "全部";
}
switch (status) {
case -1:
return "删除";
case 21:
return "待发布";
case 22:
return "销售中";
case 23:
return "已售罄";
case 31:
return "手动下架";
case 33:
return "售出下架";
case 36:
return "自动下架";
default:
return String.valueOf(status);
}
}
/**
* 批量上架商品
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:publish')")
@Log(title = "批量上架商品", businessType = BusinessType.UPDATE)
@PostMapping("/batchPublish")
public AjaxResult batchPublish(@RequestBody BatchOperationRequest request)
{
try {
return success("批量上架功能请调用 /erp/product/batchPublish 接口");
} catch (Exception e) {
return error("批量上架失败: " + e.getMessage());
}
}
/**
* 批量下架商品
*/
@PreAuthorize("@ss.hasPermi('jarvis:erpProduct:downShelf')")
@Log(title = "批量下架商品", businessType = BusinessType.UPDATE)
@PostMapping("/batchDownShelf")
public AjaxResult batchDownShelf(@RequestBody BatchOperationRequest request)
{
try {
return success("批量下架功能请调用 /erp/product/batchDownShelf 接口");
} catch (Exception e) {
return error("批量下架失败: " + e.getMessage());
}
}
/**
* 批量操作请求体
*/
public static class BatchOperationRequest {
private java.util.List<Long> productIds;
private String appid;
public java.util.List<Long> getProductIds() {
return productIds;
}
public void setProductIds(java.util.List<Long> productIds) {
this.productIds = productIds;
}
public String getAppid() {
return appid;
}
public void setAppid(String appid) {
this.appid = appid;
}
}
}

View File

@@ -21,13 +21,15 @@ public class InstructionController extends BaseController {
}
/**
* 执行文本指令
* body: { command: "京今日统计" }
* 执行文本指令(控制台入口,需要权限)
* body: { command: "京今日统计", forceGenerate: false }
*/
@PostMapping("/execute")
public AjaxResult execute(@RequestBody Map<String, String> body) {
String cmd = body != null ? body.get("command") : null;
java.util.List<String> result = instructionService.execute(cmd);
public AjaxResult execute(@RequestBody Map<String, Object> body) {
String cmd = body != null ? (body.get("command") != null ? String.valueOf(body.get("command")) : null) : null;
boolean forceGenerate = body != null && body.get("forceGenerate") != null && Boolean.parseBoolean(String.valueOf(body.get("forceGenerate")));
// 控制台入口,传递 isFromConsole=true跳过订单查询校验
java.util.List<String> result = instructionService.execute(cmd, forceGenerate, true);
return AjaxResult.success(result);
}

View File

@@ -13,10 +13,13 @@ import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IOrderRowsService;
import com.ruoyi.jarvis.service.IGiftCouponService;
import com.ruoyi.jarvis.domain.GiftCoupon;
import com.ruoyi.system.service.ISysConfigService;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.beans.factory.annotation.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,15 +40,46 @@ public class JDOrderController extends BaseController {
private final IJDOrderService jdOrderService;
private final IOrderRowsService orderRowsService;
private final IGiftCouponService giftCouponService;
private final ISysConfigService sysConfigService;
private static final String CONFIG_KEY_PREFIX = "logistics.push.touser.";
private static final java.util.regex.Pattern URL_DETECT_PATTERN = java.util.regex.Pattern.compile(
"(https?://[^\\s]+)|(u\\.jd\\.com/[^\\s]+)",
java.util.regex.Pattern.CASE_INSENSITIVE);
private static final java.util.regex.Pattern UJD_LINK_PATTERN = java.util.regex.Pattern.compile(
"^https?://u\\.jd\\.com/[A-Za-z0-9]+[A-Za-z0-9_-]*$",
java.util.regex.Pattern.CASE_INSENSITIVE);
private static final java.util.regex.Pattern JINGFEN_LINK_PATTERN = java.util.regex.Pattern.compile(
"^https?://jingfen\\.jd\\.com/detail/[A-Za-z0-9]+\\.html$",
java.util.regex.Pattern.CASE_INSENSITIVE);
public JDOrderController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService, IGiftCouponService giftCouponService) {
public JDOrderController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService,
IGiftCouponService giftCouponService, ISysConfigService sysConfigService) {
this.jdOrderService = jdOrderService;
this.orderRowsService = orderRowsService;
this.giftCouponService = giftCouponService;
this.sysConfigService = sysConfigService;
}
private final static String skey = "2192057370ef8140c201079969c956a3";
private final static String requestUrl = "http://192.168.8.88:6666/jd/";
@Value("${jarvis.server.jarvis-java.base-url:http://127.0.0.1:6666}")
private String jarvisJavaBaseUrl;
@Value("${jarvis.server.jarvis-java.jd-api-path:/jd}")
private String jdApiPath;
@Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}")
private String logisticsBaseUrl;
@Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}")
private String logisticsFetchPath;
/**
* 获取JD接口请求URL
*/
private String getRequestUrl() {
return jarvisJavaBaseUrl + jdApiPath + "/";
}
@@ -55,7 +89,7 @@ public class JDOrderController extends BaseController {
String promotionContent = requestBody.get("promotionContent");
String result = "";
try {
String url = requestUrl + "generatePromotionContent";
String url = getRequestUrl() + "generatePromotionContent";
JSONObject param = new JSONObject();
param.put("skey", skey);
param.put("promotionContent", promotionContent);
@@ -134,10 +168,16 @@ public class JDOrderController extends BaseController {
if (w instanceof JSONObject) {
JSONObject wj = (JSONObject) w;
Object content = wj.get("content");
if (content instanceof String) {
String cleaned = stripUrls((String) content);
wj.put("content", cleaned);
if (!(content instanceof String)) {
continue;
}
String type = wj.getString("type");
if ("通用文案".equals(type)) {
// 通用文案需要保留链接,用于后续替换/复制
continue;
}
String cleaned = stripUrls((String) content);
wj.put("content", cleaned);
}
}
}
@@ -170,7 +210,7 @@ public class JDOrderController extends BaseController {
body.get("materialUrl"), body.get("skuId"), body.get("amount"),
body.get("quantity"), body.get("owner"), body.get("skuName"));
String url = requestUrl + "createGiftCoupon";
String url = getRequestUrl() + "createGiftCoupon";
JSONObject param = new JSONObject();
param.put("skey", skey);
// 透传必要参数
@@ -289,7 +329,7 @@ public class JDOrderController extends BaseController {
@PostMapping("/transfer")
public AjaxResult transfer(@RequestBody Map<String, Object> body) {
try {
String url = requestUrl + "transfer";
String url = getRequestUrl() + "transfer";
JSONObject param = new JSONObject();
param.put("skey", skey);
if (body.get("materialUrl") != null) param.put("materialUrl", body.get("materialUrl"));
@@ -319,7 +359,7 @@ public class JDOrderController extends BaseController {
body.get("materialUrl"), body.get("skuId"), body.get("amount"),
body.get("quantity"), body.get("batchSize"), body.get("owner"), body.get("skuName"));
String url = requestUrl + "batchCreateGiftCoupons";
String url = getRequestUrl() + "batchCreateGiftCoupons";
JSONObject param = new JSONObject();
param.put("skey", skey);
if (body.get("materialUrl") != null) param.put("materialUrl", body.get("materialUrl"));
@@ -475,7 +515,7 @@ public class JDOrderController extends BaseController {
for (String url : urls) {
try {
logger.info("查询商品信息 - URL: {}", url);
String queryUrl = requestUrl + "generatePromotionContent";
String queryUrl = getRequestUrl() + "generatePromotionContent";
JSONObject param = new JSONObject();
param.put("skey", skey);
param.put("promotionContent", url);
@@ -543,8 +583,8 @@ public class JDOrderController extends BaseController {
}
/**
* 文本替换:根据选中的商品创建礼金并替换文案中的URL
* 入参:{ content, selectedProducts: [{skuId/materialUrl, amount, quantity, owner, skuName, originalUrl}], ... }
* 文本替换:为每个URL单独查询商品创建礼金券,然后替换
* 入参:{ content, amount, quantity, owner }
* 返回:替换后的文本和礼金信息
*/
@PostMapping("/replaceUrlsWithGiftCoupons")
@@ -555,115 +595,309 @@ public class JDOrderController extends BaseController {
return AjaxResult.error("content is required");
}
logger.info("文本URL替换请求 - content长度={}, 参数: materialUrl={}, skuId={}, amount={}, quantity={}, owner={}",
content.length(), body.get("materialUrl"), body.get("skuId"), body.get("amount"),
body.get("quantity"), body.get("owner"));
Object amountObj = body.get("amount");
Object quantityObj = body.get("quantity");
String owner = body.get("owner") != null ? String.valueOf(body.get("owner")) : "g";
double amount = amountObj != null ? Double.parseDouble(String.valueOf(amountObj)) : 1.8;
int quantity = quantityObj != null ? Integer.parseInt(String.valueOf(quantityObj)) : 12;
logger.info("文本URL替换请求 - content长度={}, amount={}, quantity={}, owner={}",
content.length(), amount, quantity, owner);
// 提取文本中的所有URL京东链接
java.util.List<String> urls = new java.util.ArrayList<>();
java.util.regex.Pattern urlPattern = java.util.regex.Pattern.compile(
"(https?://[^\\s]+)|(u\\.jd\\.com/[^\\s]+)",
java.util.regex.Pattern.CASE_INSENSITIVE);
java.util.regex.Matcher matcher = urlPattern.matcher(content);
java.util.List<UrlSegment> urlSegments = new java.util.ArrayList<>();
java.util.regex.Matcher matcher = URL_DETECT_PATTERN.matcher(content);
while (matcher.find()) {
String url = matcher.group(0);
if (url != null && !url.trim().isEmpty()) {
urls.add(url.trim());
String segment = matcher.group(0);
UrlSegment urlInfo = parseUrlSegment(segment);
if (urlInfo != null) {
urlSegments.add(urlInfo);
}
}
logger.info("文本URL替换 - 提取到{}个URL", urls.size());
logger.info("文本URL替换 - 提取到{}个URL", urlSegments.size());
if (urls.isEmpty()) {
return AjaxResult.success(new JSONObject().fluentPut("replacedContent", content)
if (urlSegments.isEmpty()) {
return AjaxResult.success(new JSONObject()
.fluentPut("replacedContent", content)
.fluentPut("originalContent", content)
.fluentPut("replacements", new JSONArray())
.fluentPut("totalUrls", 0)
.fluentPut("replacedCount", 0)
.fluentPut("message", "未找到URL无需替换"));
}
// 批量创建礼金券
Map<String, Object> batchParams = new java.util.HashMap<>();
batchParams.put("materialUrl", body.get("materialUrl"));
batchParams.put("skuId", body.get("skuId"));
batchParams.put("amount", body.get("amount"));
batchParams.put("quantity", body.get("quantity"));
batchParams.put("batchSize", urls.size());
batchParams.put("owner", body.get("owner"));
batchParams.put("skuName", body.get("skuName"));
AjaxResult batchResult = batchCreateGiftCoupons(batchParams);
Integer code = (Integer) batchResult.get(AjaxResult.CODE_TAG);
if (code == null || code != 200) {
String msg = (String) batchResult.get(AjaxResult.MSG_TAG);
return AjaxResult.error("批量创建礼金失败: " + (msg != null ? msg : "未知错误"));
}
// 解析批量创建结果
Object data = batchResult.get(AjaxResult.DATA_TAG);
JSONObject batchData = null;
if (data instanceof JSONObject) {
batchData = (JSONObject) data;
} else if (data instanceof String) {
try {
batchData = JSON.parseObject((String) data);
} catch (Exception e) {
logger.error("解析批量创建结果失败", e);
return AjaxResult.error("解析批量创建结果失败");
}
}
if (batchData == null || !batchData.containsKey("results")) {
return AjaxResult.error("批量创建结果格式错误");
}
JSONArray results = batchData.getJSONArray("results");
if (results == null || results.size() != urls.size()) {
logger.warn("批量创建礼金数量不匹配 - 期望={}, 实际={}", urls.size(), results != null ? results.size() : 0);
}
if (results == null) {
return AjaxResult.error("批量创建结果为空");
}
// 替换文本中的URL
// 为每个URL单独处理查询商品 → 创建礼金 → 转链 → 替换
String replacedContent = content;
JSONArray replacementInfo = new JSONArray();
int successCount = 0;
int minSize = Math.min(urls.size(), results.size());
for (int i = 0; i < minSize; i++) {
String originalUrl = urls.get(i);
JSONObject result = results.getJSONObject(i);
for (int i = 0; i < urlSegments.size(); i++) {
UrlSegment urlSegment = urlSegments.get(i);
logger.info("处理第{}/{}个URL: {}", i + 1, urlSegments.size(), urlSegment.urlPart);
String newUrl = null;
if (Boolean.TRUE.equals(result.getBoolean("success"))) {
newUrl = result.getString("shortURL");
if (newUrl == null || newUrl.trim().isEmpty()) {
newUrl = originalUrl; // 如果转链失败保持原URL
JSONObject product = null; // 在try外部声明以便在catch中使用
try {
if (urlSegment.normalizedJdUrl == null) {
logger.warn("URL{}不是京东推广链接,跳过: {}", i + 1, urlSegment.original);
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("error", "非京东推广链接或格式不支持");
replacementInfo.add(info);
continue;
}
} else {
newUrl = originalUrl; // 创建失败保持原URL
// 1. 查询该URL的商品信息
String queryUrl = getRequestUrl() + "generatePromotionContent";
JSONObject queryParam = new JSONObject();
queryParam.put("skey", skey);
queryParam.put("promotionContent", urlSegment.normalizedJdUrl);
String queryResult = HttpUtils.sendJsonPost(queryUrl, queryParam.toJSONString());
logger.debug("商品查询响应: {}", queryResult);
Object parsed = JSON.parse(queryResult);
// 解析商品信息
if (parsed instanceof JSONArray) {
JSONArray arr = (JSONArray) parsed;
if (arr.size() > 0 && arr.get(0) instanceof JSONObject) {
product = arr.getJSONObject(0);
}
} else if (parsed instanceof JSONObject) {
JSONObject obj = (JSONObject) parsed;
if (obj.get("list") instanceof JSONArray) {
JSONArray list = obj.getJSONArray("list");
if (list.size() > 0) product = list.getJSONObject(0);
} else if (obj.get("data") instanceof JSONArray) {
JSONArray data = obj.getJSONArray("data");
if (data.size() > 0) product = data.getJSONObject(0);
} else if (obj.containsKey("materialUrl") || obj.containsKey("skuName")) {
product = obj;
}
}
if (product == null || product.containsKey("error")) {
String errorMsg = "商品信息查询失败";
if (product != null) {
String apiError = product.getString("error");
String apiMessage = product.getString("message");
if (apiMessage != null && !apiMessage.trim().isEmpty()) {
errorMsg = apiMessage;
} else if (apiError != null && !apiError.trim().isEmpty()) {
errorMsg = apiError;
}
}
logger.warn("URL{}商品信息查询失败: {}", urlSegment.urlPart, errorMsg);
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("error", errorMsg);
replacementInfo.add(info);
continue;
}
// 2. 为该商品创建礼金券
String createUrl = getRequestUrl() + "createGiftCoupon";
JSONObject createParam = new JSONObject();
createParam.put("skey", skey);
createParam.put("amount", amount);
createParam.put("quantity", quantity);
createParam.put("skuName", product.getString("skuName"));
// 处理owner字段p -> pop, g -> g
String productOwner = product.getString("owner");
if (productOwner == null || productOwner.trim().isEmpty()) {
productOwner = owner; // 使用用户选择的
} else if ("p".equalsIgnoreCase(productOwner)) {
productOwner = "pop";
}
createParam.put("owner", productOwner);
// 设置skuId或materialUrl注意POP商品优先使用materialUrl或oriItemId
String skuId = product.getString("skuId");
String materialUrl = product.getString("materialUrl");
String oriItemId = product.getString("oriItemId");
// 判断skuId是否有效排除字符串"null"
boolean hasValidSkuId = skuId != null && !skuId.trim().isEmpty() &&
!"null".equalsIgnoreCase(skuId) &&
!"undefined".equalsIgnoreCase(skuId);
// 判断materialUrl是否有效
boolean hasValidMaterialUrl = materialUrl != null && !materialUrl.trim().isEmpty() &&
!"null".equalsIgnoreCase(materialUrl);
// 判断oriItemId是否有效
boolean hasValidOriItemId = oriItemId != null && !oriItemId.trim().isEmpty() &&
!"null".equalsIgnoreCase(oriItemId);
if ("pop".equalsIgnoreCase(productOwner)) {
// POP商品优先使用oriItemId或materialUrl
if (hasValidOriItemId) {
createParam.put("materialUrl", oriItemId);
logger.info("POP商品使用oriItemId: {}", oriItemId);
} else if (hasValidMaterialUrl) {
createParam.put("materialUrl", materialUrl);
logger.info("POP商品使用materialUrl: {}", materialUrl);
} else if (hasValidSkuId) {
createParam.put("skuId", skuId);
logger.info("POP商品使用skuId: {}", skuId);
} else {
logger.warn("POP商品{}缺少有效的oriItemId/materialUrl/skuId", product.getString("skuName"));
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("error", "POP商品信息不完整");
replacementInfo.add(info);
continue;
}
} else {
// 自营商品优先使用skuId
if (hasValidSkuId) {
createParam.put("skuId", skuId);
logger.info("自营商品使用skuId: {}", skuId);
} else if (hasValidMaterialUrl) {
createParam.put("materialUrl", materialUrl);
logger.info("自营商品使用materialUrl: {}", materialUrl);
} else {
logger.warn("自营商品{}缺少有效的skuId/materialUrl", product.getString("skuName"));
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("error", "自营商品信息不完整");
replacementInfo.add(info);
continue;
}
}
String createResult = HttpUtils.sendJsonPost(createUrl, createParam.toJSONString());
logger.debug("礼金创建响应: {}", createResult);
JSONObject createData = JSON.parseObject(createResult);
if (createData == null || createData.containsKey("error") ||
createData.getString("giftCouponKey") == null ||
createData.getString("giftCouponKey").trim().isEmpty()) {
String errorMsg = "礼金创建失败";
if (createData != null) {
String apiError = createData.getString("error");
String apiMessage = createData.getString("message");
if (apiMessage != null && !apiMessage.trim().isEmpty()) {
errorMsg = apiMessage;
} else if (apiError != null && !apiError.trim().isEmpty()) {
errorMsg = apiError;
}
}
logger.warn("URL{}礼金创建失败: {}", urlSegment.urlPart, errorMsg);
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("error", errorMsg);
if (product != null && product.getString("skuName") != null) {
info.put("skuName", product.getString("skuName"));
}
replacementInfo.add(info);
continue;
}
String giftCouponKey = createData.getString("giftCouponKey");
// 3. 转链(带礼金)
String transferUrl = getRequestUrl() + "transfer";
JSONObject transferParam = new JSONObject();
transferParam.put("skey", skey);
transferParam.put("materialUrl", urlSegment.normalizedJdUrl);
transferParam.put("giftCouponKey", giftCouponKey);
String transferResult = HttpUtils.sendJsonPost(transferUrl, transferParam.toJSONString());
logger.debug("转链响应: {}", transferResult);
JSONObject transferData = JSON.parseObject(transferResult);
if (transferData == null || transferData.containsKey("error") ||
transferData.getString("shortURL") == null ||
transferData.getString("shortURL").trim().isEmpty()) {
String errorMsg = "转链失败";
if (transferData != null) {
String apiError = transferData.getString("error");
String apiMessage = transferData.getString("message");
if (apiMessage != null && !apiMessage.trim().isEmpty()) {
errorMsg = apiMessage;
} else if (apiError != null && !apiError.trim().isEmpty()) {
errorMsg = apiError;
}
}
logger.warn("URL{}转链失败: {}", urlSegment.urlPart, errorMsg);
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("giftCouponKey", giftCouponKey);
info.put("error", errorMsg);
if (product != null && product.getString("skuName") != null) {
info.put("skuName", product.getString("skuName"));
}
replacementInfo.add(info);
continue;
}
String shortURL = transferData.getString("shortURL");
// 4. 替换文本中的URL
replacedContent = replaceUrlInContent(replacedContent, urlSegment, shortURL);
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", shortURL);
info.put("success", true);
info.put("giftCouponKey", giftCouponKey);
info.put("skuName", product.getString("skuName"));
replacementInfo.add(info);
successCount++;
logger.info("URL{}处理成功: {} -> {}", i + 1, urlSegment.urlPart, shortURL);
} catch (Exception e) {
logger.error("处理URL{}失败: {}", urlSegment.urlPart, e.getMessage(), e);
String errorMsg = e.getMessage();
if (errorMsg == null || errorMsg.trim().isEmpty()) {
errorMsg = "处理失败: " + e.getClass().getSimpleName();
}
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", urlSegment.urlPart);
info.put("newUrl", urlSegment.urlPart);
info.put("success", false);
info.put("error", errorMsg);
if (product != null && product.getString("skuName") != null) {
info.put("skuName", product.getString("skuName"));
}
replacementInfo.add(info);
}
// 替换文本中的URL支持多种格式
replacedContent = replacedContent.replace(originalUrl, newUrl != null ? newUrl : originalUrl);
JSONObject info = new JSONObject();
info.put("index", i + 1);
info.put("originalUrl", originalUrl);
info.put("newUrl", newUrl);
info.put("success", Boolean.TRUE.equals(result.getBoolean("success")));
info.put("giftCouponKey", result.getString("giftCouponKey"));
replacementInfo.add(info);
}
logger.info("文本URL替换完成 - 替换了{}个URL", minSize);
logger.info("文本URL替换完成 - 成功{}/{}", successCount, urlSegments.size());
JSONObject response = new JSONObject();
response.put("replacedContent", replacedContent);
response.put("originalContent", content);
response.put("replacements", replacementInfo);
response.put("totalUrls", urls.size());
response.put("replacedCount", minSize);
response.put("totalUrls", urlSegments.size());
response.put("replacedCount", successCount);
return AjaxResult.success(response);
} catch (Exception e) {
@@ -702,9 +936,7 @@ public class JDOrderController extends BaseController {
// 检查分销标识
String distributionMark = order.getDistributionMark();
if (distributionMark == null || (!distributionMark.equals("F") && !distributionMark.equals("PDD"))) {
return AjaxResult.error("该订单的分销标识不是F或PDD无需处理。当前分销标识: " + distributionMark);
}
// 检查物流链接
String logisticsLink = order.getLogisticsLink();
@@ -716,7 +948,7 @@ public class JDOrderController extends BaseController {
orderId, order.getOrderId(), distributionMark, logisticsLink);
// 构建外部接口URL
String externalUrl = "http://192.168.8.88:5001/fetch_logistics?tracking_url=" +
String externalUrl = logisticsBaseUrl + logisticsFetchPath + "?tracking_url=" +
java.net.URLEncoder.encode(logisticsLink, "UTF-8");
logger.info("准备调用外部接口 - URL: {}", externalUrl);
@@ -810,6 +1042,43 @@ public class JDOrderController extends BaseController {
}
}
/**
* 根据分销标识获取接收人列表
* 从系统配置中读取配置键名格式logistics.push.touser.{分销标识}
* 配置值格式接收人1,接收人2,接收人3逗号分隔
*
* @param distributionMark 分销标识
* @return 接收人列表逗号分隔如果未配置则返回null
*/
private String getTouserByDistributionMark(String distributionMark) {
if (!StringUtils.hasText(distributionMark)) {
logger.warn("分销标识为空,无法获取接收人配置");
return null;
}
try {
// 构建配置键名
String configKey = CONFIG_KEY_PREFIX + distributionMark.trim();
// 从系统配置中获取接收人列表
String configValue = sysConfigService.selectConfigByKey(configKey);
if (StringUtils.hasText(configValue)) {
// 清理配置值(去除空格)
String touser = configValue.trim().replaceAll(",\\s+", ",");
logger.info("从配置获取接收人列表 - 分销标识: {}, 配置键: {}, 接收人: {}",
distributionMark, configKey, touser);
return touser;
} else {
logger.debug("未找到接收人配置 - 分销标识: {}, 配置键: {}", distributionMark, configKey);
return null;
}
} catch (Exception e) {
logger.error("获取接收人配置失败 - 分销标识: {}, 错误: {}", distributionMark, e.getMessage(), e);
return null;
}
}
/**
* 调用企业应用推送逻辑
* @param order 订单信息
@@ -838,7 +1107,7 @@ public class JDOrderController extends BaseController {
// 收货地址
pushContent.append("收货地址:").append(order.getAddress() != null ? order.getAddress() : "").append("\n");
// 运单号
pushContent.append("运单号:").append(waybillNo).append("\n");
pushContent.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
// 调用企业微信推送接口参考WxtsUtil的实现
String pushUrl = "https://wxts.van333.cn/wx/send/pdd";
@@ -848,8 +1117,23 @@ public class JDOrderController extends BaseController {
String content = pushContent.toString();
pushParam.put("text", content);
// 根据分销标识获取接收人列表
String touser = getTouserByDistributionMark(distributionMark);
if (StringUtils.hasText(touser)) {
pushParam.put("touser", touser);
logger.info("企业微信推送设置接收人 - 订单ID: {}, 分销标识: {}, 接收人: {}",
order.getId(), distributionMark, touser);
} else {
logger.warn("未找到分销标识对应的接收人配置 - 订单ID: {}, 分销标识: {}",
order.getId(), distributionMark);
}
// 记录完整的推送参数(用于调试)
String jsonBody = pushParam.toJSONString();
logger.info("企业微信推送完整参数 - 订单ID: {}, JSON: {}", order.getId(), jsonBody);
// 使用支持自定义header的HTTP请求
String pushResult = sendPostWithHeaders(pushUrl, pushParam.toJSONString(), token);
String pushResult = sendPostWithHeaders(pushUrl, jsonBody, token);
logger.info("企业应用推送调用结果 - 订单ID: {}, waybill_no: {}, 推送结果: {}",
order.getId(), waybillNo, pushResult);
@@ -926,4 +1210,80 @@ public class JDOrderController extends BaseController {
}
return result.toString();
}
private static class UrlSegment {
private final String original;
private final String urlPart;
private final String suffix;
private final String normalizedJdUrl;
private UrlSegment(String original, String urlPart, String suffix, String normalizedJdUrl) {
this.original = original;
this.urlPart = urlPart;
this.suffix = suffix;
this.normalizedJdUrl = normalizedJdUrl;
}
}
private static UrlSegment parseUrlSegment(String segment) {
if (segment == null || segment.trim().isEmpty()) {
return null;
}
String original = segment.trim();
int urlLength = calculateUrlLength(original);
if (urlLength <= 0) {
return null;
}
String urlPart = original.substring(0, urlLength);
String suffix = original.substring(urlLength);
String normalized = normalizeJdUrl(urlPart);
return new UrlSegment(original, urlPart, suffix, normalized);
}
private static int calculateUrlLength(String text) {
int length = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (isAllowedUrlChar(c)) {
length++;
} else {
break;
}
}
return length;
}
private static boolean isAllowedUrlChar(char c) {
if (c <= 0x7F) {
return Character.isLetterOrDigit(c) || "-._~:/?#[]@!$&'()*+,;=%".indexOf(c) >= 0;
}
return false;
}
private static String normalizeJdUrl(String urlPart) {
if (urlPart == null || urlPart.isEmpty()) {
return null;
}
String normalized = urlPart;
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
normalized = "https://" + normalized;
}
if (UJD_LINK_PATTERN.matcher(normalized).matches() || JINGFEN_LINK_PATTERN.matcher(normalized).matches()) {
return normalized;
}
return null;
}
private static String replaceUrlInContent(String content, UrlSegment segment, String newUrl) {
if (content == null || segment == null || newUrl == null || newUrl.trim().isEmpty()) {
return content;
}
String suffix = segment.suffix != null ? segment.suffix : "";
String replacementWithSuffix = newUrl + suffix;
String result = content.replace(segment.original, replacementWithSuffix);
if (result.equals(content) && !segment.original.equals(segment.urlPart)) {
result = result.replace(segment.urlPart, newUrl);
}
return result;
}
}

View File

@@ -0,0 +1,70 @@
package com.ruoyi.web.controller.jarvis;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.service.IPhoneReplaceConfigService;
/**
* 手机号替换配置Controller
*
* @author ruoyi
*/
@RestController
@RequestMapping("/jarvis/phoneReplaceConfig")
public class PhoneReplaceConfigController extends BaseController
{
@Autowired
private IPhoneReplaceConfigService phoneReplaceConfigService;
/**
* 获取指定类型的手机号列表
*/
@GetMapping("/{type}")
public AjaxResult getPhoneList(@PathVariable("type") String type)
{
List<String> phoneList = phoneReplaceConfigService.getPhoneList(type);
return AjaxResult.success(phoneList);
}
/**
* 设置指定类型的手机号列表
*/
@Log(title = "手机号替换配置", businessType = BusinessType.UPDATE)
@PutMapping("/{type}")
public AjaxResult setPhoneList(@PathVariable("type") String type, @RequestBody List<String> phoneList)
{
return toAjax(phoneReplaceConfigService.setPhoneList(type, phoneList));
}
/**
* 添加手机号到指定类型
*/
@Log(title = "手机号替换配置", businessType = BusinessType.UPDATE)
@PostMapping("/{type}/add")
public AjaxResult addPhone(@PathVariable("type") String type, @RequestBody String phone)
{
return toAjax(phoneReplaceConfigService.addPhone(type, phone));
}
/**
* 从指定类型删除手机号
*/
@Log(title = "手机号替换配置", businessType = BusinessType.UPDATE)
@PostMapping("/{type}/remove")
public AjaxResult removePhone(@PathVariable("type") String type, @RequestBody String phone)
{
return toAjax(phoneReplaceConfigService.removePhone(type, phone));
}
}

View File

@@ -0,0 +1,159 @@
package com.ruoyi.web.controller.jarvis;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.service.ISocialMediaService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 小红书/抖音内容生成Controller
*
* @author ruoyi
* @date 2025-01-XX
*/
@RestController
@RequestMapping("/jarvis/social-media")
public class SocialMediaController extends BaseController
{
@Autowired
private ISocialMediaService socialMediaService;
/**
* 提取关键词
*/
@PostMapping("/extract-keywords")
public AjaxResult extractKeywords(@RequestBody Map<String, Object> request)
{
try {
String productName = (String) request.get("productName");
if (productName == null || productName.trim().isEmpty()) {
return AjaxResult.error("商品名称不能为空");
}
Map<String, Object> result = socialMediaService.extractKeywords(productName);
return AjaxResult.success(result);
} catch (Exception e) {
logger.error("提取关键词失败", e);
return AjaxResult.error("提取关键词失败: " + e.getMessage());
}
}
/**
* 生成文案
*/
@PostMapping("/generate-content")
public AjaxResult generateContent(@RequestBody Map<String, Object> request)
{
try {
String productName = (String) request.get("productName");
if (productName == null || productName.trim().isEmpty()) {
return AjaxResult.error("商品名称不能为空");
}
Object originalPriceObj = request.get("originalPrice");
Object finalPriceObj = request.get("finalPrice");
String keywords = (String) request.get("keywords");
String style = (String) request.getOrDefault("style", "both");
Map<String, Object> result = socialMediaService.generateContent(
productName, originalPriceObj, finalPriceObj, keywords, style
);
return AjaxResult.success(result);
} catch (Exception e) {
logger.error("生成文案失败", e);
return AjaxResult.error("生成文案失败: " + e.getMessage());
}
}
/**
* 一键生成完整内容(关键词 + 文案 + 图片)
*/
@Log(title = "小红书/抖音内容生成", businessType = BusinessType.OTHER)
@PostMapping("/generate-complete")
public AjaxResult generateComplete(@RequestBody Map<String, Object> request)
{
try {
String productImageUrl = (String) request.get("productImageUrl");
String productName = (String) request.get("productName");
if (productName == null || productName.trim().isEmpty()) {
return AjaxResult.error("商品名称不能为空");
}
Object originalPriceObj = request.get("originalPrice");
Object finalPriceObj = request.get("finalPrice");
String style = (String) request.getOrDefault("style", "both");
Map<String, Object> result = socialMediaService.generateCompleteContent(
productImageUrl, productName, originalPriceObj, finalPriceObj, style
);
return AjaxResult.success(result);
} catch (Exception e) {
logger.error("生成完整内容失败", e);
return AjaxResult.error("生成完整内容失败: " + e.getMessage());
}
}
/**
* 获取提示词模板列表
*/
@GetMapping("/prompt/list")
public AjaxResult listPromptTemplates()
{
try {
return socialMediaService.listPromptTemplates();
} catch (Exception e) {
logger.error("获取提示词模板列表失败", e);
return AjaxResult.error("获取失败: " + e.getMessage());
}
}
/**
* 获取单个提示词模板
*/
@GetMapping("/prompt/{key}")
public AjaxResult getPromptTemplate(@PathVariable String key)
{
try {
return socialMediaService.getPromptTemplate(key);
} catch (Exception e) {
logger.error("获取提示词模板失败", e);
return AjaxResult.error("获取失败: " + e.getMessage());
}
}
/**
* 保存提示词模板
*/
@Log(title = "保存提示词模板", businessType = BusinessType.UPDATE)
@PostMapping("/prompt/save")
public AjaxResult savePromptTemplate(@RequestBody Map<String, Object> request)
{
try {
return socialMediaService.savePromptTemplate(request);
} catch (Exception e) {
logger.error("保存提示词模板失败", e);
return AjaxResult.error("保存失败: " + e.getMessage());
}
}
/**
* 删除提示词模板(恢复默认)
*/
@Log(title = "删除提示词模板", businessType = BusinessType.DELETE)
@DeleteMapping("/prompt/{key}")
public AjaxResult deletePromptTemplate(@PathVariable String key)
{
try {
return socialMediaService.deletePromptTemplate(key);
} catch (Exception e) {
logger.error("删除提示词模板失败", e);
return AjaxResult.error("删除失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,90 @@
package com.ruoyi.web.controller.jarvis;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.jarvis.domain.TaobaoComment;
import com.ruoyi.jarvis.service.ITaobaoCommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 淘宝评论管理 Controller
*/
@RestController
@RequestMapping("/jarvis/taobaoComment")
public class TaobaoCommentController extends BaseController {
@Autowired
private ITaobaoCommentService taobaoCommentService;
/**
* 查询淘宝评论列表
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:list')")
@GetMapping("/list")
public TableDataInfo list(TaobaoComment taobaoComment) {
startPage();
List<TaobaoComment> list = taobaoCommentService.selectTaobaoCommentList(taobaoComment);
return getDataTable(list);
}
/**
* 导出淘宝评论列表
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:export')")
@Log(title = "淘宝评论", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TaobaoComment taobaoComment) {
List<TaobaoComment> list = taobaoCommentService.selectTaobaoCommentList(taobaoComment);
ExcelUtil<TaobaoComment> util = new ExcelUtil<TaobaoComment>(TaobaoComment.class);
util.exportExcel(response, list, "淘宝评论数据");
}
/**
* 获取淘宝评论详细信息
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Integer id) {
return success(taobaoCommentService.selectTaobaoCommentById(id));
}
/**
* 修改评论使用状态
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:edit')")
@Log(title = "评论管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TaobaoComment taobaoComment) {
return toAjax(taobaoCommentService.updateTaobaoCommentIsUse(taobaoComment));
}
/**
* 删除评论
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:remove')")
@Log(title = "评论管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Integer[] ids) {
return toAjax(taobaoCommentService.deleteTaobaoCommentByIds(ids));
}
/**
* 重置评论使用状态按商品ID
*/
@PreAuthorize("@ss.hasPermi('jarvis:comment:edit')")
@Log(title = "评论管理", businessType = BusinessType.UPDATE)
@PutMapping("/reset/{productId}")
public AjaxResult resetByProductId(@PathVariable String productId) {
return toAjax(taobaoCommentService.resetTaobaoCommentIsUseByProductId(productId));
}
}

View File

@@ -8,6 +8,7 @@ import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.config.TencentDocConfig;
import com.ruoyi.jarvis.service.ITencentDocService;
import com.ruoyi.jarvis.service.ITencentDocTokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -33,47 +34,121 @@ public class TencentDocConfigController extends BaseController {
@Autowired
private ITencentDocService tencentDocService;
@Autowired
private ITencentDocTokenService tencentDocTokenService;
@Autowired
private RedisCache redisCache;
// Redis key前缀
// Redis key前缀(用于存储文档配置)
private static final String REDIS_KEY_PREFIX = "tencent:doc:auto:config:";
/**
* 获取当前配置
* 注意accessToken 由系统自动管理(通过授权登录),此接口只返回状态
*/
@GetMapping
public AjaxResult getConfig() {
try {
JSONObject config = new JSONObject();
// 从Redis获取配置如果存在
String accessToken = redisCache.getCacheObject(REDIS_KEY_PREFIX + "accessToken");
// 1. 检查 accessToken 状态从Token服务
boolean hasAccessToken = false;
String accessTokenStatus = "未授权";
try {
String accessToken = tencentDocTokenService.getValidAccessToken();
if (accessToken != null && !accessToken.isEmpty()) {
hasAccessToken = true;
accessTokenStatus = "已授权";
}
} catch (Exception e) {
// Token不存在或已过期
accessTokenStatus = "未授权:" + e.getMessage();
}
// 2. 从Redis获取文档配置
String fileId = redisCache.getCacheObject(REDIS_KEY_PREFIX + "fileId");
String sheetId = redisCache.getCacheObject(REDIS_KEY_PREFIX + "sheetId");
Integer headerRow = redisCache.getCacheObject(REDIS_KEY_PREFIX + "headerRow");
Integer startRow = redisCache.getCacheObject(REDIS_KEY_PREFIX + "startRow");
// 如果Redis中没有则使用配置文件中的默认值
if (accessToken == null || accessToken.isEmpty()) {
accessToken = tencentDocConfig.getAccessToken();
}
if (fileId == null || fileId.isEmpty()) {
fileId = tencentDocConfig.getFileId();
}
if (sheetId == null || sheetId.isEmpty()) {
sheetId = tencentDocConfig.getSheetId();
}
if (headerRow == null) {
headerRow = tencentDocConfig.getHeaderRow();
}
if (startRow == null) {
startRow = tencentDocConfig.getStartRow();
}
config.put("accessToken", maskSensitiveInfo(accessToken)); // 脱敏显示
config.put("hasAccessToken", hasAccessToken);
config.put("accessTokenStatus", accessTokenStatus);
config.put("fileId", fileId);
config.put("sheetId", sheetId);
config.put("headerRow", headerRow);
config.put("startRow", startRow);
config.put("appId", tencentDocConfig.getAppId());
config.put("apiBaseUrl", tencentDocConfig.getApiBaseUrl());
// 获取当前同步进度(如果有配置)
// 注意:使用与 TencentDocController 相同的 Redis key 前缀
if (fileId != null && !fileId.isEmpty() && sheetId != null && !sheetId.isEmpty()) {
String syncProgressKey = "tendoc:last_row:" + fileId + ":" + sheetId;
Integer currentProgress = redisCache.getCacheObject(syncProgressKey);
log.debug("读取同步进度 - key: {}, value: {}", syncProgressKey, currentProgress);
if (currentProgress != null) {
config.put("currentProgress", currentProgress);
// 根据回溯机制计算下次起始行
int threshold = startRow + 100;
int nextStartRow;
String progressHint;
if (currentProgress <= (startRow + 49)) {
// 进度较小,下次从配置起始行开始
nextStartRow = startRow;
progressHint = String.format("已读取到第 %d 行,下次将从第 %d 行重新开始(进度较小)",
currentProgress, nextStartRow);
} else if (currentProgress > threshold) {
// 进度较大下次回溯100行但不能小于起始行
nextStartRow = Math.max(startRow, currentProgress - 100);
progressHint = String.format("已读取到第 %d 行,下次将从第 %d 行开始回溯100行防止遗漏",
currentProgress, nextStartRow);
} else {
// 进度在阈值范围内,下次从配置起始行开始
nextStartRow = startRow;
progressHint = String.format("已读取到第 %d 行,下次将从第 %d 行重新开始",
currentProgress, nextStartRow);
}
config.put("nextStartRow", nextStartRow);
config.put("progressHint", progressHint);
} else {
config.put("currentProgress", null);
config.put("nextStartRow", startRow);
config.put("progressHint", String.format("尚未开始同步,将从第 %d 行开始", startRow));
}
}
// 检查配置是否完整
boolean isComplete = accessToken != null && !accessToken.isEmpty() &&
fileId != null && !fileId.isEmpty() &&
sheetId != null && !sheetId.isEmpty();
config.put("isConfigured", isComplete);
boolean isConfigured = hasAccessToken &&
fileId != null && !fileId.isEmpty() &&
sheetId != null && !sheetId.isEmpty();
config.put("isConfigured", isConfigured);
// 提供配置建议
if (!hasAccessToken) {
config.put("hint", "请先访问 /jarvis/tendoc/authUrl 完成授权登录");
} else if (fileId == null || fileId.isEmpty() || sheetId == null || sheetId.isEmpty()) {
config.put("hint", "请配置目标文档的 fileId 和 sheetId");
} else {
config.put("hint", "配置完整H-TF订单将自动写入腾讯文档");
}
return AjaxResult.success("获取配置成功", config);
} catch (Exception e) {
@@ -84,21 +159,20 @@ public class TencentDocConfigController extends BaseController {
/**
* 更新配置保存到Redis180天有效期
* 注意accessToken 由系统自动管理,无需手动配置
*
* @param params 包含 accessToken, fileId, sheetId
* @param params 包含 fileId, sheetId, startRow
*/
@Log(title = "腾讯文档配置", businessType = BusinessType.UPDATE)
@PostMapping
public AjaxResult updateConfig(@RequestBody JSONObject params) {
try {
String accessToken = params.getString("accessToken");
String fileId = params.getString("fileId");
String sheetId = params.getString("sheetId");
Integer headerRow = params.getInteger("headerRow");
Integer startRow = params.getInteger("startRow");
// 验证必填字段
if (accessToken == null || accessToken.trim().isEmpty()) {
return AjaxResult.error("访问令牌不能为空");
}
if (fileId == null || fileId.trim().isEmpty()) {
return AjaxResult.error("文件ID不能为空");
}
@@ -106,22 +180,59 @@ public class TencentDocConfigController extends BaseController {
return AjaxResult.error("工作表ID不能为空");
}
// headerRow默认值为2
if (headerRow == null || headerRow < 1) {
headerRow = 2;
}
// startRow默认值为3
if (startRow == null || startRow < 1) {
startRow = 3;
}
// 检查是否已授权
boolean hasAccessToken = false;
try {
String accessToken = tencentDocTokenService.getValidAccessToken();
hasAccessToken = (accessToken != null && !accessToken.isEmpty());
} catch (Exception e) {
log.warn("检查授权状态时出错: {}", e.getMessage());
}
if (!hasAccessToken) {
return AjaxResult.error("尚未完成腾讯文档授权,请先访问 /jarvis/tendoc/authUrl 完成授权");
}
// 保存到Redis180天有效期
redisCache.setCacheObject(REDIS_KEY_PREFIX + "accessToken", accessToken.trim(), 180, TimeUnit.DAYS);
redisCache.setCacheObject(REDIS_KEY_PREFIX + "fileId", fileId.trim(), 180, TimeUnit.DAYS);
redisCache.setCacheObject(REDIS_KEY_PREFIX + "sheetId", sheetId.trim(), 180, TimeUnit.DAYS);
redisCache.setCacheObject(REDIS_KEY_PREFIX + "headerRow", headerRow, 180, TimeUnit.DAYS);
redisCache.setCacheObject(REDIS_KEY_PREFIX + "startRow", startRow, 180, TimeUnit.DAYS);
// 清除该文档的同步进度配置更新时重置进度从新的startRow重新开始
// 注意:使用与 TencentDocController 相同的 Redis key 前缀
String syncProgressKey = "tendoc:last_row:" + fileId.trim() + ":" + sheetId.trim();
String configVersionKey = "tencent:doc:sync:config_version:" + fileId.trim() + ":" + sheetId.trim();
redisCache.deleteObject(syncProgressKey);
redisCache.deleteObject(configVersionKey);
log.info("配置已更新,已清除同步进度 - key: {}, 将从第 {} 行重新开始同步", syncProgressKey, startRow);
// 同时更新TencentDocConfig对象内存中
tencentDocConfig.setAccessToken(accessToken.trim());
tencentDocConfig.setFileId(fileId.trim());
tencentDocConfig.setSheetId(sheetId.trim());
tencentDocConfig.setHeaderRow(headerRow);
tencentDocConfig.setStartRow(startRow);
log.info("腾讯文档配置已更新 - fileId: {}, sheetId: {}", fileId.trim(), sheetId.trim());
log.info("H-TF订单自动写入配置已更新 - fileId: {}, sheetId: {}, headerRow: {}, startRow: {}",
fileId.trim(), sheetId.trim(), headerRow, startRow);
JSONObject result = new JSONObject();
result.put("message", "配置更新成功已保存到Redis180天有效期");
result.put("fileId", fileId.trim());
result.put("sheetId", sheetId.trim());
result.put("headerRow", headerRow);
result.put("startRow", startRow);
result.put("hint", "现在录入分销标识为 H-TF 的订单时,将自动追加到此腾讯文档(从第" + startRow + "行开始匹配)");
return AjaxResult.success("配置更新成功", result);
} catch (Exception e) {
@@ -137,26 +248,30 @@ public class TencentDocConfigController extends BaseController {
@GetMapping("/test")
public AjaxResult testConfig() {
try {
// 获取当前配置
String accessToken = redisCache.getCacheObject(REDIS_KEY_PREFIX + "accessToken");
String fileId = redisCache.getCacheObject(REDIS_KEY_PREFIX + "fileId");
// 1. 获取访问令牌从Token服务
String accessToken;
try {
accessToken = tencentDocTokenService.getValidAccessToken();
} catch (Exception e) {
return AjaxResult.error("获取访问令牌失败:" + e.getMessage() +
"。请先访问 /jarvis/tendoc/authUrl 完成授权");
}
if (accessToken == null || accessToken.isEmpty()) {
accessToken = tencentDocConfig.getAccessToken();
return AjaxResult.error("访问令牌未配置,请先完成腾讯文档授权");
}
// 2. 获取文档配置
String fileId = redisCache.getCacheObject(REDIS_KEY_PREFIX + "fileId");
if (fileId == null || fileId.isEmpty()) {
fileId = tencentDocConfig.getFileId();
}
// 验证配置
if (accessToken == null || accessToken.isEmpty()) {
return AjaxResult.error("访问令牌未配置");
}
if (fileId == null || fileId.isEmpty()) {
return AjaxResult.error("文件ID未配置");
return AjaxResult.error("文件ID未配置,请先配置目标文档");
}
// 测试API调用获取工作表列表
// 3. 测试API调用获取工作表列表
log.info("测试腾讯文档配置 - fileId: {}", fileId);
JSONObject result = tencentDocService.getSheetList(accessToken, fileId);
@@ -178,19 +293,24 @@ public class TencentDocConfigController extends BaseController {
}
/**
* 清除配置从Redis中删除
* 清除配置从Redis中删除文档配置
* 注意:这不会清除授权令牌,如需清除令牌请访问 /jarvis/tendoc/clearToken
*/
@Log(title = "腾讯文档配置", businessType = BusinessType.DELETE)
@DeleteMapping
public AjaxResult clearConfig() {
try {
redisCache.deleteObject(REDIS_KEY_PREFIX + "accessToken");
redisCache.deleteObject(REDIS_KEY_PREFIX + "fileId");
redisCache.deleteObject(REDIS_KEY_PREFIX + "sheetId");
redisCache.deleteObject(REDIS_KEY_PREFIX + "startRow");
log.info("腾讯文档配置已清除");
log.info("H-TF订单自动写入配置已清除");
return AjaxResult.success("配置已清除");
JSONObject result = new JSONObject();
result.put("message", "文档配置已清除");
result.put("hint", "授权令牌未清除。如需清除授权,请访问 /jarvis/tendoc/clearToken");
return AjaxResult.success("配置已清除", result);
} catch (Exception e) {
log.error("清除腾讯文档配置失败", e);
return AjaxResult.error("清除配置失败: " + e.getMessage());
@@ -205,14 +325,17 @@ public class TencentDocConfigController extends BaseController {
@GetMapping("/sheets")
public AjaxResult getSheetList(@RequestParam String fileId) {
try {
// 获取访问令牌
String accessToken = redisCache.getCacheObject(REDIS_KEY_PREFIX + "accessToken");
if (accessToken == null || accessToken.isEmpty()) {
accessToken = tencentDocConfig.getAccessToken();
// 获取访问令牌从Token服务
String accessToken;
try {
accessToken = tencentDocTokenService.getValidAccessToken();
} catch (Exception e) {
return AjaxResult.error("获取访问令牌失败:" + e.getMessage() +
"。请先访问 /jarvis/tendoc/authUrl 完成授权");
}
if (accessToken == null || accessToken.isEmpty()) {
return AjaxResult.error("访问令牌未配置,请先设置accessToken");
return AjaxResult.error("访问令牌未配置,请先完成腾讯文档授权");
}
if (fileId == null || fileId.isEmpty()) {
@@ -220,6 +343,7 @@ public class TencentDocConfigController extends BaseController {
}
// 调用API获取工作表列表
log.info("获取腾讯文档工作表列表 - fileId: {}", fileId);
JSONObject result = tencentDocService.getSheetList(accessToken, fileId);
if (result != null) {
@@ -228,23 +352,9 @@ public class TencentDocConfigController extends BaseController {
return AjaxResult.error("获取工作表列表失败API返回null");
}
} catch (Exception e) {
log.error("获取工作表列表失败", e);
log.error("获取工作表列表失败 - fileId: {}", fileId, e);
return AjaxResult.error("获取工作表列表失败: " + e.getMessage());
}
}
/**
* 脱敏显示敏感信息
* 例如eyJhbGciOi... -> eyJhb...***...o6U显示前6个和后3个字符
*/
private String maskSensitiveInfo(String info) {
if (info == null || info.isEmpty()) {
return "未配置";
}
if (info.length() <= 10) {
return info.substring(0, 3) + "***";
}
return info.substring(0, 6) + "***" + info.substring(info.length() - 3);
}
}

View File

@@ -0,0 +1,275 @@
package com.ruoyi.web.controller.jarvis;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
import com.ruoyi.jarvis.service.IWPS365OAuthService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* WPS365 OAuth回调控制器
* 用于处理OAuth回调避免前端路由拦截
*
* @author system
*/
@RestController
@RequestMapping("/wps365-callback")
public class WPS365CallbackController extends BaseController {
private static final Logger log = LoggerFactory.getLogger(WPS365CallbackController.class);
@Autowired
private IWPS365OAuthService wps365OAuthService;
/**
* OAuth回调 - 通过授权码获取访问令牌GET请求
* 路径:/wps365-callback
* 注意在WPS365开放平台只需配置域名jarvis.van333.cn不能包含路径
* 授权URL中的redirect_uri参数会自动使用配置中的完整URLhttps://jarvis.van333.cn/wps365-callback
*/
@Anonymous
@GetMapping
public ResponseEntity<?> oauthCallbackGet(@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "state", required = false) String state,
@RequestParam(value = "error", required = false) String error,
@RequestParam(value = "error_description", required = false) String errorDescription,
@RequestParam(value = "challenge", required = false) String challenge) {
return handleOAuthCallback(code, state, error, errorDescription, challenge, null);
}
/**
* OAuth回调 - 通过授权码获取访问令牌POST请求
* WPS365在验证回调URL时会发送POST请求进行challenge验证
* 支持application/json和application/x-www-form-urlencoded两种格式
*/
@Anonymous
@PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE, MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<?> oauthCallbackPost(@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "state", required = false) String state,
@RequestParam(value = "error", required = false) String error,
@RequestParam(value = "error_description", required = false) String errorDescription,
@RequestParam(value = "challenge", required = false) String challenge,
@RequestBody(required = false) String requestBody) {
log.info("收到WPS365 POST回调请求 - code: {}, challenge: {}, requestBody: {}",
code != null ? "" : "",
challenge != null ? challenge : "",
requestBody != null && requestBody.length() > 0 ? requestBody.substring(0, Math.min(100, requestBody.length())) : "");
// 如果challenge在URL参数中直接使用
// 如果不在URL参数中尝试从请求体中解析可能是JSON或form-data
if (challenge == null && requestBody != null && !requestBody.trim().isEmpty()) {
String bodyTrimmed = requestBody.trim();
// 尝试解析JSON格式
if (bodyTrimmed.startsWith("{")) {
try {
com.alibaba.fastjson2.JSONObject json = com.alibaba.fastjson2.JSON.parseObject(requestBody);
if (json.containsKey("challenge")) {
challenge = json.getString("challenge");
log.info("从JSON请求体中解析到challenge: {}", challenge);
}
} catch (Exception e) {
log.debug("解析JSON请求体失败", e);
}
}
// 尝试解析form-urlencoded格式 (challenge=xxx)
else if (bodyTrimmed.contains("challenge=")) {
try {
String[] pairs = bodyTrimmed.split("&");
for (String pair : pairs) {
if (pair.startsWith("challenge=")) {
challenge = java.net.URLDecoder.decode(pair.substring("challenge=".length()), "UTF-8");
log.info("从form-urlencoded请求体中解析到challenge: {}", challenge);
break;
}
}
} catch (Exception e) {
log.debug("解析form-urlencoded请求体失败", e);
}
}
// 如果请求体就是challenge值本身纯文本
else if (bodyTrimmed.length() < 200) {
challenge = bodyTrimmed;
log.info("将请求体作为challenge值: {}", challenge);
}
}
return handleOAuthCallback(code, state, error, errorDescription, challenge, requestBody);
}
/**
* 处理OAuth回调的核心逻辑
*/
private ResponseEntity<?> handleOAuthCallback(String code, String state, String error,
String errorDescription, String challenge, String requestBody) {
try {
// 处理challenge验证WPS365后台配置时用于验证回调URL
if (challenge != null && !challenge.trim().isEmpty()) {
log.info("收到WPS365 challenge验证请求 - challenge: {}", challenge);
// 尝试返回JSON格式符合OAuth标准
// 格式:{"challenge": "xxx"} 或直接返回challenge值
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String jsonResponse = "{\"challenge\":\"" + challenge + "\"}";
return new ResponseEntity<>(jsonResponse, headers, HttpStatus.OK);
} catch (Exception e) {
log.warn("返回JSON格式失败尝试纯文本格式", e);
// 如果JSON失败尝试纯文本
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_PLAIN);
return new ResponseEntity<>(challenge, headers, HttpStatus.OK);
}
}
// 处理授权错误
if (error != null) {
log.error("WPS365授权失败 - error: {}, error_description: {}", error, errorDescription);
String errorMsg = errorDescription != null ? errorDescription : error;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(false, "授权失败: " + errorMsg, null), headers, HttpStatus.OK);
}
// 如果没有code也没有challenge可能是直接访问显示提示信息
if (code == null || code.trim().isEmpty()) {
log.warn("访问回调地址但没有授权码,可能是测试访问");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(false, "等待授权回调... 如果没有授权码,请通过授权流程访问", null), headers, HttpStatus.OK);
}
log.info("收到WPS365授权回调 - code: {}, state: {}", code, state);
// 使用授权码换取access_token
WPS365TokenInfo tokenInfo = wps365OAuthService.getAccessTokenByCode(code);
// 验证返回的token信息
if (tokenInfo == null || tokenInfo.getAccessToken() == null) {
log.error("获取访问令牌失败 - tokenInfo: {}", tokenInfo);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(false, "获取访问令牌失败,响应数据格式不正确", null), headers, HttpStatus.OK);
}
log.info("成功获取访问令牌 - userId: {}, access_token: {}",
tokenInfo.getUserId(),
tokenInfo.getAccessToken() != null ? tokenInfo.getAccessToken().substring(0, 20) + "..." : "null");
// 自动保存token到后端
try {
if (tokenInfo.getUserId() != null) {
wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo);
log.info("访问令牌已自动保存到后端缓存 - userId: {}", tokenInfo.getUserId());
} else {
log.warn("userId为空无法保存Token");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(false, "获取用户ID失败无法保存Token", null), headers, HttpStatus.OK);
}
} catch (Exception e) {
log.error("保存访问令牌失败", e);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(false, "保存访问令牌失败: " + e.getMessage(), null), headers, HttpStatus.OK);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(true, "授权成功,访问令牌已自动保存", tokenInfo), headers, HttpStatus.OK);
} catch (Exception e) {
log.error("OAuth回调处理失败", e);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
return new ResponseEntity<>(generateCallbackHtml(false, "授权失败: " + e.getMessage(), null), headers, HttpStatus.OK);
}
}
/**
* 生成回调HTML页面
*/
private String generateCallbackHtml(boolean success, String message, WPS365TokenInfo tokenInfo) {
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html>");
html.append("<html lang='zh-CN'>");
html.append("<head>");
html.append("<meta charset='UTF-8'>");
html.append("<meta name='viewport' content='width=device-width, initial-scale=1.0'>");
html.append("<title>WPS365授权").append(success ? "成功" : "失败").append("</title>");
html.append("<style>");
html.append("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; ");
html.append("display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; ");
html.append("background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }");
html.append(".container { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); text-align: center; max-width: 500px; }");
html.append(".icon { font-size: 64px; margin-bottom: 20px; }");
html.append(".success { color: #52c41a; }");
html.append(".error { color: #ff4d4f; }");
html.append(".message { font-size: 16px; color: #333; margin-bottom: 20px; line-height: 1.6; }");
html.append(".info { font-size: 14px; color: #666; margin-top: 20px; padding: 15px; background: #f5f5f5; border-radius: 5px; text-align: left; }");
html.append("</style>");
html.append("</head>");
html.append("<body>");
html.append("<div class='container'>");
if (success) {
html.append("<div class='icon success'>✓</div>");
html.append("<h2 style='color: #52c41a; margin-bottom: 10px;'>授权成功</h2>");
} else {
html.append("<div class='icon error'>✗</div>");
html.append("<h2 style='color: #ff4d4f; margin-bottom: 10px;'>授权失败</h2>");
}
html.append("<div class='message'>").append(message).append("</div>");
// 显示Token信息仅成功时
if (success && tokenInfo != null) {
html.append("<div class='info'>");
html.append("<strong>授权信息:</strong><br>");
if (tokenInfo.getUserId() != null) {
html.append("用户ID: ").append(tokenInfo.getUserId()).append("<br>");
}
if (tokenInfo.getExpiresIn() != null) {
html.append("有效期: ").append(tokenInfo.getExpiresIn()).append(" 秒<br>");
}
html.append("</div>");
}
html.append("<p style='color: #999; font-size: 14px; margin-top: 20px;'>窗口将在3秒后自动关闭...</p>");
html.append("</div>");
html.append("<script>");
html.append("// 通知父窗口授权结果");
html.append("if (window.opener) {");
html.append(" window.opener.postMessage({");
html.append(" type: 'wps365_oauth_callback',");
html.append(" success: ").append(success).append(",");
html.append(" message: '").append(message.replace("'", "\\'").replace("\n", "\\n").replace("\r", "\\r")).append("'");
if (success && tokenInfo != null && tokenInfo.getUserId() != null) {
html.append(",");
html.append(" userId: '").append(tokenInfo.getUserId()).append("'");
}
html.append(" }, '*');");
html.append("}");
html.append("// 3秒后自动关闭窗口");
html.append("setTimeout(function() {");
html.append(" if (window.opener) {");
html.append(" window.close();");
html.append(" } else {");
html.append(" window.location.href = '/';");
html.append(" }");
html.append("}, 3000);");
html.append("</script>");
html.append("</body>");
html.append("</html>");
return html.toString();
}
}

View File

@@ -0,0 +1,572 @@
package com.ruoyi.web.controller.jarvis;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
import com.ruoyi.jarvis.service.IWPS365ApiService;
import com.ruoyi.jarvis.service.IWPS365OAuthService;
import com.ruoyi.jarvis.service.impl.WPS365OAuthServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* WPS365控制器
*
* @author system
*/
@RestController
@RequestMapping("/jarvis/wps365")
public class WPS365Controller extends BaseController {
private static final Logger log = LoggerFactory.getLogger(WPS365Controller.class);
@Autowired
private IWPS365OAuthService wps365OAuthService;
@Autowired
private IWPS365ApiService wps365ApiService;
@Autowired
private WPS365OAuthServiceImpl wps365OAuthServiceImpl;
@Autowired
private RedisCache redisCache;
/**
* 获取授权URL
*/
@GetMapping("/authUrl")
public AjaxResult getAuthUrl(@RequestParam(required = false) String state) {
try {
String authUrl = wps365OAuthService.getAuthUrl(state);
return AjaxResult.success("获取授权URL成功", authUrl);
} catch (Exception e) {
log.error("获取授权URL失败", e);
return AjaxResult.error("获取授权URL失败: " + e.getMessage());
}
}
/**
* OAuth回调处理已废弃请使用 /wps365-callback
* 保留此接口用于兼容,实际回调请使用 WPS365CallbackController
*/
@Anonymous
@GetMapping("/oauth/callback")
public AjaxResult oauthCallback(@RequestParam String code,
@RequestParam(required = false) String state) {
try {
log.warn("使用已废弃的回调接口 /jarvis/wps365/oauth/callback建议使用 /wps365-callback");
log.info("收到OAuth回调 - code: {}, state: {}", code, state);
// 通过授权码获取访问令牌
WPS365TokenInfo tokenInfo = wps365OAuthService.getAccessTokenByCode(code);
// 保存Token到Redis使用userId作为key
if (tokenInfo.getUserId() != null) {
wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo);
}
return AjaxResult.success("授权成功", tokenInfo);
} catch (Exception e) {
log.error("OAuth回调处理失败", e);
return AjaxResult.error("授权失败: " + e.getMessage());
}
}
/**
* 刷新访问令牌
*/
@PostMapping("/refreshToken")
public AjaxResult refreshToken(@RequestBody Map<String, Object> params) {
try {
String refreshToken = (String) params.get("refreshToken");
if (refreshToken == null || refreshToken.trim().isEmpty()) {
return AjaxResult.error("refreshToken不能为空");
}
WPS365TokenInfo tokenInfo = wps365OAuthService.refreshAccessToken(refreshToken);
// 更新Token到Redis
if (tokenInfo.getUserId() != null) {
wps365OAuthService.saveToken(tokenInfo.getUserId(), tokenInfo);
}
return AjaxResult.success("刷新令牌成功", tokenInfo);
} catch (Exception e) {
log.error("刷新访问令牌失败", e);
return AjaxResult.error("刷新令牌失败: " + e.getMessage());
}
}
/**
* 获取当前用户的Token状态
*/
@GetMapping("/tokenStatus")
public AjaxResult getTokenStatus(@RequestParam(required = false) String userId) {
try {
WPS365TokenInfo tokenInfo = null;
// 如果提供了userId查询指定用户的token
if (userId != null && !userId.trim().isEmpty()) {
tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
} else {
// 如果没有提供userId尝试查找所有token通常只有一个
// 这里使用getCurrentToken方法它会尝试查找可用的token
tokenInfo = wps365OAuthService.getCurrentToken();
}
if (tokenInfo == null) {
JSONObject result = new JSONObject();
result.put("hasToken", false);
result.put("isValid", false);
return AjaxResult.success("未授权", result);
}
boolean isValid = wps365OAuthService.isTokenValid(tokenInfo);
JSONObject result = new JSONObject();
result.put("hasToken", true);
result.put("isValid", isValid);
result.put("userId", tokenInfo.getUserId());
result.put("expired", tokenInfo.isExpired());
if (tokenInfo.getExpiresIn() != null) {
result.put("expiresIn", tokenInfo.getExpiresIn());
}
return AjaxResult.success("获取Token状态成功", result);
} catch (Exception e) {
log.error("获取Token状态失败", e);
return AjaxResult.error("获取Token状态失败: " + e.getMessage());
}
}
/**
* 手动设置Token用于测试或手动授权
*/
@PostMapping("/setToken")
public AjaxResult setToken(@RequestBody Map<String, Object> params) {
try {
String accessToken = (String) params.get("accessToken");
String refreshToken = (String) params.get("refreshToken");
String userId = (String) params.get("userId");
Integer expiresIn = params.get("expiresIn") != null ?
Integer.valueOf(params.get("expiresIn").toString()) : 7200;
if (accessToken == null || accessToken.trim().isEmpty()) {
return AjaxResult.error("accessToken不能为空");
}
if (userId == null || userId.trim().isEmpty()) {
return AjaxResult.error("userId不能为空");
}
WPS365TokenInfo tokenInfo = new WPS365TokenInfo();
tokenInfo.setAccessToken(accessToken);
tokenInfo.setRefreshToken(refreshToken);
tokenInfo.setExpiresIn(expiresIn);
tokenInfo.setUserId(userId);
wps365OAuthService.saveToken(userId, tokenInfo);
return AjaxResult.success("设置Token成功");
} catch (Exception e) {
log.error("设置Token失败", e);
return AjaxResult.error("设置Token失败: " + e.getMessage());
}
}
/**
* 获取用户信息
*/
@GetMapping("/userInfo")
public AjaxResult getUserInfo(@RequestParam String userId) {
try {
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效如果过期则尝试刷新
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject userInfo = wps365ApiService.getUserInfo(tokenInfo.getAccessToken());
return AjaxResult.success("获取用户信息成功", userInfo);
} catch (Exception e) {
log.error("获取用户信息失败", e);
return AjaxResult.error("获取用户信息失败: " + e.getMessage());
}
}
/**
* 获取文件列表
*/
@GetMapping("/files")
public AjaxResult getFileList(@RequestParam String userId,
@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam(required = false, defaultValue = "20") Integer pageSize) {
try {
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效如果过期则尝试刷新
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
Map<String, Object> params = new java.util.HashMap<>();
params.put("page", page);
params.put("page_size", pageSize);
JSONObject fileList = wps365ApiService.getFileList(tokenInfo.getAccessToken(), params);
return AjaxResult.success("获取文件列表成功", fileList);
} catch (Exception e) {
log.error("获取文件列表失败", e);
return AjaxResult.error("获取文件列表失败: " + e.getMessage());
}
}
/**
* 获取文件信息
*/
@GetMapping("/fileInfo")
public AjaxResult getFileInfo(@RequestParam String userId,
@RequestParam String fileToken) {
try {
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject fileInfo = wps365ApiService.getFileInfo(tokenInfo.getAccessToken(), fileToken);
return AjaxResult.success("获取文件信息成功", fileInfo);
} catch (Exception e) {
log.error("获取文件信息失败 - fileToken: {}", fileToken, e);
return AjaxResult.error("获取文件信息失败: " + e.getMessage());
}
}
/**
* 获取工作表列表
*/
@GetMapping("/sheets")
public AjaxResult getSheetList(@RequestParam String userId,
@RequestParam String fileToken) {
try {
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject sheetList = wps365ApiService.getSheetList(tokenInfo.getAccessToken(), fileToken);
return AjaxResult.success("获取工作表列表成功", sheetList);
} catch (Exception e) {
log.error("获取工作表列表失败 - fileToken: {}", fileToken, e);
return AjaxResult.error("获取工作表列表失败: " + e.getMessage());
}
}
/**
* 读取单元格数据
*/
@GetMapping("/readCells")
public AjaxResult readCells(@RequestParam String userId,
@RequestParam String fileToken,
@RequestParam(defaultValue = "0") int sheetIdx,
@RequestParam(required = false) String range) {
try {
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject result = wps365ApiService.readCells(tokenInfo.getAccessToken(), fileToken, sheetIdx, range);
return AjaxResult.success("读取单元格数据成功", result);
} catch (Exception e) {
log.error("读取单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e);
return AjaxResult.error("读取单元格数据失败: " + e.getMessage());
}
}
/**
* 更新单元格数据
*/
@PostMapping("/updateCells")
public AjaxResult updateCells(@RequestBody Map<String, Object> params) {
try {
String userId = (String) params.get("userId");
String fileToken = (String) params.get("fileToken");
Integer sheetIdx = params.get("sheetIdx") != null ?
Integer.valueOf(params.get("sheetIdx").toString()) : 0;
String range = (String) params.get("range");
@SuppressWarnings("unchecked")
List<List<Object>> values = (List<List<Object>>) params.get("values");
if (userId == null || userId.trim().isEmpty()) {
return AjaxResult.error("userId不能为空");
}
if (fileToken == null || fileToken.trim().isEmpty()) {
return AjaxResult.error("fileToken不能为空");
}
if (range == null || range.trim().isEmpty()) {
return AjaxResult.error("range不能为空");
}
if (values == null || values.isEmpty()) {
return AjaxResult.error("values不能为空");
}
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject result = wps365ApiService.updateCells(
tokenInfo.getAccessToken(),
fileToken,
sheetIdx,
range,
values
);
return AjaxResult.success("更新单元格数据成功", result);
} catch (Exception e) {
log.error("更新单元格数据失败", e);
return AjaxResult.error("更新单元格数据失败: " + e.getMessage());
}
}
/**
* 创建数据表
*/
@PostMapping("/createSheet")
public AjaxResult createSheet(@RequestBody Map<String, Object> params) {
try {
String userId = (String) params.get("userId");
String fileToken = (String) params.get("fileToken");
String sheetName = (String) params.get("sheetName");
if (userId == null || userId.trim().isEmpty()) {
return AjaxResult.error("userId不能为空");
}
if (fileToken == null || fileToken.trim().isEmpty()) {
return AjaxResult.error("fileToken不能为空");
}
if (sheetName == null || sheetName.trim().isEmpty()) {
return AjaxResult.error("sheetName不能为空");
}
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject result = wps365ApiService.createSheet(tokenInfo.getAccessToken(), fileToken, sheetName);
return AjaxResult.success("创建数据表成功", result);
} catch (Exception e) {
log.error("创建数据表失败", e);
return AjaxResult.error("创建数据表失败: " + e.getMessage());
}
}
/**
* 批量更新单元格数据
*/
@PostMapping("/batchUpdateCells")
public AjaxResult batchUpdateCells(@RequestBody Map<String, Object> params) {
try {
String userId = (String) params.get("userId");
String fileToken = (String) params.get("fileToken");
Integer sheetIdx = params.get("sheetIdx") != null ?
Integer.valueOf(params.get("sheetIdx").toString()) : 0;
@SuppressWarnings("unchecked")
List<Map<String, Object>> updates = (List<Map<String, Object>>) params.get("updates");
if (userId == null || userId.trim().isEmpty()) {
return AjaxResult.error("userId不能为空");
}
if (fileToken == null || fileToken.trim().isEmpty()) {
return AjaxResult.error("fileToken不能为空");
}
if (updates == null || updates.isEmpty()) {
return AjaxResult.error("updates不能为空");
}
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject result = wps365ApiService.batchUpdateCells(
tokenInfo.getAccessToken(),
fileToken,
sheetIdx,
updates
);
return AjaxResult.success("批量更新单元格数据成功", result);
} catch (Exception e) {
log.error("批量更新单元格数据失败", e);
return AjaxResult.error("批量更新单元格数据失败: " + e.getMessage());
}
}
/**
* 读取AirSheet工作表数据
*/
@GetMapping("/readAirSheetCells")
public AjaxResult readAirSheetCells(@RequestParam String userId,
@RequestParam String fileId,
@RequestParam(required = false, defaultValue = "0") String worksheetId,
@RequestParam(required = false) String range) {
try {
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject result = wps365ApiService.readAirSheetCells(tokenInfo.getAccessToken(), fileId, worksheetId, range);
return AjaxResult.success("读取AirSheet数据成功", result);
} catch (Exception e) {
log.error("读取AirSheet数据失败 - fileId: {}, worksheetId: {}, range: {}", fileId, worksheetId, range, e);
return AjaxResult.error("读取AirSheet数据失败: " + e.getMessage());
}
}
/**
* 更新AirSheet工作表数据
*/
@PostMapping("/updateAirSheetCells")
public AjaxResult updateAirSheetCells(@RequestBody Map<String, Object> params) {
try {
String userId = (String) params.get("userId");
String fileId = (String) params.get("fileId");
String worksheetId = params.get("worksheetId") != null ? params.get("worksheetId").toString() : "0";
String range = (String) params.get("range");
@SuppressWarnings("unchecked")
List<List<Object>> values = (List<List<Object>>) params.get("values");
if (userId == null || fileId == null) {
return AjaxResult.error("userId和fileId不能为空");
}
WPS365TokenInfo tokenInfo = wps365OAuthServiceImpl.getTokenByUserId(userId);
if (tokenInfo == null) {
return AjaxResult.error("用户未授权,请先完成授权");
}
// 检查Token是否有效
if (tokenInfo.isExpired() && tokenInfo.getRefreshToken() != null) {
try {
tokenInfo = wps365OAuthService.refreshAccessToken(tokenInfo.getRefreshToken());
wps365OAuthService.saveToken(userId, tokenInfo);
} catch (Exception e) {
log.error("刷新Token失败", e);
return AjaxResult.error("Token已过期且刷新失败请重新授权");
}
}
JSONObject result = wps365ApiService.updateAirSheetCells(tokenInfo.getAccessToken(), fileId, worksheetId, range, values);
return AjaxResult.success("更新AirSheet数据成功", result);
} catch (Exception e) {
log.error("更新AirSheet数据失败", e);
return AjaxResult.error("更新AirSheet数据失败: " + e.getMessage());
}
}
}

View File

@@ -6,6 +6,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.framework.web.domain.Server;
import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IWxSendService;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
/**
* 服务器监控
@@ -16,6 +22,12 @@ import com.ruoyi.framework.web.domain.Server;
@RequestMapping("/monitor/server")
public class ServerController
{
@Resource
private ILogisticsService logisticsService;
@Resource
private IWxSendService wxSendService;
@PreAuthorize("@ss.hasPermi('monitor:server:list')")
@GetMapping()
public AjaxResult getInfo() throws Exception
@@ -24,4 +36,52 @@ public class ServerController
server.copyTo();
return AjaxResult.success(server);
}
/**
* 获取服务健康度检测
*/
@PreAuthorize("@ss.hasPermi('monitor:server:list')")
@GetMapping("/health")
public AjaxResult getHealth() throws Exception
{
Map<String, Object> healthMap = new HashMap<>();
// 物流服务健康检测
try {
ILogisticsService.HealthCheckResult logisticsHealth = logisticsService.checkHealth();
Map<String, Object> logisticsMap = new HashMap<>();
logisticsMap.put("healthy", logisticsHealth.isHealthy());
logisticsMap.put("status", logisticsHealth.getStatus());
logisticsMap.put("message", logisticsHealth.getMessage());
logisticsMap.put("serviceUrl", logisticsHealth.getServiceUrl());
healthMap.put("logistics", logisticsMap);
} catch (Exception e) {
Map<String, Object> logisticsMap = new HashMap<>();
logisticsMap.put("healthy", false);
logisticsMap.put("status", "异常");
logisticsMap.put("message", "健康检测异常: " + e.getMessage());
logisticsMap.put("serviceUrl", "");
healthMap.put("logistics", logisticsMap);
}
// 微信推送服务健康检测
try {
IWxSendService.HealthCheckResult wxSendHealth = wxSendService.checkHealth();
Map<String, Object> wxSendMap = new HashMap<>();
wxSendMap.put("healthy", wxSendHealth.isHealthy());
wxSendMap.put("status", wxSendHealth.getStatus());
wxSendMap.put("message", wxSendHealth.getMessage());
wxSendMap.put("serviceUrl", wxSendHealth.getServiceUrl());
healthMap.put("wxSend", wxSendMap);
} catch (Exception e) {
Map<String, Object> wxSendMap = new HashMap<>();
wxSendMap.put("healthy", false);
wxSendMap.put("status", "异常");
wxSendMap.put("message", "健康检测异常: " + e.getMessage());
wxSendMap.put("serviceUrl", "");
healthMap.put("wxSend", wxSendMap);
}
return AjaxResult.success(healthMap);
}
}

View File

@@ -111,11 +111,14 @@ public class PublicOrderController extends BaseController {
log.info("日期验证通过: 订单日期[{}]", orderDate);
// 获取forceGenerate参数默认为false
boolean forceGenerate = body != null && body.get("forceGenerate") != null && Boolean.parseBoolean(String.valueOf(body.get("forceGenerate")));
// 执行指令
List<String> result;
try {
log.info("开始执行订单指令...");
result = instructionService.execute(trimmedCmd);
log.info("开始执行订单指令... forceGenerate={}", forceGenerate);
result = instructionService.execute(trimmedCmd, forceGenerate);
log.info("订单指令执行完成");
// 记录执行结果

View File

@@ -3,11 +3,17 @@ package com.ruoyi.web.controller.publicapi;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.jarvis.domain.dto.CommentCallHistory;
import com.ruoyi.jarvis.service.ICommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
/**
@@ -18,22 +24,43 @@ import java.util.*;
@RequestMapping("/public/comment")
public class CommentPublicController extends BaseController {
// TODO: 可改为读取配置
private static final String JD_BASE = "http://192.168.8.88:6666/jd";
private static final String SKEY = "2192057370ef8140c201079969c956a3";
@Value("${jarvis.server.jarvis-java.base-url:http://127.0.0.1:6666}")
private String jarvisJavaBaseUrl;
@Value("${jarvis.server.jarvis-java.jd-api-path:/jd}")
private String jdApiPath;
@Autowired(required = false)
private ICommentService commentService;
/**
* 获取JD接口基础URL
*/
private String getJdBase() {
return jarvisJavaBaseUrl + jdApiPath;
}
/**
* 获取可选型号/类型(示例)
*/
@GetMapping("/types")
public AjaxResult types() {
boolean success = false;
try {
String url = JD_BASE + "/comment/types?skey=" + SKEY;
String url = getJdBase() + "/comment/types?skey=" + SKEY;
String result = HttpUtils.sendGet(url);
Object parsed = JSON.parse(result);
success = true;
return AjaxResult.success(parsed);
} catch (Exception e) {
return AjaxResult.error("types failed: " + e.getMessage());
} finally {
// 记录接口调用统计
if (commentService != null) {
commentService.recordApiCall("jd", "types", success);
}
}
}
@@ -42,22 +69,129 @@ public class CommentPublicController extends BaseController {
* 入参productType型号/类型)
*/
@PostMapping("/generate")
public AjaxResult generate(@RequestBody Map<String, String> body) {
public AjaxResult generate(@RequestBody Map<String, String> body, HttpServletRequest request) {
boolean success = false;
String productType = null;
String clientIp = getClientIp(request);
try {
String url = JD_BASE + "/comment/generate";
String url = getJdBase() + "/comment/generate";
JSONObject param = new JSONObject();
param.put("skey", SKEY);
if (body != null && body.get("productType") != null) {
param.put("productType", body.get("productType"));
productType = body.get("productType");
param.put("productType", productType);
}
String result = HttpUtils.sendJsonPost(url, param.toJSONString());
Object parsed = JSON.parse(result);
success = true;
return AjaxResult.success(parsed);
} catch (Exception e) {
return AjaxResult.error("generate failed: " + e.getMessage());
} finally {
// 记录接口调用统计和历史
if (commentService != null && productType != null) {
commentService.recordApiCall("jd", productType, success);
commentService.recordApiCallHistory(productType, clientIp);
}
}
}
/**
* 获取当前IP地址
*/
@GetMapping("/ip")
public AjaxResult getIp(HttpServletRequest request) {
try {
String ip = getClientIp(request);
Map<String, String> result = new HashMap<>();
result.put("ip", ip);
return AjaxResult.success(result);
} catch (Exception e) {
return AjaxResult.error("获取IP失败: " + e.getMessage());
}
}
/**
* 获取使用统计(今天/7天/30天/累计)
*/
@GetMapping("/usage-statistics")
public AjaxResult getUsageStatistics() {
try {
if (commentService != null) {
Map<String, Long> statistics = commentService.getUsageStatistics();
return AjaxResult.success(statistics);
} else {
Map<String, Long> statistics = new HashMap<>();
statistics.put("today", 0L);
statistics.put("last7Days", 0L);
statistics.put("last30Days", 0L);
statistics.put("total", 0L);
return AjaxResult.success(statistics);
}
} catch (Exception e) {
return AjaxResult.error("获取使用统计失败: " + e.getMessage());
}
}
/**
* 获取历史记录
*/
@GetMapping("/history")
public TableDataInfo getHistory(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
try {
if (commentService != null) {
List<CommentCallHistory> historyList = commentService.getApiCallHistory(pageNum, pageSize);
TableDataInfo dataTable = new TableDataInfo();
dataTable.setCode(200);
dataTable.setMsg("查询成功");
dataTable.setRows(historyList);
dataTable.setTotal(historyList.size()); // 注意:这里返回的是当前页的数量,实际总数可能需要单独查询
return dataTable;
} else {
TableDataInfo dataTable = new TableDataInfo();
dataTable.setCode(200);
dataTable.setMsg("查询成功");
dataTable.setRows(new ArrayList<>());
dataTable.setTotal(0);
return dataTable;
}
} catch (Exception e) {
TableDataInfo dataTable = new TableDataInfo();
dataTable.setCode(500);
dataTable.setMsg("获取历史记录失败: " + e.getMessage());
return dataTable;
}
}
/**
* 获取客户端真实IP地址
* 考虑代理和负载均衡的情况
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 对于通过多个代理的情况第一个IP为客户端真实IP
if (ip != null && ip.contains(",")) {
ip = ip.substring(0, ip.indexOf(",")).trim();
}
return ip;
}
}

View File

@@ -10,11 +10,14 @@ import com.ruoyi.jarvis.service.IOrderRowsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.JDOrderSimpleDTO;
import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IInstructionService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@@ -30,10 +33,12 @@ public class JDOrderListController extends BaseController
private final IJDOrderService jdOrderService;
private final IOrderRowsService orderRowsService;
private final IInstructionService instructionService;
public JDOrderListController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService) {
public JDOrderListController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService, IInstructionService instructionService) {
this.jdOrderService = jdOrderService;
this.orderRowsService = orderRowsService;
this.instructionService = instructionService;
}
/**
@@ -64,6 +69,12 @@ public class JDOrderListController extends BaseController
query.getParams().put("hasFinishTime", true);
}
// 处理混合搜索参数(订单号/第三方单号/分销标识)
String orderSearch = request.getParameter("orderSearch");
if (orderSearch != null && !orderSearch.trim().isEmpty()) {
query.getParams().put("orderSearch", orderSearch.trim());
}
java.util.List<JDOrder> list;
if (orderBy != null && !orderBy.isEmpty()) {
// 设置排序参数
@@ -85,9 +96,11 @@ public class JDOrderListController extends BaseController
if (orderRows != null) {
jdOrder.setProPriceAmount(orderRows.getProPriceAmount());
jdOrder.setFinishTime(orderRows.getFinishTime());
jdOrder.setOrderStatus(orderRows.getValidCode());
} else {
jdOrder.setProPriceAmount(0.0);
jdOrder.setFinishTime(null);
jdOrder.setOrderStatus(null);
}
}
// 过滤掉完成时间为空的订单
@@ -101,8 +114,10 @@ public class JDOrderListController extends BaseController
if (orderRows != null) {
jdOrder.setProPriceAmount(orderRows.getProPriceAmount());
jdOrder.setFinishTime(orderRows.getFinishTime());
jdOrder.setOrderStatus(orderRows.getValidCode());
} else {
jdOrder.setProPriceAmount(0.0);
jdOrder.setOrderStatus(null);
}
}
}
@@ -171,4 +186,251 @@ public class JDOrderListController extends BaseController
{
return toAjax(jdOrderService.deleteJDOrderByIds(ids));
}
/**
* 订单搜索工具接口(返回简易字段)
*/
@Anonymous
@GetMapping("/tools/search")
public TableDataInfo searchOrders(
@RequestParam(required = false) String orderSearch,
@RequestParam(required = false) String address,
HttpServletRequest request)
{
// startPage会从request中读取pageNum和pageSize参数
startPage();
JDOrder query = new JDOrder();
// 处理单号搜索过滤TF、H、F、PDD等关键词
if (orderSearch != null && !orderSearch.trim().isEmpty()) {
String searchKeyword = orderSearch.trim().toUpperCase();
// 过滤掉TF、H、F、PDD等关键词
if (searchKeyword.contains("TF") || searchKeyword.contains("H") ||
searchKeyword.contains("F") || searchKeyword.contains("PDD")) {
// 如果包含过滤关键词,返回空结果
return getDataTable(new java.util.ArrayList<>());
}
// 至少5个字符
if (searchKeyword.length() >= 5) {
query.getParams().put("orderSearch", orderSearch.trim());
}
}
// 处理地址搜索至少3个字符
if (address != null && !address.trim().isEmpty()) {
if (address.trim().length() >= 3) {
query.setAddress(address.trim());
}
}
// 如果没有有效的搜索条件,返回空结果
if ((orderSearch == null || orderSearch.trim().isEmpty() || orderSearch.trim().length() < 5) &&
(address == null || address.trim().isEmpty() || address.trim().length() < 3)) {
return getDataTable(new java.util.ArrayList<>());
}
java.util.List<JDOrder> list = jdOrderService.selectJDOrderList(query);
// 转换为简易DTO只返回前端需要的字段其他字段脱敏
java.util.List<JDOrderSimpleDTO> simpleList = new java.util.ArrayList<>();
for (JDOrder jdOrder : list) {
JDOrderSimpleDTO dto = new JDOrderSimpleDTO();
// 只设置前端需要的字段
dto.setRemark(jdOrder.getRemark());
dto.setOrderId(jdOrder.getOrderId());
dto.setThirdPartyOrderNo(jdOrder.getThirdPartyOrderNo());
dto.setModelNumber(jdOrder.getModelNumber());
dto.setAddress(jdOrder.getAddress());
dto.setIsRefunded(jdOrder.getIsRefunded() != null ? jdOrder.getIsRefunded() : 0);
dto.setIsRebateReceived(jdOrder.getIsRebateReceived() != null ? jdOrder.getIsRebateReceived() : 0);
dto.setStatus(jdOrder.getStatus());
dto.setCreateTime(jdOrder.getCreateTime());
// 关联查询订单状态和赔付金额
OrderRows orderRows = orderRowsService.selectOrderRowsByOrderId(jdOrder.getOrderId());
if (orderRows != null) {
dto.setProPriceAmount(orderRows.getProPriceAmount());
dto.setOrderStatus(orderRows.getValidCode());
} else {
dto.setProPriceAmount(0.0);
dto.setOrderStatus(null);
}
simpleList.add(dto);
}
return getDataTable(simpleList);
}
/**
* 一次性批量更新历史订单将赔付金额大于0的订单标记为后返到账
* 此方法只应执行一次,用于处理历史数据
*/
@Log(title = "批量标记后返到账", businessType = BusinessType.UPDATE)
@RequestMapping(value = "/tools/batch-mark-rebate-received", method = {RequestMethod.POST, RequestMethod.GET})
public AjaxResult batchMarkRebateReceivedForCompensation() {
try {
// 调用批量更新方法
if (instructionService instanceof com.ruoyi.jarvis.service.impl.InstructionServiceImpl) {
((com.ruoyi.jarvis.service.impl.InstructionServiceImpl) instructionService)
.batchMarkRebateReceivedForCompensation();
return AjaxResult.success("批量标记后返到账完成,请查看控制台日志");
} else {
return AjaxResult.error("无法执行批量更新操作");
}
} catch (Exception e) {
return AjaxResult.error("批量标记失败: " + e.getMessage());
}
}
/**
* 生成录单格式文本Excel可粘贴格式
* 根据当前查询条件生成Tab分隔的文本可以直接粘贴到Excel
*/
@GetMapping("/generateExcelText")
public AjaxResult generateExcelText(JDOrder query, HttpServletRequest request) {
try {
// 处理时间筛选参数
String beginTimeStr = request.getParameter("beginTime");
String endTimeStr = request.getParameter("endTime");
if (beginTimeStr != null && !beginTimeStr.isEmpty()) {
query.getParams().put("beginTime", beginTimeStr);
}
if (endTimeStr != null && !endTimeStr.isEmpty()) {
query.getParams().put("endTime", endTimeStr);
}
// 处理混合搜索参数
String orderSearch = request.getParameter("orderSearch");
if (orderSearch != null && !orderSearch.trim().isEmpty()) {
query.getParams().put("orderSearch", orderSearch.trim());
}
// 处理其他查询参数
if (query.getRemark() != null && !query.getRemark().trim().isEmpty()) {
query.setRemark(query.getRemark().trim());
}
if (query.getDistributionMark() != null && !query.getDistributionMark().trim().isEmpty()) {
query.setDistributionMark(query.getDistributionMark().trim());
}
if (query.getModelNumber() != null && !query.getModelNumber().trim().isEmpty()) {
query.setModelNumber(query.getModelNumber().trim());
}
if (query.getBuyer() != null && !query.getBuyer().trim().isEmpty()) {
query.setBuyer(query.getBuyer().trim());
}
if (query.getAddress() != null && !query.getAddress().trim().isEmpty()) {
query.setAddress(query.getAddress().trim());
}
if (query.getStatus() != null && !query.getStatus().trim().isEmpty()) {
query.setStatus(query.getStatus().trim());
}
// 获取订单列表(不分页,获取所有符合条件的订单)
List<JDOrder> list = jdOrderService.selectJDOrderList(query);
if (list == null || list.isEmpty()) {
return AjaxResult.success("暂无订单数据");
}
// 关联查询订单状态和赔付金额
for (JDOrder jdOrder : list) {
OrderRows orderRows = orderRowsService.selectOrderRowsByOrderId(jdOrder.getOrderId());
if (orderRows != null) {
jdOrder.setProPriceAmount(orderRows.getProPriceAmount());
// estimateCosPrice 是京粉实际价格
if (orderRows.getEstimateCosPrice() != null) {
jdOrder.setJingfenActualPrice(orderRows.getEstimateCosPrice());
}
}
}
// 按 remark 排序
list.sort((o1, o2) -> {
String r1 = o1.getRemark() != null ? o1.getRemark() : "";
String r2 = o2.getRemark() != null ? o2.getRemark() : "";
return r1.compareTo(r2);
});
// 生成Excel格式文本Tab分隔
StringBuilder sb = new StringBuilder();
for (JDOrder o : list) {
// 日期格式yyyy/MM/dd
String dateStr = "";
if (o.getOrderTime() != null) {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy/MM/dd");
dateStr = sdf.format(o.getOrderTime());
}
// 多多单号(第三方单号,如果没有则使用内部单号)
String duoduoOrderNo = o.getThirdPartyOrderNo() != null && !o.getThirdPartyOrderNo().trim().isEmpty()
? o.getThirdPartyOrderNo() : (o.getRemark() != null ? o.getRemark() : "");
// 型号
String modelNumber = o.getModelNumber() != null ? o.getModelNumber() : "";
// 数量固定为1
String quantity = "1";
// 地址
String address = o.getAddress() != null ? o.getAddress() : "";
// 姓名(从地址中提取,地址格式通常是"姓名 电话 详细地址"
String buyer = "";
if (address != null && !address.trim().isEmpty()) {
String[] addressParts = address.trim().split("\\s+");
if (addressParts.length > 0) {
buyer = addressParts[0];
}
}
// 售价固定为0
String sellingPriceStr = "0";
// 成本售价是0成本也设为空
String costStr = "";
// 利润(后返金额)
Double rebate = o.getRebateAmount() != null ? o.getRebateAmount() : 0.0;
String profitStr = rebate > 0
? String.format(java.util.Locale.ROOT, "%.2f", rebate) : "";
// 京东单号
String orderId = o.getOrderId() != null ? o.getOrderId() : "";
// 物流链接
String logisticsLink = o.getLogisticsLink() != null ? o.getLogisticsLink() : "";
// 下单付款
String paymentAmountStr = o.getPaymentAmount() != null
? String.format(java.util.Locale.ROOT, "%.2f", o.getPaymentAmount()) : "";
// 后返
String rebateAmountStr = o.getRebateAmount() != null
? String.format(java.util.Locale.ROOT, "%.2f", o.getRebateAmount()) : "";
// 按顺序拼接:日期、多多单号、型号、数量、姓名、地址、售价、成本、利润、京东单号、物流、下单付款、后返
sb.append(dateStr).append('\t')
.append(duoduoOrderNo).append('\t')
.append(modelNumber).append('\t')
.append(quantity).append('\t')
.append(buyer).append('\t')
.append(address).append('\t')
.append(sellingPriceStr).append('\t')
.append(costStr).append('\t')
.append(profitStr).append('\t')
.append(orderId).append('\t')
.append(logisticsLink).append('\t')
.append(paymentAmountStr).append('\t')
.append(rebateAmountStr).append('\n');
}
return AjaxResult.success(sb.toString());
} catch (Exception e) {
return AjaxResult.error("生成失败: " + e.getMessage());
}
}
}

View File

@@ -185,7 +185,21 @@ xss:
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# 服务地址配置(用于服务器迁移)
jarvis:
# 服务器基础地址如果所有服务都在同一台服务器可以使用127.0.0.1
# 开发环境:根据实际情况配置
server:
host: 192.168.8.88
# Jarvis Java服务地址JD相关接口
jarvis-java:
base-url: http://192.168.8.88:6666
jd-api-path: /jd
# 物流接口服务地址
logistics:
base-url: http://192.168.8.88:5001
fetch-path: /fetch_logistics
health-path: /health
# 腾讯文档开放平台配置
# 文档地址https://docs.qq.com/open/document/app/openapi/v3/sheet/model/spreadsheet.html
tencent:
@@ -208,3 +222,5 @@ tencent:
# 刷新Token地址用于通过refresh_token刷新access_token
refresh-token-url: https://docs.qq.com/oauth/v2/token

View File

@@ -66,7 +66,7 @@ spring:
# redis 配置
redis:
# 地址
host: 192.168.8.88
host: 127.0.0.1
# 端口默认为6379
port: 6379
# 数据库索引
@@ -92,7 +92,7 @@ spring:
druid:
# 主库数据源
master:
url: jdbc:mysql://192.168.8.88:3306/jd?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
url: jdbc:mysql://127.0.0.1:3306/jd?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: mysql_7sjTXH
# 从库数据源
@@ -185,6 +185,21 @@ xss:
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
# 服务地址配置(用于服务器迁移)
jarvis:
# 服务器基础地址如果所有服务都在同一台服务器可以使用127.0.0.1
# 生产环境192.168.8.88 或 127.0.0.1
server:
host: 127.0.0.1
# Jarvis Java服务地址JD相关接口
jarvis-java:
base-url: http://127.0.0.1:6666
jd-api-path: /jd
# 物流接口服务地址
logistics:
base-url: http://127.0.0.1:5001
fetch-path: /fetch_logistics
health-path: /health
# 腾讯文档开放平台配置
# 文档地址https://docs.qq.com/open/document/app/openapi/v3/sheet/model/spreadsheet.html
tencent:

View File

@@ -2,3 +2,50 @@
spring:
profiles:
active: dev
# 腾讯文档延迟推送配置
tencent:
doc:
delayed:
push:
# 延迟时间分钟默认10分钟
minutes: 10
# WPS365开放平台配置
# 文档地址https://open.wps.cn/
wps365:
# 应用IDAppId- 需要在WPS365开放平台申请
app-id: AK20260114NNQJKV
# 应用密钥AppKey- 需要在WPS365开放平台申请注意保密
app-key: 4c58bc1642e5e8fa731f75af9370496a
# 授权回调地址需要在WPS365开放平台配置授权域名
# 注意:使用 /wps365-callback 路径,避免前端路由拦截
redirect-uri: https://jarvis.van333.cn/wps365-callback
# API基础地址
api-base-url: https://openapi.wps.cn/api/v1
# OAuth授权地址正确格式https://openapi.wps.cn/oauth2/auth
oauth-url: https://openapi.wps.cn/oauth2/auth
# 获取Token地址
token-url: https://openapi.wps.cn/oauth2/token
# 刷新Token地址
refresh-token-url: https://openapi.wps.cn/oauth2/token
# OAuth授权请求的scope权限可选
# 如果不配置默认使用kso.file.readwrite文件读写权限支持在线表格操作
#
# ⚠️ 重要如果报错invalid_scope必须按以下步骤操作
# 1. 登录WPS365开放平台https://open.wps.cn/
# 2. 进入"开发配置" > "权限管理"
# 3. 查看已申请权限的准确名称(必须以 kso. 开头)
# 4. 在下方配置scope使用英文逗号分隔WPS365官方要求
#
# 根据WPS365官方文档https://open.wps.cn/documents/app-integration-dev/wps365/server/
# - 必须使用英文逗号分隔(不是空格)
# - 权限名称必须以 kso. 开头格式如kso.file.read, kso.file.readwrite
# - 常见权限名称:
# * kso.file.read (文件读取)
# * kso.file.readwrite (文件读写,支持在线表格操作)
# * kso.doclib.readwrite (文档库读写)
# * kso.wiki.readwrite (知识库读写)
#
# 示例配置(根据平台后台实际显示的权限名称修改):
# scope: kso.file.readwrite
# scope: kso.file.read,kso.file.readwrite
# scope: kso.doclib.readwrite

View File

@@ -75,6 +75,17 @@
<logger name="com.ruoyi" level="info" />
<!-- Spring日志级别控制 -->
<logger name="org.springframework" level="warn" />
<!-- MyBatis SQL日志级别控制 - 关闭SQL打印 -->
<logger name="org.apache.ibatis" level="warn" />
<logger name="java.sql" level="warn" />
<logger name="java.sql.Connection" level="warn" />
<logger name="java.sql.Statement" level="warn" />
<logger name="java.sql.PreparedStatement" level="warn" />
<logger name="java.sql.ResultSet" level="warn" />
<!-- MyBatis Mapper接口日志级别控制 - 关闭Mapper SQL打印 -->
<logger name="com.ruoyi.jarvis.mapper" level="warn" />
<logger name="com.ruoyi.system.mapper" level="warn" />
<logger name="com.ruoyi.erp.mapper" level="warn" />
<root level="info">
<appender-ref ref="console" />

View File

@@ -217,6 +217,79 @@ public class HttpUtils
return result.toString();
}
/**
* 向指定 URL 发送DELETE方法的请求
*
* @param url 发送请求的 URL
* @return 所代表远程资源的响应结果
*/
public static String sendDelete(String url)
{
StringBuilder result = new StringBuilder();
BufferedReader in = null;
try
{
log.info("sendDelete - {}", url);
URL realUrl = new URL(url);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) realUrl.openConnection();
conn.setRequestMethod("DELETE");
conn.setRequestProperty("accept", "*/*");
conn.setRequestProperty("connection", "Keep-Alive");
conn.setRequestProperty("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
conn.setRequestProperty("Accept-Charset", "utf-8");
conn.setConnectTimeout(10000);
conn.setReadTimeout(20000);
conn.connect();
int responseCode = conn.getResponseCode();
InputStream inputStream = (responseCode >= 200 && responseCode < 300)
? conn.getInputStream()
: conn.getErrorStream();
if (inputStream != null)
{
in = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
while ((line = in.readLine()) != null)
{
result.append(line);
}
}
log.info("recv - {}", result);
}
catch (ConnectException e)
{
log.error("调用HttpUtils.sendDelete ConnectException, url=" + url, e);
}
catch (SocketTimeoutException e)
{
log.error("调用HttpUtils.sendDelete SocketTimeoutException, url=" + url, e);
}
catch (IOException e)
{
log.error("调用HttpUtils.sendDelete IOException, url=" + url, e);
}
catch (Exception e)
{
log.error("调用HttpUtils.sendDelete Exception, url=" + url, e);
}
finally
{
try
{
if (in != null)
{
in.close();
}
}
catch (Exception ex)
{
log.error("调用in.close Exception, url=" + url, ex);
}
}
return result.toString();
}
public static String sendSSLPost(String url, String param)
{
return sendSSLPost(url, param, MediaType.APPLICATION_FORM_URLENCODED_VALUE);

View File

@@ -50,6 +50,12 @@ public class TencentDocConfig {
/** 工作表IDH-TF订单的目标工作表ID */
private String sheetId;
/** 表头行号表头所在的行默认为2 */
private Integer headerRow = 2;
/** 起始行号数据开始的行从第几行开始搜索匹配单号默认为3 */
private Integer startRow = 3;
/**
* 配置初始化后验证
*/
@@ -150,5 +156,21 @@ public class TencentDocConfig {
public void setSheetId(String sheetId) {
this.sheetId = sheetId;
}
public Integer getHeaderRow() {
return headerRow;
}
public void setHeaderRow(Integer headerRow) {
this.headerRow = headerRow;
}
public Integer getStartRow() {
return startRow;
}
public void setStartRow(Integer startRow) {
this.startRow = startRow;
}
}

View File

@@ -0,0 +1,132 @@
package com.ruoyi.jarvis.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* WPS365开放平台配置
*
* @author system
*/
@Configuration
@Component
@ConfigurationProperties(prefix = "wps365")
public class WPS365Config {
private static final Logger log = LoggerFactory.getLogger(WPS365Config.class);
/** 应用IDAppId */
private String appId;
/** 应用密钥AppKey */
private String appKey;
/** 授权回调地址 */
private String redirectUri;
/** API基础地址 */
private String apiBaseUrl = "https://openapi.wps.cn/api/v1";
/** OAuth授权地址 */
private String oauthUrl = "https://openapi.wps.cn/oauth2/auth";
/** 获取Token地址 */
private String tokenUrl = "https://openapi.wps.cn/oauth2/token";
/** 刷新Token地址 */
private String refreshTokenUrl = "https://openapi.wps.cn/oauth2/token";
/** OAuth授权请求的scope权限可选如果不配置则使用默认值 */
private String scope;
/**
* 配置初始化后验证
*/
@PostConstruct
public void init() {
log.info("WPS365配置加载 - appId: {}, redirectUri: {}, apiBaseUrl: {}",
appId != null && appId.length() > 10 ? appId.substring(0, 10) + "..." : (appId != null ? appId : "null"),
redirectUri != null ? redirectUri : "null",
apiBaseUrl);
if (appId == null || appId.trim().isEmpty()) {
log.warn("WPS365应用ID未配置请检查application.yml中的wps365.app-id");
}
if (appKey == null || appKey.trim().isEmpty()) {
log.warn("WPS365应用密钥未配置请检查application.yml中的wps365.app-key");
}
if (redirectUri == null || redirectUri.trim().isEmpty()) {
log.warn("WPS365回调地址未配置请检查application.yml中的wps365.redirect-uri");
}
}
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getAppKey() {
return appKey;
}
public void setAppKey(String appKey) {
this.appKey = appKey;
}
public String getRedirectUri() {
return redirectUri;
}
public void setRedirectUri(String redirectUri) {
this.redirectUri = redirectUri;
}
public String getApiBaseUrl() {
return apiBaseUrl;
}
public void setApiBaseUrl(String apiBaseUrl) {
this.apiBaseUrl = apiBaseUrl;
}
public String getOauthUrl() {
return oauthUrl;
}
public void setOauthUrl(String oauthUrl) {
this.oauthUrl = oauthUrl;
}
public String getTokenUrl() {
return tokenUrl;
}
public void setTokenUrl(String tokenUrl) {
this.tokenUrl = tokenUrl;
}
public String getRefreshTokenUrl() {
return refreshTokenUrl;
}
public void setRefreshTokenUrl(String refreshTokenUrl) {
this.refreshTokenUrl = refreshTokenUrl;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
}

View File

@@ -0,0 +1,60 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import com.ruoyi.common.annotation.Excel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
/**
* 京东评论对象 comments
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class Comment extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 主键ID */
@Excel(name = "ID")
private Long id;
/** 商品ID */
@Excel(name = "商品ID")
private String productId;
/** 用户名 */
@Excel(name = "用户名")
private String userName;
/** 评论内容 */
@Excel(name = "评论内容")
private String commentText;
/** 评论ID */
@Excel(name = "评论ID")
private String commentId;
/** 图片URLs */
@Excel(name = "图片URLs")
private String pictureUrls;
/** 创建时间 */
@Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
/** 评论日期 */
@Excel(name = "评论日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date commentDate;
/** 是否已使用 0-未使用 1-已使用 */
@Excel(name = "使用状态", readConverterExp = "0=未使用,1=已使用")
private Integer isUse;
/** 产品类型从Redis映射获取 */
private String productType;
/** Redis映射的产品ID */
private String mappedProductId;
}

View File

@@ -0,0 +1,282 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
/**
* 闲鱼商品对象 erp_product
*
* @author ruoyi
* @date 2024-01-01
*/
public class ErpProduct extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 管家商品ID */
@Excel(name = "管家商品ID")
private Long productId;
/** 商品标题 */
@Excel(name = "商品标题")
private String title;
/** 商品图片(主图) */
@Excel(name = "商品图片")
private String mainImage;
/** 商品价格(分) */
@Excel(name = "商品价格")
private Long price;
/** 商品库存 */
@Excel(name = "商品库存")
private Integer stock;
/** 商品状态 -1:删除 21:待发布 22:销售中 23:已售罄 31:手动下架 33:售出下架 36:自动下架 */
@Excel(name = "商品状态", readConverterExp = "-1=删除,21=待发布,22=销售中,23=已售罄,31=手动下架,33=售出下架,36=自动下架")
private Integer productStatus;
/** 销售状态 */
@Excel(name = "销售状态")
private Integer saleStatus;
/** 闲鱼会员名 */
@Excel(name = "闲鱼会员名")
private String userName;
/** 上架时间 */
@Excel(name = "上架时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Long onlineTime;
/** 下架时间 */
@Excel(name = "下架时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Long offlineTime;
/** 售出时间 */
@Excel(name = "售出时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Long soldTime;
/** 创建时间(闲鱼) */
@Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Long createTimeXy;
/** 更新时间(闲鱼) */
@Excel(name = "更新时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Long updateTimeXy;
/** ERP应用ID */
@Excel(name = "ERP应用ID")
private String appid;
/** 商品链接 */
@Excel(name = "商品链接")
private String productUrl;
/** 备注 */
@Excel(name = "备注")
private String remark;
public void setId(Long id)
{
this.id = id;
}
public Long getId()
{
return id;
}
public void setProductId(Long productId)
{
this.productId = productId;
}
public Long getProductId()
{
return productId;
}
public void setTitle(String title)
{
this.title = title;
}
public String getTitle()
{
return title;
}
public void setMainImage(String mainImage)
{
this.mainImage = mainImage;
}
public String getMainImage()
{
return mainImage;
}
public void setPrice(Long price)
{
this.price = price;
}
public Long getPrice()
{
return price;
}
public void setStock(Integer stock)
{
this.stock = stock;
}
public Integer getStock()
{
return stock;
}
public void setProductStatus(Integer productStatus)
{
this.productStatus = productStatus;
}
public Integer getProductStatus()
{
return productStatus;
}
public void setSaleStatus(Integer saleStatus)
{
this.saleStatus = saleStatus;
}
public Integer getSaleStatus()
{
return saleStatus;
}
public void setUserName(String userName)
{
this.userName = userName;
}
public String getUserName()
{
return userName;
}
public void setOnlineTime(Long onlineTime)
{
this.onlineTime = onlineTime;
}
public Long getOnlineTime()
{
return onlineTime;
}
public void setOfflineTime(Long offlineTime)
{
this.offlineTime = offlineTime;
}
public Long getOfflineTime()
{
return offlineTime;
}
public void setSoldTime(Long soldTime)
{
this.soldTime = soldTime;
}
public Long getSoldTime()
{
return soldTime;
}
public void setCreateTimeXy(Long createTimeXy)
{
this.createTimeXy = createTimeXy;
}
public Long getCreateTimeXy()
{
return createTimeXy;
}
public void setUpdateTimeXy(Long updateTimeXy)
{
this.updateTimeXy = updateTimeXy;
}
public Long getUpdateTimeXy()
{
return updateTimeXy;
}
public void setAppid(String appid)
{
this.appid = appid;
}
public String getAppid()
{
return appid;
}
public void setProductUrl(String productUrl)
{
this.productUrl = productUrl;
}
public String getProductUrl()
{
return productUrl;
}
@Override
public void setRemark(String remark)
{
this.remark = remark;
}
@Override
public String getRemark()
{
return remark;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("id", getId())
.append("productId", getProductId())
.append("title", getTitle())
.append("mainImage", getMainImage())
.append("price", getPrice())
.append("stock", getStock())
.append("productStatus", getProductStatus())
.append("saleStatus", getSaleStatus())
.append("userName", getUserName())
.append("onlineTime", getOnlineTime())
.append("offlineTime", getOfflineTime())
.append("soldTime", getSoldTime())
.append("createTimeXy", getCreateTimeXy())
.append("updateTimeXy", getUpdateTimeXy())
.append("appid", getAppid())
.append("productUrl", getProductUrl())
.append("remark", getRemark())
.append("createTime", getCreateTime())
.append("updateTime", getUpdateTime())
.toString();
}
}

View File

@@ -50,6 +50,12 @@ public class JDOrder extends BaseEntity {
@Excel(name = "物流链接")
private String logisticsLink;
/** 是否已推送到腾讯文档0-未推送1-已推送) */
private Integer tencentDocPushed;
/** 推送到腾讯文档的时间 */
private Date tencentDocPushTime;
/** 订单号 */
@Excel(name = "订单号", cellType = ColumnType.STRING)
private String orderId;
@@ -74,6 +80,11 @@ public class JDOrder extends BaseEntity {
@Excel(name = "完成时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date finishTime;
/** 订单状态从order_rows表查询 */
@Transient
@Excel(name = "订单状态")
private Integer orderStatus;
/** 是否参与统计0否 1是 */
@Excel(name = "参与统计")
private Integer isCountEnabled;
@@ -86,6 +97,54 @@ public class JDOrder extends BaseEntity {
@Excel(name = "京粉实际价格")
private Double jingfenActualPrice;
/** 是否退款0否 1是 */
@Excel(name = "是否退款")
private Integer isRefunded;
/** 退款日期 */
@Excel(name = "退款日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date refundDate;
/** 是否退款到账0否 1是 */
@Excel(name = "是否退款到账")
private Integer isRefundReceived;
/** 退款到账日期 */
@Excel(name = "退款到账日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date refundReceivedDate;
/** 后返到账0否 1是 */
@Excel(name = "后返到账")
private Integer isRebateReceived;
/** 后返到账日期 */
@Excel(name = "后返到账日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date rebateReceivedDate;
/** 点过价保0否 1是 */
@Excel(name = "点过价保")
private Integer isPriceProtected;
/** 价保日期 */
@Excel(name = "价保日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date priceProtectedDate;
/** 开过专票0否 1是 */
@Excel(name = "开过专票")
private Integer isInvoiceOpened;
/** 开票日期 */
@Excel(name = "开票日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date invoiceOpenedDate;
/** 晒过评价0否 1是 */
@Excel(name = "晒过评价")
private Integer isReviewPosted;
/** 评价日期 */
@Excel(name = "评价日期", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date reviewPostedDate;
}

View File

@@ -44,6 +44,10 @@ public class SuperAdmin extends BaseEntity
@Excel(name = "是否参与订单统计", readConverterExp = "0=否,1=是")
private Integer isCount;
/** 接收人企业微信用户ID多个用逗号分隔 */
@Excel(name = "接收人")
private String touser;
/** 创建时间 */
@Excel(name = "创建时间")
private Date createdAt;
@@ -151,4 +155,14 @@ public class SuperAdmin extends BaseEntity
{
this.isCount = isCount;
}
public String getTouser()
{
return touser;
}
public void setTouser(String touser)
{
this.touser = touser;
}
}

View File

@@ -0,0 +1,58 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import com.ruoyi.common.annotation.Excel;
import lombok.Data;
import java.util.Date;
/**
* 淘宝评论对象 taobao_comments
*/
@Data
public class TaobaoComment extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 主键ID */
@Excel(name = "ID")
private Integer id;
/** 商品ID */
@Excel(name = "商品ID")
private String productId;
/** 用户名 */
@Excel(name = "用户名")
private String userName;
/** 评论内容 */
@Excel(name = "评论内容")
private String commentText;
/** 评论ID */
@Excel(name = "评论ID")
private String commentId;
/** 图片URLs */
@Excel(name = "图片URLs")
private String pictureUrls;
/** 创建时间 */
@Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
/** 评论日期 */
@Excel(name = "评论日期")
private String commentDate;
/** 是否已使用 0-未使用 1-已使用 */
@Excel(name = "使用状态", readConverterExp = "0=未使用,1=已使用")
private Integer isUse;
/** 产品类型从Redis映射获取 */
private String productType;
/** Redis映射的产品ID */
private String mappedProductId;
}

View File

@@ -0,0 +1,226 @@
package com.ruoyi.jarvis.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
import java.util.List;
/**
* 腾讯文档批量推送记录
*/
public class TencentDocBatchPushRecord extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 批次ID */
private String batchId;
/** 文件ID */
private String fileId;
/** 工作表ID */
private String sheetId;
/** 推送类型AUTO-自动推送MANUAL-手动推送 */
private String pushType;
/** 触发来源DELAYED_TIMER-延迟定时器USER-用户手动 */
private String triggerSource;
/** 推送开始时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
/** 推送结束时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/** 推送耗时(毫秒) */
private Long durationMs;
/** 起始行号 */
private Integer startRow;
/** 结束行号 */
private Integer endRow;
/** 总行数 */
private Integer totalRows;
/** 成功数量 */
private Integer successCount;
/** 跳过数量 */
private Integer skipCount;
/** 错误数量 */
private Integer errorCount;
/** 状态RUNNING-执行中SUCCESS-成功PARTIAL-部分成功FAILED-失败 */
private String status;
/** 结果消息 */
private String resultMessage;
/** 错误信息 */
private String errorMessage;
/** 关联的操作日志列表(非数据库字段) */
private List<TencentDocOperationLog> operationLogs;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getBatchId() {
return batchId;
}
public void setBatchId(String batchId) {
this.batchId = batchId;
}
public String getFileId() {
return fileId;
}
public void setFileId(String fileId) {
this.fileId = fileId;
}
public String getSheetId() {
return sheetId;
}
public void setSheetId(String sheetId) {
this.sheetId = sheetId;
}
public String getPushType() {
return pushType;
}
public void setPushType(String pushType) {
this.pushType = pushType;
}
public String getTriggerSource() {
return triggerSource;
}
public void setTriggerSource(String triggerSource) {
this.triggerSource = triggerSource;
}
public Date getStartTime() {
return startTime;
}
public void setStartTime(Date startTime) {
this.startTime = startTime;
}
public Date getEndTime() {
return endTime;
}
public void setEndTime(Date endTime) {
this.endTime = endTime;
}
public Long getDurationMs() {
return durationMs;
}
public void setDurationMs(Long durationMs) {
this.durationMs = durationMs;
}
public Integer getStartRow() {
return startRow;
}
public void setStartRow(Integer startRow) {
this.startRow = startRow;
}
public Integer getEndRow() {
return endRow;
}
public void setEndRow(Integer endRow) {
this.endRow = endRow;
}
public Integer getTotalRows() {
return totalRows;
}
public void setTotalRows(Integer totalRows) {
this.totalRows = totalRows;
}
public Integer getSuccessCount() {
return successCount;
}
public void setSuccessCount(Integer successCount) {
this.successCount = successCount;
}
public Integer getSkipCount() {
return skipCount;
}
public void setSkipCount(Integer skipCount) {
this.skipCount = skipCount;
}
public Integer getErrorCount() {
return errorCount;
}
public void setErrorCount(Integer errorCount) {
this.errorCount = errorCount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getResultMessage() {
return resultMessage;
}
public void setResultMessage(String resultMessage) {
this.resultMessage = resultMessage;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public List<TencentDocOperationLog> getOperationLogs() {
return operationLogs;
}
public void setOperationLogs(List<TencentDocOperationLog> operationLogs) {
this.operationLogs = operationLogs;
}
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.jarvis.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 腾讯文档操作日志对象 tencent_doc_operation_log
*
* @author system
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class TencentDocOperationLog extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 批次ID关联批量推送记录 */
private String batchId;
/** 文档ID */
private String fileId;
/** 工作表ID */
private String sheetId;
/** 操作类型 */
private String operationType;
/** 订单单号 */
private String orderNo;
/** 目标行号 */
private Integer targetRow;
/** 写入的物流链接 */
private String logisticsLink;
/** 操作状态 */
private String operationStatus;
/** 错误信息 */
private String errorMessage;
/** 操作人 */
private String operator;
}

View File

@@ -0,0 +1,30 @@
package com.ruoyi.jarvis.domain.dto;
import lombok.Data;
import java.util.Date;
/**
* 评论接口调用统计
*/
@Data
public class CommentApiStatistics {
/** 统计日期 */
private Date statDate;
/** 接口类型jd-京东tb-淘宝 */
private String apiType;
/** 产品类型 */
private String productType;
/** 调用次数 */
private Long callCount;
/** 成功次数 */
private Long successCount;
/** 失败次数 */
private Long failCount;
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.jarvis.domain.dto;
import lombok.Data;
import java.util.Date;
/**
* 评论接口调用历史记录
*/
@Data
public class CommentCallHistory {
/** 产品类型 */
private String productType;
/** IP地址 */
private String ip;
/** 创建时间 */
private Date createTime;
}

View File

@@ -0,0 +1,38 @@
package com.ruoyi.jarvis.domain.dto;
import lombok.Data;
import java.util.Date;
/**
* 评论统计信息
*/
@Data
public class CommentStatistics {
/** 评论来源jd-京东tb-淘宝 */
private String source;
/** 产品类型 */
private String productType;
/** 产品ID */
private String productId;
/** 总评论数 */
private Long totalCount;
/** 可用评论数(未使用) */
private Long availableCount;
/** 已使用评论数 */
private Long usedCount;
/** 接口调用次数 */
private Long apiCallCount;
/** 今日调用次数 */
private Long todayCallCount;
/** 最后一条评论的创建时间(作为更新日期) */
private Date lastCommentUpdateTime;
}

View File

@@ -0,0 +1,132 @@
package com.ruoyi.jarvis.domain.dto;
import java.util.Date;
/**
* 订单搜索工具返回的简易DTO
* 只包含前端展示需要的字段,其他字段脱敏
*/
public class JDOrderSimpleDTO {
/** 内部单号 */
private String remark;
/** 京东单号 */
private String orderId;
/** 第三方单号 */
private String thirdPartyOrderNo;
/** 型号 */
private String modelNumber;
/** 地址 */
private String address;
/** 退款状态0否 1是 */
private Integer isRefunded;
/** 后返到账0否 1是 */
private Integer isRebateReceived;
/** 赔付金额 */
private Double proPriceAmount;
/** 订单状态 */
private Integer orderStatus;
/** 备注/状态 */
private String status;
/** 创建时间 */
private Date createTime;
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
public String getThirdPartyOrderNo() {
return thirdPartyOrderNo;
}
public void setThirdPartyOrderNo(String thirdPartyOrderNo) {
this.thirdPartyOrderNo = thirdPartyOrderNo;
}
public String getModelNumber() {
return modelNumber;
}
public void setModelNumber(String modelNumber) {
this.modelNumber = modelNumber;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public Integer getIsRefunded() {
return isRefunded;
}
public void setIsRefunded(Integer isRefunded) {
this.isRefunded = isRefunded;
}
public Integer getIsRebateReceived() {
return isRebateReceived;
}
public void setIsRebateReceived(Integer isRebateReceived) {
this.isRebateReceived = isRebateReceived;
}
public Double getProPriceAmount() {
return proPriceAmount;
}
public void setProPriceAmount(Double proPriceAmount) {
this.proPriceAmount = proPriceAmount;
}
public Integer getOrderStatus() {
return orderStatus;
}
public void setOrderStatus(Integer orderStatus) {
this.orderStatus = orderStatus;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Date getCreateTime() {
return createTime;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
}

View File

@@ -0,0 +1,108 @@
package com.ruoyi.jarvis.domain.dto;
import java.io.Serializable;
/**
* WPS365 Token信息
*
* @author system
*/
public class WPS365TokenInfo implements Serializable {
private static final long serialVersionUID = 1L;
/** 访问令牌 */
private String accessToken;
/** 刷新令牌 */
private String refreshToken;
/** 令牌类型 */
private String tokenType;
/** 过期时间(秒) */
private Integer expiresIn;
/** 作用域 */
private String scope;
/** 用户ID */
private String userId;
/** 创建时间戳(毫秒) */
private Long createTime;
public WPS365TokenInfo() {
this.createTime = System.currentTimeMillis();
}
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public Integer getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(Integer expiresIn) {
this.expiresIn = expiresIn;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public Long getCreateTime() {
return createTime;
}
public void setCreateTime(Long createTime) {
this.createTime = createTime;
}
/**
* 检查token是否过期
*/
public boolean isExpired() {
if (expiresIn == null || createTime == null) {
return true;
}
long currentTime = System.currentTimeMillis();
long expireTime = createTime + (expiresIn * 1000L);
// 提前5分钟认为过期留出刷新时间
return currentTime >= (expireTime - 5 * 60 * 1000);
}
}

View File

@@ -0,0 +1,42 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.Comment;
import java.util.List;
import java.util.Map;
/**
* 京东评论 Mapper 接口
*/
public interface CommentMapper {
/**
* 查询京东评论列表
*/
List<Comment> selectCommentList(Comment comment);
/**
* 根据ID查询京东评论
*/
Comment selectCommentById(Long id);
/**
* 根据商品ID查询评论统计
*/
Map<String, Object> selectCommentStatisticsByProductId(String productId);
/**
* 更新评论使用状态
*/
int updateCommentIsUse(Comment comment);
/**
* 批量删除评论
*/
int deleteCommentByIds(Long[] ids);
/**
* 重置评论使用状态(批量)
*/
int resetCommentIsUseByProductId(String productId);
}

View File

@@ -0,0 +1,80 @@
package com.ruoyi.jarvis.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.jarvis.domain.ErpProduct;
/**
* 闲鱼商品Mapper接口
*
* @author ruoyi
* @date 2024-01-01
*/
public interface ErpProductMapper
{
/**
* 查询闲鱼商品
*
* @param id 闲鱼商品主键
* @return 闲鱼商品
*/
public ErpProduct selectErpProductById(Long id);
/**
* 查询闲鱼商品列表
*
* @param erpProduct 闲鱼商品
* @return 闲鱼商品集合
*/
public List<ErpProduct> selectErpProductList(ErpProduct erpProduct);
/**
* 新增闲鱼商品
*
* @param erpProduct 闲鱼商品
* @return 结果
*/
public int insertErpProduct(ErpProduct erpProduct);
/**
* 修改闲鱼商品
*
* @param erpProduct 闲鱼商品
* @return 结果
*/
public int updateErpProduct(ErpProduct erpProduct);
/**
* 删除闲鱼商品
*
* @param id 闲鱼商品主键
* @return 结果
*/
public int deleteErpProductById(Long id);
/**
* 批量删除闲鱼商品
*
* @param ids 需要删除的数据主键集合
* @return 结果
*/
public int deleteErpProductByIds(Long[] ids);
/**
* 根据商品ID和appid查询
*
* @param productId 商品ID
* @param appid ERP应用ID
* @return 闲鱼商品
*/
public ErpProduct selectErpProductByProductIdAndAppid(@Param("productId") Long productId, @Param("appid") String appid);
/**
* 批量插入或更新闲鱼商品
*
* @param erpProducts 闲鱼商品列表
* @return 结果
*/
public int batchInsertOrUpdateErpProduct(List<ErpProduct> erpProducts);
}

View File

@@ -0,0 +1,42 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.TaobaoComment;
import java.util.List;
import java.util.Map;
/**
* 淘宝评论 Mapper 接口
*/
public interface TaobaoCommentMapper {
/**
* 查询淘宝评论列表
*/
List<TaobaoComment> selectTaobaoCommentList(TaobaoComment taobaoComment);
/**
* 根据ID查询淘宝评论
*/
TaobaoComment selectTaobaoCommentById(Integer id);
/**
* 根据商品ID查询评论统计
*/
Map<String, Object> selectTaobaoCommentStatisticsByProductId(String productId);
/**
* 更新评论使用状态
*/
int updateTaobaoCommentIsUse(TaobaoComment taobaoComment);
/**
* 批量删除评论
*/
int deleteTaobaoCommentByIds(Integer[] ids);
/**
* 重置评论使用状态(批量)
*/
int resetTaobaoCommentIsUseByProductId(String productId);
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.TencentDocBatchPushRecord;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 腾讯文档批量推送记录 Mapper
*/
public interface TencentDocBatchPushRecordMapper {
/**
* 插入批量推送记录
*/
int insertBatchPushRecord(TencentDocBatchPushRecord record);
/**
* 更新批量推送记录
*/
int updateBatchPushRecord(TencentDocBatchPushRecord record);
/**
* 根据批次ID查询
*/
TencentDocBatchPushRecord selectByBatchId(@Param("batchId") String batchId);
/**
* 查询批量推送记录列表
*/
List<TencentDocBatchPushRecord> selectBatchPushRecordList(TencentDocBatchPushRecord record);
/**
* 查询最近的推送记录
*/
List<TencentDocBatchPushRecord> selectRecentRecords(@Param("fileId") String fileId,
@Param("limit") int limit);
/**
* 查询最后一次成功的推送记录
*/
TencentDocBatchPushRecord selectLastSuccessRecord(@Param("fileId") String fileId,
@Param("sheetId") String sheetId);
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.TencentDocOperationLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 腾讯文档操作日志Mapper接口
*
* @author system
*/
@Mapper
public interface TencentDocOperationLogMapper {
/**
* 插入操作日志
*
* @param log 操作日志
* @return 结果
*/
int insertLog(TencentDocOperationLog log);
/**
* 查询操作日志列表
*
* @param log 操作日志
* @return 操作日志集合
*/
List<TencentDocOperationLog> selectLogList(TencentDocOperationLog log);
/**
* 查询最近的操作日志
*
* @param fileId 文件ID
* @param limit 限制数量
* @return 操作日志集合
*/
List<TencentDocOperationLog> selectRecentLogs(@Param("fileId") String fileId, @Param("limit") int limit);
/**
* 根据批次ID查询操作日志
*
* @param batchId 批次ID
* @return 操作日志集合
*/
List<TencentDocOperationLog> selectLogsByBatchId(@Param("batchId") String batchId);
}

View File

@@ -0,0 +1,81 @@
package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.Comment;
import com.ruoyi.jarvis.domain.dto.CommentStatistics;
import com.ruoyi.jarvis.domain.dto.CommentApiStatistics;
import com.ruoyi.jarvis.domain.dto.CommentCallHistory;
import java.util.List;
import java.util.Map;
/**
* 评论管理 Service 接口
*/
public interface ICommentService {
/**
* 查询京东评论列表
*/
List<Comment> selectCommentList(Comment comment);
/**
* 根据ID查询京东评论
*/
Comment selectCommentById(Long id);
/**
* 更新评论使用状态
*/
int updateCommentIsUse(Comment comment);
/**
* 批量删除评论
*/
int deleteCommentByIds(Long[] ids);
/**
* 重置评论使用状态(批量)
*/
int resetCommentIsUseByProductId(String productId);
/**
* 获取评论统计信息包含Redis映射
*/
List<CommentStatistics> getCommentStatistics(String source);
/**
* 记录接口调用统计
*/
void recordApiCall(String apiType, String productType, boolean success);
/**
* 记录接口调用历史带IP
*/
void recordApiCallHistory(String productType, String ip);
/**
* 获取接口调用历史记录
*/
List<CommentCallHistory> getApiCallHistory(int pageNum, int pageSize);
/**
* 获取使用统计(今天/7天/30天/累计)
*/
Map<String, Long> getUsageStatistics();
/**
* 获取接口调用统计
*/
List<CommentApiStatistics> getApiStatistics(String apiType, String productType, String startDate, String endDate);
/**
* 获取Redis产品类型映射京东
*/
Map<String, String> getJdProductTypeMap();
/**
* 获取Redis产品类型映射淘宝
*/
Map<String, String> getTbProductTypeMap();
}

View File

@@ -0,0 +1,107 @@
package com.ruoyi.jarvis.service;
import java.util.List;
import com.ruoyi.jarvis.domain.ErpProduct;
/**
* 闲鱼商品Service接口
*
* @author ruoyi
* @date 2024-01-01
*/
public interface IErpProductService
{
/**
* 查询闲鱼商品
*
* @param id 闲鱼商品主键
* @return 闲鱼商品
*/
public ErpProduct selectErpProductById(Long id);
/**
* 查询闲鱼商品列表
*
* @param erpProduct 闲鱼商品
* @return 闲鱼商品集合
*/
public List<ErpProduct> selectErpProductList(ErpProduct erpProduct);
/**
* 新增闲鱼商品
*
* @param erpProduct 闲鱼商品
* @return 结果
*/
public int insertErpProduct(ErpProduct erpProduct);
/**
* 修改闲鱼商品
*
* @param erpProduct 闲鱼商品
* @return 结果
*/
public int updateErpProduct(ErpProduct erpProduct);
/**
* 批量删除闲鱼商品
*
* @param ids 需要删除的闲鱼商品主键集合
* @return 结果
*/
public int deleteErpProductByIds(Long[] ids);
/**
* 删除闲鱼商品信息
*
* @param id 闲鱼商品主键
* @return 结果
*/
public int deleteErpProductById(Long id);
/**
* 从闲鱼ERP拉取商品列表并保存
*
* @param appid ERP应用ID
* @param pageNo 页码
* @param pageSize 每页大小
* @param productStatus 商品状态
* @return 拉取结果
*/
public int pullAndSaveProductList(String appid, Integer pageNo, Integer pageSize, Integer productStatus);
/**
* 全量同步商品列表(自动遍历所有页码,同步更新和删除)
*
* @param appid ERP应用ID
* @param productStatus 商品状态null表示全部状态
* @return 同步结果
*/
public SyncResult syncAllProducts(String appid, Integer productStatus);
/**
* 同步结果
*/
public static class SyncResult {
private int totalPulled; // 拉取总数
private int added; // 新增数量
private int updated; // 更新数量
private int deleted; // 删除数量
private int failed; // 失败数量
private String message; // 结果消息
public int getTotalPulled() { return totalPulled; }
public void setTotalPulled(int totalPulled) { this.totalPulled = totalPulled; }
public int getAdded() { return added; }
public void setAdded(int added) { this.added = added; }
public int getUpdated() { return updated; }
public void setUpdated(int updated) { this.updated = updated; }
public int getDeleted() { return deleted; }
public void setDeleted(int deleted) { this.deleted = deleted; }
public int getFailed() { return failed; }
public void setFailed(int failed) { this.failed = failed; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}
}

View File

@@ -11,6 +11,23 @@ public interface IInstructionService {
*/
java.util.List<String> execute(String command);
/**
* 执行文本指令,返回结果文本(支持强制生成参数)
* @param command 指令内容
* @param forceGenerate 是否强制生成表单(跳过地址重复检查)
* @return 执行结果文本列表(可能为单条或多条)
*/
java.util.List<String> execute(String command, boolean forceGenerate);
/**
* 执行文本指令,返回结果文本(支持强制生成参数和控制台入口标识)
* @param command 指令内容
* @param forceGenerate 是否强制生成表单(跳过地址重复检查)
* @param isFromConsole 是否来自控制台入口(控制台入口跳过订单查询校验)
* @return 执行结果文本列表(可能为单条或多条)
*/
java.util.List<String> execute(String command, boolean forceGenerate, boolean isFromConsole);
/**
* 获取历史消息记录
* @param type 消息类型request(请求) 或 response(响应)

View File

@@ -19,5 +19,60 @@ public interface ILogisticsService {
* @return 如果已处理返回true否则返回false
*/
boolean isOrderProcessed(Long orderId);
/**
* 检查物流服务健康状态
* @return 健康状态信息,包含是否健康、状态描述等
*/
HealthCheckResult checkHealth();
/**
* 健康检测结果
*/
class HealthCheckResult {
private boolean healthy;
private String status;
private String message;
private String serviceUrl;
public HealthCheckResult(boolean healthy, String status, String message, String serviceUrl) {
this.healthy = healthy;
this.status = status;
this.message = message;
this.serviceUrl = serviceUrl;
}
public boolean isHealthy() {
return healthy;
}
public void setHealthy(boolean healthy) {
this.healthy = healthy;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getServiceUrl() {
return serviceUrl;
}
public void setServiceUrl(String serviceUrl) {
this.serviceUrl = serviceUrl;
}
}
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.jarvis.service;
import java.util.List;
/**
* 手机号替换配置Service接口
*
* @author ruoyi
*/
public interface IPhoneReplaceConfigService
{
/**
* 获取指定类型的手机号列表
*
* @param type 类型(腾锋或昭迎)
* @return 手机号列表
*/
public List<String> getPhoneList(String type);
/**
* 设置指定类型的手机号列表
*
* @param type 类型(腾锋或昭迎)
* @param phoneList 手机号列表
* @return 结果
*/
public int setPhoneList(String type, List<String> phoneList);
/**
* 添加手机号到指定类型
*
* @param type 类型(腾锋或昭迎)
* @param phone 手机号
* @return 结果
*/
public int addPhone(String type, String phone);
/**
* 从指定类型删除手机号
*
* @param type 类型(腾锋或昭迎)
* @param phone 手机号
* @return 结果
*/
public int removePhone(String type, String phone);
/**
* 获取下一个循环使用的手机号
*
* @param type 类型(腾锋或昭迎)
* @param originalPhone 原始手机号
* @return 替换后的手机号
*/
public String getNextPhone(String type, String originalPhone);
}

View File

@@ -0,0 +1,67 @@
package com.ruoyi.jarvis.service;
import java.util.Map;
/**
* 小红书/抖音内容生成Service接口
*
* @author ruoyi
* @date 2025-01-XX
*/
public interface ISocialMediaService
{
/**
* 提取商品标题关键词
*
* @param productName 商品名称
* @return 关键词结果
*/
Map<String, Object> extractKeywords(String productName);
/**
* 生成文案
*
* @param productName 商品名称
* @param originalPrice 原价
* @param finalPrice 到手价
* @param keywords 关键词
* @param style 文案风格
* @return 生成的文案
*/
Map<String, Object> generateContent(String productName, Object originalPrice,
Object finalPrice, String keywords, String style);
/**
* 一键生成完整内容(关键词 + 文案 + 图片)
*
* @param productImageUrl 商品主图URL
* @param productName 商品名称
* @param originalPrice 原价
* @param finalPrice 到手价
* @param style 文案风格
* @return 完整内容
*/
Map<String, Object> generateCompleteContent(String productImageUrl, String productName,
Object originalPrice, Object finalPrice, String style);
/**
* 获取提示词模板列表
*/
com.ruoyi.common.core.domain.AjaxResult listPromptTemplates();
/**
* 获取单个提示词模板
*/
com.ruoyi.common.core.domain.AjaxResult getPromptTemplate(String key);
/**
* 保存提示词模板
*/
com.ruoyi.common.core.domain.AjaxResult savePromptTemplate(Map<String, Object> request);
/**
* 删除提示词模板(恢复默认)
*/
com.ruoyi.common.core.domain.AjaxResult deletePromptTemplate(String key);
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.TaobaoComment;
import java.util.List;
/**
* 淘宝评论管理 Service 接口
*/
public interface ITaobaoCommentService {
/**
* 查询淘宝评论列表
*/
List<TaobaoComment> selectTaobaoCommentList(TaobaoComment taobaoComment);
/**
* 根据ID查询淘宝评论
*/
TaobaoComment selectTaobaoCommentById(Integer id);
/**
* 更新评论使用状态
*/
int updateTaobaoCommentIsUse(TaobaoComment taobaoComment);
/**
* 批量删除评论
*/
int deleteTaobaoCommentByIds(Integer[] ids);
/**
* 重置评论使用状态(批量)
*/
int resetTaobaoCommentIsUseByProductId(String productId);
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.TencentDocBatchPushRecord;
import java.util.List;
import java.util.Map;
/**
* 腾讯文档批量推送记录服务
*/
public interface ITencentDocBatchPushService {
/**
* 创建批量推送记录
*/
String createBatchPushRecord(String fileId, String sheetId, String pushType,
String triggerSource, Integer startRow, Integer endRow);
/**
* 更新批量推送记录
*/
void updateBatchPushRecord(String batchId, String status, Integer successCount,
Integer skipCount, Integer errorCount, String resultMessage, String errorMessage);
/**
* 根据批次ID查询
*/
TencentDocBatchPushRecord getBatchPushRecord(String batchId);
/**
* 查询批量推送记录列表(带操作日志)
*/
List<TencentDocBatchPushRecord> getBatchPushRecordListWithLogs(String fileId, String sheetId, Integer limit);
/**
* 查询最后一次成功的推送记录
*/
TencentDocBatchPushRecord getLastSuccessRecord(String fileId, String sheetId);
/**
* 获取推送状态和倒计时信息
*/
Map<String, Object> getPushStatusAndCountdown();
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.jarvis.service;
/**
* 腾讯文档延迟推送服务接口
*
* @author system
*/
public interface ITencentDocDelayedPushService {
/**
* 触发延迟推送
* 录单时调用此方法会重置10分钟倒计时
*/
void triggerDelayedPush();
/**
* 立即执行推送(用于手动触发)
*/
void executePushNow();
/**
* 获取下次推送的剩余时间(秒)
*
* @return 剩余秒数,如果没有待推送返回-1
*/
long getRemainingSeconds();
/**
* 取消待推送任务
*/
void cancelPendingPush();
}

View File

@@ -55,7 +55,7 @@ public interface ITencentDocService {
* @param order 订单信息
* @return 上传结果
*/
JSONObject appendLogisticsToSheet(String accessToken, String fileId, String sheetId, JDOrder order);
JSONObject appendLogisticsToSheet(String accessToken, String fileId, String sheetId, Integer startRow, JDOrder order);
/**
* 读取表格数据

View File

@@ -0,0 +1,117 @@
package com.ruoyi.jarvis.service;
import com.alibaba.fastjson2.JSONObject;
import java.util.List;
import java.util.Map;
/**
* WPS365 API服务接口
*
* @author system
*/
public interface IWPS365ApiService {
/**
* 获取用户信息
*
* @param accessToken 访问令牌
* @return 用户信息
*/
JSONObject getUserInfo(String accessToken);
/**
* 获取文件列表
*
* @param accessToken 访问令牌
* @param params 查询参数page, page_size等
* @return 文件列表
*/
JSONObject getFileList(String accessToken, Map<String, Object> params);
/**
* 获取文件信息
*
* @param accessToken 访问令牌
* @param fileToken 文件token
* @return 文件信息
*/
JSONObject getFileInfo(String accessToken, String fileToken);
/**
* 更新单元格数据KSheet - 在线表格)
*
* @param accessToken 访问令牌
* @param fileToken 文件token
* @param sheetIdx 工作表索引从0开始
* @param range 单元格范围A1:B2
* @param values 单元格值(二维数组,第一维是行,第二维是列)
* @return 更新结果
*/
JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List<List<Object>> values);
/**
* 读取单元格数据
*
* @param accessToken 访问令牌
* @param fileToken 文件token
* @param sheetIdx 工作表索引
* @param range 单元格范围
* @return 单元格数据
*/
JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range);
/**
* 获取工作表列表
*
* @param accessToken 访问令牌
* @param fileToken 文件token
* @return 工作表列表
*/
JSONObject getSheetList(String accessToken, String fileToken);
/**
* 创建数据表
*
* @param accessToken 访问令牌
* @param fileToken 文件token
* @param sheetName 工作表名称
* @return 创建结果
*/
JSONObject createSheet(String accessToken, String fileToken, String sheetName);
/**
* 批量更新单元格数据
*
* @param accessToken 访问令牌
* @param fileToken 文件token
* @param sheetIdx 工作表索引
* @param updates 更新列表每个元素包含range和values
* @return 更新结果
*/
JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates);
/**
* 读取AirSheet工作表数据
*
* @param accessToken 访问令牌
* @param fileId 文件ID
* @param worksheetId 工作表ID整数通常为0表示第一个工作表
* @param range 单元格范围A1:B10可选
* @return 单元格数据
*/
JSONObject readAirSheetCells(String accessToken, String fileId, String worksheetId, String range);
/**
* 更新AirSheet工作表数据
*
* @param accessToken 访问令牌
* @param fileId 文件ID
* @param worksheetId 工作表ID整数通常为0表示第一个工作表
* @param range 单元格范围A1:B2
* @param values 单元格值(二维数组,第一维是行,第二维是列)
* @return 更新结果
*/
JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List<List<Object>> values);
}

View File

@@ -0,0 +1,66 @@
package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
/**
* WPS365 OAuth授权服务接口
*
* @author system
*/
public interface IWPS365OAuthService {
/**
* 获取授权URL
*
* @param state 状态参数可选用于防止CSRF攻击
* @return 授权URL
*/
String getAuthUrl(String state);
/**
* 通过授权码获取访问令牌
*
* @param code 授权码
* @return Token信息
*/
WPS365TokenInfo getAccessTokenByCode(String code);
/**
* 刷新访问令牌
*
* @param refreshToken 刷新令牌
* @return 新的Token信息
*/
WPS365TokenInfo refreshAccessToken(String refreshToken);
/**
* 获取当前用户的访问令牌
*
* @return Token信息如果未授权则返回null
*/
WPS365TokenInfo getCurrentToken();
/**
* 保存用户令牌信息
*
* @param userId 用户ID
* @param tokenInfo Token信息
*/
void saveToken(String userId, WPS365TokenInfo tokenInfo);
/**
* 清除用户令牌信息
*
* @param userId 用户ID
*/
void clearToken(String userId);
/**
* 检查令牌是否有效
*
* @param tokenInfo Token信息
* @return true表示有效false表示需要刷新或重新授权
*/
boolean isTokenValid(WPS365TokenInfo tokenInfo);
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.jarvis.service;
/**
* 微信推送服务接口
*/
public interface IWxSendService {
/**
* 检查微信推送服务健康状态
* @return 健康状态信息,包含是否健康、状态描述等
*/
HealthCheckResult checkHealth();
/**
* 健康检测结果
*/
class HealthCheckResult {
private boolean healthy;
private String status;
private String message;
private String serviceUrl;
public HealthCheckResult(boolean healthy, String status, String message, String serviceUrl) {
this.healthy = healthy;
this.status = status;
this.message = message;
this.serviceUrl = serviceUrl;
}
public boolean isHealthy() {
return healthy;
}
public void setHealthy(boolean healthy) {
this.healthy = healthy;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getServiceUrl() {
return serviceUrl;
}
public void setServiceUrl(String serviceUrl) {
this.serviceUrl = serviceUrl;
}
}
}

View File

@@ -24,6 +24,7 @@ import com.ruoyi.common.utils.http.HttpUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -53,10 +54,21 @@ public class BatchPublishServiceImpl implements IBatchPublishService
@Autowired
private IJDOrderService jdOrderService;
// 京东接口配置
private final static String requestUrl = "http://192.168.8.88:6666/jd/";
@Value("${jarvis.server.jarvis-java.base-url:http://127.0.0.1:6666}")
private String jarvisJavaBaseUrl;
@Value("${jarvis.server.jarvis-java.jd-api-path:/jd}")
private String jdApiPath;
private final static String skey = "2192057370ef8140c201079969c956a3";
/**
* 获取JD接口请求URL
*/
private String getRequestUrl() {
return jarvisJavaBaseUrl + jdApiPath + "/";
}
@Autowired
private IOuterIdGeneratorService outerIdGeneratorService;
@@ -98,9 +110,18 @@ private String cleanForbiddenPhrases(String text) {
return text;
}
String cleaned = text;
// 新增:清理【】符号(包括单独出现或成对出现的情况)
cleaned = cleaned.replaceAll("", ""); // 移除左括号【
cleaned = cleaned.replaceAll("", ""); // 移除右括号】
// 新增:清理"咨询客服立减""咨询客服""客服"及变体(含空格)
// 优先处理长组合,避免被拆分后遗漏
cleaned = cleaned.replaceAll("咨询\\s*客服\\s*立减", ""); // 匹配"咨询客服立减""咨询 客服 立减"等
cleaned = cleaned.replaceAll("咨询\\s*客服", ""); // 匹配"咨询客服""咨询 客服"等
cleaned = cleaned.replaceAll("\\s*服", ""); // 匹配"客服""客 服"等
// 一、政策补贴及特殊渠道类(长组合优先)
cleaned = cleaned.replaceAll("咨询客服领\\s*国补", "");
cleaned = cleaned.replaceAll("政府\\s*补贴", ""); // 匹配"政府 补贴"等带空格的情况
cleaned = cleaned.replaceAll("政府\\s*补贴", "");
cleaned = cleaned.replaceAll("购车\\s*补贴", "");
cleaned = cleaned.replaceAll("家电\\s*下乡", "");
cleaned = cleaned.replaceAll("内部\\s*渠道", "");
@@ -128,15 +149,15 @@ private String cleanForbiddenPhrases(String text) {
cleaned = cleaned.replaceAll("原单", "");
cleaned = cleaned.replaceAll("尾单", "");
cleaned = cleaned.replaceAll("工厂\\s*货", "");
cleaned = cleaned.replaceAll("专柜\\s*验货", ""); // 无授权时违规
cleaned = cleaned.replaceAll("专柜\\s*验货", "");
// 四、线下导流及规避监管类(多变体覆盖)
cleaned = cleaned.replaceAll("\\s*信", ""); // 匹配"微信""微 信"
cleaned = cleaned.replaceAll("\\s*信", ""); // 谐音变体
cleaned = cleaned.replaceAll("\\s*信", "");
cleaned = cleaned.replaceAll("\\s*信", "");
cleaned = cleaned.replaceAll("V我", "");
cleaned = cleaned.replaceAll("\\s*卫星", "");
cleaned = cleaned.replaceAll("QQ", "");
cleaned = cleaned.replaceAll("扣扣", ""); // 谐音
cleaned = cleaned.replaceAll("扣扣", "");
cleaned = cleaned.replaceAll("手机\\s*号", "");
cleaned = cleaned.replaceAll("淘宝\\s*链接", "");
cleaned = cleaned.replaceAll("拼多\\s*多", "");
@@ -146,7 +167,7 @@ private String cleanForbiddenPhrases(String text) {
cleaned = cleaned.replaceAll("", "");
cleaned = cleaned.replaceAll("垃圾", "");
cleaned = cleaned.replaceAll("笨蛋", "");
cleaned = cleaned.replaceAll("SB", ""); // 单独出现时清理(避免误判可后续加上下文校验)
cleaned = cleaned.replaceAll("SB", "");
cleaned = cleaned.replaceAll("原味", "");
cleaned = cleaned.replaceAll("情趣", "");
@@ -174,7 +195,7 @@ private String cleanForbiddenPhrases(String text) {
*/
private String generatePromotionContent(Map<String, String> requestBody) {
try {
String url = requestUrl + "generatePromotionContent";
String url = getRequestUrl() + "generatePromotionContent";
JSONObject param = new JSONObject();
param.put("skey", skey);
param.put("promotionContent", requestBody.get("promotionContent"));
@@ -922,7 +943,8 @@ private String cleanForbiddenPhrases(String text) {
// 调用ERP上架接口
ProductPublishRequest publishRequest = new ProductPublishRequest(account);
publishRequest.setProductId(item.getProductId());
publishRequest.setUserName(commonParams.getUserName());
// 【修复】使用商品对应的子账号,而不是通用参数中的第一个子账号
publishRequest.setUserName(item.getSubAccount() != null ? item.getSubAccount() : commonParams.getUserName());
publishRequest.setSpecifyPublishTime(null); // 立即上架
String resp = publishRequest.getResponseBody();
@@ -1005,32 +1027,57 @@ private String cleanForbiddenPhrases(String text) {
throw new RuntimeException("任务不存在: " + taskId);
}
// 仅重试 待发布(0)/发布失败(3) 的明细
List<BatchPublishItem> allItems = itemMapper.selectBatchPublishItemByTaskId(taskId);
List<BatchPublishItem> itemsToRetry = new ArrayList<>();
// 分类处理:需要重新发布的 和 需要重新上架的
List<BatchPublishItem> itemsToRepublish = new ArrayList<>(); // 待发布(0)/发布失败(3)
List<BatchPublishItem> itemsToRelist = new ArrayList<>(); // 发布成功(2)/上架失败(6)
for (BatchPublishItem it : allItems) {
if (it.getStatus() != null && (it.getStatus() == 0 || it.getStatus() == 3)) {
itemsToRetry.add(it);
if (it.getStatus() != null) {
if (it.getStatus() == 0 || it.getStatus() == 3) {
// 待发布或发布失败,需要重新发布
itemsToRepublish.add(it);
} else if (it.getStatus() == 2 || it.getStatus() == 6) {
// 发布成功但未上架,或上架失败,需要重新上架
itemsToRelist.add(it);
}
}
}
if (itemsToRetry.isEmpty()) {
log.info("任务{} 无需重试,未发现待发布/失败的明细", taskId);
if (itemsToRepublish.isEmpty() && itemsToRelist.isEmpty()) {
log.info("任务{} 无需重试,未发现待处理的明细", taskId);
return;
}
// 构造最小请求对象,仅提供通用参数用于发布
BatchPublishRequest req = new BatchPublishRequest();
try {
BatchPublishRequest.CommonParams commonParams = JSON.parseObject(
task.getCommonParams(), BatchPublishRequest.CommonParams.class);
req.setCommonParams(commonParams);
} catch (Exception e) {
log.warn("解析任务通用参数失败,将使用默认参数: {}", task.getCommonParams(), e);
// 处理需要重新发布的商品
if (!itemsToRepublish.isEmpty()) {
BatchPublishRequest req = new BatchPublishRequest();
try {
BatchPublishRequest.CommonParams commonParams = JSON.parseObject(
task.getCommonParams(), BatchPublishRequest.CommonParams.class);
req.setCommonParams(commonParams);
} catch (Exception e) {
log.warn("解析任务通用参数失败,将使用默认参数: {}", task.getCommonParams(), e);
}
log.info("开始重新发布任务{} 的 {} 条明细", taskId, itemsToRepublish.size());
self.asyncBatchPublish(taskId, itemsToRepublish, req);
}
log.info("开始重试任务{} 的 {} 条明细", taskId, itemsToRetry.size());
self.asyncBatchPublish(taskId, itemsToRetry, req);
// 处理需要重新上架的商品(直接上架,不需要重新发布)
if (!itemsToRelist.isEmpty()) {
log.info("开始重新上架任务{} 的 {} 条明细", taskId, itemsToRelist.size());
for (BatchPublishItem item : itemsToRelist) {
// 重置状态为发布成功,准备上架
item.setStatus(2);
item.setErrorMessage(null);
itemMapper.updateBatchPublishItem(item);
appendItemLogSafe(item.getId(), "【重试】准备重新上架");
// 立即调度上架延迟1秒避免过快
schedulePublish(item.getId(), 1);
}
}
}
}

View File

@@ -0,0 +1,443 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.Comment;
import com.ruoyi.jarvis.domain.dto.CommentApiStatistics;
import com.ruoyi.jarvis.domain.dto.CommentStatistics;
import com.ruoyi.jarvis.domain.dto.CommentCallHistory;
import com.ruoyi.jarvis.mapper.CommentMapper;
import com.ruoyi.jarvis.service.ICommentService;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 评论管理 Service 实现
*/
@Service
public class CommentServiceImpl implements ICommentService {
private static final Logger log = LoggerFactory.getLogger(CommentServiceImpl.class);
private static final String PRODUCT_TYPE_MAP_PREFIX = "product_type_map";
private static final String PRODUCT_TYPE_MAP_PREFIX_TB = "product_type_map_tb";
private static final String API_CALL_STAT_PREFIX = "comment:api:stat:";
private static final String API_CALL_TODAY_PREFIX = "comment:api:today:";
private static final String API_CALL_HISTORY_KEY = "comment:api:history:list";
private static final int MAX_HISTORY_SIZE = 1000; // 最多保留1000条历史记录
@Autowired
private CommentMapper commentMapper;
@Autowired(required = false)
private com.ruoyi.jarvis.mapper.TaobaoCommentMapper taobaoCommentMapper;
@Autowired(required = false)
private StringRedisTemplate stringRedisTemplate;
@Override
public List<Comment> selectCommentList(Comment comment) {
List<Comment> list = commentMapper.selectCommentList(comment);
// 填充Redis映射的产品类型信息
if (stringRedisTemplate != null) {
Map<String, String> jdMap = getJdProductTypeMap();
for (Comment c : list) {
// 查找对应的产品类型
String productId = c.getProductId();
if (jdMap != null) {
for (Map.Entry<String, String> entry : jdMap.entrySet()) {
if (entry.getValue().equals(productId)) {
c.setProductType(entry.getKey());
c.setMappedProductId(productId);
break;
}
}
}
}
}
return list;
}
@Override
public Comment selectCommentById(Long id) {
Comment comment = commentMapper.selectCommentById(id);
if (comment != null && stringRedisTemplate != null) {
Map<String, String> jdMap = getJdProductTypeMap();
if (jdMap != null) {
String productId = comment.getProductId();
for (Map.Entry<String, String> entry : jdMap.entrySet()) {
if (entry.getValue().equals(productId)) {
comment.setProductType(entry.getKey());
comment.setMappedProductId(productId);
break;
}
}
}
}
return comment;
}
@Override
public int updateCommentIsUse(Comment comment) {
return commentMapper.updateCommentIsUse(comment);
}
@Override
public int deleteCommentByIds(Long[] ids) {
return commentMapper.deleteCommentByIds(ids);
}
@Override
public int resetCommentIsUseByProductId(String productId) {
return commentMapper.resetCommentIsUseByProductId(productId);
}
@Override
public List<CommentStatistics> getCommentStatistics(String source) {
List<CommentStatistics> statisticsList = new ArrayList<>();
Map<String, String> productTypeMap = null;
if ("jd".equals(source) || source == null) {
productTypeMap = getJdProductTypeMap();
} else if ("tb".equals(source)) {
productTypeMap = getTbProductTypeMap();
}
if (productTypeMap == null || productTypeMap.isEmpty()) {
return statisticsList;
}
for (Map.Entry<String, String> entry : productTypeMap.entrySet()) {
String productType = entry.getKey();
String productId = entry.getValue();
CommentStatistics stats = new CommentStatistics();
stats.setSource("jd".equals(source) ? "京东评论" : "淘宝评论");
stats.setProductType(productType);
stats.setProductId(productId);
// 查询评论统计
Map<String, Object> statMap = null;
if ("jd".equals(source) || source == null) {
statMap = commentMapper.selectCommentStatisticsByProductId(productId);
} else if ("tb".equals(source) && taobaoCommentMapper != null) {
statMap = taobaoCommentMapper.selectTaobaoCommentStatisticsByProductId(productId);
}
if (statMap != null) {
stats.setTotalCount(((Number) statMap.get("totalCount")).longValue());
stats.setAvailableCount(((Number) statMap.get("availableCount")).longValue());
stats.setUsedCount(((Number) statMap.get("usedCount")).longValue());
// 设置最后一条评论的创建时间
Object lastUpdateTime = statMap.get("lastCommentUpdateTime");
if (lastUpdateTime != null) {
if (lastUpdateTime instanceof Date) {
stats.setLastCommentUpdateTime((Date) lastUpdateTime);
} else if (lastUpdateTime instanceof java.sql.Timestamp) {
stats.setLastCommentUpdateTime(new Date(((java.sql.Timestamp) lastUpdateTime).getTime()));
}
}
}
// 获取接口调用统计
if (stringRedisTemplate != null) {
String todayKey = API_CALL_TODAY_PREFIX + source + ":" + productType + ":" + getTodayDate();
String todayCount = stringRedisTemplate.opsForValue().get(todayKey);
stats.setTodayCallCount(todayCount != null ? Long.parseLong(todayCount) : 0L);
// 获取总调用次数从Redis中统计
String statKey = API_CALL_STAT_PREFIX + source + ":" + productType;
String totalCount = stringRedisTemplate.opsForValue().get(statKey);
stats.setApiCallCount(totalCount != null ? Long.parseLong(totalCount) : 0L);
}
statisticsList.add(stats);
}
return statisticsList;
}
@Override
public void recordApiCall(String apiType, String productType, boolean success) {
if (stringRedisTemplate == null) {
return;
}
try {
String today = getTodayDate();
String todayKey = API_CALL_TODAY_PREFIX + apiType + ":" + productType + ":" + today;
stringRedisTemplate.opsForValue().increment(todayKey);
stringRedisTemplate.expire(todayKey, 7, TimeUnit.DAYS); // 保留7天
String statKey = API_CALL_STAT_PREFIX + apiType + ":" + productType;
stringRedisTemplate.opsForValue().increment(statKey);
// 记录成功/失败统计
String successKey = API_CALL_STAT_PREFIX + apiType + ":" + productType + ":success";
String failKey = API_CALL_STAT_PREFIX + apiType + ":" + productType + ":fail";
if (success) {
stringRedisTemplate.opsForValue().increment(successKey);
} else {
stringRedisTemplate.opsForValue().increment(failKey);
}
} catch (Exception e) {
log.error("记录接口调用统计失败", e);
}
}
@Override
public List<CommentApiStatistics> getApiStatistics(String apiType, String productType, String startDate, String endDate) {
List<CommentApiStatistics> statisticsList = new ArrayList<>();
if (stringRedisTemplate == null) {
return statisticsList;
}
try {
// 如果指定了产品类型,只查询该类型的统计
if (productType != null && !productType.isEmpty()) {
CommentApiStatistics stats = new CommentApiStatistics();
stats.setApiType(apiType);
stats.setProductType(productType);
String statKey = API_CALL_STAT_PREFIX + apiType + ":" + productType;
String totalCount = stringRedisTemplate.opsForValue().get(statKey);
stats.setCallCount(totalCount != null ? Long.parseLong(totalCount) : 0L);
String successKey = statKey + ":success";
String successCount = stringRedisTemplate.opsForValue().get(successKey);
stats.setSuccessCount(successCount != null ? Long.parseLong(successCount) : 0L);
String failKey = statKey + ":fail";
String failCount = stringRedisTemplate.opsForValue().get(failKey);
stats.setFailCount(failCount != null ? Long.parseLong(failCount) : 0L);
statisticsList.add(stats);
} else {
// 查询所有产品类型的统计
Map<String, String> productTypeMap = "jd".equals(apiType) ? getJdProductTypeMap() : getTbProductTypeMap();
if (productTypeMap != null) {
for (String pt : productTypeMap.keySet()) {
CommentApiStatistics stats = new CommentApiStatistics();
stats.setApiType(apiType);
stats.setProductType(pt);
String statKey = API_CALL_STAT_PREFIX + apiType + ":" + pt;
String totalCount = stringRedisTemplate.opsForValue().get(statKey);
stats.setCallCount(totalCount != null ? Long.parseLong(totalCount) : 0L);
String successKey = statKey + ":success";
String successCount = stringRedisTemplate.opsForValue().get(successKey);
stats.setSuccessCount(successCount != null ? Long.parseLong(successCount) : 0L);
String failKey = statKey + ":fail";
String failCount = stringRedisTemplate.opsForValue().get(failKey);
stats.setFailCount(failCount != null ? Long.parseLong(failCount) : 0L);
statisticsList.add(stats);
}
}
}
} catch (Exception e) {
log.error("获取接口调用统计失败", e);
}
return statisticsList;
}
@Override
public Map<String, String> getJdProductTypeMap() {
if (stringRedisTemplate == null) {
return new HashMap<>();
}
try {
Map<Object, Object> rawMap = stringRedisTemplate.opsForHash().entries(PRODUCT_TYPE_MAP_PREFIX);
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<Object, Object> entry : rawMap.entrySet()) {
result.put(entry.getKey().toString(), entry.getValue().toString());
}
return result;
} catch (Exception e) {
log.error("获取京东产品类型映射失败", e);
return new HashMap<>();
}
}
@Override
public Map<String, String> getTbProductTypeMap() {
if (stringRedisTemplate == null) {
return new HashMap<>();
}
try {
Map<Object, Object> rawMap = stringRedisTemplate.opsForHash().entries(PRODUCT_TYPE_MAP_PREFIX_TB);
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<Object, Object> entry : rawMap.entrySet()) {
result.put(entry.getKey().toString(), entry.getValue().toString());
}
return result;
} catch (Exception e) {
log.error("获取淘宝产品类型映射失败", e);
return new HashMap<>();
}
}
private String getTodayDate() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.format(new Date());
}
@Override
public void recordApiCallHistory(String productType, String ip) {
if (stringRedisTemplate == null) {
return;
}
try {
CommentCallHistory history = new CommentCallHistory();
history.setProductType(productType);
history.setIp(ip);
history.setCreateTime(new Date());
String historyJson = JSON.toJSONString(history);
// 使用List存储历史记录从左侧推入
stringRedisTemplate.opsForList().leftPush(API_CALL_HISTORY_KEY, historyJson);
// 限制列表大小只保留最近MAX_HISTORY_SIZE条
stringRedisTemplate.opsForList().trim(API_CALL_HISTORY_KEY, 0, MAX_HISTORY_SIZE - 1);
} catch (Exception e) {
log.error("记录接口调用历史失败", e);
}
}
@Override
public List<CommentCallHistory> getApiCallHistory(int pageNum, int pageSize) {
List<CommentCallHistory> historyList = new ArrayList<>();
if (stringRedisTemplate == null) {
return historyList;
}
try {
long start = (pageNum - 1) * pageSize;
long end = start + pageSize - 1;
List<String> jsonList = stringRedisTemplate.opsForList().range(API_CALL_HISTORY_KEY, start, end);
if (jsonList != null) {
for (String json : jsonList) {
try {
CommentCallHistory history = JSON.parseObject(json, CommentCallHistory.class);
historyList.add(history);
} catch (Exception e) {
log.warn("解析历史记录失败: " + json, e);
}
}
}
} catch (Exception e) {
log.error("获取接口调用历史失败", e);
}
return historyList;
}
@Override
public Map<String, Long> getUsageStatistics() {
Map<String, Long> statistics = new HashMap<>();
statistics.put("today", 0L);
statistics.put("last7Days", 0L);
statistics.put("last30Days", 0L);
statistics.put("total", 0L);
if (stringRedisTemplate == null) {
return statistics;
}
try {
String today = getTodayDate();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Date todayDate = sdf.parse(today);
Calendar calendar = Calendar.getInstance();
// 统计今天
long todayCount = 0;
String todayPattern = API_CALL_TODAY_PREFIX + "jd:*:" + today;
Set<String> todayKeys = stringRedisTemplate.keys(todayPattern);
if (todayKeys != null) {
for (String key : todayKeys) {
String count = stringRedisTemplate.opsForValue().get(key);
if (count != null) {
todayCount += Long.parseLong(count);
}
}
}
statistics.put("today", todayCount);
// 统计近7天
long last7DaysCount = 0;
calendar.setTime(todayDate);
for (int i = 0; i < 7; i++) {
String dateStr = sdf.format(calendar.getTime());
String pattern = API_CALL_TODAY_PREFIX + "jd:*:" + dateStr;
Set<String> keys = stringRedisTemplate.keys(pattern);
if (keys != null) {
for (String key : keys) {
String count = stringRedisTemplate.opsForValue().get(key);
if (count != null) {
last7DaysCount += Long.parseLong(count);
}
}
}
calendar.add(Calendar.DAY_OF_MONTH, -1);
}
statistics.put("last7Days", last7DaysCount);
// 统计近30天
long last30DaysCount = 0;
calendar.setTime(todayDate);
for (int i = 0; i < 30; i++) {
String dateStr = sdf.format(calendar.getTime());
String pattern = API_CALL_TODAY_PREFIX + "jd:*:" + dateStr;
Set<String> keys = stringRedisTemplate.keys(pattern);
if (keys != null) {
for (String key : keys) {
String count = stringRedisTemplate.opsForValue().get(key);
if (count != null) {
last30DaysCount += Long.parseLong(count);
}
}
}
calendar.add(Calendar.DAY_OF_MONTH, -1);
}
statistics.put("last30Days", last30DaysCount);
// 统计累计从统计key获取
long totalCount = 0;
String totalPattern = API_CALL_STAT_PREFIX + "jd:*";
Set<String> totalKeys = stringRedisTemplate.keys(totalPattern);
if (totalKeys != null) {
for (String key : totalKeys) {
// 排除success和fail后缀的key
if (!key.endsWith(":success") && !key.endsWith(":fail")) {
String count = stringRedisTemplate.opsForValue().get(key);
if (count != null) {
totalCount += Long.parseLong(count);
}
}
}
}
statistics.put("total", totalCount);
} catch (Exception e) {
log.error("获取使用统计失败", e);
}
return statistics;
}
}

View File

@@ -0,0 +1,480 @@
package com.ruoyi.jarvis.service.impl;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.jarvis.mapper.ErpProductMapper;
import com.ruoyi.jarvis.domain.ErpProduct;
import com.ruoyi.jarvis.service.IErpProductService;
import com.ruoyi.erp.request.ERPAccount;
import com.ruoyi.erp.request.ProductListQueryRequest;
import com.ruoyi.erp.request.ProductDeleteRequest;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Set;
import java.util.HashSet;
import java.util.stream.Collectors;
/**
* 闲鱼商品Service业务层处理
*
* @author ruoyi
* @date 2024-01-01
*/
@Service
public class ErpProductServiceImpl implements IErpProductService
{
private static final Logger log = LoggerFactory.getLogger(ErpProductServiceImpl.class);
@Autowired
private ErpProductMapper erpProductMapper;
/**
* 查询闲鱼商品
*
* @param id 闲鱼商品主键
* @return 闲鱼商品
*/
@Override
public ErpProduct selectErpProductById(Long id)
{
return erpProductMapper.selectErpProductById(id);
}
/**
* 查询闲鱼商品列表
*
* @param erpProduct 闲鱼商品
* @return 闲鱼商品
*/
@Override
public List<ErpProduct> selectErpProductList(ErpProduct erpProduct)
{
return erpProductMapper.selectErpProductList(erpProduct);
}
/**
* 新增闲鱼商品
*
* @param erpProduct 闲鱼商品
* @return 结果
*/
@Override
public int insertErpProduct(ErpProduct erpProduct)
{
// 检查是否已存在
ErpProduct existing = erpProductMapper.selectErpProductByProductIdAndAppid(
erpProduct.getProductId(), erpProduct.getAppid());
if (existing != null) {
// 更新已存在的商品
erpProduct.setId(existing.getId());
return erpProductMapper.updateErpProduct(erpProduct);
}
return erpProductMapper.insertErpProduct(erpProduct);
}
/**
* 修改闲鱼商品
*
* @param erpProduct 闲鱼商品
* @return 结果
*/
@Override
public int updateErpProduct(ErpProduct erpProduct)
{
return erpProductMapper.updateErpProduct(erpProduct);
}
/**
* 批量删除闲鱼商品
*
* @param ids 需要删除的闲鱼商品主键
* @return 结果
*/
@Override
public int deleteErpProductByIds(Long[] ids)
{
return erpProductMapper.deleteErpProductByIds(ids);
}
/**
* 删除闲鱼商品信息
*
* @param id 闲鱼商品主键
* @return 结果
*/
@Override
public int deleteErpProductById(Long id)
{
return erpProductMapper.deleteErpProductById(id);
}
/**
* 从闲鱼ERP拉取商品列表并保存
*
* @param appid ERP应用ID
* @param pageNo 页码
* @param pageSize 每页大小
* @param productStatus 商品状态
* @return 拉取并保存的商品数量
*/
@Override
public int pullAndSaveProductList(String appid, Integer pageNo, Integer pageSize, Integer productStatus)
{
try {
// 解析ERP账号
ERPAccount account = resolveAccount(appid);
// 创建查询请求
ProductListQueryRequest request = new ProductListQueryRequest(account);
if (pageNo != null) {
request.setPageNo(pageNo);
}
if (pageSize != null) {
request.setPageSize(pageSize);
}
if (productStatus != null) {
// API要求的状态值-1(全部), 10(上架), 21(下架), 22(草稿), 23(审核中), 31(已售), 33(已删除), 36(违规)
// 前端传入的简化状态值1(上架), 2(下架), 3(已售)
Integer apiStatus = convertProductStatus(productStatus);
if (apiStatus != null) {
request.setProductStatus(apiStatus);
}
}
// 调用接口获取商品列表
String responseBody = request.getResponseBody();
JSONObject response = JSONObject.parseObject(responseBody);
if (response == null || response.getInteger("code") == null || response.getInteger("code") != 0) {
String errorMsg = response != null ? response.getString("msg") : "未知错误";
log.error("拉取商品列表失败: code={}, msg={}, response={}",
response != null ? response.getInteger("code") : null, errorMsg, responseBody);
throw new RuntimeException("拉取商品列表失败: " + errorMsg);
}
// 解析商品列表
JSONObject data = response.getJSONObject("data");
if (data == null) {
log.warn("拉取商品列表返回数据为空");
return 0;
}
JSONArray productList = data.getJSONArray("list");
Integer totalCount = data.getInteger("count");
if (productList == null || productList.isEmpty()) {
String statusMsg = productStatus != null ? "(状态:" + productStatus + "" : "";
if (totalCount != null && totalCount > 0) {
log.info("拉取商品列表为空,但总数显示为 {},可能是分页问题", totalCount);
} else {
log.info("拉取商品列表为空{},该账号下没有符合条件的商品", statusMsg);
}
return 0;
}
log.info("拉取到 {} 个商品,开始保存", productList.size());
// 转换为实体对象并保存
List<ErpProduct> erpProducts = new ArrayList<>();
for (int i = 0; i < productList.size(); i++) {
JSONObject productJson = productList.getJSONObject(i);
ErpProduct erpProduct = parseProductJson(productJson, appid);
if (erpProduct != null) {
erpProducts.add(erpProduct);
}
}
// 批量保存或更新
if (!erpProducts.isEmpty()) {
// 逐个保存(兼容更新)
int savedCount = 0;
for (ErpProduct product : erpProducts) {
ErpProduct existing = erpProductMapper.selectErpProductByProductIdAndAppid(
product.getProductId(), product.getAppid());
if (existing != null) {
product.setId(existing.getId());
erpProductMapper.updateErpProduct(product);
} else {
erpProductMapper.insertErpProduct(product);
}
savedCount++;
}
log.info("成功拉取并保存 {} 个商品", savedCount);
return savedCount;
}
return 0;
} catch (Exception e) {
log.error("拉取商品列表异常", e);
throw new RuntimeException("拉取商品列表失败: " + e.getMessage(), e);
}
}
/**
* 解析商品JSON数据
*/
private ErpProduct parseProductJson(JSONObject productJson, String appid) {
try {
ErpProduct product = new ErpProduct();
// 管家商品ID
Long productId = productJson.getLong("product_id");
if (productId == null) {
log.warn("商品ID为空跳过: {}", productJson);
return null;
}
product.setProductId(productId);
// 商品标题
product.setTitle(productJson.getString("title"));
// 商品图片(取第一张)
JSONArray images = productJson.getJSONArray("images");
if (images != null && !images.isEmpty()) {
product.setMainImage(images.getString(0));
}
// 价格(分)
Long price = productJson.getLong("price");
if (price == null) {
// 尝试从price字段解析
Object priceObj = productJson.get("price");
if (priceObj instanceof Number) {
price = ((Number) priceObj).longValue();
}
}
product.setPrice(price);
// 库存
Integer stock = productJson.getInteger("stock");
product.setStock(stock);
// 商品状态
Integer productStatus = productJson.getInteger("product_status");
product.setProductStatus(productStatus);
// 销售状态
Integer saleStatus = productJson.getInteger("sale_status");
product.setSaleStatus(saleStatus);
// 闲鱼会员名
product.setUserName(productJson.getString("user_name"));
// 时间字段(时间戳,秒)
product.setOnlineTime(productJson.getLong("online_time"));
product.setOfflineTime(productJson.getLong("offline_time"));
product.setSoldTime(productJson.getLong("sold_time"));
product.setCreateTimeXy(productJson.getLong("create_time"));
product.setUpdateTimeXy(productJson.getLong("update_time"));
// ERP应用ID
product.setAppid(appid);
// 商品链接
product.setProductUrl(productJson.getString("product_url"));
return product;
} catch (Exception e) {
log.error("解析商品JSON失败: {}", productJson, e);
return null;
}
}
/**
* 转换商品状态值将前端状态值转换为API需要的状态值
* 实际状态值:-1(删除), 21(待发布), 22(销售中), 23(已售罄), 31(手动下架), 33(售出下架), 36(自动下架)
* API支持的状态值-1, 10, 21, 22, 23, 31, 33, 36
* 前端传入的状态值直接使用,不做转换
*/
private Integer convertProductStatus(Integer frontendStatus) {
if (frontendStatus == null) {
return null;
}
// 直接使用前端传入的状态值(-1, 21, 22, 23, 31, 33, 36
// API支持的状态值列表
if (frontendStatus == -1 || frontendStatus == 10 || frontendStatus == 21 ||
frontendStatus == 22 || frontendStatus == 23 || frontendStatus == 31 ||
frontendStatus == 33 || frontendStatus == 36) {
return frontendStatus;
}
log.warn("未知的商品状态值: {}, 将不设置状态筛选", frontendStatus);
return null;
}
/**
* 全量同步商品列表(自动遍历所有页码,同步更新和删除)
*/
@Override
public IErpProductService.SyncResult syncAllProducts(String appid, Integer productStatus) {
IErpProductService.SyncResult result = new IErpProductService.SyncResult();
Set<Long> remoteProductIds = new HashSet<>(); // 远程商品ID集合
int pageNo = 1;
int pageSize = 50; // 每页50条尽量少请求次数
int totalPulled = 0;
int added = 0;
int updated = 0;
try {
ERPAccount account = resolveAccount(appid);
// 第一步:遍历所有页码,拉取并保存所有商品
log.info("开始全量同步商品,账号:{}", appid);
while (true) {
ProductListQueryRequest request = new ProductListQueryRequest(account);
request.setPage(pageNo, pageSize);
if (productStatus != null) {
Integer apiStatus = convertProductStatus(productStatus);
if (apiStatus != null) {
request.setProductStatus(apiStatus);
}
}
String responseBody = request.getResponseBody();
JSONObject response = JSONObject.parseObject(responseBody);
if (response == null || response.getInteger("code") == null || response.getInteger("code") != 0) {
String errorMsg = response != null ? response.getString("msg") : "未知错误";
log.error("拉取商品列表失败(页码:{}: {}", pageNo, errorMsg);
result.setFailed(result.getFailed() + 1);
break;
}
JSONObject data = response.getJSONObject("data");
if (data == null) {
log.warn("拉取商品列表返回数据为空(页码:{}", pageNo);
break;
}
JSONArray productList = data.getJSONArray("list");
if (productList == null || productList.isEmpty()) {
log.info("第 {} 页数据为空,同步完成", pageNo);
break;
}
// 处理当前页商品
for (int i = 0; i < productList.size(); i++) {
JSONObject productJson = productList.getJSONObject(i);
ErpProduct erpProduct = parseProductJson(productJson, appid);
if (erpProduct != null && erpProduct.getProductId() != null) {
remoteProductIds.add(erpProduct.getProductId());
// 保存或更新商品
ErpProduct existing = erpProductMapper.selectErpProductByProductIdAndAppid(
erpProduct.getProductId(), erpProduct.getAppid());
if (existing != null) {
erpProduct.setId(existing.getId());
erpProductMapper.updateErpProduct(erpProduct);
updated++;
} else {
erpProductMapper.insertErpProduct(erpProduct);
added++;
}
totalPulled++;
}
}
log.info("已同步第 {} 页,共 {} 条商品", pageNo, productList.size());
// 判断是否还有下一页
if (productList.size() < pageSize) {
log.info("已拉取完所有页码,共 {} 页", pageNo);
break;
}
pageNo++;
}
result.setTotalPulled(totalPulled);
result.setAdded(added);
result.setUpdated(updated);
// 第二步:对比本地和远程,删除本地有但远程没有的商品
log.info("开始同步删除,远程商品数:{}", remoteProductIds.size());
// 查询本地该账号下的所有商品
ErpProduct queryParam = new ErpProduct();
queryParam.setAppid(appid);
List<ErpProduct> localProducts = erpProductMapper.selectErpProductList(queryParam);
// 找出需要删除的商品(本地有但远程没有的)
List<ErpProduct> toDelete = localProducts.stream()
.filter(p -> !remoteProductIds.contains(p.getProductId()))
.collect(Collectors.toList());
if (!toDelete.isEmpty()) {
log.info("发现 {} 个本地商品在远程已不存在,开始删除", toDelete.size());
for (ErpProduct product : toDelete) {
try {
// 先调用远程删除接口
ProductDeleteRequest deleteRequest = new ProductDeleteRequest(account);
deleteRequest.setProductId(product.getProductId());
String deleteResponse = deleteRequest.getResponseBody();
JSONObject deleteResult = JSONObject.parseObject(deleteResponse);
if (deleteResult != null && deleteResult.getInteger("code") != null &&
deleteResult.getInteger("code") == 0) {
// 远程删除成功,删除本地记录
erpProductMapper.deleteErpProductById(product.getId());
result.setDeleted(result.getDeleted() + 1);
log.debug("成功删除商品:{}", product.getProductId());
} else {
// 远程删除失败,记录日志但不删除本地(可能是远程已经删除了)
String errorMsg = deleteResult != null ? deleteResult.getString("msg") : "未知错误";
log.warn("远程删除商品失败(可能已不存在):{},错误:{}", product.getProductId(), errorMsg);
// 如果远程返回商品不存在的错误,也删除本地记录
if (errorMsg != null && (errorMsg.contains("不存在") || errorMsg.contains("not found"))) {
erpProductMapper.deleteErpProductById(product.getId());
result.setDeleted(result.getDeleted() + 1);
} else {
result.setFailed(result.getFailed() + 1);
}
}
} catch (Exception e) {
log.error("删除商品异常:{}", product.getProductId(), e);
result.setFailed(result.getFailed() + 1);
}
}
}
// 构建结果消息
StringBuilder msg = new StringBuilder();
msg.append(String.format("同步完成!拉取:%d个新增%d个更新%d个删除%d个",
totalPulled, added, updated, result.getDeleted()));
if (result.getFailed() > 0) {
msg.append(String.format(",失败:%d个", result.getFailed()));
}
result.setMessage(msg.toString());
log.info(result.getMessage());
return result;
} catch (Exception e) {
log.error("全量同步商品异常", e);
result.setMessage("同步失败: " + e.getMessage());
result.setFailed(result.getFailed() + 1);
throw new RuntimeException("全量同步商品失败: " + e.getMessage(), e);
}
}
/**
* 解析ERP账号
*/
private ERPAccount resolveAccount(String appid) {
if (appid != null && !appid.isEmpty()) {
for (ERPAccount account : ERPAccount.values()) {
if (account.getApiKey().equals(appid)) {
return account;
}
}
}
return ERPAccount.ACCOUNT_HUGE; // 默认账号
}
}

View File

@@ -5,13 +5,20 @@ import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.system.service.ISysConfigService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import javax.annotation.Resource;
import javax.annotation.PostConstruct;
import java.net.URLEncoder;
import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
@@ -22,13 +29,43 @@ public class LogisticsServiceImpl implements ILogisticsService {
private static final Logger logger = LoggerFactory.getLogger(LogisticsServiceImpl.class);
private static final String REDIS_WAYBILL_KEY_PREFIX = "logistics:waybill:order:";
private static final String EXTERNAL_API_URL = "http://192.168.8.88:5001/fetch_logistics?tracking_url=";
private static final String REDIS_LOCK_KEY_PREFIX = "logistics:lock:order:";
private static final String REDIS_HEALTH_CHECK_ALERT_KEY = "logistics:health:alert:";
private static final String PUSH_URL = "https://wxts.van333.cn/wx/send/pdd";
private static final String PUSH_TOKEN = "super_token_b62190c26";
private static final String CONFIG_KEY_PREFIX = "logistics.push.touser.";
private static final long LOCK_EXPIRE_SECONDS = 300; // 锁过期时间5分钟防止死锁
private static final long HEALTH_CHECK_ALERT_INTERVAL_MINUTES = 30; // 健康检查失败提醒间隔30分钟避免频繁推送
@Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}")
private String logisticsBaseUrl;
@Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}")
private String logisticsFetchPath;
@Value("${jarvis.server.logistics.health-path:/health}")
private String logisticsHealthPath;
private String externalApiUrlTemplate;
private String healthCheckUrl;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ISysConfigService sysConfigService;
@Resource
private IJDOrderService jdOrderService;
@PostConstruct
public void init() {
externalApiUrlTemplate = logisticsBaseUrl + logisticsFetchPath + "?tracking_url=";
healthCheckUrl = logisticsBaseUrl + logisticsHealthPath;
logger.info("物流服务地址已初始化: {}", externalApiUrlTemplate);
logger.info("物流服务健康检查地址已初始化: {}", healthCheckUrl);
}
@Override
public boolean isOrderProcessed(Long orderId) {
if (orderId == null) {
@@ -38,6 +75,122 @@ public class LogisticsServiceImpl implements ILogisticsService {
return stringRedisTemplate.hasKey(redisKey);
}
@Override
public ILogisticsService.HealthCheckResult checkHealth() {
try {
logger.debug("开始检查物流服务健康状态 - URL: {}", healthCheckUrl);
String healthResult = HttpUtils.sendGet(healthCheckUrl);
if (healthResult == null || healthResult.trim().isEmpty()) {
logger.warn("物流服务健康检查返回空结果");
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查返回空结果", healthCheckUrl);
}
// 尝试解析JSON响应
try {
JSONObject response = JSON.parseObject(healthResult);
if (response != null) {
// 检查常见的健康状态字段
String status = response.getString("status");
Boolean healthy = response.getBoolean("healthy");
Integer code = response.getInteger("code");
if ("ok".equalsIgnoreCase(status) || "healthy".equalsIgnoreCase(status) ||
Boolean.TRUE.equals(healthy) || (code != null && code == 200)) {
logger.debug("物流服务健康检查通过");
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
}
}
} catch (Exception e) {
// 如果不是JSON格式检查是否包含成功标识
String lowerResult = healthResult.toLowerCase();
if (lowerResult.contains("ok") || lowerResult.contains("healthy") || lowerResult.contains("success")) {
logger.debug("物流服务健康检查通过非JSON格式");
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
}
}
logger.warn("物流服务健康检查失败 - 响应: {}", healthResult);
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查返回异常状态: " + healthResult, healthCheckUrl);
} catch (Exception e) {
logger.error("物流服务健康检查异常 - URL: {}, 错误: {}", healthCheckUrl, e.getMessage(), e);
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查异常: " + e.getMessage(), healthCheckUrl);
}
}
/**
* 检查物流服务健康状态(内部方法,用于业务逻辑)
* @return 是否健康
*/
@SuppressWarnings("unused")
private boolean checkLogisticsServiceHealth() {
ILogisticsService.HealthCheckResult result = checkHealth();
if (result.isHealthy()) {
// 清除健康检查失败标记
stringRedisTemplate.delete(REDIS_HEALTH_CHECK_ALERT_KEY);
} else {
handleHealthCheckFailure(result.getMessage());
}
return result.isHealthy();
}
/**
* 处理健康检查失败,推送提醒消息
* @param reason 失败原因
*/
@SuppressWarnings("null")
private void handleHealthCheckFailure(String reason) {
try {
// 检查是否在提醒间隔内已经推送过
String alertKey = REDIS_HEALTH_CHECK_ALERT_KEY;
String lastAlertTime = stringRedisTemplate.opsForValue().get(alertKey);
if (lastAlertTime != null) {
// 如果30分钟内已经推送过不再重复推送
logger.debug("健康检查失败提醒已在30分钟内推送过跳过本次推送");
return;
}
// 构建推送消息
StringBuilder pushContent = new StringBuilder();
pushContent.append("【物流服务异常提醒】\n");
pushContent.append("服务地址:").append(healthCheckUrl).append("\n");
pushContent.append("失败原因:").append(reason).append("\n");
pushContent.append("时间:").append(new Date()).append("\n");
pushContent.append("请及时检查服务状态!");
JSONObject pushParam = new JSONObject();
pushParam.put("title", "物流服务健康检查失败");
pushParam.put("text", pushContent.toString());
// 尝试获取系统管理员接收人配置,如果没有则使用默认接收人
String adminTouser = sysConfigService.selectConfigByKey("logistics.push.touser.ADMIN");
if (StringUtils.hasText(adminTouser)) {
pushParam.put("touser", adminTouser.trim());
logger.info("使用管理员接收人配置推送健康检查失败提醒");
} else {
logger.info("未配置管理员接收人,将使用远程接口默认接收人推送健康检查失败提醒");
}
String jsonBody = pushParam.toJSONString();
String pushResult = sendPostWithHeaders(PUSH_URL, jsonBody, PUSH_TOKEN);
if (pushResult != null && !pushResult.trim().isEmpty()) {
logger.info("健康检查失败提醒已推送 - 响应: {}", pushResult);
// 记录推送时间30分钟内不再重复推送
long currentTime = System.currentTimeMillis();
stringRedisTemplate.opsForValue().set(alertKey, Long.toString(currentTime),
HEALTH_CHECK_ALERT_INTERVAL_MINUTES, TimeUnit.MINUTES);
} else {
logger.warn("健康检查失败提醒推送失败 - 响应为空");
}
} catch (Exception e) {
logger.error("推送健康检查失败提醒异常", e);
}
}
@Override
public boolean fetchLogisticsAndPush(JDOrder order) {
if (order == null || order.getId() == null) {
@@ -45,27 +198,57 @@ public class LogisticsServiceImpl implements ILogisticsService {
return false;
}
// 检查物流链接
String logisticsLink = order.getLogisticsLink();
if (logisticsLink == null || logisticsLink.trim().isEmpty()) {
logger.info("订单暂无物流链接,跳过处理 - 订单ID: {}", order.getId());
return false;
Long orderId = order.getId();
// 双重检查:先检查是否已处理过
if (isOrderProcessed(orderId)) {
logger.info("订单已处理过,跳过 - 订单ID: {}", orderId);
return true; // 返回true表示已处理避免重复处理
}
// 获取分布式锁,防止并发处理同一订单
String lockKey = REDIS_LOCK_KEY_PREFIX + orderId;
Boolean lockAcquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "locked", LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(lockAcquired)) {
logger.warn("订单正在被其他线程处理,跳过 - 订单ID: {}", orderId);
return false; // 其他线程正在处理返回false让调用方稍后重试
}
try {
// 获取锁后再次检查是否已处理(双重检查锁定模式)
if (isOrderProcessed(orderId)) {
logger.info("订单在获取锁后检查发现已处理,跳过 - 订单ID: {}", orderId);
return true;
}
// 检查物流链接
String logisticsLink = order.getLogisticsLink();
if (logisticsLink == null || logisticsLink.trim().isEmpty()) {
logger.info("订单暂无物流链接,跳过处理 - 订单ID: {}", orderId);
return false;
}
// 先进行健康检查
ILogisticsService.HealthCheckResult healthResult = checkHealth();
if (!healthResult.isHealthy()) {
logger.error("物流服务健康检查失败,跳过处理订单 - 订单ID: {}, 原因: {}", orderId, healthResult.getMessage());
return false;
}
// 构建外部接口URL
String externalUrl = EXTERNAL_API_URL + URLEncoder.encode(logisticsLink, "UTF-8");
logger.info("调用外部接口获取物流信息 - 订单ID: {}, URL: {}", order.getId(), externalUrl);
String externalUrl = externalApiUrlTemplate + URLEncoder.encode(logisticsLink, "UTF-8");
logger.info("调用外部接口获取物流信息 - 订单ID: {}, URL: {}", orderId, externalUrl);
// 在服务端执行HTTP请求
String result = HttpUtils.sendGet(externalUrl);
if (result == null || result.trim().isEmpty()) {
logger.warn("外部接口返回空结果 - 订单ID: {}", order.getId());
logger.warn("外部接口返回空结果 - 订单ID: {}", orderId);
return false;
}
logger.info("外部接口调用成功 - 订单ID: {}, 返回数据长度: {}", order.getId(), result.length());
logger.info("外部接口调用成功 - 订单ID: {}, 返回数据长度: {}", orderId, result.length());
// 解析返回结果
JSONObject parsedData = null;
@@ -74,42 +257,165 @@ public class LogisticsServiceImpl implements ILogisticsService {
if (parsed instanceof JSONObject) {
parsedData = (JSONObject) parsed;
} else {
logger.warn("返回数据不是JSON对象格式 - 订单ID: {}", order.getId());
logger.warn("返回数据不是JSON对象格式 - 订单ID: {}", orderId);
return false;
}
} catch (Exception e) {
logger.warn("解析返回数据失败 - 订单ID: {}, 错误: {}", order.getId(), e.getMessage());
logger.warn("解析返回数据失败 - 订单ID: {}, 错误: {}", orderId, e.getMessage());
return false;
}
// 检查waybill_no
JSONObject dataObj = parsedData.getJSONObject("data");
if (dataObj == null) {
logger.info("返回数据中没有data字段 - 订单ID: {}", order.getId());
logger.info("返回数据中没有data字段 - 订单ID: {}", orderId);
return false;
}
String waybillNo = dataObj.getString("waybill_no");
if (waybillNo == null || waybillNo.trim().isEmpty()) {
logger.info("waybill_no为空无需处理 - 订单ID: {}", order.getId());
logger.info("waybill_no为空无需处理 - 订单ID: {}", orderId);
return false;
}
logger.info("检测到waybill_no: {} - 订单ID: {}", waybillNo, order.getId());
logger.info("检测到waybill_no: {} - 订单ID: {}", waybillNo, orderId);
// 保存运单号到Redis避免重复处理
String redisKey = REDIS_WAYBILL_KEY_PREFIX + order.getId();
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
// 标记物流链接是否更新
boolean logisticsLinkUpdated = false;
String oldLogisticsLink = null;
String newLogisticsLink = null;
// 调用企业应用推送
sendEnterprisePushNotification(order, waybillNo);
// 检查并更新物流链接(如果返回的数据中包含新的物流链接)
// 尝试多种可能的字段名
if (dataObj.containsKey("tracking_url")) {
newLogisticsLink = dataObj.getString("tracking_url");
} else if (dataObj.containsKey("logistics_link")) {
newLogisticsLink = dataObj.getString("logistics_link");
} else if (dataObj.containsKey("logisticsLink")) {
newLogisticsLink = dataObj.getString("logisticsLink");
} else if (dataObj.containsKey("trackingUrl")) {
newLogisticsLink = dataObj.getString("trackingUrl");
}
logger.info("物流信息获取并推送成功 - 订单ID: {}, waybill_no: {}", order.getId(), waybillNo);
// 如果获取到新的物流链接,与数据库中的进行比对
if (newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
String currentLogisticsLink = order.getLogisticsLink();
String trimmedNewLink = newLogisticsLink.trim();
String trimmedCurrentLink = (currentLogisticsLink != null) ? currentLogisticsLink.trim() : "";
oldLogisticsLink = trimmedCurrentLink;
// 比对物流链接,如果不一致则更新数据库
if (!trimmedNewLink.equals(trimmedCurrentLink)) {
logger.info("========== 检测到物流链接发生变化 - 订单ID: {}, 订单号: {}, 旧链接: {}, 新链接: {}, 开始更新数据库 ==========",
orderId, order.getOrderId(), trimmedCurrentLink, trimmedNewLink);
try {
// 重新查询订单,确保获取最新数据
JDOrder updateOrder = jdOrderService.selectJDOrderById(orderId);
if (updateOrder != null) {
updateOrder.setLogisticsLink(trimmedNewLink);
int updateResult = jdOrderService.updateJDOrder(updateOrder);
if (updateResult > 0) {
logisticsLinkUpdated = true;
logger.info("========== 物流链接更新成功 - 订单ID: {}, 订单号: {}, 旧链接: {}, 新链接: {} ==========",
orderId, order.getOrderId(), trimmedCurrentLink, trimmedNewLink);
// 更新内存中的order对象以便后续使用最新数据
order.setLogisticsLink(trimmedNewLink);
} else {
logger.warn("物流链接更新失败影响行数为0 - 订单ID: {}, 订单号: {}, 新链接: {}",
orderId, order.getOrderId(), trimmedNewLink);
}
} else {
logger.warn("未找到订单,无法更新物流链接 - 订单ID: {}, 订单号: {}", orderId, order.getOrderId());
}
} catch (Exception e) {
logger.error("更新物流链接时发生异常 - 订单ID: {}, 订单号: {}, 新链接: {}, 错误: {}",
orderId, order.getOrderId(), trimmedNewLink, e.getMessage(), e);
// 不中断主流程,继续处理
}
} else {
logger.debug("物流链接未变化,无需更新 - 订单ID: {}, 订单号: {}, 链接: {}",
orderId, order.getOrderId(), trimmedCurrentLink);
}
} else {
logger.debug("返回数据中未包含物流链接字段 - 订单ID: {}, 订单号: {}", orderId, order.getOrderId());
}
// 兼容处理检查Redis中是否已有该订单的运单号记录
// 如果存在且运单号一致,说明之前已经推送过了(可能是之前没有配置接收人但推送成功的情况)
String redisKey = REDIS_WAYBILL_KEY_PREFIX + orderId;
String existingWaybillNo = stringRedisTemplate.opsForValue().get(redisKey);
if (existingWaybillNo != null && existingWaybillNo.trim().equals(waybillNo.trim())) {
// 运单号一致,说明之前已经推送过了,直接标记为已处理,跳过推送
logger.info("订单运单号已存在且一致,说明之前已推送过,跳过重复推送 - 订单ID: {}, waybill_no: {}", orderId, waybillNo);
// 更新过期时间,确保记录不会过期
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
return true;
}
// 兼容处理如果Redis中没有记录但订单创建时间在30天之前且获取到了运单号
// 说明可能是之前推送过但没标记的情况(比如之前没有配置接收人但推送成功,响应解析失败)
// 这种情况下,直接标记为已处理,跳过推送,避免重复推送旧订单
if (existingWaybillNo == null && order.getCreateTime() != null) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, -30); // 30天前
Date thresholdDate = calendar.getTime();
if (order.getCreateTime().before(thresholdDate)) {
// 订单创建时间在30天之前且Redis中没有记录但获取到了运单号
// 视为之前已推送过但未标记,直接标记为已处理,跳过推送
logger.info("订单创建时间较早({}且Redis中无记录但已获取到运单号视为之前已推送过直接标记为已处理跳过推送 - 订单ID: {}, waybill_no: {}",
order.getCreateTime(), orderId, waybillNo);
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
return true;
}
}
// 如果Redis中有记录但运单号不一致记录警告
if (existingWaybillNo != null && !existingWaybillNo.trim().equals(waybillNo.trim())) {
logger.warn("订单运单号发生变化 - 订单ID: {}, 旧运单号: {}, 新运单号: {}, 将重新推送",
orderId, existingWaybillNo, waybillNo);
}
// 调用企业应用推送,只有推送成功才记录状态
boolean pushSuccess = sendEnterprisePushNotification(order, waybillNo, logisticsLinkUpdated, oldLogisticsLink, newLogisticsLink);
if (!pushSuccess) {
logger.warn("企业微信推送未确认成功,稍后将重试 - 订单ID: {}, waybill_no: {}", orderId, waybillNo);
return false;
}
// 保存运单号到Redis避免重复处理- 使用原子操作确保只写入一次
Boolean setSuccess = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, waybillNo, 30, TimeUnit.DAYS);
if (Boolean.FALSE.equals(setSuccess)) {
// 如果Redis中已存在说明可能被其他线程处理了记录警告但不算失败
logger.warn("订单运单号已存在(可能被并发处理),但推送已成功 - 订单ID: {}, waybill_no: {}", orderId, waybillNo);
// 更新过期时间,确保记录不会过期
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
}
// 记录最终处理结果
if (logisticsLinkUpdated) {
logger.info("========== 物流信息获取并推送成功(已更新物流链接) - 订单ID: {}, 订单号: {}, waybill_no: {}, 新链接: {} ==========",
orderId, order.getOrderId(), waybillNo, newLogisticsLink);
} else {
logger.info("物流信息获取并推送成功 - 订单ID: {}, 订单号: {}, waybill_no: {}",
orderId, order.getOrderId(), waybillNo);
}
return true;
} catch (Exception e) {
logger.error("获取物流信息失败 - 订单ID: {}, 错误: {}", order.getId(), e.getMessage(), e);
logger.error("获取物流信息失败 - 订单ID: {}, 错误: {}", orderId, e.getMessage(), e);
return false;
} finally {
// 释放分布式锁
try {
stringRedisTemplate.delete(lockKey);
logger.debug("释放订单处理锁 - 订单ID: {}", orderId);
} catch (Exception e) {
logger.warn("释放订单处理锁失败 - 订单ID: {}, 错误: {}", orderId, e.getMessage());
}
}
}
@@ -117,8 +423,11 @@ public class LogisticsServiceImpl implements ILogisticsService {
* 调用企业应用推送逻辑
* @param order 订单信息
* @param waybillNo 运单号
* @param logisticsLinkUpdated 物流链接是否已更新
* @param oldLogisticsLink 旧的物流链接(如果更新了)
* @param newLogisticsLink 新的物流链接(如果更新了)
*/
private void sendEnterprisePushNotification(JDOrder order, String waybillNo) {
private boolean sendEnterprisePushNotification(JDOrder order, String waybillNo, boolean logisticsLinkUpdated, String oldLogisticsLink, String newLogisticsLink) {
try {
// 构建推送消息内容
StringBuilder pushContent = new StringBuilder();
@@ -127,35 +436,214 @@ public class LogisticsServiceImpl implements ILogisticsService {
String distributionMark = order.getDistributionMark() != null ? order.getDistributionMark() : "未知";
pushContent.append(distributionMark).append("\n");
// PDD订单包含第三方单号F订单不包含
if ("PDD".equals(distributionMark)) {
String thirdPartyOrderNo = order.getThirdPartyOrderNo();
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
pushContent.append("第三方单号:").append(thirdPartyOrderNo).append("\n");
}
String thirdPartyOrderNo = order.getThirdPartyOrderNo();
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
pushContent.append("第三方单号:").append(thirdPartyOrderNo).append("\n");
}
// 型号
pushContent.append("型号:").append(order.getModelNumber() != null ? order.getModelNumber() : "").append("\n");
// 收货地址
pushContent.append("收货地址:").append(order.getAddress() != null ? order.getAddress() : "").append("\n");
// 如果物流链接已更新,在推送消息中说明
if (logisticsLinkUpdated && newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
pushContent.append("【物流链接已更新】").append("\n");
pushContent.append("新物流链接:").append(newLogisticsLink.trim()).append("\n");
if (oldLogisticsLink != null && !oldLogisticsLink.trim().isEmpty()) {
pushContent.append("旧物流链接:").append(oldLogisticsLink.trim()).append("\n");
}
pushContent.append("\n");
}
// 运单号
pushContent.append("运单号:").append(waybillNo).append("\n");
pushContent.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
// 调用企业微信推送接口
JSONObject pushParam = new JSONObject();
pushParam.put("title", "JD物流信息推送");
pushParam.put("text", pushContent.toString());
// 根据分销标识获取接收人列表
String touser = getTouserByDistributionMark(distributionMark);
if (StringUtils.hasText(touser)) {
pushParam.put("touser", touser);
logger.info("企业微信推送设置接收人 - 订单ID: {}, 分销标识: {}, 接收人: {}",
order.getId(), distributionMark, touser);
} else {
// 未配置接收人时,使用远程接口的默认接收人,这是正常情况
logger.info("未找到分销标识对应的接收人配置,将使用远程接口默认接收人 - 订单ID: {}, 分销标识: {}",
order.getId(), distributionMark);
}
// 记录完整的推送参数(用于调试)
String jsonBody = pushParam.toJSONString();
if (logisticsLinkUpdated) {
logger.info("企业微信推送完整参数(已更新物流链接) - 订单ID: {}, 订单号: {}, 旧链接: {}, 新链接: {}, JSON: {}",
order.getId(), order.getOrderId(), oldLogisticsLink, newLogisticsLink, jsonBody);
} else {
logger.info("企业微信推送完整参数 - 订单ID: {}, 订单号: {}, JSON: {}",
order.getId(), order.getOrderId(), jsonBody);
}
// 使用支持自定义header的HTTP请求
String pushResult = sendPostWithHeaders(PUSH_URL, pushParam.toJSONString(), PUSH_TOKEN);
logger.info("企业应用推送调用结果 - 订单ID: {}, waybill_no: {}, 推送结果: {}",
order.getId(), waybillNo, pushResult);
String pushResult = sendPostWithHeaders(PUSH_URL, jsonBody, PUSH_TOKEN);
if (pushResult == null || pushResult.trim().isEmpty()) {
logger.warn("企业应用推送响应为空 - 订单ID: {}, waybill_no: {}", order.getId(), waybillNo);
return false;
}
boolean success = isPushResponseSuccess(pushResult);
if (success) {
if (logisticsLinkUpdated) {
logger.info("企业应用推送成功(已更新物流链接) - 订单ID: {}, 订单号: {}, waybill_no: {}, 新链接: {}, 推送结果: {}",
order.getId(), order.getOrderId(), waybillNo, newLogisticsLink, pushResult);
} else {
logger.info("企业应用推送成功 - 订单ID: {}, 订单号: {}, waybill_no: {}, 推送结果: {}",
order.getId(), order.getOrderId(), waybillNo, pushResult);
}
} else {
logger.warn("企业应用推送响应未确认成功 - 订单ID: {}, 订单号: {}, waybill_no: {}, 响应: {}",
order.getId(), order.getOrderId(), waybillNo, pushResult);
}
return success;
} catch (Exception e) {
logger.error("调用企业应用推送失败 - 订单ID: {}, waybill_no: {}, 错误: {}",
order.getId(), waybillNo, e.getMessage(), e);
// 不抛出异常,避免影响主流程
// 不抛出异常,主流程根据返回值决定是否重试
return false;
}
}
/**
* 根据分销标识获取接收人列表
* 从系统配置中读取配置键名格式logistics.push.touser.{分销标识}
* 配置值格式接收人1,接收人2,接收人3逗号分隔
*
* @param distributionMark 分销标识
* @return 接收人列表逗号分隔如果未配置则返回null
*/
private String getTouserByDistributionMark(String distributionMark) {
if (!StringUtils.hasText(distributionMark)) {
logger.warn("分销标识为空,无法获取接收人配置");
return null;
}
try {
// 构建配置键名
String configKey = CONFIG_KEY_PREFIX + distributionMark.trim();
// 从系统配置中获取接收人列表
String configValue = sysConfigService.selectConfigByKey(configKey);
if (StringUtils.hasText(configValue)) {
// 清理配置值(去除空格)
String touser = configValue.trim().replaceAll(",\\s+", ",");
logger.info("从配置获取接收人列表 - 分销标识: {}, 配置键: {}, 接收人: {}",
distributionMark, configKey, touser);
return touser;
} else {
logger.debug("未找到接收人配置 - 分销标识: {}, 配置键: {}", distributionMark, configKey);
return null;
}
} catch (Exception e) {
logger.error("获取接收人配置失败 - 分销标识: {}, 错误: {}", distributionMark, e.getMessage(), e);
return null;
}
}
/**
* 判断推送返回值是否为成功状态
* @param pushResult 推送接口返回结果
* @return 是否成功
*/
private boolean isPushResponseSuccess(String pushResult) {
if (pushResult == null || pushResult.trim().isEmpty()) {
logger.warn("推送响应为空,视为失败");
return false;
}
try {
JSONObject response = JSON.parseObject(pushResult);
if (response == null) {
logger.warn("推送响应解析为null视为失败。原始响应: {}", pushResult);
return false;
}
Integer code = response.getInteger("code");
Boolean successFlag = response.getBoolean("success");
String status = response.getString("status");
String message = response.getString("msg");
String errcode = response.getString("errcode");
// 记录完整的响应信息用于调试
logger.debug("推送响应解析 - code: {}, success: {}, status: {}, msg: {}, errcode: {}",
code, successFlag, status, message, errcode);
// 检查错误码errcode为0表示成功
if (errcode != null && "0".equals(errcode)) {
logger.info("推送成功通过errcode=0判断");
return true;
}
// 检查code字段0或200表示成功
if (code != null && (code == 0 || code == 200)) {
logger.info("推送成功通过code={}判断)", code);
return true;
}
// 检查success字段
if (Boolean.TRUE.equals(successFlag)) {
logger.info("推送成功通过success=true判断");
return true;
}
// 检查status字段
if (status != null && ("success".equalsIgnoreCase(status) || "ok".equalsIgnoreCase(status))) {
logger.info("推送成功通过status={}判断)", status);
return true;
}
// 检查message字段某些接口可能用message表示成功
if (message != null && ("success".equalsIgnoreCase(message) || "ok".equalsIgnoreCase(message))) {
logger.info("推送成功通过message={}判断)", message);
return true;
}
// 检查是否包含明确的错误标识
String responseStr = pushResult.toLowerCase();
boolean hasErrorKeyword = responseStr.contains("\"error\"") || responseStr.contains("\"fail\"") ||
responseStr.contains("\"failed\"") || responseStr.contains("\"errmsg\"");
// 如果包含错误标识,检查是否有明确的错误码
if (hasErrorKeyword) {
if (code != null && code < 0) {
logger.warn("推送失败(检测到错误标识和负错误码) - code: {}, 完整响应: {}", code, pushResult);
return false;
}
if (errcode != null && !"0".equals(errcode) && !errcode.isEmpty()) {
logger.warn("推送失败(检测到错误标识和非零错误码) - errcode: {}, 完整响应: {}", errcode, pushResult);
return false;
}
}
// 如果响应是有效的JSON且没有明确的错误标识视为成功
// 因为远程接口有默认接收人,即使没有配置接收人,推送也应该成功
// 如果响应格式特殊(不是标准格式),只要没有错误,也视为成功
if (!hasErrorKeyword) {
logger.info("推送成功(响应格式有效且无错误标识,使用默认接收人) - 完整响应: {}", pushResult);
return true;
}
// 如果所有判断都失败,记录详细信息
logger.warn("推送响应未确认成功 - code: {}, success: {}, status: {}, msg: {}, errcode: {}, 完整响应: {}",
code, successFlag, status, message, errcode, pushResult);
return false;
} catch (Exception e) {
logger.error("解析企业应用推送响应失败,将视为未成功。原始响应: {}, 错误: {}", pushResult, e.getMessage(), e);
return false;
}
}

View File

@@ -0,0 +1,119 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.service.IPhoneReplaceConfigService;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
/**
* 手机号替换配置Service业务层处理
*
* @author ruoyi
*/
@Service
public class PhoneReplaceConfigServiceImpl implements IPhoneReplaceConfigService
{
@Resource
private StringRedisTemplate stringRedisTemplate;
// Redis key前缀
private static final String REDIS_KEY_PREFIX = "phone_replace_config:";
// 循环索引key前缀
private static final String CYCLE_INDEX_KEY_PREFIX = "phone_cycle_index:";
@Override
public List<String> getPhoneList(String type) {
if (stringRedisTemplate == null) {
return new ArrayList<>();
}
try {
String key = REDIS_KEY_PREFIX + type;
String value = stringRedisTemplate.opsForValue().get(key);
if (value == null || value.isEmpty()) {
return new ArrayList<>();
}
return JSON.parseArray(value, String.class);
} catch (Exception e) {
return new ArrayList<>();
}
}
@Override
public int setPhoneList(String type, List<String> phoneList) {
if (stringRedisTemplate == null) {
return 0;
}
try {
String key = REDIS_KEY_PREFIX + type;
if (phoneList == null || phoneList.isEmpty()) {
stringRedisTemplate.delete(key);
} else {
String value = JSON.toJSONString(phoneList);
stringRedisTemplate.opsForValue().set(key, value);
}
return 1;
} catch (Exception e) {
return 0;
}
}
@Override
public int addPhone(String type, String phone) {
List<String> phoneList = getPhoneList(type);
if (!phoneList.contains(phone)) {
phoneList.add(phone);
return setPhoneList(type, phoneList);
}
return 1;
}
@Override
public int removePhone(String type, String phone) {
List<String> phoneList = getPhoneList(type);
phoneList.remove(phone);
return setPhoneList(type, phoneList);
}
@Override
public String getNextPhone(String type, String originalPhone) {
List<String> phoneList = getPhoneList(type);
if (phoneList == null || phoneList.isEmpty()) {
return originalPhone;
}
if (stringRedisTemplate == null) {
// 如果没有Redis使用简单的轮询基于时间戳
int index = (int) (System.currentTimeMillis() % phoneList.size());
return phoneList.get(index);
}
try {
String sequenceKey = CYCLE_INDEX_KEY_PREFIX + type;
Long index = stringRedisTemplate.opsForValue().increment(sequenceKey);
if (index == null) {
// 如果获取失败,使用时间戳
int idx = (int) (System.currentTimeMillis() % phoneList.size());
return phoneList.get(idx);
}
int phoneIndex = (int) ((index - 1) % phoneList.size());
String phone = phoneList.get(phoneIndex) != null ? phoneList.get(phoneIndex) : originalPhone;
// 当完成一轮循环后重置索引为0
if (index % phoneList.size() == 0) {
stringRedisTemplate.opsForValue().set(sequenceKey, "0");
}
return phone;
} catch (Exception e) {
// 异常时使用时间戳方式
int idx = (int) (System.currentTimeMillis() % phoneList.size());
return phoneList.get(idx);
}
}
}

View File

@@ -0,0 +1,422 @@
package com.ruoyi.jarvis.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.jarvis.service.ISocialMediaService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* 小红书/抖音内容生成Service业务层处理
*
* @author ruoyi
* @date 2025-01-XX
*/
@Service
public class SocialMediaServiceImpl implements ISocialMediaService
{
private static final Logger log = LoggerFactory.getLogger(SocialMediaServiceImpl.class);
@Autowired(required = false)
private StringRedisTemplate redisTemplate;
@Value("${jarvis.server.jarvis-java.base-url:http://127.0.0.1:6666}")
private String jarvisBaseUrl;
// 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 - 关键词信息");
}};
/**
* 提取商品标题关键词
*/
@Override
public Map<String, Object> extractKeywords(String productName) {
Map<String, Object> result = new HashMap<>();
if (StringUtils.isEmpty(productName)) {
result.put("success", false);
result.put("error", "商品名称不能为空");
return result;
}
try {
// 调用 jarvis_java 的接口
String url = jarvisBaseUrl + "/jarvis/social-media/extract-keywords";
JSONObject requestBody = new JSONObject();
requestBody.put("productName", productName);
log.info("调用jarvis_java提取关键词接口URL: {}, 参数: {}", url, requestBody.toJSONString());
String response = HttpUtils.sendJsonPost(url, requestBody.toJSONString());
log.info("jarvis_java响应: {}", response);
if (StringUtils.isEmpty(response)) {
throw new Exception("jarvis_java返回空结果");
}
// 解析响应
Object parsed = JSON.parse(response);
if (parsed instanceof JSONObject) {
JSONObject jsonResponse = (JSONObject) parsed;
if (jsonResponse.getInteger("code") == 200) {
Object data = jsonResponse.get("data");
if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) data;
return dataMap;
}
} else {
String msg = jsonResponse.getString("msg");
result.put("success", false);
result.put("error", msg != null ? msg : "提取关键词失败");
return result;
}
}
result.put("success", false);
result.put("error", "响应格式错误");
return result;
} catch (Exception e) {
log.error("提取关键词失败", e);
result.put("success", false);
result.put("error", "提取关键词失败: " + e.getMessage());
return result;
}
}
/**
* 生成文案
*/
@Override
public Map<String, Object> generateContent(String productName, Object originalPrice,
Object finalPrice, String keywords, String style) {
Map<String, Object> result = new HashMap<>();
if (StringUtils.isEmpty(productName)) {
result.put("success", false);
result.put("error", "商品名称不能为空");
return result;
}
try {
// 调用 jarvis_java 的接口
String url = jarvisBaseUrl + "/jarvis/social-media/generate-content";
JSONObject requestBody = new JSONObject();
requestBody.put("productName", productName);
if (originalPrice != null) {
requestBody.put("originalPrice", parseDouble(originalPrice));
}
if (finalPrice != null) {
requestBody.put("finalPrice", parseDouble(finalPrice));
}
if (StringUtils.isNotEmpty(keywords)) {
requestBody.put("keywords", keywords);
}
if (StringUtils.isNotEmpty(style)) {
requestBody.put("style", style);
}
log.info("调用jarvis_java生成文案接口URL: {}, 参数: {}", url, requestBody.toJSONString());
String response = HttpUtils.sendJsonPost(url, requestBody.toJSONString());
log.info("jarvis_java响应: {}", response);
if (StringUtils.isEmpty(response)) {
throw new Exception("jarvis_java返回空结果");
}
// 解析响应
Object parsed = JSON.parse(response);
if (parsed instanceof JSONObject) {
JSONObject jsonResponse = (JSONObject) parsed;
if (jsonResponse.getInteger("code") == 200) {
Object data = jsonResponse.get("data");
if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) data;
return dataMap;
}
} else {
String msg = jsonResponse.getString("msg");
result.put("success", false);
result.put("error", msg != null ? msg : "生成文案失败");
return result;
}
}
result.put("success", false);
result.put("error", "响应格式错误");
return result;
} catch (Exception e) {
log.error("生成文案失败", e);
result.put("success", false);
result.put("error", "生成文案失败: " + e.getMessage());
return result;
}
}
/**
* 一键生成完整内容(关键词 + 文案 + 图片)
*/
@Override
public Map<String, Object> generateCompleteContent(String productImageUrl, String productName,
Object originalPrice, Object finalPrice, String style) {
Map<String, Object> result = new HashMap<>();
if (StringUtils.isEmpty(productName)) {
result.put("success", false);
result.put("error", "商品名称不能为空");
return result;
}
try {
// 调用 jarvis_java 的接口
String url = jarvisBaseUrl + "/jarvis/social-media/generate-complete";
JSONObject requestBody = new JSONObject();
if (StringUtils.isNotEmpty(productImageUrl)) {
requestBody.put("productImageUrl", productImageUrl);
}
requestBody.put("productName", productName);
if (originalPrice != null) {
requestBody.put("originalPrice", parseDouble(originalPrice));
}
if (finalPrice != null) {
requestBody.put("finalPrice", parseDouble(finalPrice));
}
if (StringUtils.isNotEmpty(style)) {
requestBody.put("style", style);
}
log.info("调用jarvis_java生成完整内容接口URL: {}, 参数: {}", url, requestBody.toJSONString());
String response = HttpUtils.sendJsonPost(url, requestBody.toJSONString());
log.info("jarvis_java响应: {}", response);
if (StringUtils.isEmpty(response)) {
throw new Exception("jarvis_java返回空结果");
}
// 解析响应
Object parsed = JSON.parse(response);
if (parsed instanceof JSONObject) {
JSONObject jsonResponse = (JSONObject) parsed;
if (jsonResponse.getInteger("code") == 200) {
Object data = jsonResponse.get("data");
if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) data;
return dataMap;
}
} else {
String msg = jsonResponse.getString("msg");
result.put("success", false);
result.put("error", msg != null ? msg : "生成完整内容失败");
return result;
}
}
result.put("success", false);
result.put("error", "响应格式错误");
return result;
} catch (Exception e) {
log.error("生成完整内容失败", e);
result.put("success", false);
result.put("error", "生成完整内容失败: " + e.getMessage());
return result;
}
}
/**
* 获取提示词模板列表
*/
@Override
public AjaxResult listPromptTemplates() {
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);
}
return AjaxResult.success(templates);
} catch (Exception e) {
log.error("获取提示词模板列表失败", e);
return AjaxResult.error("获取失败: " + e.getMessage());
}
}
/**
* 获取单个提示词模板
*/
@Override
public AjaxResult getPromptTemplate(String key) {
try {
if (!isValidKey(key)) {
return AjaxResult.error("无效的模板键名");
}
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);
return AjaxResult.success(data);
} catch (Exception e) {
log.error("获取提示词模板失败", e);
return AjaxResult.error("获取失败: " + e.getMessage());
}
}
/**
* 保存提示词模板
*/
@Override
public AjaxResult savePromptTemplate(Map<String, Object> request) {
try {
String key = (String) request.get("key");
String template = (String) request.get("template");
if (!isValidKey(key)) {
return AjaxResult.error("无效的模板键名");
}
if (StringUtils.isEmpty(template)) {
return AjaxResult.error("模板内容不能为空");
}
if (redisTemplate == null) {
return AjaxResult.error("Redis未配置无法保存模板");
}
String redisKey = REDIS_KEY_PREFIX + key;
String templateValue = template.trim();
if (StringUtils.isEmpty(templateValue)) {
return AjaxResult.error("模板内容不能为空");
}
redisTemplate.opsForValue().set(redisKey, templateValue);
log.info("保存提示词模板成功: {}", key);
return AjaxResult.success("保存成功");
} catch (Exception e) {
log.error("保存提示词模板失败", e);
return AjaxResult.error("保存失败: " + e.getMessage());
}
}
/**
* 删除提示词模板(恢复默认)
*/
@Override
public AjaxResult deletePromptTemplate(String key) {
try {
if (!isValidKey(key)) {
return AjaxResult.error("无效的模板键名");
}
if (redisTemplate == null) {
return AjaxResult.error("Redis未配置无法删除模板");
}
String redisKey = REDIS_KEY_PREFIX + key;
redisTemplate.delete(redisKey);
log.info("删除提示词模板成功: {}", key);
return AjaxResult.success("删除成功,已恢复默认模板");
} catch (Exception e) {
log.error("删除提示词模板失败", e);
return AjaxResult.error("删除失败: " + e.getMessage());
}
}
/**
* 从 Redis 获取模板
*/
private String getTemplateFromRedis(String key) {
if (redisTemplate == null) {
return null;
}
try {
String redisKey = REDIS_KEY_PREFIX + key;
String template = redisTemplate.opsForValue().get(redisKey);
return StringUtils.isNotEmpty(template) ? template : null;
} catch (Exception e) {
log.warn("读取Redis模板失败: {}", key, e);
return null;
}
}
/**
* 验证模板键名是否有效
*/
private boolean isValidKey(String key) {
if (StringUtils.isEmpty(key)) {
return false;
}
for (String validKey : TEMPLATE_KEYS) {
if (validKey.equals(key)) {
return true;
}
}
return false;
}
/**
* 解析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,100 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.TaobaoComment;
import com.ruoyi.jarvis.mapper.TaobaoCommentMapper;
import com.ruoyi.jarvis.service.ITaobaoCommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 淘宝评论管理 Service 实现
*/
@Service
public class TaobaoCommentServiceImpl implements ITaobaoCommentService {
private static final String PRODUCT_TYPE_MAP_PREFIX_TB = "product_type_map_tb";
@Autowired
private TaobaoCommentMapper taobaoCommentMapper;
@Autowired(required = false)
private StringRedisTemplate stringRedisTemplate;
@Override
public List<TaobaoComment> selectTaobaoCommentList(TaobaoComment taobaoComment) {
List<TaobaoComment> list = taobaoCommentMapper.selectTaobaoCommentList(taobaoComment);
// 填充Redis映射的产品类型信息
if (stringRedisTemplate != null) {
Map<String, String> tbMap = getTbProductTypeMap();
for (TaobaoComment c : list) {
// 查找对应的产品类型
String productId = c.getProductId();
if (tbMap != null) {
for (Map.Entry<String, String> entry : tbMap.entrySet()) {
if (entry.getValue().equals(productId)) {
c.setProductType(entry.getKey());
c.setMappedProductId(productId);
break;
}
}
}
}
}
return list;
}
@Override
public TaobaoComment selectTaobaoCommentById(Integer id) {
TaobaoComment comment = taobaoCommentMapper.selectTaobaoCommentById(id);
if (comment != null && stringRedisTemplate != null) {
Map<String, String> tbMap = getTbProductTypeMap();
if (tbMap != null) {
String productId = comment.getProductId();
for (Map.Entry<String, String> entry : tbMap.entrySet()) {
if (entry.getValue().equals(productId)) {
comment.setProductType(entry.getKey());
comment.setMappedProductId(productId);
break;
}
}
}
}
return comment;
}
@Override
public int updateTaobaoCommentIsUse(TaobaoComment taobaoComment) {
return taobaoCommentMapper.updateTaobaoCommentIsUse(taobaoComment);
}
@Override
public int deleteTaobaoCommentByIds(Integer[] ids) {
return taobaoCommentMapper.deleteTaobaoCommentByIds(ids);
}
@Override
public int resetTaobaoCommentIsUseByProductId(String productId) {
return taobaoCommentMapper.resetTaobaoCommentIsUseByProductId(productId);
}
private Map<String, String> getTbProductTypeMap() {
if (stringRedisTemplate == null) {
return null;
}
try {
Map<Object, Object> rawMap = stringRedisTemplate.opsForHash().entries(PRODUCT_TYPE_MAP_PREFIX_TB);
Map<String, String> result = new java.util.LinkedHashMap<>();
for (Map.Entry<Object, Object> entry : rawMap.entrySet()) {
result.put(entry.getKey().toString(), entry.getValue().toString());
}
return result;
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,160 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.jarvis.domain.TencentDocBatchPushRecord;
import com.ruoyi.jarvis.domain.TencentDocOperationLog;
import com.ruoyi.jarvis.mapper.TencentDocBatchPushRecordMapper;
import com.ruoyi.jarvis.mapper.TencentDocOperationLogMapper;
import com.ruoyi.jarvis.service.ITencentDocBatchPushService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 腾讯文档批量推送记录服务实现
*/
@Service
public class TencentDocBatchPushServiceImpl implements ITencentDocBatchPushService {
@Resource
private TencentDocBatchPushRecordMapper batchPushRecordMapper;
@Resource
private TencentDocOperationLogMapper operationLogMapper;
@Resource
private RedisCache redisCache;
private static final String DELAYED_PUSH_TASK_KEY = "tendoc:delayed_push:task_scheduled";
private static final String DELAYED_PUSH_SCHEDULE_TIME_KEY = "tendoc:delayed_push:next_time";
@Override
public String createBatchPushRecord(String fileId, String sheetId, String pushType,
String triggerSource, Integer startRow, Integer endRow) {
String batchId = UUID.randomUUID().toString().replace("-", "");
TencentDocBatchPushRecord record = new TencentDocBatchPushRecord();
record.setBatchId(batchId);
record.setFileId(fileId);
record.setSheetId(sheetId);
record.setPushType(pushType);
record.setTriggerSource(triggerSource);
record.setStartTime(new Date());
record.setStartRow(startRow);
record.setEndRow(endRow);
record.setTotalRows(endRow - startRow + 1);
record.setSuccessCount(0);
record.setSkipCount(0);
record.setErrorCount(0);
record.setStatus("RUNNING");
batchPushRecordMapper.insertBatchPushRecord(record);
return batchId;
}
@Override
public void updateBatchPushRecord(String batchId, String status, Integer successCount,
Integer skipCount, Integer errorCount, String resultMessage, String errorMessage) {
TencentDocBatchPushRecord record = new TencentDocBatchPushRecord();
record.setBatchId(batchId);
record.setEndTime(new Date());
record.setStatus(status);
record.setSuccessCount(successCount);
record.setSkipCount(skipCount);
record.setErrorCount(errorCount);
record.setResultMessage(resultMessage);
record.setErrorMessage(errorMessage);
// 计算耗时
TencentDocBatchPushRecord existingRecord = batchPushRecordMapper.selectByBatchId(batchId);
if (existingRecord != null && existingRecord.getStartTime() != null) {
long durationMs = new Date().getTime() - existingRecord.getStartTime().getTime();
record.setDurationMs(durationMs);
}
batchPushRecordMapper.updateBatchPushRecord(record);
}
@Override
public TencentDocBatchPushRecord getBatchPushRecord(String batchId) {
TencentDocBatchPushRecord record = batchPushRecordMapper.selectByBatchId(batchId);
if (record != null) {
// 加载关联的操作日志
List<TencentDocOperationLog> logs = operationLogMapper.selectLogsByBatchId(batchId);
record.setOperationLogs(logs);
}
return record;
}
@Override
public List<TencentDocBatchPushRecord> getBatchPushRecordListWithLogs(String fileId, String sheetId, Integer limit) {
TencentDocBatchPushRecord query = new TencentDocBatchPushRecord();
query.setFileId(fileId);
query.setSheetId(sheetId);
List<TencentDocBatchPushRecord> records = limit != null && limit > 0
? batchPushRecordMapper.selectRecentRecords(fileId, limit)
: batchPushRecordMapper.selectBatchPushRecordList(query);
// 为每条记录加载操作日志
for (TencentDocBatchPushRecord record : records) {
List<TencentDocOperationLog> logs = operationLogMapper.selectLogsByBatchId(record.getBatchId());
record.setOperationLogs(logs);
}
return records;
}
@Override
public TencentDocBatchPushRecord getLastSuccessRecord(String fileId, String sheetId) {
TencentDocBatchPushRecord record = batchPushRecordMapper.selectLastSuccessRecord(fileId, sheetId);
if (record != null) {
List<TencentDocOperationLog> logs = operationLogMapper.selectLogsByBatchId(record.getBatchId());
record.setOperationLogs(logs);
}
return record;
}
@Override
public Map<String, Object> getPushStatusAndCountdown() {
Map<String, Object> result = new HashMap<>();
// 检查是否有定时任务(通过时间戳是否存在来判断)
Long scheduleTime = redisCache.getCacheObject(DELAYED_PUSH_SCHEDULE_TIME_KEY);
// 如果有推送时间戳,就认为有定时任务
boolean isScheduled = scheduleTime != null;
result.put("isScheduled", isScheduled);
if (isScheduled && scheduleTime != null) {
long now = System.currentTimeMillis();
long remainingMs = scheduleTime - now;
if (remainingMs > 0) {
result.put("scheduledTime", new Date(scheduleTime));
result.put("remainingSeconds", remainingMs / 1000);
result.put("remainingMs", remainingMs);
// 格式化倒计时显示
long minutes = remainingMs / 60000;
long seconds = (remainingMs % 60000) / 1000;
result.put("countdownText", String.format("%d分%d秒", minutes, seconds));
} else {
result.put("remainingSeconds", 0);
result.put("remainingMs", 0);
result.put("countdownText", "即将执行");
}
} else {
result.put("scheduledTime", null);
result.put("remainingSeconds", 0);
result.put("remainingMs", 0);
result.put("countdownText", "无定时任务");
}
return result;
}
}

View File

@@ -0,0 +1,347 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.jarvis.config.TencentDocConfig;
import com.ruoyi.jarvis.service.ITencentDocBatchPushService;
import com.ruoyi.jarvis.service.ITencentDocDelayedPushService;
import com.ruoyi.jarvis.service.ITencentDocTokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 腾讯文档延迟推送服务实现
*
* 功能说明:
* 1. 录单时触发10分钟倒计时
* 2. 10分钟内有新录单重置倒计时
* 3. 10分钟到期后自动执行推送
* 4. 推送执行期间有录单,推送完成后重新开始倒计时
* 5. 使用分布式锁防止并发推送
*
* @author system
*/
@Service
public class TencentDocDelayedPushServiceImpl implements ITencentDocDelayedPushService, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(TencentDocDelayedPushServiceImpl.class);
@Autowired
private RedisCache redisCache;
@Autowired
private ITencentDocBatchPushService batchPushService;
@Autowired
private TencentDocConfig tencentDocConfig;
@Autowired
private ITencentDocTokenService tokenService;
private ApplicationContext applicationContext;
/**
* 延迟时间(分钟),可通过配置文件修改
*/
@Value("${tencent.doc.delayed.push.minutes:10}")
private int delayMinutes;
/**
* Redis Key - 存储下次推送的时间戳
*/
private static final String REDIS_KEY_NEXT_PUSH_TIME = "tendoc:delayed_push:next_time";
/**
* Redis Key - 推送执行锁
*/
private static final String REDIS_KEY_PUSH_LOCK = "tendoc:delayed_push:lock";
/**
* Redis Key - 推送期间有新录单标记
*/
private static final String REDIS_KEY_NEW_ORDER_FLAG = "tendoc:delayed_push:new_order_flag";
/**
* 定时任务执行器
*/
private ScheduledExecutorService scheduler;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
/**
* 初始化定时任务
*/
@PostConstruct
public void init() {
// 创建单线程的定时任务执行器
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "TencentDoc-DelayedPush-Thread");
thread.setDaemon(true);
return thread;
});
// 每30秒检查一次是否需要推送
scheduler.scheduleWithFixedDelay(this::checkAndExecutePush, 30, 30, TimeUnit.SECONDS);
log.info("腾讯文档延迟推送服务已启动,延迟时间: {} 分钟", delayMinutes);
}
/**
* 关闭定时任务
*/
@PreDestroy
public void destroy() {
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
log.info("腾讯文档延迟推送服务已关闭");
}
@Override
public void triggerDelayedPush() {
try {
// 计算下次推送时间 = 当前时间 + 延迟分钟数
long nextPushTime = System.currentTimeMillis() + (delayMinutes * 60 * 1000L);
// 检查是否正在执行推送
String lockValue = redisCache.getCacheObject(REDIS_KEY_PUSH_LOCK);
if (lockValue != null && "locked".equals(lockValue)) {
// 正在推送中,标记有新订单,推送完成后会重新触发
redisCache.setCacheObject(REDIS_KEY_NEW_ORDER_FLAG, "true", 1, TimeUnit.HOURS);
log.info("推送执行中,标记有新订单,推送完成后将重新开始倒计时");
return;
}
// 更新下次推送时间
redisCache.setCacheObject(REDIS_KEY_NEXT_PUSH_TIME, nextPushTime, delayMinutes + 5, TimeUnit.MINUTES);
log.info("触发延迟推送,{}分钟后执行({}", delayMinutes,
new java.text.SimpleDateFormat("HH:mm:ss").format(new java.util.Date(nextPushTime)));
} catch (Exception e) {
log.error("触发延迟推送失败", e);
}
}
@Override
public void executePushNow() {
log.info("手动触发立即推送");
// 清除待推送标记
redisCache.deleteObject(REDIS_KEY_NEXT_PUSH_TIME);
// 执行推送
doExecutePush();
}
@Override
public long getRemainingSeconds() {
try {
Long nextPushTime = redisCache.getCacheObject(REDIS_KEY_NEXT_PUSH_TIME);
if (nextPushTime == null) {
return -1;
}
long remaining = (nextPushTime - System.currentTimeMillis()) / 1000;
return remaining > 0 ? remaining : 0;
} catch (Exception e) {
log.error("获取剩余时间失败", e);
return -1;
}
}
@Override
public void cancelPendingPush() {
redisCache.deleteObject(REDIS_KEY_NEXT_PUSH_TIME);
log.info("已取消待推送任务");
}
/**
* 定时检查并执行推送
*/
private void checkAndExecutePush() {
try {
// 获取下次推送时间
Long nextPushTime = redisCache.getCacheObject(REDIS_KEY_NEXT_PUSH_TIME);
if (nextPushTime == null) {
// 没有待推送任务
return;
}
long now = System.currentTimeMillis();
if (now < nextPushTime) {
// 还没到推送时间
long remainingSeconds = (nextPushTime - now) / 1000;
log.debug("距离下次推送还有 {} 秒", remainingSeconds);
return;
}
// 时间到了,执行推送
log.info("倒计时结束,开始执行推送");
doExecutePush();
} catch (Exception e) {
log.error("检查推送任务失败", e);
}
}
/**
* 执行推送
*/
private void doExecutePush() {
String lockValue = null;
try {
// 1. 尝试获取分布式锁
lockValue = redisCache.getCacheObject(REDIS_KEY_PUSH_LOCK);
if (lockValue != null && "locked".equals(lockValue)) {
log.warn("推送任务已在执行中,跳过本次推送");
return;
}
// 2. 加锁30分钟超时防止死锁
redisCache.setCacheObject(REDIS_KEY_PUSH_LOCK, "locked", 30, TimeUnit.MINUTES);
log.info("✓ 获取推送锁成功,开始执行推送");
// 3. 清除待推送标记
redisCache.deleteObject(REDIS_KEY_NEXT_PUSH_TIME);
// 4. 清除新订单标记
redisCache.deleteObject(REDIS_KEY_NEW_ORDER_FLAG);
// 5. 调用批量同步接口
// 注意这里需要通过HTTP调用Controller的接口或者注入Controller的方法
// 为了避免循环依赖这里使用Spring的ApplicationContext来获取Bean
executeBatchSync();
log.info("✓ 推送执行完成");
} catch (Exception e) {
log.error("❌ 推送执行失败", e);
} finally {
// 6. 释放锁
try {
redisCache.deleteObject(REDIS_KEY_PUSH_LOCK);
log.info("✓ 释放推送锁");
} catch (Exception e) {
log.error("释放推送锁失败", e);
}
// 7. 检查是否有新订单标记
String newOrderFlag = redisCache.getCacheObject(REDIS_KEY_NEW_ORDER_FLAG);
if (newOrderFlag != null && "true".equals(newOrderFlag)) {
log.info("推送期间有新订单,重新开始倒计时");
redisCache.deleteObject(REDIS_KEY_NEW_ORDER_FLAG);
triggerDelayedPush();
}
}
}
/**
* 执行批量同步
*
* 说明创建批量推送记录然后通过HTTP调用本地接口
*/
private void executeBatchSync() {
String batchId = null;
try {
log.info("开始执行批量同步...");
// 从 Redis 读取配置信息(用户通过前端配置页面设置)
// 注意:使用与 TencentDocConfigController 相同的 key 前缀
final String CONFIG_KEY_PREFIX = "tencent:doc:auto:config:";
String fileId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "fileId");
String sheetId = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "sheetId");
Integer startRow = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "startRow");
if (startRow == null) {
startRow = 3; // 默认值
}
log.info("读取配置 - fileId: {}, sheetId: {}, startRow: {}", fileId, sheetId, startRow);
if (StringUtils.isEmpty(fileId) || StringUtils.isEmpty(sheetId)) {
log.error("腾讯文档配置不完整无法执行批量同步。请先在前端配置页面设置文件ID和工作表ID");
return;
}
// 创建批量推送记录
batchId = batchPushService.createBatchPushRecord(
fileId,
sheetId,
"AUTO",
"DELAYED_TIMER",
startRow,
startRow + 199 // 暂定范围
);
log.info("✓ 创建批量推送记录批次ID: {}", batchId);
// 直接通过 ApplicationContext 获取 Controller Bean 并调用方法
// 这样避免了 HTTP 调用,是后端内部方法调用
try {
log.info("开始调用批量同步方法(后端内部调用)...");
// 获取 TencentDocController Bean
Object controller = applicationContext.getBean("tencentDocController");
// 通过反射调用 fillLogisticsByOrderNo 方法
java.lang.reflect.Method method = controller.getClass().getMethod(
"fillLogisticsByOrderNo",
java.util.Map.class
);
// 构造参数
java.util.Map<String, Object> params = new java.util.HashMap<>();
params.put("batchId", batchId);
params.put("fileId", fileId);
params.put("sheetId", sheetId);
// 调用方法
Object result = method.invoke(controller, params);
log.info("✓ 批量同步执行完成,结果: {}", result);
} catch (Exception ex) {
log.error("批量同步调用失败", ex);
if (batchId != null) {
batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0,
null, "批量同步调用失败: " + ex.getMessage());
}
}
} catch (Exception e) {
log.error("执行批量同步失败", e);
// 更新批量推送记录为失败状态
if (batchId != null) {
try {
batchPushService.updateBatchPushRecord(batchId, "FAILED", 0, 0, 0,
null, "执行批量同步失败: " + e.getMessage());
} catch (Exception ex) {
log.error("更新批量推送记录失败", ex);
}
}
throw new RuntimeException("执行批量同步失败", e);
}
}
}

View File

@@ -28,6 +28,9 @@ public class TencentDocServiceImpl implements ITencentDocService {
@Autowired
private TencentDocConfig tencentDocConfig;
@Autowired
private com.ruoyi.common.core.redis.RedisCache redisCache;
@Override
public String getAuthUrl() {
if (tencentDocConfig == null) {
@@ -168,59 +171,224 @@ public class TencentDocServiceImpl implements ITencentDocService {
}
@Override
public JSONObject appendLogisticsToSheet(String accessToken, String fileId, String sheetId, JDOrder order) {
public JSONObject appendLogisticsToSheet(String accessToken, String fileId, String sheetId, Integer startRow, JDOrder order) {
try {
if (order == null) {
throw new IllegalArgumentException("订单信息不能为空");
}
// 获取用户信息包含Open-Id
// 官方响应格式:{ "ret": 0, "msg": "Succeed", "data": { "openID": "xxx", ... } }
JSONObject userInfo = TencentDocApiUtil.getUserInfo(accessToken);
JSONObject data = userInfo.getJSONObject("data");
if (data == null) {
throw new RuntimeException("无法获取用户数据请检查Access Token是否有效");
}
String openId = data.getString("openID"); // 注意:官方返回的字段名是 openID大写ID
if (openId == null || openId.isEmpty()) {
throw new RuntimeException("无法获取Open-Id请检查Access Token是否有效");
log.info("录单自动写入腾讯文档 - fileId: {}, sheetId: {}, startRow: {}, 订单单号: {}",
fileId, sheetId, startRow, order.getThirdPartyOrderNo());
// 1. 读取表头从Redis或配置获取headerRow
final String CONFIG_KEY_PREFIX = "tencent:doc:auto:config:";
Integer headerRowNum = redisCache.getCacheObject(CONFIG_KEY_PREFIX + "headerRow");
if (headerRowNum == null) {
headerRowNum = tencentDocConfig.getHeaderRow();
}
// 构建单行数据
JSONArray values = new JSONArray();
JSONArray row = new JSONArray();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String headerRange = String.format("A%d:Z%d", headerRowNum, headerRowNum);
JSONObject headerData = readSheetData(accessToken, fileId, sheetId, headerRange);
// 根据表格列顺序添加数据
row.add(order.getRemark() != null ? order.getRemark() : "");
row.add(order.getOrderId() != null ? order.getOrderId() : "");
row.add(order.getOrderTime() != null ? sdf.format(order.getOrderTime()) : "");
row.add(order.getModelNumber() != null ? order.getModelNumber() : "");
row.add(order.getAddress() != null ? order.getAddress() : "");
row.add(order.getLogisticsLink() != null ? order.getLogisticsLink() : "");
row.add(order.getBuyer() != null ? order.getBuyer() : "");
row.add(order.getPaymentAmount() != null ? order.getPaymentAmount().toString() : "");
row.add(order.getRebateAmount() != null ? order.getRebateAmount().toString() : "");
row.add(order.getStatus() != null ? order.getStatus() : "");
if (headerData == null || !headerData.containsKey("values")) {
throw new RuntimeException("无法读取表头数据");
}
values.add(row);
JSONArray headerValues = headerData.getJSONArray("values");
if (headerValues == null || headerValues.isEmpty()) {
throw new RuntimeException("表头数据为空");
}
// 追加数据到表格
return TencentDocApiUtil.appendSheetData(
accessToken,
tencentDocConfig.getAppId(),
openId,
fileId,
sheetId,
values,
tencentDocConfig.getApiBaseUrl()
);
JSONArray headerCells = headerValues.getJSONArray(0);
// 2. 识别列位置(根据表头)
Integer dateColumn = null;
Integer companyColumn = null;
Integer orderNoColumn = null;
Integer modelColumn = null;
Integer quantityColumn = null;
Integer nameColumn = null;
Integer phoneColumn = null;
Integer addressColumn = null;
Integer priceColumn = null;
Integer remarkColumn = null;
Integer arrangedColumn = null; // 是否安排列
Integer logisticsColumn = null;
for (int i = 0; i < headerCells.size(); i++) {
String cellText = headerCells.getString(i);
if (cellText != null) {
if (cellText.contains("日期")) dateColumn = i;
else if (cellText.contains("公司")) companyColumn = i;
else if (cellText.contains("单号")) orderNoColumn = i;
else if (cellText.contains("型号")) modelColumn = i;
else if (cellText.contains("数量")) quantityColumn = i;
else if (cellText.contains("姓名")) nameColumn = i;
else if (cellText.contains("电话")) phoneColumn = i;
else if (cellText.contains("地址")) addressColumn = i;
else if (cellText.contains("价格")) priceColumn = i;
else if (cellText.contains("备注")) remarkColumn = i;
else if (cellText.contains("是否安排") || cellText.contains("安排")) arrangedColumn = i;
else if (cellText.contains("物流")) logisticsColumn = i;
}
}
if (orderNoColumn == null) {
throw new RuntimeException("未找到'单号'列,请检查表头配置");
}
log.info("表头识别完成 - 单号列: {}, 物流列: {}", orderNoColumn, logisticsColumn);
// 3. 读取数据区域,查找第一个空行(单号列为空)
String dataRange = String.format("A%d:Z%d", startRow, startRow + 999);
JSONObject sheetData = readSheetData(accessToken, fileId, sheetId, dataRange);
if (sheetData == null || !sheetData.containsKey("values")) {
throw new RuntimeException("无法读取数据区域");
}
JSONArray dataRows = sheetData.getJSONArray("values");
int targetRow = -1;
// 查找第一个单号列为空的行
if (dataRows == null || dataRows.isEmpty()) {
// 数据区域完全为空使用startRow
targetRow = startRow;
} else {
for (int i = 0; i < dataRows.size(); i++) {
JSONArray row = dataRows.getJSONArray(i);
if (row == null || row.size() <= orderNoColumn) {
targetRow = startRow + i;
break;
}
String orderNo = row.getString(orderNoColumn);
if (orderNo == null || orderNo.trim().isEmpty()) {
targetRow = startRow + i;
break;
}
}
// 如果没找到空行,追加到数据区域末尾
if (targetRow == -1) {
targetRow = startRow + dataRows.size();
}
}
log.info("找到空行位置:第 {} 行", targetRow);
// 4. 构建要写入的数据(按表头列顺序)
SimpleDateFormat dateFormat = new SimpleDateFormat("yyMMdd");
String today = dateFormat.format(new java.util.Date());
JSONArray requests = new JSONArray();
int rowIndex = targetRow - 1; // 转为0索引
// 写入各列数据
if (dateColumn != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, dateColumn, today, false));
}
if (companyColumn != null && order.getDistributionMark() != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, companyColumn, order.getDistributionMark(), false));
}
if (orderNoColumn != null && order.getThirdPartyOrderNo() != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, orderNoColumn, order.getThirdPartyOrderNo(), false));
}
if (modelColumn != null && order.getModelNumber() != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, modelColumn, order.getModelNumber(), false));
}
// 数量列 - JDOrder 没有 quantity 字段,暂时跳过
// if (quantityColumn != null) { ... }
if (nameColumn != null && order.getBuyer() != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, nameColumn, order.getBuyer(), false));
}
// 电话列 - JDOrder 没有 phone 字段,暂时跳过
// if (phoneColumn != null) { ... }
if (addressColumn != null && order.getAddress() != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, addressColumn, order.getAddress(), false));
}
if (priceColumn != null && order.getPaymentAmount() != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, priceColumn, order.getPaymentAmount().toString(), false));
}
// 备注列固定填写"有货"
if (remarkColumn != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, remarkColumn, "有货", false));
}
// 是否安排列固定填写"2"
if (arrangedColumn != null) {
requests.add(buildUpdateRequest(sheetId, rowIndex, arrangedColumn, "2", false));
}
if (logisticsColumn != null && order.getLogisticsLink() != null && !order.getLogisticsLink().isEmpty()) {
requests.add(buildUpdateRequest(sheetId, rowIndex, logisticsColumn, order.getLogisticsLink(), true)); // 超链接
}
// 5. 使用 batchUpdate 写入
if (requests.isEmpty()) {
throw new RuntimeException("没有数据可以写入");
}
JSONObject batchUpdateBody = new JSONObject();
batchUpdateBody.put("requests", requests);
JSONObject result = batchUpdate(accessToken, fileId, batchUpdateBody);
log.info("✓ 订单成功写入腾讯文档 - 行: {}, 单号: {}", targetRow, order.getThirdPartyOrderNo());
JSONObject response = new JSONObject();
response.put("row", targetRow);
response.put("orderNo", order.getThirdPartyOrderNo());
response.put("success", true);
return response;
} catch (Exception e) {
log.error("追加物流信息到表格失败", e);
throw new RuntimeException("追加物流信息失败: " + e.getMessage(), e);
}
}
/**
* 构建单元格更新请求
*/
private JSONObject buildUpdateRequest(String sheetId, int rowIndex, int columnIndex, String value, boolean isLink) {
JSONObject updateRangeRequest = new JSONObject();
updateRangeRequest.put("sheetId", sheetId);
JSONObject gridData = new JSONObject();
gridData.put("startRow", rowIndex);
gridData.put("startColumn", columnIndex);
JSONArray rows = new JSONArray();
JSONObject rowData = new JSONObject();
JSONArray cellValues = new JSONArray();
JSONObject cellData = new JSONObject();
JSONObject cellValue = new JSONObject();
if (isLink) {
JSONObject link = new JSONObject();
link.put("url", value);
link.put("text", value);
cellValue.put("link", link);
} else {
cellValue.put("text", value);
}
cellData.put("cellValue", cellValue);
cellValues.add(cellData);
rowData.put("values", cellValues);
rows.add(rowData);
gridData.put("rows", rows);
updateRangeRequest.put("gridData", gridData);
JSONObject request = new JSONObject();
request.put("updateRangeRequest", updateRangeRequest);
return request;
}
@Override
public JSONObject readSheetData(String accessToken, String fileId, String sheetId, String range) {
try {
@@ -261,7 +429,7 @@ public class TencentDocServiceImpl implements ITencentDocService {
tencentDocConfig.getApiBaseUrl()
);
log.info("API调用成功原始返回结果: {}", result != null ? result.toJSONString() : "null");
//log.info("API调用成功原始返回结果: {}", result != null ? result.toJSONString() : "null");
// 检查API响应中的错误码
// 根据官方文档,成功响应包含 ret=0错误响应包含 code!=0

View File

@@ -0,0 +1,483 @@
package com.ruoyi.jarvis.service.impl;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.jarvis.config.WPS365Config;
import com.ruoyi.jarvis.service.IWPS365ApiService;
import com.ruoyi.jarvis.util.WPS365ApiUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* WPS365 API服务实现类
*
* @author system
*/
@Service
public class WPS365ApiServiceImpl implements IWPS365ApiService {
private static final Logger log = LoggerFactory.getLogger(WPS365ApiServiceImpl.class);
@Autowired
private WPS365Config wps365Config;
@Override
public JSONObject getUserInfo(String accessToken) {
try {
// WPS365用户信息API: GET /api/v1/user/info
// 注意如果此API不存在或需要不同的路径请查看WPS365官方文档
String url = wps365Config.getApiBaseUrl() + "/user/info";
log.debug("调用用户信息API: {}", url);
JSONObject result = WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
log.debug("用户信息API响应: {}", result);
return result;
} catch (Exception e) {
log.error("获取用户信息失败 - url: {}, error: {}",
wps365Config.getApiBaseUrl() + "/user/info", e.getMessage(), e);
// 不抛出异常,让调用方处理(使用降级方案)
throw new RuntimeException("获取用户信息失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject getFileList(String accessToken, Map<String, Object> params) {
try {
// WPS365文件列表API路径可能是 /yundoc/files 而不是 /files
// 根据WPS365 API文档文件相关API通常在 /yundoc 路径下
StringBuilder url = new StringBuilder(wps365Config.getApiBaseUrl() + "/yundoc/files");
// 添加查询参数
if (params != null && !params.isEmpty()) {
url.append("?");
boolean first = true;
for (Map.Entry<String, Object> entry : params.entrySet()) {
if (!first) {
url.append("&");
}
// URL编码参数值
try {
String key = entry.getKey();
String value = entry.getValue() != null ? entry.getValue().toString() : "";
url.append(key).append("=").append(java.net.URLEncoder.encode(value, "UTF-8"));
} catch (java.io.UnsupportedEncodingException e) {
url.append(entry.getKey()).append("=").append(entry.getValue());
}
first = false;
}
}
log.debug("调用文件列表API: {}", url.toString());
return WPS365ApiUtil.httpRequest("GET", url.toString(), accessToken, null);
} catch (Exception e) {
log.error("获取文件列表失败 - url: {}", wps365Config.getApiBaseUrl() + "/yundoc/files", e);
throw new RuntimeException("获取文件列表失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject getFileInfo(String accessToken, String fileToken) {
try {
// WPS365文件信息API路径/yundoc/files/{fileToken}
String url = wps365Config.getApiBaseUrl() + "/yundoc/files/" + fileToken;
log.debug("调用文件信息API: {}", url);
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.error("获取文件信息失败 - fileToken: {}, url: {}", fileToken,
wps365Config.getApiBaseUrl() + "/yundoc/files/" + fileToken, e);
throw new RuntimeException("获取文件信息失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject updateCells(String accessToken, String fileToken, int sheetIdx, String range, List<List<Object>> values) {
try {
// WPS365 KSheet API: /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells";
// 构建请求体
JSONObject requestBody = new JSONObject();
requestBody.put("range", range);
// 将values转换为JSONArray
JSONArray valuesArray = new JSONArray();
if (values != null) {
for (List<Object> row : values) {
JSONArray rowArray = new JSONArray();
if (row != null) {
for (Object cell : row) {
rowArray.add(cell != null ? cell : "");
}
}
valuesArray.add(rowArray);
}
}
requestBody.put("values", valuesArray);
String bodyStr = requestBody.toJSONString();
log.debug("更新单元格数据 - url: {}, range: {}, values: {}", url, range, bodyStr);
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
} catch (Exception e) {
log.error("更新单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e);
throw new RuntimeException("更新单元格数据失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject readCells(String accessToken, String fileToken, int sheetIdx, String range) {
try {
// WPS365 KSheet API: GET /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells";
if (range != null && !range.trim().isEmpty()) {
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.error("读取单元格数据失败 - fileToken: {}, sheetIdx: {}, range: {}", fileToken, sheetIdx, range, e);
throw new RuntimeException("读取单元格数据失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject getSheetList(String accessToken, String fileToken) {
try {
// WPS365 KSheet API: GET /api/v1/openapi/ksheet/:file_token/sheets
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets";
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.error("获取工作表列表失败 - fileToken: {}", fileToken, e);
throw new RuntimeException("获取工作表列表失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject createSheet(String accessToken, String fileToken, String sheetName) {
try {
// WPS365 KSheet API: POST /api/v1/openapi/ksheet/:file_token/sheets
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets";
JSONObject requestBody = new JSONObject();
requestBody.put("name", sheetName);
String bodyStr = requestBody.toJSONString();
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
} catch (Exception e) {
log.error("创建数据表失败 - fileToken: {}, sheetName: {}", fileToken, sheetName, e);
throw new RuntimeException("创建数据表失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject batchUpdateCells(String accessToken, String fileToken, int sheetIdx, List<Map<String, Object>> updates) {
try {
// WPS365 KSheet API: POST /api/v1/openapi/ksheet/:file_token/sheets/:sheet_idx/cells/batch
String url = wps365Config.getApiBaseUrl() + "/openapi/ksheet/" + fileToken + "/sheets/" + sheetIdx + "/cells/batch";
JSONObject requestBody = new JSONObject();
JSONArray updatesArray = new JSONArray();
if (updates != null) {
for (Map<String, Object> update : updates) {
JSONObject updateObj = new JSONObject();
updateObj.put("range", update.get("range"));
// 将values转换为JSONArray
@SuppressWarnings("unchecked")
List<List<Object>> values = (List<List<Object>>) update.get("values");
JSONArray valuesArray = new JSONArray();
if (values != null) {
for (List<Object> row : values) {
JSONArray rowArray = new JSONArray();
if (row != null) {
for (Object cell : row) {
rowArray.add(cell != null ? cell : "");
}
}
valuesArray.add(rowArray);
}
}
updateObj.put("values", valuesArray);
updatesArray.add(updateObj);
}
}
requestBody.put("updates", updatesArray);
String bodyStr = requestBody.toJSONString();
return WPS365ApiUtil.httpRequest("POST", url, accessToken, bodyStr);
} catch (Exception e) {
log.error("批量更新单元格数据失败 - fileToken: {}, sheetIdx: {}", fileToken, sheetIdx, e);
throw new RuntimeException("批量更新单元格数据失败: " + e.getMessage(), e);
}
}
@Override
public JSONObject readAirSheetCells(String accessToken, String fileId, String worksheetId, String range) {
try {
// WPS365 AirSheet API路径格式
// 根据文档https://open.wps.cn/documents/app-integration-dev/wps365/server/airsheet/worksheets/VbHZwButmh
// 注意AirSheet中fileId和worksheetId可能是同一个值或者worksheetId是整数索引
// 如果用户只提供了一个IDfileId则fileId和worksheetId使用同一个值
String baseUrl = "https://openapi.wps.cn/v7";
// 根据WPS365官方文档AirSheet的worksheet_id必须是整数
// 如果worksheetId为空或"0"使用0第一个工作表
// 如果worksheetId与fileId相同说明用户只配置了一个ID尝试使用fileId作为worksheetId
String wsId;
if (worksheetId == null || worksheetId.trim().isEmpty() || "0".equals(worksheetId)) {
// 默认使用0第一个工作表
wsId = "0";
} else if (worksheetId.equals(fileId)) {
// 如果worksheetId与fileId相同说明用户只配置了一个ID类似腾讯文档
// 在AirSheet中这个ID可能既是file_id也是worksheet_id
wsId = fileId;
} else {
// 使用提供的worksheetId应该是整数
wsId = worksheetId;
}
// 根据官方文档https://open.wps.cn/documents/app-integration-dev/wps365/server/airsheet/worksheets/VbHZwButmh
// 正确路径https://openapi.wps.cn/v7/airsheet/{file_id}/worksheets
// 注意:路径中不需要 worksheet_id只需要 file_id
try {
String url = baseUrl + "/airsheet/" + fileId + "/worksheets";
// 如果指定了range添加range参数
if (range != null && !range.trim().isEmpty()) {
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
// 如果指定了worksheetId也可以作为查询参数传递如果API支持
if (worksheetId != null && !worksheetId.trim().isEmpty() && !worksheetId.equals("0") && !worksheetId.equals(fileId)) {
if (url.contains("?")) {
url += "&worksheet_id=" + java.net.URLEncoder.encode(worksheetId, "UTF-8");
} else {
url += "?worksheet_id=" + java.net.URLEncoder.encode(worksheetId, "UTF-8");
}
}
log.debug("使用官方文档路径 - url: {}, fileId: {}, worksheetId: {}, range: {}", url, fileId, worksheetId, range);
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.debug("官方文档路径失败,尝试其他方案", e);
}
// 尝试多种API路径格式降级方案
// 方案1: 尝试使用fileId作为worksheetId如果用户只配置了一个ID
if (wsId.equals("0") && !fileId.equals(worksheetId)) {
try {
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + fileId;
if (range != null && !range.trim().isEmpty()) {
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
log.debug("尝试方案1 - 使用fileId作为worksheetId - url: {}", url);
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.debug("方案1失败尝试其他方案", e);
}
}
// 方案2: 使用 /range_data 子路径(根据官方文档,这是读取区域数据的标准路径)
// 注意range_data接口需要使用 row_from, row_to, col_from, col_to 参数,而不是 range=A1:B5
try {
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId + "/range_data";
if (range != null && !range.trim().isEmpty()) {
// 尝试解析A1:B5格式的range转换为行列参数
int[] rangeParams = parseRangeToRowCol(range);
if (rangeParams != null && rangeParams.length == 4) {
// 使用行列参数格式row_from, row_to, col_from, col_to
// 注意WPS365的行列索引可能从0开始或从1开始需要测试确认
url += "?row_from=" + rangeParams[0] + "&row_to=" + rangeParams[1]
+ "&col_from=" + rangeParams[2] + "&col_to=" + rangeParams[3];
} else {
// 如果解析失败尝试作为range参数传递
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
}
log.debug("尝试方案2 - 使用/range_data子路径 - url: {}", url);
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.debug("方案2失败尝试其他方案", e);
}
// 方案3: 使用 /values 子路径
try {
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId + "/values";
if (range != null && !range.trim().isEmpty()) {
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
log.debug("尝试方案3 - 使用/values子路径 - url: {}", url);
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e) {
log.debug("方案3失败尝试其他方案", e);
}
// 方案4: 基础路径(不带子路径)
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId;
if (range != null && !range.trim().isEmpty()) {
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
log.debug("尝试方案4 - 基础路径 - url: {}", url);
JSONObject result = WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
return result;
} catch (Exception e) {
log.error("读取AirSheet数据失败 - fileId: {}, worksheetId: {}, range: {}", fileId, worksheetId, range, e);
// 如果失败,尝试使用 /values 子路径
try {
String baseUrl = "https://openapi.wps.cn/v7";
String wsId = (worksheetId != null && !worksheetId.trim().isEmpty() && !worksheetId.equals(fileId)) ? worksheetId : fileId;
String url = baseUrl + "/airsheet/" + fileId + "/worksheets/" + wsId + "/values";
if (range != null && !range.trim().isEmpty()) {
url += "?range=" + java.net.URLEncoder.encode(range, "UTF-8");
}
log.debug("尝试使用/values子路径 - url: {}", url);
return WPS365ApiUtil.httpRequest("GET", url, accessToken, null);
} catch (Exception e2) {
log.error("使用/values子路径也失败", e2);
throw new RuntimeException("读取AirSheet数据失败: " + e.getMessage(), e);
}
}
}
@Override
public JSONObject updateAirSheetCells(String accessToken, String fileId, String worksheetId, String range, List<List<Object>> values) {
try {
// WPS365 AirSheet API路径格式
// 根据文档https://open.wps.cn/documents/app-integration-dev/wps365/server/airsheet/worksheets/VbHZwButmh
// 正确路径https://openapi.wps.cn/v7/airsheet/{file_id}/worksheets
// 注意:路径中不需要 worksheet_id只需要 file_id
String baseUrl = "https://openapi.wps.cn/v7";
// 使用官方文档中的正确路径格式
String url = baseUrl + "/airsheet/" + fileId + "/worksheets";
// 构建请求体
JSONObject requestBody = new JSONObject();
if (range != null && !range.trim().isEmpty()) {
requestBody.put("range", range);
}
// 构建values数组
JSONArray valuesArray = new JSONArray();
if (values != null) {
for (List<Object> row : values) {
JSONArray rowArray = new JSONArray();
if (row != null) {
for (Object cell : row) {
rowArray.add(cell);
}
}
valuesArray.add(rowArray);
}
}
requestBody.put("values", valuesArray);
// 如果指定了worksheetId也可以添加到请求体中如果API支持
if (worksheetId != null && !worksheetId.trim().isEmpty() && !worksheetId.equals("0") && !worksheetId.equals(fileId)) {
requestBody.put("worksheet_id", worksheetId);
}
String bodyStr = requestBody.toJSONString();
log.debug("更新AirSheet数据 - url: {}, fileId: {}, worksheetId: {}, range: {}, values: {}",
url, fileId, worksheetId, range, bodyStr);
return WPS365ApiUtil.httpRequest("PUT", url, accessToken, bodyStr);
} catch (Exception e) {
log.error("更新AirSheet数据失败 - fileId: {}, worksheetId: {}, range: {}", fileId, worksheetId, range, e);
throw new RuntimeException("更新AirSheet数据失败: " + e.getMessage(), e);
}
}
/**
* 解析A1:B5格式的range转换为行列参数
* 返回数组:[row_from, row_to, col_from, col_to]
* 注意WPS365的行列索引可能从0开始或从1开始这里假设从1开始Excel标准
*
* @param range 单元格范围,如 "A1:B5"
* @return 行列参数数组如果解析失败返回null
*/
private int[] parseRangeToRowCol(String range) {
if (range == null || range.trim().isEmpty()) {
return null;
}
try {
// 解析A1:B5格式
String[] parts = range.split(":");
if (parts.length != 2) {
return null;
}
String startCell = parts[0].trim();
String endCell = parts[1].trim();
// 解析起始单元格,如 "A1" -> row=1, col=1
int[] start = parseCellAddress(startCell);
int[] end = parseCellAddress(endCell);
if (start == null || end == null) {
return null;
}
// 返回 [row_from, row_to, col_from, col_to]
// 注意WPS365可能从0开始索引这里先使用从1开始的索引Excel标准
// 如果API要求从0开始需要减1
return new int[]{start[0], end[0], start[1], end[1]};
} catch (Exception e) {
log.warn("解析range失败: {}", range, e);
return null;
}
}
/**
* 解析单元格地址,如 "A1" -> [row=1, col=1]
*
* @param cellAddress 单元格地址,如 "A1", "B5"
* @return [row, col] 数组如果解析失败返回null
*/
private int[] parseCellAddress(String cellAddress) {
if (cellAddress == null || cellAddress.trim().isEmpty()) {
return null;
}
try {
// 分离字母部分(列)和数字部分(行)
// 例如 "A1" -> col="A", row="1"
String colStr = "";
String rowStr = "";
for (char c : cellAddress.toCharArray()) {
if (Character.isLetter(c)) {
colStr += c;
} else if (Character.isDigit(c)) {
rowStr += c;
}
}
if (colStr.isEmpty() || rowStr.isEmpty()) {
return null;
}
// 转换列字母为数字A=1, B=2, ..., Z=26, AA=27, ...
int col = 0;
for (char c : colStr.toUpperCase().toCharArray()) {
col = col * 26 + (c - 'A' + 1);
}
// 转换行号为整数
int row = Integer.parseInt(rowStr);
return new int[]{row, col};
} catch (Exception e) {
log.warn("解析单元格地址失败: {}", cellAddress, e);
return null;
}
}
}

View File

@@ -0,0 +1,364 @@
package com.ruoyi.jarvis.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.jarvis.config.WPS365Config;
import com.ruoyi.jarvis.domain.dto.WPS365TokenInfo;
import com.ruoyi.jarvis.service.IWPS365OAuthService;
import com.ruoyi.jarvis.service.IWPS365ApiService;
import com.ruoyi.jarvis.util.WPS365ApiUtil;
import com.ruoyi.common.core.redis.RedisCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* WPS365 OAuth授权服务实现类
*
* @author system
*/
@Service
public class WPS365OAuthServiceImpl implements IWPS365OAuthService {
private static final Logger log = LoggerFactory.getLogger(WPS365OAuthServiceImpl.class);
/** Redis key前缀用于存储用户token */
private static final String TOKEN_KEY_PREFIX = "wps365:token:";
/** Token过期时间默认30天 */
private static final long TOKEN_EXPIRE_TIME = 30 * 24 * 60 * 60;
@Autowired
private WPS365Config wps365Config;
@Autowired
private RedisCache redisCache;
@Autowired
private IWPS365ApiService wps365ApiService;
@Override
public String getAuthUrl(String state) {
if (wps365Config == null) {
throw new RuntimeException("WPS365配置未加载请检查WPS365Config是否正确注入");
}
String appId = wps365Config.getAppId();
String redirectUri = wps365Config.getRedirectUri();
String oauthUrl = wps365Config.getOauthUrl();
log.debug("获取授权URL - appId: {}, redirectUri: {}, oauthUrl: {}", appId, redirectUri, oauthUrl);
// 验证配置参数
if (appId == null || appId.trim().isEmpty()) {
throw new RuntimeException("WPS365应用ID未配置请检查application.yml中的wps365.app-id配置");
}
if (redirectUri == null || redirectUri.trim().isEmpty()) {
throw new RuntimeException("WPS365回调地址未配置请检查application.yml中的wps365.redirect-uri配置");
}
// 构建授权URL
StringBuilder authUrl = new StringBuilder();
authUrl.append(oauthUrl);
// WPS365可能使用 app_id 而不是 client_id先尝试 client_id标准OAuth2参数
// 如果失败,可能需要改为 app_id
authUrl.append("?client_id=").append(appId);
log.debug("授权URL参数 - client_id: {}", appId);
// 重要redirect_uri必须与WPS365开放平台配置的回调地址完全一致
// 包括协议(https)、域名、路径,不能有多余的斜杠
String finalRedirectUri = redirectUri.trim();
// 确保URL末尾没有斜杠除非是根路径
if (finalRedirectUri.endsWith("/") && !finalRedirectUri.equals("https://")) {
finalRedirectUri = finalRedirectUri.substring(0, finalRedirectUri.length() - 1);
}
// 验证redirect_uri不为空
if (finalRedirectUri == null || finalRedirectUri.isEmpty()) {
throw new RuntimeException("redirect_uri不能为空请检查application.yml中的wps365.redirect-uri配置");
}
try {
String encodedRedirectUri = java.net.URLEncoder.encode(finalRedirectUri, "UTF-8");
authUrl.append("&redirect_uri=").append(encodedRedirectUri);
log.info("使用回调地址: {} (编码后: {})", finalRedirectUri, encodedRedirectUri);
} catch (java.io.UnsupportedEncodingException e) {
log.error("URL编码失败", e);
authUrl.append("&redirect_uri=").append(finalRedirectUri);
}
// response_type参数必需
authUrl.append("&response_type=code");
log.debug("授权URL参数 - response_type: code");
// scope参数必需根据WPS365官方文档
// 优先使用配置文件中指定的scope如果没有配置则使用默认值
// 重要WPS365官方文档明确要求使用英文逗号分隔且权限名称必须与后台注册的完全一致
// 根据官方文档https://open.wps.cn/documents/app-integration-dev/wps365/server/
// 权限名称格式为kso.xxx.read 或 kso.xxx.readwrite不是 file.read
String scope = wps365Config.getScope();
if (scope == null || scope.trim().isEmpty()) {
// 默认scope根据WPS365官方文档
// 1. 必须使用英文逗号分隔(不是空格)
// 2. 权限名称必须以 kso. 开头格式如kso.file.read, kso.file.readwrite
// 3. 常见权限名称(根据官方文档):
// - kso.file.read (文件读取)
// - kso.file.readwrite (文件读写)
// - kso.doclib.readwrite (文档库读写)
// - kso.wiki.readwrite (知识库读写)
// - 对于在线表格AirSheet/KSheet可能需要 kso.file.readwrite
//
// 如果报错invalid_scope
// 1. 登录WPS365开放平台https://open.wps.cn/
// 2. 进入"开发配置" > "权限管理"
// 3. 查看已申请权限的准确名称(必须以 kso. 开头)
// 4. 在application.yml中配置scope参数使用逗号分隔
scope = "kso.file.readwrite"; // 默认使用文件读写权限(支持在线表格操作)
}
scope = scope.trim();
// URL编码scope参数
try {
String encodedScope = java.net.URLEncoder.encode(scope, "UTF-8");
authUrl.append("&scope=").append(encodedScope);
log.debug("授权URL参数 - scope: {} (编码后: {})", scope, encodedScope);
} catch (java.io.UnsupportedEncodingException e) {
log.error("Scope URL编码失败", e);
authUrl.append("&scope=").append(scope);
}
// state参数推荐用于防止CSRF攻击
if (state == null || state.trim().isEmpty()) {
state = UUID.randomUUID().toString();
}
authUrl.append("&state=").append(state);
log.debug("授权URL参数 - state: {}", state);
// prompt参数可选用于控制授权页面显示
// prompt=consent: 强制显示授权确认页面,即使用户已授权过
// prompt=login: 强制显示登录页面
// 如果不添加此参数,已登录且已授权的用户会直接跳过授权页面
// 注意WPS365可能不支持此参数如果不支持会被忽略
authUrl.append("&prompt=consent");
log.debug("授权URL参数 - prompt: consent (强制显示授权确认页面)");
String result = authUrl.toString();
log.info("生成授权URL: {}", result);
log.warn("⚠️ 请确保WPS365开放平台配置的回调地址与以下地址完全一致包括协议、域名、路径:");
log.warn("⚠️ 回调地址: {}", finalRedirectUri);
log.info("📋 授权请求参数清单:");
log.info(" - client_id: {}", appId);
log.info(" - redirect_uri: {}", finalRedirectUri);
log.info(" - response_type: code");
log.info(" - scope: {}", scope);
log.info(" - state: {}", state);
log.info(" - prompt: consent (强制显示授权确认页面)");
log.info("💡 说明:已添加 prompt=consent 参数,强制显示授权确认页面");
log.info(" 如果用户已登录且已授权过WPS365可能会跳过授权页面直接返回code");
log.info(" 这是正常的OAuth2行为不是安全问题");
log.info("如果仍然报错,请检查:");
log.info(" 1. WPS365平台配置的回调地址是否与上述redirect_uri完全一致");
log.info(" 2. 参数名是否正确WPS365可能使用app_id而不是client_id");
log.info(" 3. scope权限是否已在WPS365平台申请");
return result;
}
@Override
public WPS365TokenInfo getAccessTokenByCode(String code) {
try {
JSONObject result = WPS365ApiUtil.getAccessToken(
wps365Config.getAppId(),
wps365Config.getAppKey(),
code,
wps365Config.getRedirectUri(),
wps365Config.getTokenUrl()
);
// 解析响应并创建TokenInfo对象
WPS365TokenInfo tokenInfo = new WPS365TokenInfo();
tokenInfo.setAccessToken(result.getString("access_token"));
tokenInfo.setRefreshToken(result.getString("refresh_token"));
tokenInfo.setTokenType(result.getString("token_type"));
tokenInfo.setExpiresIn(result.getInteger("expires_in"));
tokenInfo.setScope(result.getString("scope"));
// WPS365的token响应中可能不包含user_id需要调用用户信息API获取
String userId = result.getString("user_id");
if (userId == null || userId.trim().isEmpty()) {
// 尝试通过用户信息API获取用户ID
try {
JSONObject userInfo = wps365ApiService.getUserInfo(tokenInfo.getAccessToken());
if (userInfo != null) {
// 尝试多种可能的用户ID字段名
userId = userInfo.getString("id");
if (userId == null || userId.trim().isEmpty()) {
userId = userInfo.getString("user_id");
}
if (userId == null || userId.trim().isEmpty()) {
userId = userInfo.getString("open_id");
}
if (userId == null || userId.trim().isEmpty()) {
userId = userInfo.getString("uid");
}
// 如果还是获取不到使用access_token的前16位作为标识临时方案
if (userId == null || userId.trim().isEmpty()) {
String accessToken = tokenInfo.getAccessToken();
if (accessToken != null && accessToken.length() > 16) {
userId = "wps365_" + accessToken.substring(0, 16);
log.warn("无法从用户信息API获取用户ID使用access_token前16位作为标识: {}", userId);
} else {
userId = "wps365_default";
log.warn("无法获取用户ID使用默认值: {}", userId);
}
} else {
log.info("通过用户信息API获取到用户ID: {}", userId);
}
} else {
userId = "wps365_default";
log.warn("用户信息API返回为空使用默认用户ID: {}", userId);
}
} catch (Exception e) {
log.warn("调用用户信息API失败使用默认用户ID: {}", e.getMessage());
// 使用access_token的前16位作为标识临时方案
String accessToken = tokenInfo.getAccessToken();
if (accessToken != null && accessToken.length() > 16) {
userId = "wps365_" + accessToken.substring(0, 16);
} else {
userId = "wps365_default";
}
}
}
tokenInfo.setUserId(userId);
log.info("成功获取访问令牌 - userId: {}", userId);
return tokenInfo;
} catch (Exception e) {
log.error("通过授权码获取访问令牌失败", e);
throw new RuntimeException("获取访问令牌失败: " + e.getMessage(), e);
}
}
@Override
public WPS365TokenInfo refreshAccessToken(String refreshToken) {
try {
JSONObject result = WPS365ApiUtil.refreshAccessToken(
wps365Config.getAppId(),
wps365Config.getAppKey(),
refreshToken,
wps365Config.getRefreshTokenUrl()
);
// 解析响应并创建TokenInfo对象
WPS365TokenInfo tokenInfo = new WPS365TokenInfo();
tokenInfo.setAccessToken(result.getString("access_token"));
tokenInfo.setRefreshToken(result.getString("refresh_token"));
tokenInfo.setTokenType(result.getString("token_type"));
tokenInfo.setExpiresIn(result.getInteger("expires_in"));
tokenInfo.setScope(result.getString("scope"));
tokenInfo.setUserId(result.getString("user_id"));
log.info("成功刷新访问令牌 - userId: {}", tokenInfo.getUserId());
return tokenInfo;
} catch (Exception e) {
log.error("刷新访问令牌失败", e);
throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e);
}
}
@Override
public WPS365TokenInfo getCurrentToken() {
// 尝试查找所有WPS365 token通常只有一个
// 使用Redis的keys命令查找所有匹配的token key
try {
String pattern = TOKEN_KEY_PREFIX + "*";
// 注意keys命令在生产环境可能性能较差但这里token数量通常很少
java.util.Collection<String> keys = redisCache.keys(pattern);
if (keys != null && !keys.isEmpty()) {
// 返回第一个找到的有效token
for (String key : keys) {
WPS365TokenInfo tokenInfo = redisCache.getCacheObject(key);
if (tokenInfo != null && isTokenValid(tokenInfo)) {
log.debug("找到有效的WPS365 token: {}", key);
return tokenInfo;
}
}
// 如果没有有效的token返回第一个即使过期
for (String key : keys) {
WPS365TokenInfo tokenInfo = redisCache.getCacheObject(key);
if (tokenInfo != null) {
log.debug("找到WPS365 token可能已过期: {}", key);
return tokenInfo;
}
}
}
} catch (Exception e) {
log.warn("查找WPS365 token失败", e);
}
return null;
}
@Override
public void saveToken(String userId, WPS365TokenInfo tokenInfo) {
if (userId == null || userId.trim().isEmpty()) {
throw new IllegalArgumentException("用户ID不能为空");
}
if (tokenInfo == null || tokenInfo.getAccessToken() == null) {
throw new IllegalArgumentException("Token信息不能为空");
}
String key = TOKEN_KEY_PREFIX + userId;
redisCache.setCacheObject(key, tokenInfo, (int) TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
log.info("保存用户Token - userId: {}", userId);
}
@Override
public void clearToken(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return;
}
String key = TOKEN_KEY_PREFIX + userId;
redisCache.deleteObject(key);
log.info("清除用户Token - userId: {}", userId);
}
@Override
public boolean isTokenValid(WPS365TokenInfo tokenInfo) {
if (tokenInfo == null) {
return false;
}
// 检查是否过期
if (tokenInfo.isExpired()) {
log.debug("Token已过期");
return false;
}
// 检查必要字段
if (tokenInfo.getAccessToken() == null || tokenInfo.getAccessToken().trim().isEmpty()) {
log.debug("Token缺少accessToken");
return false;
}
return true;
}
/**
* 根据用户ID获取Token从Redis
*/
public WPS365TokenInfo getTokenByUserId(String userId) {
if (userId == null || userId.trim().isEmpty()) {
return null;
}
String key = TOKEN_KEY_PREFIX + userId;
return redisCache.getCacheObject(key);
}
}

View File

@@ -0,0 +1,167 @@
package com.ruoyi.jarvis.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.jarvis.service.IWxSendService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
/**
* 微信推送服务实现类
*/
@Service
public class WxSendServiceImpl implements IWxSendService {
private static final Logger logger = LoggerFactory.getLogger(WxSendServiceImpl.class);
private static final String PUSH_TOKEN = "super_token_b62190c26";
@Value("${jarvis.server.wxsend.base-url:https://wxts.van333.cn}")
private String wxSendBaseUrl;
@Value("${jarvis.server.wxsend.health-path:/wx/send/jd}")
private String wxSendHealthPath;
private String healthCheckUrl;
@PostConstruct
public void init() {
healthCheckUrl = wxSendBaseUrl + wxSendHealthPath;
logger.info("微信推送服务健康检查地址已初始化: {}", healthCheckUrl);
}
@Override
public IWxSendService.HealthCheckResult checkHealth() {
try {
logger.debug("开始检查微信推送服务健康状态 - URL: {}", healthCheckUrl);
// 构建健康检查请求,发送一条真实的消息
// /send/jd 接口只需要 text 字段vanToken 在 header 中传递
JSONObject testRequest = new JSONObject();
testRequest.put("text", "【系统健康检查】微信推送服务运行正常 - " + new java.util.Date());
String jsonBody = testRequest.toJSONString();
// 发送POST请求进行健康检查
String healthResult = sendPostWithHeaders(healthCheckUrl, jsonBody, PUSH_TOKEN);
if (healthResult == null || healthResult.trim().isEmpty()) {
logger.warn("微信推送服务健康检查返回空结果");
return new IWxSendService.HealthCheckResult(false, "异常", "健康检查返回空结果", healthCheckUrl);
}
// 尝试解析JSON响应
try {
JSONObject response = JSON.parseObject(healthResult);
if (response != null) {
Integer code = response.getInteger("code");
Boolean success = response.getBoolean("success");
String msg = response.getString("msg");
// 检查是否成功code为200或0或者success为true
if ((code != null && (code == 200 || code == 0)) || Boolean.TRUE.equals(success)) {
logger.debug("微信推送服务健康检查通过,消息已发送");
return new IWxSendService.HealthCheckResult(true, "正常", "服务运行正常,健康检查消息已发送", healthCheckUrl);
} else {
// 检查是否是token验证失败说明服务可用只是token问题
if (msg != null && (msg.contains("vanToken") || msg.contains("token"))) {
logger.debug("微信推送服务健康检查通过服务可用token验证失败");
return new IWxSendService.HealthCheckResult(true, "正常", "服务运行正常token验证失败", healthCheckUrl);
}
logger.warn("微信推送服务健康检查失败 - 响应: {}", healthResult);
return new IWxSendService.HealthCheckResult(false, "异常", "健康检查返回异常状态: " + (msg != null ? msg : healthResult), healthCheckUrl);
}
}
} catch (Exception e) {
// 如果不是JSON格式检查是否包含成功标识
String lowerResult = healthResult.toLowerCase();
if (lowerResult.contains("ok") || lowerResult.contains("success") || lowerResult.contains("正常")) {
logger.debug("微信推送服务健康检查通过非JSON格式");
return new IWxSendService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
}
}
logger.warn("微信推送服务健康检查失败 - 响应: {}", healthResult);
return new IWxSendService.HealthCheckResult(false, "异常", "健康检查返回异常状态: " + healthResult, healthCheckUrl);
} catch (Exception e) {
logger.error("微信推送服务健康检查异常 - URL: {}, 错误: {}", healthCheckUrl, e.getMessage(), e);
return new IWxSendService.HealthCheckResult(false, "异常", "健康检查异常: " + e.getMessage(), healthCheckUrl);
}
}
/**
* 发送POST请求支持自定义header用于健康检查
* @param url 请求URL
* @param jsonBody JSON请求体
* @param token 认证token
* @return 响应结果
*/
private String sendPostWithHeaders(String url, String jsonBody, String token) {
java.io.BufferedReader in = null;
java.io.PrintWriter out = null;
StringBuilder result = new StringBuilder();
try {
java.net.URL realUrl = new java.net.URL(url);
java.net.URLConnection conn = realUrl.openConnection();
// 设置请求头
conn.setRequestProperty("accept", "*/*");
conn.setRequestProperty("connection", "Keep-Alive");
conn.setRequestProperty("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
conn.setRequestProperty("Accept-Charset", "utf-8");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("vanToken", token);
conn.setRequestProperty("source", "XZJ_UBUNTU");
conn.setDoOutput(true);
conn.setDoInput(true);
// 设置超时时间(健康检查需要快速响应)
conn.setConnectTimeout(5000); // 5秒连接超时
conn.setReadTimeout(5000); // 5秒读取超时
// 发送请求体
out = new java.io.PrintWriter(conn.getOutputStream());
out.print(jsonBody);
out.flush();
// 读取响应
in = new java.io.BufferedReader(new java.io.InputStreamReader(conn.getInputStream(), java.nio.charset.StandardCharsets.UTF_8));
String line;
while ((line = in.readLine()) != null) {
result.append(line);
}
logger.debug("微信推送服务健康检查请求成功 - URL: {}, 响应: {}", url, result.toString());
} catch (java.net.ConnectException e) {
logger.error("微信推送服务健康检查连接失败 - URL: {}", url, e);
throw new RuntimeException("健康检查连接失败: " + e.getMessage(), e);
} catch (java.net.SocketTimeoutException e) {
logger.error("微信推送服务健康检查超时 - URL: {}", url, e);
throw new RuntimeException("健康检查请求超时: " + e.getMessage(), e);
} catch (java.io.IOException e) {
logger.error("微信推送服务健康检查IO异常 - URL: {}", url, e);
throw new RuntimeException("健康检查IO异常: " + e.getMessage(), e);
} catch (Exception e) {
logger.error("微信推送服务健康检查异常 - URL: {}", url, e);
throw new RuntimeException("健康检查异常: " + e.getMessage(), e);
} finally {
try {
if (out != null) {
out.close();
}
if (in != null) {
in.close();
}
} catch (java.io.IOException ex) {
logger.error("关闭流异常", ex);
}
}
return result.toString();
}
}

View File

@@ -13,7 +13,7 @@ import java.util.List;
/**
* 物流信息扫描定时任务
* 每1小时扫描一次分销标记为F或PDD的订单获取物流信息并推送
* 每20分钟扫描一次分销标记为F或PDD的订单最近30天,获取物流信息并推送
*/
@Component
public class LogisticsScanTask {
@@ -26,15 +26,16 @@ public class LogisticsScanTask {
private ILogisticsService logisticsService;
/**
* 定时任务:每1小时执行一次
* Cron表达式0 0 * * * ? 表示每小时的第0分钟执行
* 定时任务:每20分钟执行一次
* Cron表达式格式0 每N分钟 * * * ? 表示每N分钟执行一次
* 只扫描最近30天的订单
*/
@Scheduled(cron = "0 */30 * * * ?")
@Scheduled(cron = "0 */10 * * * ?")
public void scanAndFetchLogistics() {
logger.info("========== 开始执行物流信息扫描定时任务 ==========");
logger.info("========== 开始执行物流信息扫描定时任务最近30天订单 ==========");
try {
// 查询分销标记为F或PDD且有物流链接的订单
// 查询分销标记为F或PDD且有物流链接的订单最近30天
List<JDOrder> orders = jdOrderService.selectJDOrderListByDistributionMarkFOrPDD();
if (orders == null || orders.isEmpty()) {
@@ -54,7 +55,7 @@ public class LogisticsScanTask {
try {
// 检查Redis中是否已处理过避免重复处理
if (logisticsService.isOrderProcessed(order.getId())) {
logger.debug("订单已处理过,跳过 - 订单ID: {}", order.getId());
//logger.debug("订单已处理过,跳过 - 订单ID: {}", order.getId());
skippedCount++;
continue;
}

View File

@@ -102,6 +102,7 @@ public class TencentDocDataParser {
/**
* 从单元格对象中提取文本内容
* 支持多种单元格类型text普通文本、link超链接
*
* @param cell 单元格对象
* @return 文本内容(如果没有则返回空字符串)
@@ -117,9 +118,45 @@ public class TencentDocDataParser {
return "";
}
// 获取 text 字段
// 优先级1检查是否有 link 字段(超链接类型)
// 格式:{"cellValue": {"link": {"url": "xxx", "text": "xxx"}}}
JSONObject link = cellValue.getJSONObject("link");
if (link != null) {
// 优先返回 text如果没有则返回 url
String linkText = link.getString("text");
if (linkText != null && !linkText.isEmpty()) {
log.debug("提取link类型单元格text: {}", linkText);
return linkText;
}
String linkUrl = link.getString("url");
if (linkUrl != null && !linkUrl.isEmpty()) {
log.debug("提取link类型单元格url: {}", linkUrl);
return linkUrl;
}
}
// 优先级2检查是否有 text 字段(普通文本类型)
// 格式:{"cellValue": {"text": "xxx"}}
String text = cellValue.getString("text");
return text != null ? text : "";
if (text != null) {
return text;
}
// 优先级3检查其他可能的字段
// 数字类型
if (cellValue.containsKey("number")) {
Object number = cellValue.get("number");
return number != null ? number.toString() : "";
}
// 布尔类型
if (cellValue.containsKey("bool")) {
Object bool = cellValue.get("bool");
return bool != null ? bool.toString() : "";
}
// 如果都没有,返回空字符串
return "";
}
/**

View File

@@ -0,0 +1,284 @@
package com.ruoyi.jarvis.util;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* WPS365 API工具类
*
* @author system
*/
public class WPS365ApiUtil {
private static final Logger log = LoggerFactory.getLogger(WPS365ApiUtil.class);
// 静态初始化块:禁用系统代理
static {
System.setProperty("java.net.useSystemProxies", "false");
System.clearProperty("http.proxyHost");
System.clearProperty("http.proxyPort");
System.clearProperty("https.proxyHost");
System.clearProperty("https.proxyPort");
log.info("已禁用系统代理设置WPS365 API将直接连接");
}
/**
* 获取访问令牌
*
* @param appId 应用ID
* @param appKey 应用密钥
* @param code 授权码
* @param redirectUri 回调地址
* @param tokenUrl Token地址
* @return 包含access_token和refresh_token的JSON对象
*/
public static JSONObject getAccessToken(String appId, String appKey, String code, String redirectUri, String tokenUrl) {
try {
// 构建请求参数
StringBuilder params = new StringBuilder();
params.append("grant_type=authorization_code");
params.append("&client_id=").append(appId);
params.append("&client_secret=").append(appKey);
params.append("&code=").append(code);
params.append("&redirect_uri=").append(java.net.URLEncoder.encode(redirectUri, "UTF-8"));
// 使用HttpURLConnection不使用代理
URL url = new URL(tokenUrl);
java.net.Proxy proxy = java.net.Proxy.NO_PROXY;
HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setRequestProperty("Accept", "application/json");
conn.setDoOutput(true);
conn.setDoInput(true);
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
// 写入请求参数
try (OutputStream os = conn.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
osw.write(params.toString());
osw.flush();
}
// 读取响应
int statusCode = conn.getResponseCode();
BufferedReader reader;
if (statusCode >= 200 && statusCode < 300) {
reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
} else {
reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8));
}
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
String responseStr = response.toString();
log.info("获取访问令牌响应: statusCode={}, response={}", statusCode, responseStr);
if (statusCode < 200 || statusCode >= 300) {
throw new RuntimeException("获取访问令牌失败: HTTP " + statusCode + ", response=" + responseStr);
}
return JSON.parseObject(responseStr);
} catch (Exception e) {
log.error("获取访问令牌失败", e);
throw new RuntimeException("获取访问令牌失败: " + e.getMessage(), e);
}
}
/**
* 刷新访问令牌
*
* @param appId 应用ID
* @param appKey 应用密钥
* @param refreshToken 刷新令牌
* @param refreshTokenUrl 刷新令牌地址
* @return 包含新的access_token和refresh_token的JSON对象
*/
public static JSONObject refreshAccessToken(String appId, String appKey, String refreshToken, String refreshTokenUrl) {
try {
// 构建请求参数
StringBuilder params = new StringBuilder();
params.append("grant_type=refresh_token");
params.append("&client_id=").append(appId);
params.append("&client_secret=").append(appKey);
params.append("&refresh_token=").append(refreshToken);
// 使用HttpURLConnection不使用代理
URL url = new URL(refreshTokenUrl);
java.net.Proxy proxy = java.net.Proxy.NO_PROXY;
HttpURLConnection conn = (HttpURLConnection) url.openConnection(proxy);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setRequestProperty("Accept", "application/json");
conn.setDoOutput(true);
conn.setDoInput(true);
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
// 写入请求参数
try (OutputStream os = conn.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
osw.write(params.toString());
osw.flush();
}
// 读取响应
int statusCode = conn.getResponseCode();
StringBuilder response = new StringBuilder();
try {
BufferedReader reader;
InputStream inputStream;
if (statusCode >= 200 && statusCode < 300) {
inputStream = conn.getInputStream();
} else {
// 对于错误响应,尝试读取错误流
inputStream = conn.getErrorStream();
// 如果错误流为null尝试读取正常流某些服务器可能将错误信息放在正常流中
if (inputStream == null) {
inputStream = conn.getInputStream();
}
}
// 如果输入流仍然为null说明无法读取响应
if (inputStream == null) {
log.warn("无法读取HTTP响应流状态码: {}", statusCode);
response.append("无法读取响应内容");
} else {
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
}
} catch (Exception e) {
log.error("读取HTTP响应失败", e);
response.append("读取响应失败: ").append(e.getMessage());
}
String responseStr = response.toString();
log.info("刷新访问令牌响应: statusCode={}, response={}", statusCode, responseStr);
if (statusCode < 200 || statusCode >= 300) {
throw new RuntimeException("刷新访问令牌失败: HTTP " + statusCode + ", response=" + responseStr);
}
return JSON.parseObject(responseStr);
} catch (Exception e) {
log.error("刷新访问令牌失败", e);
throw new RuntimeException("刷新访问令牌失败: " + e.getMessage(), e);
}
}
/**
* 发送HTTP请求
*
* @param method 请求方法GET/POST/PUT/DELETE
* @param url 请求URL
* @param accessToken 访问令牌
* @param body 请求体JSON字符串GET请求可为null
* @return 响应JSON对象
*/
public static JSONObject httpRequest(String method, String url, String accessToken, String body) {
try {
log.debug("发送HTTP请求: method={}, url={}", method, url);
URL requestUrl = new URL(url);
java.net.Proxy proxy = java.net.Proxy.NO_PROXY;
HttpURLConnection conn = (HttpURLConnection) requestUrl.openConnection(proxy);
conn.setRequestMethod(method);
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
if ("POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method)) {
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
if (body != null && !body.isEmpty()) {
try (OutputStream os = conn.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
osw.write(body);
osw.flush();
}
}
}
// 读取响应
int statusCode = conn.getResponseCode();
StringBuilder response = new StringBuilder();
try {
BufferedReader reader;
InputStream inputStream;
if (statusCode >= 200 && statusCode < 300) {
inputStream = conn.getInputStream();
} else {
// 对于错误响应,尝试读取错误流
inputStream = conn.getErrorStream();
// 如果错误流为null尝试读取正常流某些服务器可能将错误信息放在正常流中
if (inputStream == null) {
inputStream = conn.getInputStream();
}
}
// 如果输入流仍然为null说明无法读取响应
if (inputStream == null) {
log.warn("无法读取HTTP响应流状态码: {}", statusCode);
response.append("无法读取响应内容");
} else {
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
}
} catch (Exception e) {
log.error("读取HTTP响应失败", e);
response.append("读取响应失败: ").append(e.getMessage());
}
String responseStr = response.toString();
log.info("HTTP响应: statusCode={}, url={}, response={}", statusCode, url, responseStr);
if (statusCode < 200 || statusCode >= 300) {
String errorMsg = String.format("HTTP请求失败: statusCode=%d, url=%s, response=%s",
statusCode, url, responseStr);
log.error(errorMsg);
throw new RuntimeException(errorMsg);
}
if (responseStr == null || responseStr.trim().isEmpty()) {
return new JSONObject();
}
return JSON.parseObject(responseStr);
} catch (Exception e) {
log.error("HTTP请求失败: method={}, url={}", method, url, e);
throw new RuntimeException("HTTP请求失败: " + e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.CommentMapper">
<resultMap id="CommentResult" type="Comment">
<result property="id" column="id"/>
<result property="productId" column="product_id"/>
<result property="userName" column="user_name"/>
<result property="commentText" column="comment_text"/>
<result property="commentId" column="comment_id"/>
<result property="pictureUrls" column="picture_urls"/>
<result property="createdAt" column="created_at"/>
<result property="commentDate" column="comment_date"/>
<result property="isUse" column="is_use"/>
</resultMap>
<sql id="selectCommentBase">
select id, product_id, user_name, comment_text, comment_id, picture_urls,
created_at, comment_date, is_use
from comments
</sql>
<select id="selectCommentList" parameterType="Comment" resultMap="CommentResult">
<include refid="selectCommentBase"/>
<where>
<if test="productId != null and productId != ''"> and product_id = #{productId}</if>
<if test="userName != null and userName != ''"> and user_name like concat('%', #{userName}, '%')</if>
<if test="commentText != null and commentText != ''"> and comment_text like concat('%', #{commentText}, '%')</if>
<if test="isUse != null"> and is_use = #{isUse}</if>
<if test="params.beginTime != null and params.beginTime != ''">
and created_at &gt;= #{params.beginTime}
</if>
<if test="params.endTime != null and params.endTime != ''">
and created_at &lt;= #{params.endTime}
</if>
</where>
order by created_at desc
</select>
<select id="selectCommentById" parameterType="Long" resultMap="CommentResult">
<include refid="selectCommentBase"/>
where id = #{id}
</select>
<select id="selectCommentStatisticsByProductId" parameterType="String" resultType="java.util.Map">
select
count(*) as totalCount,
sum(case when is_use = 0 then 1 else 0 end) as availableCount,
sum(case when is_use = 1 then 1 else 0 end) as usedCount,
(select max(created_at) from comments where product_id = #{productId}) as lastCommentUpdateTime
from comments
where product_id = #{productId}
</select>
<update id="updateCommentIsUse" parameterType="Comment">
update comments
<set>
<if test="isUse != null">is_use = #{isUse},</if>
</set>
where id = #{id}
</update>
<update id="resetCommentIsUseByProductId" parameterType="String">
update comments
set is_use = 0
where product_id = #{productId}
</update>
<delete id="deleteCommentByIds" parameterType="String">
delete from comments where id in
<foreach item="id" collection="array" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.ErpProductMapper">
<resultMap type="ErpProduct" id="ErpProductResult">
<result property="id" column="id" />
<result property="productId" column="product_id" />
<result property="title" column="title" />
<result property="mainImage" column="main_image" />
<result property="price" column="price" />
<result property="stock" column="stock" />
<result property="productStatus" column="product_status" />
<result property="saleStatus" column="sale_status" />
<result property="userName" column="user_name" />
<result property="onlineTime" column="online_time" />
<result property="offlineTime" column="offline_time" />
<result property="soldTime" column="sold_time" />
<result property="createTimeXy" column="create_time_xy" />
<result property="updateTimeXy" column="update_time_xy" />
<result property="appid" column="appid" />
<result property="productUrl" column="product_url" />
<result property="remark" column="remark" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectErpProductVo">
select id, product_id, title, main_image, price, stock, product_status, sale_status,
user_name, online_time, offline_time, sold_time, create_time_xy, update_time_xy,
appid, product_url, remark, create_time, update_time
from erp_product
</sql>
<select id="selectErpProductList" parameterType="ErpProduct" resultMap="ErpProductResult">
<include refid="selectErpProductVo"/>
<where>
<if test="productId != null "> and product_id = #{productId}</if>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="productStatus != null "> and product_status = #{productStatus}</if>
<if test="saleStatus != null "> and sale_status = #{saleStatus}</if>
<if test="userName != null and userName != ''"> and user_name = #{userName}</if>
<if test="appid != null and appid != ''"> and appid = #{appid}</if>
</where>
order by update_time_xy desc, id desc
</select>
<select id="selectErpProductById" parameterType="Long" resultMap="ErpProductResult">
<include refid="selectErpProductVo"/>
where id = #{id}
</select>
<select id="selectErpProductByProductIdAndAppid" resultMap="ErpProductResult">
<include refid="selectErpProductVo"/>
where product_id = #{productId} and appid = #{appid}
</select>
<insert id="insertErpProduct" parameterType="ErpProduct" useGeneratedKeys="true" keyProperty="id">
insert into erp_product
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="productId != null">product_id,</if>
<if test="title != null">title,</if>
<if test="mainImage != null">main_image,</if>
<if test="price != null">price,</if>
<if test="stock != null">stock,</if>
<if test="productStatus != null">product_status,</if>
<if test="saleStatus != null">sale_status,</if>
<if test="userName != null">user_name,</if>
<if test="onlineTime != null">online_time,</if>
<if test="offlineTime != null">offline_time,</if>
<if test="soldTime != null">sold_time,</if>
<if test="createTimeXy != null">create_time_xy,</if>
<if test="updateTimeXy != null">update_time_xy,</if>
<if test="appid != null">appid,</if>
<if test="productUrl != null">product_url,</if>
<if test="remark != null">remark,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="productId != null">#{productId},</if>
<if test="title != null">#{title},</if>
<if test="mainImage != null">#{mainImage},</if>
<if test="price != null">#{price},</if>
<if test="stock != null">#{stock},</if>
<if test="productStatus != null">#{productStatus},</if>
<if test="saleStatus != null">#{saleStatus},</if>
<if test="userName != null">#{userName},</if>
<if test="onlineTime != null">#{onlineTime},</if>
<if test="offlineTime != null">#{offlineTime},</if>
<if test="soldTime != null">#{soldTime},</if>
<if test="createTimeXy != null">#{createTimeXy},</if>
<if test="updateTimeXy != null">#{updateTimeXy},</if>
<if test="appid != null">#{appid},</if>
<if test="productUrl != null">#{productUrl},</if>
<if test="remark != null">#{remark},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim>
</insert>
<update id="updateErpProduct" parameterType="ErpProduct">
update erp_product
<trim prefix="SET" suffixOverrides=",">
<if test="productId != null">product_id = #{productId},</if>
<if test="title != null">title = #{title},</if>
<if test="mainImage != null">main_image = #{mainImage},</if>
<if test="price != null">price = #{price},</if>
<if test="stock != null">stock = #{stock},</if>
<if test="productStatus != null">product_status = #{productStatus},</if>
<if test="saleStatus != null">sale_status = #{saleStatus},</if>
<if test="userName != null">user_name = #{userName},</if>
<if test="onlineTime != null">online_time = #{onlineTime},</if>
<if test="offlineTime != null">offline_time = #{offlineTime},</if>
<if test="soldTime != null">sold_time = #{soldTime},</if>
<if test="createTimeXy != null">create_time_xy = #{createTimeXy},</if>
<if test="updateTimeXy != null">update_time_xy = #{updateTimeXy},</if>
<if test="appid != null">appid = #{appid},</if>
<if test="productUrl != null">product_url = #{productUrl},</if>
<if test="remark != null">remark = #{remark},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteErpProductById" parameterType="Long">
delete from erp_product where id = #{id}
</delete>
<delete id="deleteErpProductByIds" parameterType="String">
delete from erp_product where id in
<foreach item="id" collection="array" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<insert id="batchInsertOrUpdateErpProduct" parameterType="java.util.List">
insert into erp_product
(product_id, title, main_image, price, stock, product_status, sale_status,
user_name, online_time, offline_time, sold_time, create_time_xy, update_time_xy,
appid, product_url, remark)
values
<foreach collection="list" item="item" separator=",">
(#{item.productId}, #{item.title}, #{item.mainImage}, #{item.price}, #{item.stock},
#{item.productStatus}, #{item.saleStatus}, #{item.userName}, #{item.onlineTime},
#{item.offlineTime}, #{item.soldTime}, #{item.createTimeXy}, #{item.updateTimeXy},
#{item.appid}, #{item.productUrl}, #{item.remark})
</foreach>
ON DUPLICATE KEY UPDATE
title = VALUES(title),
main_image = VALUES(main_image),
price = VALUES(price),
stock = VALUES(stock),
product_status = VALUES(product_status),
sale_status = VALUES(sale_status),
user_name = VALUES(user_name),
online_time = VALUES(online_time),
offline_time = VALUES(offline_time),
sold_time = VALUES(sold_time),
create_time_xy = VALUES(create_time_xy),
update_time_xy = VALUES(update_time_xy),
product_url = VALUES(product_url),
remark = VALUES(remark),
update_time = NOW()
</insert>
</mapper>

View File

@@ -12,6 +12,8 @@
<result property="rebateAmount" column="rebate_amount"/>
<result property="address" column="address"/>
<result property="logisticsLink" column="logistics_link"/>
<result property="tencentDocPushed" column="tencent_doc_pushed"/>
<result property="tencentDocPushTime" column="tencent_doc_push_time"/>
<result property="orderId" column="order_id"/>
<result property="buyer" column="buyer"/>
<result property="orderTime" column="order_time"/>
@@ -21,11 +23,25 @@
<result property="isCountEnabled" column="is_count_enabled"/>
<result property="thirdPartyOrderNo" column="third_party_order_no"/>
<result property="jingfenActualPrice" column="jingfen_actual_price"/>
<result property="isRefunded" column="is_refunded"/>
<result property="refundDate" column="refund_date"/>
<result property="isRefundReceived" column="is_refund_received"/>
<result property="refundReceivedDate" column="refund_received_date"/>
<result property="isRebateReceived" column="is_rebate_received"/>
<result property="rebateReceivedDate" column="rebate_received_date"/>
<result property="isPriceProtected" column="is_price_protected"/>
<result property="priceProtectedDate" column="price_protected_date"/>
<result property="isInvoiceOpened" column="is_invoice_opened"/>
<result property="invoiceOpenedDate" column="invoice_opened_date"/>
<result property="isReviewPosted" column="is_review_posted"/>
<result property="reviewPostedDate" column="review_posted_date"/>
</resultMap>
<sql id="selectJDOrderBase">
select id, remark, distribution_mark, model_number, link, payment_amount, rebate_amount,
address, logistics_link, order_id, buyer, order_time, create_time, update_time, status, is_count_enabled, third_party_order_no, jingfen_actual_price
address, logistics_link, order_id, buyer, order_time, create_time, update_time, status, is_count_enabled, third_party_order_no, jingfen_actual_price,
is_refunded, refund_date, is_refund_received, refund_received_date, is_rebate_received, rebate_received_date,
is_price_protected, price_protected_date, is_invoice_opened, invoice_opened_date, is_review_posted, review_posted_date
from jd_order
</sql>
@@ -33,17 +49,29 @@
<include refid="selectJDOrderBase"/>
<where>
<if test="remark != null and remark != ''"> and remark like concat('%', #{remark}, '%')</if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark like concat('%', #{distributionMark}, '%')</if>
<if test="params.orderSearch != null and params.orderSearch != ''">
and (
order_id like concat('%', #{params.orderSearch}, '%')
or third_party_order_no like concat('%', #{params.orderSearch}, '%')
or distribution_mark like concat('%', #{params.orderSearch}, '%')
)
</if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if>
<if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if>
<if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if>
<if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if>
<if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if>
<if test="address != null and address != ''"> and address like concat('%', #{address}, '%')</if>
<if test="logisticsLink != null and logisticsLink != ''"> and logistics_link like concat('%', #{logisticsLink}, '%')</if>
<if test="orderId != null and orderId != ''"> and order_id like concat('%', #{orderId}, '%')</if>
<if test="buyer != null and buyer != ''"> and buyer like concat('%', #{buyer}, '%')</if>
<if test="orderTime != null"> and order_time = #{orderTime}</if>
<if test="status != null and status != ''"> and status like concat('%', #{status}, '%')</if>
<if test="isRefunded != null"> and is_refunded = #{isRefunded}</if>
<if test="isRefundReceived != null"> and is_refund_received = #{isRefundReceived}</if>
<if test="isRebateReceived != null"> and is_rebate_received = #{isRebateReceived}</if>
<if test="isPriceProtected != null"> and is_price_protected = #{isPriceProtected}</if>
<if test="isInvoiceOpened != null"> and is_invoice_opened = #{isInvoiceOpened}</if>
<if test="isReviewPosted != null"> and is_review_posted = #{isReviewPosted}</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date(order_time) &gt;= #{params.beginTime}
</if>
@@ -58,17 +86,29 @@
<include refid="selectJDOrderBase"/>
<where>
<if test="remark != null and remark != ''"> and remark like concat('%', #{remark}, '%')</if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark like concat('%', #{distributionMark}, '%')</if>
<if test="params.orderSearch != null and params.orderSearch != ''">
and (
order_id like concat('%', #{params.orderSearch}, '%')
or third_party_order_no like concat('%', #{params.orderSearch}, '%')
or distribution_mark like concat('%', #{params.orderSearch}, '%')
)
</if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if>
<if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if>
<if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if>
<if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if>
<if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if>
<if test="address != null and address != ''"> and address like concat('%', #{address}, '%')</if>
<if test="logisticsLink != null and logisticsLink != ''"> and logistics_link like concat('%', #{logisticsLink}, '%')</if>
<if test="orderId != null and orderId != ''"> and order_id like concat('%', #{orderId}, '%')</if>
<if test="buyer != null and buyer != ''"> and buyer like concat('%', #{buyer}, '%')</if>
<if test="orderTime != null"> and order_time = #{orderTime}</if>
<if test="status != null and status != ''"> and status like concat('%', #{status}, '%')</if>
<if test="isRefunded != null"> and is_refunded = #{isRefunded}</if>
<if test="isRefundReceived != null"> and is_refund_received = #{isRefundReceived}</if>
<if test="isRebateReceived != null"> and is_rebate_received = #{isRebateReceived}</if>
<if test="isPriceProtected != null"> and is_price_protected = #{isPriceProtected}</if>
<if test="isInvoiceOpened != null"> and is_invoice_opened = #{isInvoiceOpened}</if>
<if test="isReviewPosted != null"> and is_review_posted = #{isReviewPosted}</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date(order_time) &gt;= #{params.beginTime}
</if>
@@ -97,11 +137,17 @@
insert into jd_order (
remark, distribution_mark, model_number, link,
payment_amount, rebate_amount, address, logistics_link,
order_id, buyer, order_time, create_time, update_time, status, is_count_enabled, third_party_order_no, jingfen_actual_price
tencent_doc_pushed, tencent_doc_push_time,
order_id, buyer, order_time, create_time, update_time, status, is_count_enabled, third_party_order_no, jingfen_actual_price,
is_refunded, refund_date, is_refund_received, refund_received_date, is_rebate_received, rebate_received_date,
is_price_protected, price_protected_date, is_invoice_opened, invoice_opened_date, is_review_posted, review_posted_date
) values (
#{remark}, #{distributionMark}, #{modelNumber}, #{link},
#{paymentAmount}, #{rebateAmount}, #{address}, #{logisticsLink},
#{orderId}, #{buyer}, #{orderTime}, now(), now(), #{status}, #{isCountEnabled}, #{thirdPartyOrderNo}, #{jingfenActualPrice}
0, null,
#{orderId}, #{buyer}, #{orderTime}, now(), now(), #{status}, #{isCountEnabled}, #{thirdPartyOrderNo}, #{jingfenActualPrice},
#{isRefunded}, #{refundDate}, #{isRefundReceived}, #{refundReceivedDate}, #{isRebateReceived}, #{rebateReceivedDate},
#{isPriceProtected}, #{priceProtectedDate}, #{isInvoiceOpened}, #{invoiceOpenedDate}, #{isReviewPosted}, #{reviewPostedDate}
)
</insert>
@@ -116,6 +162,8 @@
<if test="rebateAmount != null"> rebate_amount = #{rebateAmount},</if>
<if test="address != null"> address = #{address},</if>
<if test="logisticsLink != null"> logistics_link = #{logisticsLink},</if>
<if test="tencentDocPushed != null"> tencent_doc_pushed = #{tencentDocPushed},</if>
<if test="tencentDocPushTime != null"> tencent_doc_push_time = #{tencentDocPushTime},</if>
<if test="orderId != null"> order_id = #{orderId},</if>
<if test="buyer != null"> buyer = #{buyer},</if>
<if test="orderTime != null"> order_time = #{orderTime},</if>
@@ -123,6 +171,18 @@
<if test="isCountEnabled != null"> is_count_enabled = #{isCountEnabled},</if>
<if test="thirdPartyOrderNo != null"> third_party_order_no = #{thirdPartyOrderNo},</if>
<if test="jingfenActualPrice != null"> jingfen_actual_price = #{jingfenActualPrice},</if>
<if test="isRefunded != null"> is_refunded = #{isRefunded},</if>
<if test="refundDate != null"> refund_date = #{refundDate},</if>
<if test="isRefundReceived != null"> is_refund_received = #{isRefundReceived},</if>
<if test="refundReceivedDate != null"> refund_received_date = #{refundReceivedDate},</if>
<if test="isRebateReceived != null"> is_rebate_received = #{isRebateReceived},</if>
<if test="rebateReceivedDate != null"> rebate_received_date = #{rebateReceivedDate},</if>
<if test="isPriceProtected != null"> is_price_protected = #{isPriceProtected},</if>
<if test="priceProtectedDate != null"> price_protected_date = #{priceProtectedDate},</if>
<if test="isInvoiceOpened != null"> is_invoice_opened = #{isInvoiceOpened},</if>
<if test="invoiceOpenedDate != null"> invoice_opened_date = #{invoiceOpenedDate},</if>
<if test="isReviewPosted != null"> is_review_posted = #{isReviewPosted},</if>
<if test="reviewPostedDate != null"> review_posted_date = #{reviewPostedDate},</if>
update_time = now()
</set>
where id = #{id}
@@ -170,9 +230,10 @@
<select id="selectJDOrderListByDistributionMarkFOrPDD" resultMap="JDOrderResult">
<include refid="selectJDOrderBase"/>
<where>
(distribution_mark = 'F' OR distribution_mark = 'PDD')
(distribution_mark = 'F' OR distribution_mark = 'PDD' OR distribution_mark = 'H' OR distribution_mark = 'W' OR distribution_mark = 'PDD-W')
AND logistics_link IS NOT NULL
AND logistics_link != ''
AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
</where>
ORDER BY create_time DESC
</select>

View File

@@ -11,12 +11,13 @@
<result property="secretKey" column="secret_key"/>
<result property="isActive" column="is_active"/>
<result property="isCount" column="is_count"/>
<result property="touser" column="touser"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="selectSuperAdminVo">
select id, wxid, name, union_id, app_key, secret_key, is_active, is_count, created_at, updated_at from super_admin
select id, wxid, name, union_id, app_key, secret_key, is_active, is_count, touser, created_at, updated_at from super_admin
</sql>
<select id="selectSuperAdminList" parameterType="SuperAdmin" resultMap="SuperAdminResult">
@@ -51,6 +52,8 @@
<if test="appKey != null and appKey != ''">app_key,</if>
<if test="secretKey != null and secretKey != ''">secret_key,</if>
<if test="isActive != null">is_active,</if>
<if test="isCount != null">is_count,</if>
<if test="touser != null and touser != ''">touser,</if>
created_at,
updated_at,
</trim>
@@ -61,6 +64,8 @@
<if test="appKey != null and appKey != ''">#{appKey},</if>
<if test="secretKey != null and secretKey != ''">#{secretKey},</if>
<if test="isActive != null">#{isActive},</if>
<if test="isCount != null">#{isCount},</if>
<if test="touser != null and touser != ''">#{touser},</if>
now(),
now(),
</trim>
@@ -76,6 +81,7 @@
<if test="secretKey != null and secretKey != ''">secret_key = #{secretKey},</if>
<if test="isActive != null">is_active = #{isActive},</if>
<if test="isCount != null">is_count = #{isCount},</if>
<if test="touser != null">touser = #{touser},</if>
updated_at = now(),
</trim>
where id = #{id}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.TaobaoCommentMapper">
<resultMap id="TaobaoCommentResult" type="TaobaoComment">
<result property="id" column="id"/>
<result property="productId" column="product_id"/>
<result property="userName" column="user_name"/>
<result property="commentText" column="comment_text"/>
<result property="commentId" column="comment_id"/>
<result property="pictureUrls" column="picture_urls"/>
<result property="createdAt" column="created_at"/>
<result property="commentDate" column="comment_date"/>
<result property="isUse" column="is_use"/>
</resultMap>
<sql id="selectTaobaoCommentBase">
select id, product_id, user_name, comment_text, comment_id, picture_urls,
created_at, comment_date, is_use
from taobao_comments
</sql>
<select id="selectTaobaoCommentList" parameterType="TaobaoComment" resultMap="TaobaoCommentResult">
<include refid="selectTaobaoCommentBase"/>
<where>
<if test="productId != null and productId != ''"> and product_id = #{productId}</if>
<if test="userName != null and userName != ''"> and user_name like concat('%', #{userName}, '%')</if>
<if test="commentText != null and commentText != ''"> and comment_text like concat('%', #{commentText}, '%')</if>
<if test="isUse != null"> and is_use = #{isUse}</if>
<if test="params.beginTime != null and params.beginTime != ''">
and created_at &gt;= #{params.beginTime}
</if>
<if test="params.endTime != null and params.endTime != ''">
and created_at &lt;= #{params.endTime}
</if>
</where>
order by created_at desc
</select>
<select id="selectTaobaoCommentById" parameterType="Integer" resultMap="TaobaoCommentResult">
<include refid="selectTaobaoCommentBase"/>
where id = #{id}
</select>
<select id="selectTaobaoCommentStatisticsByProductId" parameterType="String" resultType="java.util.Map">
select
count(*) as totalCount,
sum(case when is_use = 0 then 1 else 0 end) as availableCount,
sum(case when is_use = 1 then 1 else 0 end) as usedCount,
(select max(created_at) from taobao_comments where product_id = #{productId}) as lastCommentUpdateTime
from taobao_comments
where product_id = #{productId}
</select>
<update id="updateTaobaoCommentIsUse" parameterType="TaobaoComment">
update taobao_comments
<set>
<if test="isUse != null">is_use = #{isUse},</if>
</set>
where id = #{id}
</update>
<update id="resetTaobaoCommentIsUseByProductId" parameterType="String">
update taobao_comments
set is_use = 0
where product_id = #{productId}
</update>
<delete id="deleteTaobaoCommentByIds" parameterType="String">
delete from taobao_comments where id in
<foreach item="id" collection="array" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.TencentDocBatchPushRecordMapper">
<resultMap type="com.ruoyi.jarvis.domain.TencentDocBatchPushRecord" id="BatchPushRecordResult">
<id property="id" column="id" />
<result property="batchId" column="batch_id" />
<result property="fileId" column="file_id" />
<result property="sheetId" column="sheet_id" />
<result property="pushType" column="push_type" />
<result property="triggerSource" column="trigger_source" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="durationMs" column="duration_ms" />
<result property="startRow" column="start_row" />
<result property="endRow" column="end_row" />
<result property="totalRows" column="total_rows" />
<result property="successCount" column="success_count" />
<result property="skipCount" column="skip_count" />
<result property="errorCount" column="error_count" />
<result property="status" column="status" />
<result property="resultMessage" column="result_message" />
<result property="errorMessage" column="error_message" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectBatchPushRecordVo">
SELECT id, batch_id, file_id, sheet_id, push_type, trigger_source,
start_time, end_time, duration_ms, start_row, end_row, total_rows,
success_count, skip_count, error_count, status, result_message,
error_message, create_time, update_time
FROM tencent_doc_batch_push_record
</sql>
<insert id="insertBatchPushRecord" parameterType="com.ruoyi.jarvis.domain.TencentDocBatchPushRecord">
INSERT INTO tencent_doc_batch_push_record (
batch_id, file_id, sheet_id, push_type, trigger_source,
start_time, end_time, duration_ms, start_row, end_row, total_rows,
success_count, skip_count, error_count, status, result_message, error_message
) VALUES (
#{batchId}, #{fileId}, #{sheetId}, #{pushType}, #{triggerSource},
#{startTime}, #{endTime}, #{durationMs}, #{startRow}, #{endRow}, #{totalRows},
#{successCount}, #{skipCount}, #{errorCount}, #{status}, #{resultMessage}, #{errorMessage}
)
</insert>
<update id="updateBatchPushRecord" parameterType="com.ruoyi.jarvis.domain.TencentDocBatchPushRecord">
UPDATE tencent_doc_batch_push_record
<set>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="durationMs != null">duration_ms = #{durationMs},</if>
<if test="successCount != null">success_count = #{successCount},</if>
<if test="skipCount != null">skip_count = #{skipCount},</if>
<if test="errorCount != null">error_count = #{errorCount},</if>
<if test="status != null and status != ''">status = #{status},</if>
<if test="resultMessage != null">result_message = #{resultMessage},</if>
<if test="errorMessage != null">error_message = #{errorMessage},</if>
</set>
WHERE batch_id = #{batchId}
</update>
<select id="selectByBatchId" parameterType="String" resultMap="BatchPushRecordResult">
<include refid="selectBatchPushRecordVo"/>
WHERE batch_id = #{batchId}
</select>
<select id="selectBatchPushRecordList" parameterType="com.ruoyi.jarvis.domain.TencentDocBatchPushRecord" resultMap="BatchPushRecordResult">
<include refid="selectBatchPushRecordVo"/>
<where>
<if test="fileId != null and fileId != ''">AND file_id = #{fileId}</if>
<if test="sheetId != null and sheetId != ''">AND sheet_id = #{sheetId}</if>
<if test="status != null and status != ''">AND status = #{status}</if>
<if test="pushType != null and pushType != ''">AND push_type = #{pushType}</if>
</where>
ORDER BY create_time DESC
</select>
<select id="selectRecentRecords" resultMap="BatchPushRecordResult">
<include refid="selectBatchPushRecordVo"/>
<where>
<if test="fileId != null and fileId != ''">AND file_id = #{fileId}</if>
</where>
ORDER BY create_time DESC
LIMIT #{limit}
</select>
<select id="selectLastSuccessRecord" resultMap="BatchPushRecordResult">
<include refid="selectBatchPushRecordVo"/>
WHERE file_id = #{fileId}
AND sheet_id = #{sheetId}
AND status IN ('SUCCESS', 'PARTIAL')
ORDER BY end_time DESC
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.jarvis.mapper.TencentDocOperationLogMapper">
<resultMap type="com.ruoyi.jarvis.domain.TencentDocOperationLog" id="TencentDocOperationLogResult">
<id property="id" column="id" />
<result property="batchId" column="batch_id" />
<result property="fileId" column="file_id" />
<result property="sheetId" column="sheet_id" />
<result property="operationType" column="operation_type" />
<result property="orderNo" column="order_no" />
<result property="targetRow" column="target_row" />
<result property="logisticsLink" column="logistics_link" />
<result property="operationStatus" column="operation_status" />
<result property="errorMessage" column="error_message" />
<result property="operator" column="operator" />
<result property="createTime" column="create_time" />
<result property="remark" column="remark" />
</resultMap>
<insert id="insertLog" parameterType="com.ruoyi.jarvis.domain.TencentDocOperationLog">
insert into tencent_doc_operation_log
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="batchId != null">batch_id,</if>
<if test="fileId != null">file_id,</if>
<if test="sheetId != null">sheet_id,</if>
<if test="operationType != null">operation_type,</if>
<if test="orderNo != null">order_no,</if>
<if test="targetRow != null">target_row,</if>
<if test="logisticsLink != null">logistics_link,</if>
<if test="operationStatus != null">operation_status,</if>
<if test="errorMessage != null">error_message,</if>
<if test="operator != null">operator,</if>
<if test="remark != null">remark,</if>
create_time
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="batchId != null">#{batchId},</if>
<if test="fileId != null">#{fileId},</if>
<if test="sheetId != null">#{sheetId},</if>
<if test="operationType != null">#{operationType},</if>
<if test="orderNo != null">#{orderNo},</if>
<if test="targetRow != null">#{targetRow},</if>
<if test="logisticsLink != null">#{logisticsLink},</if>
<if test="operationStatus != null">#{operationStatus},</if>
<if test="errorMessage != null">#{errorMessage},</if>
<if test="operator != null">#{operator},</if>
<if test="remark != null">#{remark},</if>
now()
</trim>
</insert>
<sql id="selectLogVo">
select id, batch_id, file_id, sheet_id, operation_type, order_no, target_row,
logistics_link, operation_status, error_message, operator,
create_time, remark
from tencent_doc_operation_log
</sql>
<select id="selectLogList" parameterType="com.ruoyi.jarvis.domain.TencentDocOperationLog" resultMap="TencentDocOperationLogResult">
<include refid="selectLogVo"/>
<where>
<if test="fileId != null and fileId != ''">
AND file_id = #{fileId}
</if>
<if test="sheetId != null and sheetId != ''">
AND sheet_id = #{sheetId}
</if>
<if test="operationType != null and operationType != ''">
AND operation_type = #{operationType}
</if>
<if test="orderNo != null and orderNo != ''">
AND order_no = #{orderNo}
</if>
<if test="operationStatus != null and operationStatus != ''">
AND operation_status = #{operationStatus}
</if>
</where>
order by create_time desc
</select>
<select id="selectRecentLogs" resultMap="TencentDocOperationLogResult">
<include refid="selectLogVo"/>
<where>
<if test="fileId != null and fileId != ''">
AND file_id = #{fileId}
</if>
</where>
order by create_time desc
limit #{limit}
</select>
<select id="selectLogsByBatchId" resultMap="TencentDocOperationLogResult">
<include refid="selectLogVo"/>
WHERE batch_id = #{batchId}
ORDER BY create_time ASC
</select>
</mapper>

View File

@@ -0,0 +1,43 @@
.operation-logs {
margin-top: 15px;
}
+.operation-logs >>> .el-table {
+ border-radius: 8px;
+ overflow: hidden;
+}
+.operation-logs >>> .log-row-success td {
+ background: #f0f9eb;
+}
+.operation-logs >>> .log-row-failed td {
+ background: #fef0f0;
+}
+.operation-logs >>> .log-row-skipped td {
+ background: #fdf6ec;
+}
+.status-icon {
+ font-size: 12px;
+ margin-right: 4px;
+}
+.text-placeholder {
+ color: #c0c4cc;
+}
+.text-muted {
+ color: #909399;
+}
.logs-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-weight: 500;
margin-bottom: 10px;
color: #606266;
}

View File

@@ -0,0 +1,4 @@
-- 为超级管理员表添加接收人字段
-- 字段说明touser 存储企业微信用户ID多个用逗号分隔
ALTER TABLE super_admin ADD COLUMN touser VARCHAR(500) DEFAULT NULL COMMENT '接收人企业微信用户ID多个用逗号分隔';

View File

@@ -0,0 +1,15 @@
-- 为jd_order表添加新的状态字段点过价保、开过专票、晒过评价
-- 执行日期2025-01-26
ALTER TABLE jd_order
ADD COLUMN is_price_protected INT DEFAULT 0 COMMENT '点过价保0否 1是',
ADD COLUMN price_protected_date DATETIME NULL COMMENT '价保日期',
ADD COLUMN is_invoice_opened INT DEFAULT 0 COMMENT '开过专票0否 1是',
ADD COLUMN invoice_opened_date DATETIME NULL COMMENT '开票日期',
ADD COLUMN is_review_posted INT DEFAULT 0 COMMENT '晒过评价0否 1是',
ADD COLUMN review_posted_date DATETIME NULL COMMENT '评价日期';
-- 添加索引(可选,根据查询需求)
-- CREATE INDEX idx_is_price_protected ON jd_order(is_price_protected);
-- CREATE INDEX idx_is_invoice_opened ON jd_order(is_invoice_opened);
-- CREATE INDEX idx_is_review_posted ON jd_order(is_review_posted);

View File

@@ -0,0 +1,16 @@
-- 为jd_order表添加退款相关字段
-- 执行日期2025-01-XX
ALTER TABLE jd_order
ADD COLUMN is_refunded INT DEFAULT 0 COMMENT '是否退款0否 1是',
ADD COLUMN refund_date DATETIME NULL COMMENT '退款日期',
ADD COLUMN is_refund_received INT DEFAULT 0 COMMENT '是否退款到账0否 1是',
ADD COLUMN refund_received_date DATETIME NULL COMMENT '退款到账日期',
ADD COLUMN is_rebate_received INT DEFAULT 0 COMMENT '后返到账0否 1是',
ADD COLUMN rebate_received_date DATETIME NULL COMMENT '后返到账日期';
-- 添加索引(可选,根据查询需求)
-- CREATE INDEX idx_is_refunded ON jd_order(is_refunded);
-- CREATE INDEX idx_is_refund_received ON jd_order(is_refund_received);
-- CREATE INDEX idx_is_rebate_received ON jd_order(is_rebate_received);

View File

@@ -0,0 +1,34 @@
-- 腾讯文档批量推送记录表
CREATE TABLE IF NOT EXISTS `tencent_doc_batch_push_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`batch_id` varchar(64) NOT NULL COMMENT '批次IDUUID',
`file_id` varchar(100) DEFAULT NULL COMMENT '文件ID',
`sheet_id` varchar(100) DEFAULT NULL COMMENT '工作表ID',
`push_type` varchar(20) DEFAULT 'AUTO' COMMENT '推送类型AUTO-自动推送MANUAL-手动推送',
`trigger_source` varchar(50) DEFAULT NULL COMMENT '触发来源DELAYED_TIMER-延迟定时器USER-用户手动',
`start_time` datetime DEFAULT NULL COMMENT '推送开始时间',
`end_time` datetime DEFAULT NULL COMMENT '推送结束时间',
`duration_ms` bigint(20) DEFAULT NULL COMMENT '推送耗时(毫秒)',
`start_row` int(11) DEFAULT NULL COMMENT '起始行号',
`end_row` int(11) DEFAULT NULL COMMENT '结束行号',
`total_rows` int(11) DEFAULT 0 COMMENT '总行数',
`success_count` int(11) DEFAULT 0 COMMENT '成功数量',
`skip_count` int(11) DEFAULT 0 COMMENT '跳过数量',
`error_count` int(11) DEFAULT 0 COMMENT '错误数量',
`status` varchar(20) DEFAULT 'RUNNING' COMMENT '状态RUNNING-执行中SUCCESS-成功PARTIAL-部分成功FAILED-失败',
`result_message` text COMMENT '结果消息',
`error_message` text COMMENT '错误信息',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_batch_id` (`batch_id`),
KEY `idx_file_sheet` (`file_id`, `sheet_id`),
KEY `idx_create_time` (`create_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯文档批量推送记录表';
-- 修改操作日志表添加批次ID字段
ALTER TABLE `tencent_doc_operation_log`
ADD COLUMN `batch_id` varchar(64) DEFAULT NULL COMMENT '批次ID关联批量推送记录' AFTER `id`,
ADD KEY `idx_batch_id` (`batch_id`);

View File

@@ -0,0 +1,37 @@
-- 闲鱼商品菜单配置
-- 菜单类型M=目录 C=菜单 F=按钮
-- 1. 主菜单如果是放在系统管理下parent_id为1如果是独立菜单需要先创建一个jarvis目录
-- 假设放在系统管理parent_id=1可以根据实际情况调整
-- 闲鱼商品管理菜单(主菜单)
insert into sys_menu values(2000, '闲鱼商品管理', 1, 10, 'erpProduct', 'system/erpProduct/index', '', '', 1, 0, 'C', '0', '0', 'jarvis:erpProduct:list', 'shopping', 'admin', sysdate(), '', null, '闲鱼商品管理菜单');
-- 闲鱼商品管理按钮权限
insert into sys_menu values(2001, '商品查询', 2000, 1, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2002, '商品新增', 2000, 2, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2003, '商品修改', 2000, 3, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2004, '商品删除', 2000, 4, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:remove', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2005, '商品导出', 2000, 5, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:export', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2006, '拉取商品', 2000, 6, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:pull', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2007, '批量上架', 2000, 7, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:publish', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values(2008, '批量下架', 2000, 8, '', '', '', '', 1, 0, 'F', '0', '0', 'jarvis:erpProduct:downShelf', '#', 'admin', sysdate(), '', null, '');
-- 给管理员角色role_id=1添加闲鱼商品菜单权限
insert into sys_role_menu(role_id, menu_id) values(1, 2000);
insert into sys_role_menu(role_id, menu_id) values(1, 2001);
insert into sys_role_menu(role_id, menu_id) values(1, 2002);
insert into sys_role_menu(role_id, menu_id) values(1, 2003);
insert into sys_role_menu(role_id, menu_id) values(1, 2004);
insert into sys_role_menu(role_id, menu_id) values(1, 2005);
insert into sys_role_menu(role_id, menu_id) values(1, 2006);
insert into sys_role_menu(role_id, menu_id) values(1, 2007);
insert into sys_role_menu(role_id, menu_id) values(1, 2008);
-- 注意:
-- 1. 如果菜单需要放在其他目录下比如jarvis目录请修改parent_id
-- 2. order_num 是显示顺序,可以根据需要调整(值越大越靠后)
-- 3. 如果管理员角色ID不是1请修改上面的role_id值
-- 4. 如果需要给其他角色添加权限可以复制上面的insert语句并修改role_id
-- 5. 执行完此SQL后需要清除Redis缓存或重启系统才能看到菜单

31
sql/闲鱼商品表.sql Normal file
View File

@@ -0,0 +1,31 @@
-- 闲鱼商品表
CREATE TABLE `erp_product` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_id` bigint(20) NOT NULL COMMENT '管家商品ID',
`title` varchar(500) DEFAULT NULL COMMENT '商品标题',
`main_image` varchar(1000) DEFAULT NULL COMMENT '商品图片(主图)',
`price` bigint(20) DEFAULT NULL COMMENT '商品价格(分)',
`stock` int(11) DEFAULT NULL COMMENT '商品库存',
`product_status` int(11) DEFAULT NULL COMMENT '商品状态 -1:删除 21:待发布 22:销售中 23:已售罄 31:手动下架 33:售出下架 36:自动下架',
`sale_status` int(11) DEFAULT NULL COMMENT '销售状态',
`user_name` varchar(100) DEFAULT NULL COMMENT '闲鱼会员名',
`online_time` bigint(20) DEFAULT NULL COMMENT '上架时间(时间戳)',
`offline_time` bigint(20) DEFAULT NULL COMMENT '下架时间(时间戳)',
`sold_time` bigint(20) DEFAULT NULL COMMENT '售出时间(时间戳)',
`create_time_xy` bigint(20) DEFAULT NULL COMMENT '创建时间(闲鱼,时间戳)',
`update_time_xy` bigint(20) DEFAULT NULL COMMENT '更新时间(闲鱼,时间戳)',
`appid` varchar(100) DEFAULT NULL COMMENT 'ERP应用ID',
`product_url` varchar(1000) DEFAULT NULL COMMENT '商品链接',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id_appid` (`product_id`, `appid`),
KEY `idx_product_id` (`product_id`),
KEY `idx_appid` (`appid`),
KEY `idx_product_status` (`product_status`),
KEY `idx_user_name` (`user_name`),
KEY `idx_online_time` (`online_time`),
KEY `idx_update_time_xy` (`update_time_xy`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲鱼商品表';