Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
cas-relay
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
jiatao
cas-relay
Commits
1a77b4ba
Commit
1a77b4ba
authored
Jul 24, 2026
by
jiatao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
添加消息对接
parent
f288e28a
Changes
36
Show whitespace changes
Inline
Side-by-side
Showing
36 changed files
with
2565 additions
and
24 deletions
+2565
-24
.dockerignore
.dockerignore
+1
-0
.env
.env
+3
-0
.env.example
.env.example
+26
-6
Dockerfile
Dockerfile
+2
-1
README.md
README.md
+10
-0
docker-compose.yml
docker-compose.yml
+74
-2
MESSAGE_RELAY.md
docs/MESSAGE_RELAY.md
+141
-0
NGINX_GATEWAY.md
docs/NGINX_GATEWAY.md
+226
-0
maven-settings.xml
maven-settings.xml
+1
-1
hap-oauth2-gateway.conf
nginx/hap-oauth2-gateway.conf
+166
-0
pom.xml
pom.xml
+19
-0
CampusApiException.java
...ain/java/com/jd/easywalk/casrelay/CampusApiException.java
+25
-0
CampusMessageClient.java
...in/java/com/jd/easywalk/casrelay/CampusMessageClient.java
+254
-0
CampusPushResult.java
src/main/java/com/jd/easywalk/casrelay/CampusPushResult.java
+14
-0
CampusTokenService.java
...ain/java/com/jd/easywalk/casrelay/CampusTokenService.java
+180
-0
CampusUcsType.java
src/main/java/com/jd/easywalk/casrelay/CampusUcsType.java
+31
-0
CasRelayApplication.java
...in/java/com/jd/easywalk/casrelay/CasRelayApplication.java
+1
-1
MessageRelayLogSupport.java
...java/com/jd/easywalk/casrelay/MessageRelayLogSupport.java
+97
-0
MessageRelayProperties.java
...java/com/jd/easywalk/casrelay/MessageRelayProperties.java
+51
-0
MessageRelayReceipt.java
...in/java/com/jd/easywalk/casrelay/MessageRelayReceipt.java
+21
-0
MessageRelayScheduler.java
.../java/com/jd/easywalk/casrelay/MessageRelayScheduler.java
+56
-0
MessageRelayService.java
...in/java/com/jd/easywalk/casrelay/MessageRelayService.java
+395
-0
MessageRelayStartupValidator.java
...om/jd/easywalk/casrelay/MessageRelayStartupValidator.java
+63
-0
MessageRelayTask.java
src/main/java/com/jd/easywalk/casrelay/MessageRelayTask.java
+109
-0
MessageRelayTaskRepository.java
.../com/jd/easywalk/casrelay/MessageRelayTaskRepository.java
+29
-0
MingdaoMessageController.java
...va/com/jd/easywalk/casrelay/MingdaoMessageController.java
+189
-0
MingdaoMessageRequest.java
.../java/com/jd/easywalk/casrelay/MingdaoMessageRequest.java
+44
-0
MingdaoTodoFinishedRequest.java
.../com/jd/easywalk/casrelay/MingdaoTodoFinishedRequest.java
+17
-0
RelayTaskStatus.java
src/main/java/com/jd/easywalk/casrelay/RelayTaskStatus.java
+10
-0
TeacherDirectoryService.java
...ava/com/jd/easywalk/casrelay/TeacherDirectoryService.java
+7
-8
application.yml
src/main/resources/application.yml
+35
-5
CampusNewsFormTest.java
...est/java/com/jd/easywalk/casrelay/CampusNewsFormTest.java
+55
-0
CampusResponseValidationTest.java
...om/jd/easywalk/casrelay/CampusResponseValidationTest.java
+46
-0
CampusUcsTypeTest.java
...test/java/com/jd/easywalk/casrelay/CampusUcsTypeTest.java
+29
-0
MessageRelayLogSupportTest.java
.../com/jd/easywalk/casrelay/MessageRelayLogSupportTest.java
+26
-0
MessageRelayServiceTest.java
...ava/com/jd/easywalk/casrelay/MessageRelayServiceTest.java
+112
-0
No files found.
.dockerignore
View file @
1a77b4ba
target/*
!target/cas-relay-1.0.0.jar
.git/
.m2/
*.log
cas-relay.log
...
...
.env
View file @
1a77b4ba
CAS_RELAY_PORT=18080
POSTGRES_DB=cas_relay
POSTGRES_USER=cas_relay
POSTGRES_PASSWORD=ahQ8sHzOIoeQco7vRycpU1mfaMXQw0FPOASD7RxThFw!9
CAS_SERVICE_URL=https://nercwbs-lms.xidian.edu.cn/orgsso
CAS_SERVER_URL_PREFIX=https://ids.xidian.edu.cn/authserver
CAS_SERVICE_VALIDATE_PATH=/serviceValidate
...
...
.env.example
View file @
1a77b4ba
CAS_RELAY_PORT=18080
# Pin this to the exact Nginx tag or digest approved for production.
NGINX_IMAGE=nginx:1.30.4-alpine3.24
POSTGRES_DB=cas_relay
POSTGRES_USER=cas_relay
POSTGRES_PASSWORD=ttgYgJYhNHXYyPVH
CAS_SERVICE_URL=https://nercwbs-lms.xidian.edu.cn/orgsso
CAS_SERVER_URL_PREFIX=https://ids.xidian.edu.cn/authserver
CAS_SERVICE_VALIDATE_PATH=/serviceValidate
...
...
@@ -9,11 +14,11 @@ MINGDAO_ENABLED=true
MINGDAO_REFRESH_ENABLED=true
MINGDAO_REFRESH_INTERVAL_MS=600000
MINGDAO_API_URL=https://nercwbs-lms.xidian.edu.cn/api/v2/open/worksheet/getFilterRows
MINGDAO_APP_KEY=
3ed6154939e6aa92
MINGDAO_SIGN=
MDM0ZWU1YWM4NzM0ODljZTZiNDJlYmUwYzRmODU4OTNlN2ZkYzIwMjI1ZjBhNzIwZjA4OWU3YzQ1NTdiNGM0Mg==
MINGDAO_APP_KEY=
replace_with_mingdao_app_key
MINGDAO_SIGN=
replace_with_mingdao_sign
OAUTH_CLIENT_ID=
1506958348348624896
OAUTH_CLIENT_SECRET=
19E483DC4D272E4JD5KD
OAUTH_CLIENT_ID=
replace_with_oauth_client_id
OAUTH_CLIENT_SECRET=
replace_with_oauth_client_secret
OAUTH_REDIRECT_URI=https://nercwbs-lms.xidian.edu.cn/orgsso/oauth2
OAUTH_AUTHORIZATION_URI=https://ids.xidian.edu.cn/authserver/oauth2.0/authorize
OAUTH_TOKEN_URI=https://ids.xidian.edu.cn/authserver/oauth2.0/accessToken
...
...
@@ -21,7 +26,7 @@ OAUTH_USER_INFO_URI=https://ids.xidian.edu.cn/authserver/oauthApi/user/profile
OAUTH_USER_MOBILE_URI=https://ids.xidian.edu.cn/authserver/oauthApi/user/getMobile
WECOM_RELAY_ENABLED=false
WECOM_APP_ID=
200260525100013951
WECOM_APP_ID=
replace_with_campus_app_id
WECOM_APP_SECRET=
WECOM_PLATFORM_DOMAIN=xxcapp.xidian.edu.cn
WECOM_CORP_ID=
...
...
@@ -53,3 +58,18 @@ WECOM_MOCK_WORK_NO=20240001
WECOM_MOCK_NAME=MockUser
WECOM_MOCK_EMAIL=20240001@xidian.edu.cn
WECOM_MOCK_MOBILE=13800000000
MESSAGE_RELAY_ENABLED=true
MESSAGE_RELAY_WEBHOOK_TOKEN=j*KWBZEoRQI@44Y!%%GF4Quzr1!P5q^v
CAMPUS_BASE_URL=https://xxcapp.xidian.edu.cn
CAMPUS_APP_ID=200260525100013951
CAMPUS_APP_SECRET=8ekpsdgmb9eh3m9w1gf3s1nm5ft2b3ne
CAMPUS_WID=126
MESSAGE_RELAY_CONTENT_TYPE=news
MESSAGE_RELAY_TOKEN_SKEW_SECONDS=300
MESSAGE_RELAY_CONNECT_TIMEOUT_MS=5000
MESSAGE_RELAY_READ_TIMEOUT_MS=10000
MESSAGE_RELAY_RECIPIENT_BATCH_SIZE=100
MESSAGE_RELAY_MAX_ATTEMPTS=5
MESSAGE_RELAY_SCAN_INTERVAL_MS=30000
MESSAGE_RELAY_PROCESSING_TIMEOUT_SECONDS=300
Dockerfile
View file @
1a77b4ba
FROM
eclipse-temurin:8-jre
ARG
BASE_IMAGE=eclipse-temurin:8-jre
FROM
${BASE_IMAGE}
WORKDIR
/app
...
...
README.md
View file @
1a77b4ba
...
...
@@ -81,6 +81,16 @@ docker compose logs -f cas-relay
docker compose down
```
## HAP 公网 OAuth2 网关
`docker-compose.yml`
已包含 Nginx 网关服务,用于在不修改学校现有代理的
情况下,将宿主机
`8880`
上的正式 OAuth2 路径转发到 cas-relay,其余请求
转发到本机 HAP
`8881`
。
部署前必须先把 HAP 的宿主机端口改为
`127.0.0.1:8881:8880`
,释放
`8880`
。完整配置和部署步骤见:
[
docs/NGINX_GATEWAY.md
](
docs/NGINX_GATEWAY.md
)
。
## 明道云 sso.json 示例
示例文件:
[
sso.example.json
](
sso.example.json
)
...
...
docker-compose.yml
View file @
1a77b4ba
services
:
postgres
:
image
:
postgres:16-alpine
container_name
:
cas-relay-postgres
restart
:
unless-stopped
environment
:
POSTGRES_DB
:
"
${POSTGRES_DB:-cas_relay}"
POSTGRES_USER
:
"
${POSTGRES_USER:-cas_relay}"
POSTGRES_PASSWORD
:
"
${POSTGRES_PASSWORD:?POSTGRES_PASSWORD
must
be
set}"
volumes
:
-
./volume/postgresql:/var/lib/postgresql/data
healthcheck
:
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-cas_relay}
-d
${POSTGRES_DB:-cas_relay}"
]
interval
:
10s
timeout
:
5s
retries
:
10
cas-relay
:
image
:
cas-relay:1.0.0
build
:
...
...
@@ -6,16 +22,23 @@ services:
dockerfile
:
Dockerfile
container_name
:
cas-relay
restart
:
unless-stopped
depends_on
:
postgres
:
condition
:
service_healthy
ports
:
-
"
${CAS_RELAY_PORT:-18080}:18080"
# Keep this aligned with nginx/hap-oauth2-gateway.conf.
-
"
127.0.0.1:18080:18080"
environment
:
SPRING_DATASOURCE_URL
:
"
jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cas_relay}"
SPRING_DATASOURCE_USERNAME
:
"
${POSTGRES_USER:-cas_relay}"
SPRING_DATASOURCE_PASSWORD
:
"
${POSTGRES_PASSWORD:?POSTGRES_PASSWORD
must
be
set}"
MINGDAO_ENABLED
:
"
${MINGDAO_ENABLED:-true}"
MINGDAO_REFRESH_ENABLED
:
"
${MINGDAO_REFRESH_ENABLED:-true}"
MINGDAO_REFRESH_INTERVAL_MS
:
"
${MINGDAO_REFRESH_INTERVAL_MS:-600000}"
MINGDAO_APP_KEY
:
"
${MINGDAO_APP_KEY}"
MINGDAO_SIGN
:
"
${MINGDAO_SIGN}"
WECOM_RELAY_ENABLED
:
"
${WECOM_RELAY_ENABLED:-false}"
WECOM_APP_ID
:
"
${WECOM_APP_ID:-
200260525100013951
}"
WECOM_APP_ID
:
"
${WECOM_APP_ID:-}"
WECOM_APP_SECRET
:
"
${WECOM_APP_SECRET:-}"
WECOM_PLATFORM_DOMAIN
:
"
${WECOM_PLATFORM_DOMAIN:-xxcapp.xidian.edu.cn}"
WECOM_REDIRECT_URI
:
"
${WECOM_REDIRECT_URI:-https://nercwbs-lms.xidian.edu.cn/orgsso/oauth2}"
...
...
@@ -25,6 +48,18 @@ services:
WECOM_AUTHORIZATION_REDIRECT_URI_PARAM
:
"
${WECOM_AUTHORIZATION_REDIRECT_URI_PARAM:-redirect}"
WECOM_AUTHORIZATION_QRCODE_PARAM
:
"
${WECOM_AUTHORIZATION_QRCODE_PARAM:-qrcode}"
WECOM_AUTHORIZATION_QRCODE_VALUE
:
"
${WECOM_AUTHORIZATION_QRCODE_VALUE:-1}"
WECOM_MOCK_ENABLED
:
"
${WECOM_MOCK_ENABLED:-false}"
MESSAGE_RELAY_ENABLED
:
"
${MESSAGE_RELAY_ENABLED:-false}"
MESSAGE_RELAY_WEBHOOK_TOKEN
:
"
${MESSAGE_RELAY_WEBHOOK_TOKEN:-}"
CAMPUS_BASE_URL
:
"
${CAMPUS_BASE_URL:-}"
CAMPUS_APP_ID
:
"
${CAMPUS_APP_ID:-}"
CAMPUS_APP_SECRET
:
"
${CAMPUS_APP_SECRET:-}"
CAMPUS_WID
:
"
${CAMPUS_WID:-}"
MESSAGE_RELAY_CONTENT_TYPE
:
"
${MESSAGE_RELAY_CONTENT_TYPE:-news}"
MESSAGE_RELAY_RECIPIENT_BATCH_SIZE
:
"
${MESSAGE_RELAY_RECIPIENT_BATCH_SIZE:-100}"
MESSAGE_RELAY_MAX_ATTEMPTS
:
"
${MESSAGE_RELAY_MAX_ATTEMPTS:-5}"
MESSAGE_RELAY_SCAN_INTERVAL_MS
:
"
${MESSAGE_RELAY_SCAN_INTERVAL_MS:-30000}"
MESSAGE_RELAY_PROCESSING_TIMEOUT_SECONDS
:
"
${MESSAGE_RELAY_PROCESSING_TIMEOUT_SECONDS:-300}"
JAVA_OPTS
:
>-
-Dcas.service=${CAS_SERVICE_URL}
-Dcas.server-url-prefix=${CAS_SERVER_URL_PREFIX:-https://ids.xidian.edu.cn/authserver}
...
...
@@ -51,3 +86,40 @@ services:
-Dwecom-relay.code-user-info-uri=${WECOM_CODE_USER_INFO_URI:-}
-Dwecom-relay.user-detail-uri=${WECOM_USER_DETAIL_URI:-}
-Dwecom-relay.scope=${WECOM_SCOPE:-snsapi_base}
nginx
:
image
:
"
${NGINX_IMAGE:-nginx:1.30.4-alpine3.24}"
container_name
:
cas-relay-nginx
restart
:
unless-stopped
depends_on
:
-
cas-relay
# Production runs on Linux. Host networking lets Nginx reach the
# loopback-only HAP:8881 and cas-relay:18080 listeners without exposing
# either backend port to the campus network.
network_mode
:
host
volumes
:
-
type
:
bind
source
:
./nginx/hap-oauth2-gateway.conf
target
:
/etc/nginx/conf.d/default.conf
read_only
:
true
bind
:
create_host_path
:
false
healthcheck
:
test
:
-
CMD-SHELL
-
>-
nginx -t
&& wget -q -O /dev/null
http://127.0.0.1:8880/_cas_relay_gateway_health
interval
:
30s
timeout
:
5s
retries
:
3
start_period
:
10s
logging
:
driver
:
json-file
options
:
max-size
:
"
20m"
max-file
:
"
5"
volumes
:
cas-relay-pgdata
:
docs/MESSAGE_RELAY.md
0 → 100644
View file @
1a77b4ba
# 明道云消息转发
消息链路:明道云 HAP →
`cas-relay`
→ 学校移动校园企业微信消息接口。
## 1. 配置环境变量
复制
`.env.example`
为
`.env`
,至少填写:
```
dotenv
POSTGRES_PASSWORD=强密码
MESSAGE_RELAY_ENABLED=true
MESSAGE_RELAY_WEBHOOK_TOKEN=一段足够长的随机字符串
CAMPUS_BASE_URL=https://学校移动校园域名
CAMPUS_APP_ID=学校开发者中心分配的appid
CAMPUS_APP_SECRET=学校开发者中心分配的appsecret
CAMPUS_WID=移动校园后台中的企业微信应用ID
MESSAGE_RELAY_CONTENT_TYPE=news
```
`CAMPUS_WID`
是移动校园平台侧的应用 ID,不是企业微信原始
`agentId`
。
## 2. 启动
```
bash
docker compose up
-d
--build
```
PostgreSQL 数据保存在 Compose 卷
`cas-relay-pgdata`
。应用启动时 Hibernate 会创建或更新
`message_relay_task`
表。
## 3. 配置明道云
`appextensions.json`
:
```
json
{
"WebhookUrl"
:
"https://casrelay.xidian.edu.cn/webhooks/mingdao/message"
,
"FinishTodoWebhookUrl"
:
"https://casrelay.xidian.edu.cn/webhooks/mingdao/todo/finished"
,
"WebhookHeaders"
:
{
"Authorization"
:
"j*KWBZEoRQI@44Y!%%GF4Quzr1!P5q^v"
}
}
```
明道云的
`targets`
必须是学校推送接口能识别的学工号。普通消息先幂等落库,后台按批次转发;
完成待办回调只记录为
`COMPLETED`
,不会再次给用户发送通知。
`CAMPUS_WID`
固定为移动校园应用 ID(当前为
`126`
),消息分类通过独立的
`ucs_type`
参数传递。
`messageTypeV2`
映射如下:
| 明道云
`messageTypeV2`
| 消息含义 | 移动校园
`ucs_type`
|
|---|---|---:|
| 11、12 | 工作表系统/讨论提到消息 | 3(通知) |
| 13 | 工作表讨论回复我的 | 2(提醒) |
| 14、16 | 工作流待办/发送记录的系统消息 | 4(流程) |
| 15 | 不发送记录的工作流系统消息 | 不转发、不创建发送任务 |
缺少
`messageTypeV2`
或收到未配置的类型时,Webhook 返回 HTTP 400,避免消息被错误分类。
`MESSAGE_RELAY_CONTENT_TYPE`
控制发送形式,可选
`news`
(默认)或
`text`
。
`news`
模式构造一条
图文消息:标题固定为“国工实验室综合管理平台”,简介使用明道云
`message`
,文章链接使用
`redirectUrl`
,封面字段
`picurl`
传空字符串。
`text`
模式发送正文,并在存在
`redirectUrl`
时附加
“点击查看详情”链接。
## 4. 状态说明
-
`RECEIVED`
:已接收,等待发送。
-
`RETRY`
:临时错误,等待重试。
-
`PROCESSING`
:任务已被一个实例认领并正在发送;租约超时后会自动恢复。
-
`SENT`
:学校接口已经受理。
-
`FAILED`
:达到最大重试次数,或学校接口明确拒绝。
-
`COMPLETED`
:收到明道云待办完成事件。
学校返回的推送任务 ID 保存在
`campus_ucs_ids`
,累计不识别的学工号保存在
`invalid_recipients`
,最后一次错误或最终无效人员摘要保存在
`error_message`
。每一批被学校受理后会立即
独立提交发送进度;服务按
`event_type + message_id`
防止重复接收。
## 5. 手工测试 Webhook
```
bash
curl
-X
POST
"https://casrelay.xidian.edu.cn/webhooks/mingdao/message"
\
-H
"Authorization: Bearer Lv#hiQWVhXCZnPlD"
\
-H
"Content-Type: application/json"
\
-d
'{
"messageId":"test-007",
"message":"测试消息 测试端口正确",
"messageTypeV2":14,
"redirectUrl":"https://example.edu/todo/1",
"targets":["26590023"]
}'
```
成功接收返回 HTTP 200。
`duplicate=true`
表示相同事件已经接收过,不会创建第二条任务。
## 6. 日志追踪
每条普通消息可以使用明道云
`messageId`
或本地
`taskId`
串联完整过程。日志不会输出消息正文、
收件人列表、Webhook 密钥、
`appsecret`
或完整
`access_token`
。
关键日志依次为:
```
text
Mingdao webhook received
Message relay enqueue validated
Message relay event accepted
Message relay scan found tasks
Message relay task started
Message relay payload prepared
Message relay batch started
Campus token request started/succeeded(仅缓存失效时出现)
Campus message request started/accepted
Message relay batch accepted
Message relay forwarding completed
```
发生故障时关注:
```
text
Mingdao webhook unauthorized
Campus token HTTP failure
Campus message business failure
Campus message HTTP failure
Message relay will retry
Message relay permanently failed
```
查看最近日志:
```
bash
docker compose logs
--tail
300 cas-relay
```
按消息 ID 搜索:
```
bash
docker compose logs cas-relay |
grep
'messageId=明道云消息ID'
```
数据库中可结合
`status`
、
`retry_count`
、
`processed_recipient_count`
、
`campus_ucs_ids`
、
`invalid_recipients`
和
`error_message`
判断消息所处阶段。
`SENT`
表示学校接口已受理,最终送达情况仍需使用
`ucs_id`
调用学校的发送结果查询接口确认。
docs/NGINX_GATEWAY.md
0 → 100644
View file @
1a77b4ba
# HAP OAuth2 本地网关部署
该方案不修改学校现有代理。学校代理仍将 HAP 公网域名转发到服务器
`8880`
,由
`cas-relay`
Compose 中的 Nginx 容器进行本机路径分流:
```
text
学校代理 -> 服务器:8880 (Nginx, host network)
|- HAP -> 127.0.0.1:8881
`- cas-relay -> 127.0.0.1:18080
```
## 实现逻辑
学校现有代理不需要修改,仍然把公网及校园网请求转发到部署服务器的
`8880`
。区别是宿主机
`8880`
不再由 HAP 直接占用,而是改由
`cas-relay`
Compose 中的 Nginx 容器监听。
Nginx 根据请求路径选择后端:
| 外部请求路径 | 本机目标 | 用途 |
| --- | --- | --- |
|
`/cas-relay/wecom/oauth2/authorize`
|
`127.0.0.1:18080/wecom/oauth2/authorize`
| 发起正式 OAuth2 授权 |
|
`/cas-relay/wecom/oauth2/access_token`
|
`127.0.0.1:18080/wecom/oauth2/access_token`
| HAP 使用 code 换取 token |
|
`/cas-relay/wecom/oauth2/userinfo`
|
`127.0.0.1:18080/wecom/oauth2/userinfo`
| HAP 获取用户资料 |
|
`/cas-relay/wecom/oauth2/logout`
|
`127.0.0.1:18080/wecom/oauth2/logout`
| OAuth2 登出 |
|
`/mds2*`
|
`127.0.0.1:8881`
| HAP IM WebSocket |
| 其他路径 |
`127.0.0.1:8881`
| HAP 页面、API、上传和下载 |
所有未列入白名单的
`/cas-relay/*`
路径返回
`404`
,因此不会把
`/health`
、
`/teachers/refresh`
、消息 Webhook 或 mock 授权接口发布到
公网。
### OAuth2 请求链路
```
text
1. 校外用户访问 HAP /orgsso/sso
2. HAP 跳转到 /cas-relay/wecom/oauth2/authorize
3. 本机 Nginx 将请求转发给 cas-relay
4. cas-relay 跳转到校园 OAuth2 授权平台
5. 授权平台回调 HAP /orgsso/oauth2
6. HAP 请求 /cas-relay/wecom/oauth2/access_token
7. HAP 请求 /cas-relay/wecom/oauth2/userinfo
8. HAP 根据返回的用户资料完成登录
```
### Docker 网络逻辑
Nginx 使用
`network_mode: host`
,因此容器中的
`127.0.0.1`
与宿主机
共享同一个网络命名空间,可以访问:
-
HAP:
`127.0.0.1:8881`
-
cas-relay:
`127.0.0.1:18080`
如果使用普通 Docker bridge 网络,Nginx 容器中的
`127.0.0.1`
只表示
Nginx 容器自身,无法访问宿主机上的 HAP,因此本方案要求生产服务器为
Linux 并使用 host 网络。
HAP 和 cas-relay 的后端端口均只绑定回环地址,校园网和公网只能通过
Nginx 的
`8880`
入口访问,避免直接暴露后端服务。
### 双层代理头处理
学校代理负责公网 HTTPS,服务器内部使用 HTTP 转发。Nginx 会保留外层
传入的:
-
`Host`
-
`X-Real-IP`
-
`X-Forwarded-Proto`
这样即使学校代理到本机 Nginx、本机 Nginx 到 HAP 都使用 HTTP,HAP
仍能识别老师实际使用的是公网 HTTPS 地址,不会生成错误的 HTTP 回调。
OAuth2 code、token、state 等参数可能位于查询字符串中,因此网关访问
日志只记录
`$uri`
,不记录查询字符串。
## 前置条件
-
目标服务器为 Linux;Nginx 服务使用
`network_mode: host`
。
-
HAP 和 cas-relay 部署在同一台服务器。
-
正式 SSO 使用
`common-oauth2`
和
`/wecom/oauth2/*`
,并关闭 mock。
-
学校代理继续保留原来的 Host、X-Real-IP 和 X-Forwarded-Proto 请求头。
## 1. 修改 HAP
先将 HAP 的宿主机端口从
`8880`
移到仅本机可访问的
`8881`
,否则 Nginx
无法监听
`8880`
:
```
yaml
services
:
app
:
ports
:
-
"
127.0.0.1:8881:8880"
environment
:
ENV_ADDRESS_MAIN
:
"
https://nercwbs-lms.xidian.edu.cn"
```
早期 HAP 版本使用:
```
yaml
ENV_MINGDAO_PROTO
:
"
https"
ENV_MINGDAO_HOST
:
"
nercwbs-lms.xidian.edu.cn"
ENV_MINGDAO_PORT
:
"
443"
```
重建 HAP 后先检查:
```
bash
curl
-I
http://127.0.0.1:8881/
ss
-lntp
|
grep
':8880'
```
第二条命令应确认 HAP 已经释放
`8880`
。
## 2. 检查正式 OAuth2 配置
HAP
`sso.json`
中的关键地址应为:
```
json
{
"mode"
:
"common-oauth2"
,
"oauth2"
:
{
"oauth2Url"
:
"https://nercwbs-lms.xidian.edu.cn/cas-relay"
,
"redirectUri"
:
"https://nercwbs-lms.xidian.edu.cn/orgsso/oauth2"
,
"authorizePath"
:
"/wecom/oauth2/authorize"
,
"tokenPath"
:
"/wecom/oauth2/access_token"
,
"userInfoUrl"
:
"https://nercwbs-lms.xidian.edu.cn/cas-relay/wecom/oauth2/userinfo"
}
}
```
cas-relay 使用:
```
env
WECOM_RELAY_ENABLED=true
WECOM_MOCK_ENABLED=false
WECOM_REDIRECT_URI=https://nercwbs-lms.xidian.edu.cn/orgsso/oauth2
WECOM_ALLOWED_REDIRECT_HOST=nercwbs-lms.xidian.edu.cn
```
## 3. 准备 Nginx 镜像
联网服务器可直接执行:
```
bash
docker pull nginx:1.30.4-alpine3.24
```
离线部署可在联网机器导出后上传:
```
bash
docker pull
--platform
linux/amd64 nginx:1.30.4-alpine3.24
docker save nginx:1.30.4-alpine3.24
-o
nginx-1.30.4-alpine3.24-amd64.tar
```
目标服务器导入:
```
bash
docker load
-i
nginx-1.30.4-alpine3.24-amd64.tar
```
离线启动时可禁止 Compose 尝试拉取镜像:
```
bash
docker compose up
-d
--pull
never
```
如需使用内部镜像仓库,在
`.env`
中设置:
```
env
NGINX_IMAGE=内部仓库/nginx:镜像标签
```
## 4. 启动
在 cas-relay 目录执行:
```
bash
docker compose config
docker compose up
-d
docker compose ps
docker compose logs
-f
nginx
```
Compose 会执行 Nginx 配置检查,并通过
`http://127.0.0.1:8880/_cas_relay_gateway_health`
检查容器状态。
## 5. 验收
检查 HAP:
```
bash
curl
-I
\
-H
'Host: nercwbs-lms.xidian.edu.cn'
\
-H
'X-Forwarded-Proto: https'
\
http://127.0.0.1:8880/
```
从校外网络检查 OAuth2 authorize,预期返回
`302`
:
```
bash
curl
-I
\
'https://nercwbs-lms.xidian.edu.cn/cas-relay/wecom/oauth2/authorize'
```
以下地址必须返回
`404`
:
```
bash
curl
-I
'https://nercwbs-lms.xidian.edu.cn/cas-relay/health'
curl
-I
'https://nercwbs-lms.xidian.edu.cn/cas-relay/teachers/refresh'
curl
-I
'https://nercwbs-lms.xidian.edu.cn/cas-relay/wecom/oauth2/mock/authorize'
```
最后从校外浏览器访问:
```
text
https://nercwbs-lms.xidian.edu.cn/orgsso/sso
```
## 注意事项
-
`network_mode: host`
仅按生产 Linux 环境设计。
-
不要同时运行另一个监听宿主机
`8880`
的 Nginx 或 HAP 端口映射。
-
HAP 容器还必须能够访问自己的公网 OAuth2 地址;若不能回流,需要在
HAP 侧将公网域名解析到学校代理的校园网地址。
-
学校提供的外层示例会信任客户端原有的
`X-Real-IP`
,该值不能用于安全
授权;宿主机防火墙仍应将
`8880`
的来源限制为学校代理地址。
maven-settings.xml
View file @
1a77b4ba
...
...
@@ -2,5 +2,5 @@
<settings
xmlns=
"http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation=
"http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd"
>
<localRepository>
C:/usr/code/java/jd_easywalk/
.m2/repository
</localRepository>
<localRepository>
.m2/repository
</localRepository>
</settings>
nginx/hap-oauth2-gateway.conf
0 → 100644
View file @
1a77b4ba
# HAP + cas-relay local gateway
#
# Deployment assumptions:
# 1. The school's existing proxy remains unchanged and forwards to this host:8880.
# 2. This Nginx instance runs on the HAP/cas-relay host and listens on 8880.
# 3. HAP is remapped to 127.0.0.1:8881.
# 4. cas-relay is bound to 127.0.0.1:18080.
# 5. Production SSO uses common-oauth2 with /wecom/oauth2/* (not mock mode).
#
# Mounted by docker-compose.yml as /etc/nginx/conf.d/default.conf. This file
# must be included from the http {} context (the image default).
# Preserve the public protocol supplied by the school's outer proxy. Falling
# back to $scheme is only for direct requests that do not pass through it.
map
$
http_x_forwarded_proto
$
hap_public_proto
{
default
$
scheme
;
~*^
http
$
http
;
~*^
https
$
https
;
}
map
$
http_host
$
hap_public_host
{
""
$
host
;
default
$
http_host
;
}
# The outer proxy configuration supplies X-Real-IP. Access to port 8880 should
# also be restricted by the host firewall to the school's proxy addresses.
map
$
http_x_real_ip
$
hap_client_ip
{
""
$
remote_addr
;
default
$
http_x_real_ip
;
}
map
$
http_upgrade
$
hap_connection_upgrade
{
default
upgrade
;
""
""
;
}
# Do not log query strings: OAuth authorization codes and tokens may be carried
# in query parameters.
log_format
hap_gateway_safe
'$time_iso8601 client=$hap_client_ip proxy=$remote_addr '
'host="$host" request="$request_method $uri $server_protocol" '
'status=$status bytes=$body_bytes_sent '
'request_time=$request_time upstream="$upstream_addr" '
'upstream_status="$upstream_status" upstream_time="$upstream_response_time"'
;
upstream
hap_local
{
server
127
.
0
.
0
.
1
:
8881
max_fails
=
3
fail_timeout
=
10
s
;
keepalive
32
;
}
upstream
cas_relay_local
{
server
127
.
0
.
0
.
1
:
18080
max_fails
=
3
fail_timeout
=
10
s
;
keepalive
8
;
}
server
{
listen
8880
default_server
;
server_name
_
;
# The official Nginx image sends these streams to docker compose logs.
access_log
/
dev
/
stdout
hap_gateway_safe
;
error_log
/
dev
/
stderr
warn
;
server_tokens
off
;
underscores_in_headers
on
;
# Keep the HAP upload limit aligned with the official proxy example.
client_max_body_size
2048
m
;
client_body_timeout
60
s
;
client_header_timeout
60
s
;
keepalive_timeout
65
s
;
send_timeout
1800
s
;
reset_timedout_connection
on
;
proxy_http_version
1
.
1
;
proxy_connect_timeout
10
s
;
proxy_send_timeout
1800
s
;
proxy_read_timeout
1800
s
;
proxy_redirect
off
;
proxy_set_header
Host
$
hap_public_host
;
proxy_set_header
X
-
Real
-
IP
$
hap_client_ip
;
proxy_set_header
X
-
Forwarded
-
For
$
hap_client_ip
;
proxy_set_header
X
-
Forwarded
-
Host
$
hap_public_host
;
proxy_set_header
X
-
Forwarded
-
Proto
$
hap_public_proto
;
proxy_set_header
Upgrade
$
http_upgrade
;
proxy_set_header
Connection
$
hap_connection_upgrade
;
# Container-local health check. Requests arriving through the school's
# proxy are rejected because their source address is not loopback.
location
= /
_
cas_relay_gateway_health
{
allow
127
.
0
.
0
.
1
;
deny
all
;
access_log
off
;
default_type
text
/
plain
;
return
200
"ok\n"
;
}
# Production OAuth2 endpoints exposed to HAP and external browsers.
# Exact locations prevent accidental publication of administrative,
# health, webhook, or mock endpoints from cas-relay.
location
= /
cas
-
relay
/
wecom
/
oauth2
/
authorize
{
client_max_body_size
64
k
;
proxy_send_timeout
60
s
;
proxy_read_timeout
60
s
;
limit_except
GET
{
deny
all
;
}
proxy_pass
http
://
cas_relay_local
/
wecom
/
oauth2
/
authorize
;
}
location
= /
cas
-
relay
/
wecom
/
oauth2
/
access_token
{
client_max_body_size
64
k
;
proxy_send_timeout
60
s
;
proxy_read_timeout
60
s
;
limit_except
POST
{
deny
all
;
}
proxy_pass
http
://
cas_relay_local
/
wecom
/
oauth2
/
access_token
;
}
location
= /
cas
-
relay
/
wecom
/
oauth2
/
userinfo
{
client_max_body_size
64
k
;
proxy_send_timeout
60
s
;
proxy_read_timeout
60
s
;
limit_except
GET
{
deny
all
;
}
proxy_pass
http
://
cas_relay_local
/
wecom
/
oauth2
/
userinfo
;
}
location
= /
cas
-
relay
/
wecom
/
oauth2
/
logout
{
client_max_body_size
64
k
;
proxy_send_timeout
60
s
;
proxy_read_timeout
60
s
;
limit_except
GET
{
deny
all
;
}
proxy_pass
http
://
cas_relay_local
/
wecom
/
oauth2
/
logout
;
}
# Deny every other cas-relay path, including /health,
# /teachers/refresh, webhooks, and /wecom/oauth2/mock/authorize.
location
= /
cas
-
relay
{
return
404
;
}
location
^~ /
cas
-
relay
/ {
return
404
;
}
# HAP instant messaging WebSocket.
location
^~ /
mds2
{
proxy_buffering
off
;
proxy_read_timeout
3600
s
;
proxy_send_timeout
3600
s
;
proxy_pass
http
://
hap_local
;
}
# All remaining HAP pages, APIs, uploads, and downloads.
location
/ {
proxy_request_buffering
off
;
proxy_pass
http
://
hap_local
;
}
}
pom.xml
View file @
1a77b4ba
...
...
@@ -30,11 +30,30 @@
<groupId>
org.springframework.boot
</groupId>
<artifactId>
spring-boot-starter-web
</artifactId>
</dependency>
<dependency>
<groupId>
org.springframework.boot
</groupId>
<artifactId>
spring-boot-starter-data-jpa
</artifactId>
</dependency>
<dependency>
<groupId>
org.postgresql
</groupId>
<artifactId>
postgresql
</artifactId>
<scope>
runtime
</scope>
</dependency>
<dependency>
<groupId>
org.springframework.boot
</groupId>
<artifactId>
spring-boot-configuration-processor
</artifactId>
<optional>
true
</optional>
</dependency>
<dependency>
<groupId>
org.springframework.boot
</groupId>
<artifactId>
spring-boot-starter-test
</artifactId>
<scope>
test
</scope>
</dependency>
<dependency>
<groupId>
com.h2database
</groupId>
<artifactId>
h2
</artifactId>
<scope>
test
</scope>
</dependency>
</dependencies>
<build>
...
...
src/main/java/com/jd/easywalk/casrelay/CampusApiException.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
public
class
CampusApiException
extends
RuntimeException
{
private
final
boolean
tokenInvalid
;
private
final
boolean
retryable
;
public
CampusApiException
(
String
message
,
boolean
tokenInvalid
)
{
this
(
message
,
tokenInvalid
,
tokenInvalid
);
}
public
CampusApiException
(
String
message
,
boolean
tokenInvalid
,
boolean
retryable
)
{
super
(
message
);
this
.
tokenInvalid
=
tokenInvalid
;
this
.
retryable
=
retryable
;
}
public
CampusApiException
(
String
message
,
Throwable
cause
)
{
super
(
message
,
cause
);
this
.
tokenInvalid
=
false
;
this
.
retryable
=
true
;
}
public
boolean
isTokenInvalid
()
{
return
tokenInvalid
;
}
public
boolean
isRetryable
()
{
return
retryable
;
}
}
src/main/java/com/jd/easywalk/casrelay/CampusMessageClient.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.time.Duration
;
import
java.util.ArrayList
;
import
java.util.Collections
;
import
java.util.List
;
import
java.util.Locale
;
import
java.util.Map
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.boot.web.client.RestTemplateBuilder
;
import
org.springframework.http.HttpEntity
;
import
org.springframework.http.HttpHeaders
;
import
org.springframework.http.MediaType
;
import
org.springframework.http.ResponseEntity
;
import
org.springframework.stereotype.Service
;
import
org.springframework.util.LinkedMultiValueMap
;
import
org.springframework.util.MultiValueMap
;
import
org.springframework.util.StringUtils
;
import
org.springframework.web.client.RestTemplate
;
import
org.springframework.web.client.RestClientResponseException
;
@Service
public
class
CampusMessageClient
{
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
CampusMessageClient
.
class
);
private
final
MessageRelayProperties
properties
;
private
final
CampusTokenService
tokenService
;
private
final
RestTemplate
restTemplate
;
public
CampusMessageClient
(
MessageRelayProperties
properties
,
CampusTokenService
tokenService
,
RestTemplateBuilder
builder
)
{
this
.
properties
=
properties
;
this
.
tokenService
=
tokenService
;
this
.
restTemplate
=
builder
.
setConnectTimeout
(
Duration
.
ofMillis
(
properties
.
getConnectTimeoutMs
()))
.
setReadTimeout
(
Duration
.
ofMillis
(
properties
.
getReadTimeoutMs
()))
.
build
();
}
public
CampusPushResult
send
(
List
<
String
>
workNumbers
,
String
message
,
String
redirectUrl
,
int
ucsType
,
Long
taskId
,
String
messageId
,
int
batchNumber
,
int
totalBatches
)
{
try
{
return
doSend
(
workNumbers
,
message
,
redirectUrl
,
tokenService
.
getAccessToken
(
taskId
,
messageId
),
ucsType
,
taskId
,
messageId
,
batchNumber
,
totalBatches
,
false
);
}
catch
(
CampusApiException
ex
)
{
if
(!
ex
.
isTokenInvalid
())
{
throw
ex
;
}
log
.
warn
(
"Campus message token rejected; refreshing once: taskId={}, messageId={}, batch={}/{}, contentType={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
contentType
());
tokenService
.
invalidate
(
taskId
,
messageId
,
"school-message-api-rejected-token"
);
return
doSend
(
workNumbers
,
message
,
redirectUrl
,
tokenService
.
getAccessToken
(
taskId
,
messageId
),
ucsType
,
taskId
,
messageId
,
batchNumber
,
totalBatches
,
true
);
}
}
@SuppressWarnings
(
"unchecked"
)
private
CampusPushResult
doSend
(
List
<
String
>
workNumbers
,
String
message
,
String
redirectUrl
,
String
token
,
int
ucsType
,
Long
taskId
,
String
messageId
,
int
batchNumber
,
int
totalBatches
,
boolean
tokenRefreshed
)
{
validateConfig
();
String
contentType
=
contentType
();
MultiValueMap
<
String
,
String
>
form
=
buildConfiguredForm
(
workNumbers
,
message
,
redirectUrl
,
token
,
ucsType
);
HttpHeaders
headers
=
new
HttpHeaders
();
headers
.
setContentType
(
MediaType
.
APPLICATION_FORM_URLENCODED
);
long
startNanos
=
System
.
nanoTime
();
try
{
log
.
info
(
"Campus message request started: taskId={}, messageId={}, batch={}/{}, campusHost={}, "
+
"endpoint={}, contentType={}, recipientCount={}, messageLength={}, hasRedirect={}, wid={}, ucsType={}, "
+
"tokenRefreshed={}, params={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
MessageRelayLogSupport
.
host
(
properties
.
getCampusBaseUrl
()),
"/uc/api/ucs/qywx"
,
contentType
,
workNumbers
.
size
(),
message
==
null
?
0
:
message
.
length
(),
StringUtils
.
hasText
(
redirectUrl
),
MessageRelayLogSupport
.
safe
(
properties
.
getCampusWid
(),
80
),
ucsType
,
tokenRefreshed
,
MessageRelayLogSupport
.
payload
(
form
,
2400
));
ResponseEntity
<
Map
>
response
=
restTemplate
.
postForEntity
(
baseUrl
()
+
"/uc/api/ucs/qywx"
,
new
HttpEntity
<
MultiValueMap
<
String
,
String
>>(
form
,
headers
),
Map
.
class
);
Map
<
String
,
Object
>
body
=
response
.
getBody
();
log
.
info
(
"Campus message response received: taskId={}, messageId={}, batch={}/{}, httpStatus={}, "
+
"contentType={}, wid={}, ucsType={}, body={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
response
.
getStatusCodeValue
(),
contentType
,
MessageRelayLogSupport
.
safe
(
properties
.
getCampusWid
(),
80
),
ucsType
,
MessageRelayLogSupport
.
payload
(
body
,
2400
));
CampusPushResult
result
=
parseSuccessBody
(
body
);
log
.
info
(
"Campus message request accepted: taskId={}, messageId={}, batch={}/{}, httpStatus={}, "
+
"contentType={}, wid={}, ucsType={}, ucsId={}, invalidCount={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
response
.
getStatusCodeValue
(),
contentType
,
MessageRelayLogSupport
.
safe
(
properties
.
getCampusWid
(),
80
),
ucsType
,
MessageRelayLogSupport
.
mask
(
result
.
getUcsId
()),
result
.
getErrorData
().
size
(),
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
return
result
;
}
catch
(
CampusApiException
ex
)
{
log
.
warn
(
"Campus message business failure: taskId={}, messageId={}, batch={}/{}, retryable={}, "
+
"tokenInvalid={}, contentType={}, wid={}, ucsType={}, reason={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
ex
.
isRetryable
(),
ex
.
isTokenInvalid
(),
contentType
,
MessageRelayLogSupport
.
safe
(
properties
.
getCampusWid
(),
80
),
ucsType
,
MessageRelayLogSupport
.
safe
(
ex
.
getMessage
(),
300
),
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
throw
ex
;
}
catch
(
RestClientResponseException
ex
)
{
boolean
retryable
=
ex
.
getRawStatusCode
()
>=
500
;
log
.
warn
(
"Campus message HTTP failure: taskId={}, messageId={}, batch={}/{}, httpStatus={}, "
+
"retryable={}, contentType={}, wid={}, ucsType={}, responseBody={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
ex
.
getRawStatusCode
(),
retryable
,
contentType
,
MessageRelayLogSupport
.
safe
(
properties
.
getCampusWid
(),
80
),
ucsType
,
MessageRelayLogSupport
.
safe
(
ex
.
getResponseBodyAsString
(),
2400
),
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
throw
new
CampusApiException
(
"Campus message HTTP request failed: status="
+
ex
.
getRawStatusCode
(),
false
,
retryable
);
}
catch
(
Exception
ex
)
{
log
.
warn
(
"Campus message transport failure: taskId={}, messageId={}, batch={}/{}, exceptionType={}, "
+
"contentType={}, wid={}, ucsType={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
batchNumber
,
totalBatches
,
ex
.
getClass
().
getSimpleName
(),
contentType
,
MessageRelayLogSupport
.
safe
(
properties
.
getCampusWid
(),
80
),
ucsType
,
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
throw
new
CampusApiException
(
"Campus message request failed: "
+
ex
.
getMessage
(),
ex
);
}
}
MultiValueMap
<
String
,
String
>
buildConfiguredForm
(
List
<
String
>
workNumbers
,
String
message
,
String
redirectUrl
,
String
token
,
int
ucsType
)
{
if
(
"text"
.
equals
(
contentType
()))
{
return
buildTextForm
(
workNumbers
,
message
,
redirectUrl
,
token
,
ucsType
);
}
return
buildNewsForm
(
workNumbers
,
message
,
redirectUrl
,
token
,
ucsType
);
}
MultiValueMap
<
String
,
String
>
buildNewsForm
(
List
<
String
>
workNumbers
,
String
message
,
String
redirectUrl
,
String
token
,
int
ucsType
)
{
MultiValueMap
<
String
,
String
>
form
=
baseForm
(
workNumbers
,
token
);
form
.
add
(
"content[msgtype]"
,
"news"
);
form
.
add
(
"content[news][articles][0][title]"
,
"国工实验室综合管理平台"
);
form
.
add
(
"content[news][articles][0][description]"
,
message
==
null
?
""
:
message
);
form
.
add
(
"content[news][articles][0][url]"
,
StringUtils
.
hasText
(
redirectUrl
)
?
redirectUrl
.
trim
()
:
""
);
form
.
add
(
"content[news][articles][0][picurl]"
,
""
);
addRoutingFields
(
form
,
ucsType
);
return
form
;
}
MultiValueMap
<
String
,
String
>
buildTextForm
(
List
<
String
>
workNumbers
,
String
message
,
String
redirectUrl
,
String
token
,
int
ucsType
)
{
MultiValueMap
<
String
,
String
>
form
=
baseForm
(
workNumbers
,
token
);
form
.
add
(
"content[msgtype]"
,
"text"
);
form
.
add
(
"content[text][content]"
,
buildTextContent
(
message
,
redirectUrl
));
form
.
add
(
"content[safe]"
,
"0"
);
addRoutingFields
(
form
,
ucsType
);
return
form
;
}
private
MultiValueMap
<
String
,
String
>
baseForm
(
List
<
String
>
workNumbers
,
String
token
)
{
MultiValueMap
<
String
,
String
>
form
=
new
LinkedMultiValueMap
<
String
,
String
>();
form
.
add
(
"access_token"
,
token
);
for
(
String
workNumber
:
workNumbers
)
{
form
.
add
(
"numbers[]"
,
workNumber
);
}
form
.
add
(
"isall"
,
"0"
);
return
form
;
}
private
void
addRoutingFields
(
MultiValueMap
<
String
,
String
>
form
,
int
ucsType
)
{
form
.
add
(
"wid"
,
properties
.
getCampusWid
());
form
.
add
(
"ucs_type"
,
String
.
valueOf
(
ucsType
));
}
private
String
buildTextContent
(
String
message
,
String
redirectUrl
)
{
String
content
=
escapeHtml
(
message
);
if
(!
StringUtils
.
hasText
(
redirectUrl
))
{
return
content
;
}
return
content
+
"\n<a href=\""
+
escapeHtml
(
redirectUrl
.
trim
())
+
"\">点击查看详情</a>"
;
}
private
String
escapeHtml
(
String
value
)
{
if
(
value
==
null
)
{
return
""
;
}
return
value
.
replace
(
"&"
,
"&"
).
replace
(
"<"
,
"<"
).
replace
(
">"
,
">"
)
.
replace
(
"\""
,
"""
).
replace
(
"'"
,
"'"
);
}
private
String
contentType
()
{
String
value
=
properties
.
getContentType
();
if
(
value
==
null
)
{
return
"news"
;
}
String
normalized
=
value
.
trim
().
toLowerCase
(
Locale
.
ROOT
);
if
(
"text"
.
equals
(
normalized
)
||
"news"
.
equals
(
normalized
))
{
return
normalized
;
}
throw
new
CampusApiException
(
"Unsupported message content type: "
+
value
,
false
);
}
void
assertSuccess
(
Map
<
String
,
Object
>
body
)
{
if
(
body
==
null
)
{
throw
new
CampusApiException
(
"Campus message request returned an empty body"
,
false
);
}
Object
code
=
body
.
get
(
"e"
);
if
(
"0"
.
equals
(
String
.
valueOf
(
code
)))
{
return
;
}
if
(
code
==
null
)
{
throw
new
CampusApiException
(
"Campus message request returned no result code"
,
false
,
true
);
}
String
message
=
stringValue
(
body
.
get
(
"m"
));
String
normalized
=
message
==
null
?
""
:
message
.
toLowerCase
();
boolean
tokenInvalid
=
normalized
.
contains
(
"token"
)
||
normalized
.
contains
(
"凭证"
)
||
normalized
.
contains
(
"过期"
)
||
normalized
.
contains
(
"失效"
);
throw
new
CampusApiException
(
"Campus message request failed: code="
+
code
+
", message="
+
message
,
tokenInvalid
);
}
@SuppressWarnings
(
"unchecked"
)
CampusPushResult
parseSuccessBody
(
Map
<
String
,
Object
>
body
)
{
assertSuccess
(
body
);
if
(!(
body
.
get
(
"d"
)
instanceof
Map
))
{
throw
new
CampusApiException
(
"Campus message request returned no data"
,
false
,
true
);
}
Map
<
String
,
Object
>
data
=
(
Map
<
String
,
Object
>)
body
.
get
(
"d"
);
CampusPushResult
result
=
new
CampusPushResult
();
result
.
setUcsId
(
stringValue
(
data
.
get
(
"ucs_id"
)));
result
.
setErrorData
(
stringList
(
data
.
get
(
"error_data"
)));
if
(!
StringUtils
.
hasText
(
result
.
getUcsId
()))
{
throw
new
CampusApiException
(
"Campus message request returned an empty ucs_id"
,
false
,
true
);
}
return
result
;
}
private
void
validateConfig
()
{
if
(!
StringUtils
.
hasText
(
properties
.
getCampusBaseUrl
())
||
!
StringUtils
.
hasText
(
properties
.
getCampusWid
()))
{
throw
new
CampusApiException
(
"Campus base URL and wid are required"
,
false
);
}
}
private
String
baseUrl
()
{
String
value
=
properties
.
getCampusBaseUrl
().
trim
();
return
value
.
endsWith
(
"/"
)
?
value
.
substring
(
0
,
value
.
length
()
-
1
)
:
value
;
}
private
String
stringValue
(
Object
value
)
{
return
value
==
null
?
null
:
String
.
valueOf
(
value
);
}
private
List
<
String
>
stringList
(
Object
value
)
{
if
(!(
value
instanceof
List
))
{
return
Collections
.
emptyList
();
}
List
<
String
>
result
=
new
ArrayList
<
String
>();
for
(
Object
item
:
(
List
<?>)
value
)
{
if
(
item
!=
null
)
{
result
.
add
(
String
.
valueOf
(
item
));
}
}
return
result
;
}
private
String
safe
(
String
value
)
{
return
MessageRelayLogSupport
.
safe
(
value
,
160
);
}
}
src/main/java/com/jd/easywalk/casrelay/CampusPushResult.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.util.ArrayList
;
import
java.util.List
;
public
class
CampusPushResult
{
private
String
ucsId
;
private
List
<
String
>
errorData
=
new
ArrayList
<
String
>();
public
String
getUcsId
()
{
return
ucsId
;
}
public
void
setUcsId
(
String
ucsId
)
{
this
.
ucsId
=
ucsId
;
}
public
List
<
String
>
getErrorData
()
{
return
errorData
;
}
public
void
setErrorData
(
List
<
String
>
errorData
)
{
this
.
errorData
=
errorData
;
}
}
src/main/java/com/jd/easywalk/casrelay/CampusTokenService.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.net.URI
;
import
java.time.Duration
;
import
java.time.Instant
;
import
java.util.LinkedHashMap
;
import
java.util.Map
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.boot.web.client.RestTemplateBuilder
;
import
org.springframework.http.ResponseEntity
;
import
org.springframework.stereotype.Service
;
import
org.springframework.util.StringUtils
;
import
org.springframework.web.client.RestTemplate
;
import
org.springframework.web.client.RestClientResponseException
;
import
org.springframework.web.util.UriComponentsBuilder
;
@Service
public
class
CampusTokenService
{
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
CampusTokenService
.
class
);
private
final
MessageRelayProperties
properties
;
private
final
RestTemplate
restTemplate
;
private
volatile
CachedToken
cachedToken
;
public
CampusTokenService
(
MessageRelayProperties
properties
,
RestTemplateBuilder
builder
)
{
this
.
properties
=
properties
;
this
.
restTemplate
=
builder
.
setConnectTimeout
(
Duration
.
ofMillis
(
properties
.
getConnectTimeoutMs
()))
.
setReadTimeout
(
Duration
.
ofMillis
(
properties
.
getReadTimeoutMs
()))
.
build
();
}
public
String
getAccessToken
(
Long
taskId
,
String
messageId
)
{
CachedToken
current
=
cachedToken
;
if
(
current
!=
null
&&
!
current
.
isExpired
())
{
log
.
debug
(
"Campus token cache hit: taskId={}, messageId={}, expiresAt={}"
,
taskId
,
safe
(
messageId
),
current
.
expiresAt
);
return
current
.
token
;
}
synchronized
(
this
)
{
current
=
cachedToken
;
if
(
current
!=
null
&&
!
current
.
isExpired
())
{
log
.
debug
(
"Campus token cache hit after lock: taskId={}, messageId={}, expiresAt={}"
,
taskId
,
safe
(
messageId
),
current
.
expiresAt
);
return
current
.
token
;
}
cachedToken
=
requestToken
(
taskId
,
messageId
);
return
cachedToken
.
token
;
}
}
public
synchronized
void
invalidate
(
Long
taskId
,
String
messageId
,
String
reason
)
{
cachedToken
=
null
;
log
.
warn
(
"Campus token cache invalidated: taskId={}, messageId={}, reason={}"
,
taskId
,
safe
(
messageId
),
MessageRelayLogSupport
.
safe
(
reason
,
200
));
}
@SuppressWarnings
(
"unchecked"
)
private
CachedToken
requestToken
(
Long
taskId
,
String
messageId
)
{
validateConfig
();
long
startNanos
=
System
.
nanoTime
();
URI
uri
=
UriComponentsBuilder
.
fromHttpUrl
(
baseUrl
()
+
"/api/third/get-token"
)
.
queryParam
(
"appid"
,
properties
.
getCampusAppId
())
.
queryParam
(
"appsecret"
,
properties
.
getCampusAppSecret
())
.
build
().
encode
().
toUri
();
try
{
Map
<
String
,
Object
>
requestParams
=
new
LinkedHashMap
<
String
,
Object
>();
requestParams
.
put
(
"appid"
,
properties
.
getCampusAppId
());
requestParams
.
put
(
"appsecret"
,
properties
.
getCampusAppSecret
());
log
.
info
(
"Campus token request started: taskId={}, messageId={}, campusHost={}, endpoint={}, params={}"
,
taskId
,
safe
(
messageId
),
MessageRelayLogSupport
.
host
(
properties
.
getCampusBaseUrl
()),
"/api/third/get-token"
,
MessageRelayLogSupport
.
payload
(
requestParams
,
600
));
ResponseEntity
<
Map
>
response
=
restTemplate
.
getForEntity
(
uri
,
Map
.
class
);
Map
<
String
,
Object
>
body
=
response
.
getBody
();
log
.
info
(
"Campus token response received: taskId={}, messageId={}, httpStatus={}, body={}"
,
taskId
,
safe
(
messageId
),
response
.
getStatusCodeValue
(),
MessageRelayLogSupport
.
payload
(
body
,
1200
));
assertSuccess
(
body
,
"get token"
);
Object
rawData
=
body
.
get
(
"d"
);
if
(!(
rawData
instanceof
Map
))
{
throw
new
CampusApiException
(
"Campus get token returned no data"
,
false
);
}
Map
<
String
,
Object
>
data
=
(
Map
<
String
,
Object
>)
rawData
;
String
token
=
value
(
data
.
get
(
"access_token"
));
int
expiresIn
=
intValue
(
data
.
get
(
"expires_in"
),
7200
);
if
(!
StringUtils
.
hasText
(
token
))
{
throw
new
CampusApiException
(
"Campus get token returned an empty access_token"
,
false
);
}
int
usableSeconds
=
Math
.
max
(
30
,
expiresIn
-
Math
.
max
(
0
,
properties
.
getTokenSkewSeconds
()));
CachedToken
result
=
new
CachedToken
(
token
,
Instant
.
now
().
plusSeconds
(
usableSeconds
));
log
.
info
(
"Campus token request succeeded: taskId={}, messageId={}, httpStatus={}, expiresInSeconds={}, "
+
"cacheUsableSeconds={}, expiresAt={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
response
.
getStatusCodeValue
(),
expiresIn
,
usableSeconds
,
result
.
expiresAt
,
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
return
result
;
}
catch
(
CampusApiException
ex
)
{
log
.
warn
(
"Campus token request rejected: taskId={}, messageId={}, retryable={}, reason={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
ex
.
isRetryable
(),
MessageRelayLogSupport
.
safe
(
ex
.
getMessage
(),
300
),
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
throw
ex
;
}
catch
(
RestClientResponseException
ex
)
{
boolean
retryable
=
ex
.
getRawStatusCode
()
>=
500
;
log
.
warn
(
"Campus token HTTP failure: taskId={}, messageId={}, httpStatus={}, retryable={}, responseBody={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
ex
.
getRawStatusCode
(),
retryable
,
MessageRelayLogSupport
.
safe
(
ex
.
getResponseBodyAsString
(),
1200
),
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
throw
new
CampusApiException
(
"Campus get token HTTP request failed: status="
+
ex
.
getRawStatusCode
(),
false
,
retryable
);
}
catch
(
Exception
ex
)
{
// Do not include the client exception message: it may contain the request URL and appsecret.
log
.
warn
(
"Campus token transport failure: taskId={}, messageId={}, exceptionType={}, elapsedMs={}"
,
taskId
,
safe
(
messageId
),
ex
.
getClass
().
getSimpleName
(),
MessageRelayLogSupport
.
elapsedMs
(
startNanos
));
throw
new
CampusApiException
(
"Campus get token request failed"
,
ex
);
}
}
void
assertSuccess
(
Map
<
String
,
Object
>
body
,
String
action
)
{
if
(
body
==
null
)
{
throw
new
CampusApiException
(
"Campus "
+
action
+
" returned an empty body"
,
false
);
}
Object
code
=
body
.
get
(
"e"
);
if
(
"0"
.
equals
(
String
.
valueOf
(
code
)))
{
return
;
}
if
(
code
==
null
)
{
throw
new
CampusApiException
(
"Campus "
+
action
+
" returned no result code"
,
false
,
true
);
}
throw
new
CampusApiException
(
"Campus "
+
action
+
" failed: "
+
safeMessage
(
body
),
isTokenMessage
(
body
));
}
private
String
safeMessage
(
Map
<
String
,
Object
>
body
)
{
Object
value
=
body
.
get
(
"m"
);
return
value
==
null
?
"code="
+
body
.
get
(
"e"
)
:
String
.
valueOf
(
value
);
}
private
boolean
isTokenMessage
(
Map
<
String
,
Object
>
body
)
{
String
text
=
String
.
valueOf
(
body
.
get
(
"m"
)).
toLowerCase
();
return
text
.
contains
(
"token"
)
||
text
.
contains
(
"凭证"
)
||
text
.
contains
(
"过期"
)
||
text
.
contains
(
"失效"
);
}
private
void
validateConfig
()
{
if
(!
StringUtils
.
hasText
(
properties
.
getCampusBaseUrl
())
||
!
StringUtils
.
hasText
(
properties
.
getCampusAppId
())
||
!
StringUtils
.
hasText
(
properties
.
getCampusAppSecret
()))
{
throw
new
CampusApiException
(
"Campus base URL, app ID and app secret are required"
,
false
);
}
}
private
String
baseUrl
()
{
String
value
=
properties
.
getCampusBaseUrl
().
trim
();
return
value
.
endsWith
(
"/"
)
?
value
.
substring
(
0
,
value
.
length
()
-
1
)
:
value
;
}
private
String
value
(
Object
value
)
{
return
value
==
null
?
null
:
String
.
valueOf
(
value
);
}
private
int
intValue
(
Object
value
,
int
defaultValue
)
{
try
{
return
value
==
null
?
defaultValue
:
Integer
.
parseInt
(
String
.
valueOf
(
value
));
}
catch
(
NumberFormatException
ignored
)
{
return
defaultValue
;
}
}
private
String
safe
(
String
value
)
{
return
MessageRelayLogSupport
.
safe
(
value
,
160
);
}
private
static
class
CachedToken
{
private
final
String
token
;
private
final
Instant
expiresAt
;
private
CachedToken
(
String
token
,
Instant
expiresAt
)
{
this
.
token
=
token
;
this
.
expiresAt
=
expiresAt
;
}
private
boolean
isExpired
()
{
return
!
Instant
.
now
().
isBefore
(
expiresAt
);
}
}
}
src/main/java/com/jd/easywalk/casrelay/CampusUcsType.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
final
class
CampusUcsType
{
static
final
int
REMINDER
=
2
;
static
final
int
NOTICE
=
3
;
static
final
int
WORKFLOW
=
4
;
private
CampusUcsType
()
{
}
static
Integer
fromMessageTypeV2
(
Integer
messageTypeV2
)
{
if
(
messageTypeV2
==
null
)
{
throw
new
IllegalArgumentException
(
"messageTypeV2 is required"
);
}
switch
(
messageTypeV2
.
intValue
())
{
case
11
:
case
12
:
return
NOTICE
;
case
13
:
return
REMINDER
;
case
14
:
case
16
:
return
WORKFLOW
;
case
15
:
return
null
;
default
:
throw
new
IllegalArgumentException
(
"unsupported messageTypeV2: "
+
messageTypeV2
);
}
}
}
src/main/java/com/jd/easywalk/casrelay/CasRelayApplication.java
View file @
1a77b4ba
...
...
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties
({
CasRelayProperties
.
class
,
MingdaoProperties
.
class
,
OAuthRelayProperties
.
class
,
WeComRelayProperties
.
class
})
WeComRelayProperties
.
class
,
MessageRelayProperties
.
class
})
public
class
CasRelayApplication
{
public
static
void
main
(
String
[]
args
)
{
...
...
src/main/java/com/jd/easywalk/casrelay/MessageRelayLogSupport.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.net.URI
;
import
java.util.ArrayList
;
import
java.util.Collection
;
import
java.util.LinkedHashMap
;
import
java.util.List
;
import
java.util.Map
;
final
class
MessageRelayLogSupport
{
private
MessageRelayLogSupport
()
{
}
static
String
safe
(
String
value
)
{
return
safe
(
value
,
160
);
}
static
String
safe
(
String
value
,
int
maxLength
)
{
if
(
value
==
null
)
{
return
""
;
}
String
sanitized
=
value
.
replace
(
'\r'
,
' '
).
replace
(
'\n'
,
' '
).
replace
(
'\t'
,
' '
).
trim
();
if
(
sanitized
.
length
()
<=
maxLength
)
{
return
sanitized
;
}
return
sanitized
.
substring
(
0
,
maxLength
)
+
"..."
;
}
static
String
host
(
String
url
)
{
if
(
url
==
null
||
url
.
trim
().
isEmpty
())
{
return
""
;
}
try
{
URI
uri
=
URI
.
create
(
url
.
trim
());
return
safe
(
uri
.
getHost
()
==
null
?
""
:
uri
.
getHost
(),
120
);
}
catch
(
Exception
ignored
)
{
return
"invalid-url"
;
}
}
static
String
mask
(
String
value
)
{
String
safeValue
=
safe
(
value
,
80
);
if
(
safeValue
.
length
()
<=
6
)
{
return
safeValue
.
isEmpty
()
?
""
:
"***"
;
}
return
safeValue
.
substring
(
0
,
3
)
+
"***"
+
safeValue
.
substring
(
safeValue
.
length
()
-
3
);
}
static
String
payload
(
Object
value
,
int
maxLength
)
{
return
safe
(
String
.
valueOf
(
redact
(
value
,
null
)),
maxLength
);
}
private
static
Object
redact
(
Object
value
,
String
key
)
{
if
(
value
==
null
)
{
return
null
;
}
String
normalizedKey
=
key
==
null
?
""
:
key
.
toLowerCase
();
if
(
isCredentialKey
(
normalizedKey
))
{
return
mask
(
String
.
valueOf
(
value
));
}
if
(
value
instanceof
Map
)
{
Map
<
String
,
Object
>
result
=
new
LinkedHashMap
<
String
,
Object
>();
for
(
Map
.
Entry
<?,
?>
entry
:
((
Map
<?,
?>)
value
).
entrySet
())
{
String
childKey
=
String
.
valueOf
(
entry
.
getKey
());
result
.
put
(
childKey
,
redact
(
entry
.
getValue
(),
childKey
));
}
return
result
;
}
if
(
value
instanceof
Collection
)
{
List
<
Object
>
result
=
new
ArrayList
<
Object
>();
for
(
Object
item
:
(
Collection
<?>)
value
)
{
result
.
add
(
isPersonalIdentifierKey
(
normalizedKey
)
?
mask
(
String
.
valueOf
(
item
))
:
redact
(
item
,
key
));
}
return
result
;
}
if
(
isPersonalIdentifierKey
(
normalizedKey
))
{
return
mask
(
String
.
valueOf
(
value
));
}
return
value
;
}
private
static
boolean
isCredentialKey
(
String
key
)
{
return
key
.
contains
(
"token"
)
||
key
.
contains
(
"secret"
)
||
key
.
contains
(
"password"
)
||
key
.
contains
(
"authorization"
)
||
key
.
equals
(
"sign"
);
}
private
static
boolean
isPersonalIdentifierKey
(
String
key
)
{
return
key
.
equals
(
"targets"
)
||
key
.
equals
(
"numbers[]"
)
||
key
.
equals
(
"error_data"
)
||
key
.
equals
(
"createuserid"
)
||
key
.
equals
(
"mobile"
)
||
key
.
equals
(
"email"
);
}
static
long
elapsedMs
(
long
startNanos
)
{
return
(
System
.
nanoTime
()
-
startNanos
)
/
1_000_000L
;
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayProperties.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
org.springframework.boot.context.properties.ConfigurationProperties
;
@ConfigurationProperties
(
prefix
=
"message-relay"
)
public
class
MessageRelayProperties
{
private
boolean
enabled
;
private
String
webhookToken
=
""
;
private
String
campusBaseUrl
=
""
;
private
String
campusAppId
=
""
;
private
String
campusAppSecret
=
""
;
private
String
campusWid
=
""
;
private
String
contentType
=
"news"
;
private
int
tokenSkewSeconds
=
300
;
private
int
connectTimeoutMs
=
5000
;
private
int
readTimeoutMs
=
10000
;
private
int
recipientBatchSize
=
100
;
private
int
maxAttempts
=
5
;
private
long
scanIntervalMs
=
30000L
;
private
long
processingTimeoutSeconds
=
300L
;
public
boolean
isEnabled
()
{
return
enabled
;
}
public
void
setEnabled
(
boolean
enabled
)
{
this
.
enabled
=
enabled
;
}
public
String
getWebhookToken
()
{
return
webhookToken
;
}
public
void
setWebhookToken
(
String
webhookToken
)
{
this
.
webhookToken
=
webhookToken
;
}
public
String
getCampusBaseUrl
()
{
return
campusBaseUrl
;
}
public
void
setCampusBaseUrl
(
String
campusBaseUrl
)
{
this
.
campusBaseUrl
=
campusBaseUrl
;
}
public
String
getCampusAppId
()
{
return
campusAppId
;
}
public
void
setCampusAppId
(
String
campusAppId
)
{
this
.
campusAppId
=
campusAppId
;
}
public
String
getCampusAppSecret
()
{
return
campusAppSecret
;
}
public
void
setCampusAppSecret
(
String
campusAppSecret
)
{
this
.
campusAppSecret
=
campusAppSecret
;
}
public
String
getCampusWid
()
{
return
campusWid
;
}
public
void
setCampusWid
(
String
campusWid
)
{
this
.
campusWid
=
campusWid
;
}
public
String
getContentType
()
{
return
contentType
;
}
public
void
setContentType
(
String
contentType
)
{
this
.
contentType
=
contentType
;
}
public
int
getTokenSkewSeconds
()
{
return
tokenSkewSeconds
;
}
public
void
setTokenSkewSeconds
(
int
tokenSkewSeconds
)
{
this
.
tokenSkewSeconds
=
tokenSkewSeconds
;
}
public
int
getConnectTimeoutMs
()
{
return
connectTimeoutMs
;
}
public
void
setConnectTimeoutMs
(
int
connectTimeoutMs
)
{
this
.
connectTimeoutMs
=
connectTimeoutMs
;
}
public
int
getReadTimeoutMs
()
{
return
readTimeoutMs
;
}
public
void
setReadTimeoutMs
(
int
readTimeoutMs
)
{
this
.
readTimeoutMs
=
readTimeoutMs
;
}
public
int
getRecipientBatchSize
()
{
return
recipientBatchSize
;
}
public
void
setRecipientBatchSize
(
int
recipientBatchSize
)
{
this
.
recipientBatchSize
=
recipientBatchSize
;
}
public
int
getMaxAttempts
()
{
return
maxAttempts
;
}
public
void
setMaxAttempts
(
int
maxAttempts
)
{
this
.
maxAttempts
=
maxAttempts
;
}
public
long
getScanIntervalMs
()
{
return
scanIntervalMs
;
}
public
void
setScanIntervalMs
(
long
scanIntervalMs
)
{
this
.
scanIntervalMs
=
scanIntervalMs
;
}
public
long
getProcessingTimeoutSeconds
()
{
return
processingTimeoutSeconds
;
}
public
void
setProcessingTimeoutSeconds
(
long
processingTimeoutSeconds
)
{
this
.
processingTimeoutSeconds
=
processingTimeoutSeconds
;
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayReceipt.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
public
class
MessageRelayReceipt
{
private
final
Long
taskId
;
private
final
boolean
duplicate
;
private
final
boolean
ignored
;
public
MessageRelayReceipt
(
Long
taskId
,
boolean
duplicate
)
{
this
(
taskId
,
duplicate
,
false
);
}
public
MessageRelayReceipt
(
Long
taskId
,
boolean
duplicate
,
boolean
ignored
)
{
this
.
taskId
=
taskId
;
this
.
duplicate
=
duplicate
;
this
.
ignored
=
ignored
;
}
public
Long
getTaskId
()
{
return
taskId
;
}
public
boolean
isDuplicate
()
{
return
duplicate
;
}
public
boolean
isIgnored
()
{
return
ignored
;
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayScheduler.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.time.Instant
;
import
java.util.List
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.data.domain.PageRequest
;
import
org.springframework.scheduling.annotation.Scheduled
;
import
org.springframework.stereotype.Component
;
@Component
public
class
MessageRelayScheduler
{
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
MessageRelayScheduler
.
class
);
private
final
MessageRelayProperties
properties
;
private
final
MessageRelayTaskRepository
repository
;
private
final
MessageRelayService
relayService
;
public
MessageRelayScheduler
(
MessageRelayProperties
properties
,
MessageRelayTaskRepository
repository
,
MessageRelayService
relayService
)
{
this
.
properties
=
properties
;
this
.
repository
=
repository
;
this
.
relayService
=
relayService
;
}
@Scheduled
(
fixedDelayString
=
"${message-relay.scan-interval-ms:30000}"
,
initialDelay
=
5000L
)
public
void
processDueTasks
()
{
if
(!
properties
.
isEnabled
())
{
return
;
}
long
scanStartNanos
=
System
.
nanoTime
();
try
{
Instant
now
=
Instant
.
now
();
Instant
staleBefore
=
now
.
minusSeconds
(
Math
.
max
(
1L
,
properties
.
getProcessingTimeoutSeconds
()));
List
<
Long
>
taskIds
=
repository
.
findDueTaskIds
(
RelayTaskStatus
.
RECEIVED
,
RelayTaskStatus
.
RETRY
,
RelayTaskStatus
.
PROCESSING
,
now
,
staleBefore
,
PageRequest
.
of
(
0
,
50
));
if
(
taskIds
.
isEmpty
())
{
log
.
debug
(
"Message relay scan completed: dueTaskCount=0, elapsedMs={}"
,
MessageRelayLogSupport
.
elapsedMs
(
scanStartNanos
));
return
;
}
log
.
info
(
"Message relay scan found tasks: dueTaskCount={}, firstTaskId={}, lastTaskId={}"
,
taskIds
.
size
(),
taskIds
.
get
(
0
),
taskIds
.
get
(
taskIds
.
size
()
-
1
));
int
processedCount
=
0
;
for
(
Long
taskId
:
taskIds
)
{
relayService
.
processTask
(
taskId
);
processedCount
++;
}
log
.
info
(
"Message relay scan completed: dueTaskCount={}, processedCount={}, elapsedMs={}"
,
taskIds
.
size
(),
processedCount
,
MessageRelayLogSupport
.
elapsedMs
(
scanStartNanos
));
}
catch
(
Exception
ex
)
{
log
.
error
(
"Message relay task scan failed: exceptionType={}, elapsedMs={}"
,
ex
.
getClass
().
getSimpleName
(),
MessageRelayLogSupport
.
elapsedMs
(
scanStartNanos
),
ex
);
}
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayService.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.time.Instant
;
import
java.util.ArrayList
;
import
java.util.LinkedHashSet
;
import
java.util.List
;
import
java.util.Locale
;
import
java.util.Optional
;
import
java.util.Set
;
import
java.util.UUID
;
import
com.fasterxml.jackson.databind.ObjectMapper
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.dao.DataIntegrityViolationException
;
import
org.springframework.stereotype.Service
;
import
org.springframework.transaction.PlatformTransactionManager
;
import
org.springframework.transaction.support.TransactionTemplate
;
import
org.springframework.util.StringUtils
;
@Service
public
class
MessageRelayService
{
public
static
final
String
EVENT_MESSAGE
=
"MESSAGE"
;
public
static
final
String
EVENT_TODO_FINISHED
=
"TODO_FINISHED"
;
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
MessageRelayService
.
class
);
private
static
final
long
[]
RETRY_DELAYS_SECONDS
=
{
60L
,
300L
,
900L
,
3600L
,
3600L
};
private
final
MessageRelayTaskRepository
repository
;
private
final
CampusMessageClient
campusMessageClient
;
private
final
MessageRelayProperties
properties
;
private
final
ObjectMapper
objectMapper
;
private
final
TransactionTemplate
transactionTemplate
;
public
MessageRelayService
(
MessageRelayTaskRepository
repository
,
CampusMessageClient
campusMessageClient
,
MessageRelayProperties
properties
,
ObjectMapper
objectMapper
,
PlatformTransactionManager
transactionManager
)
{
this
.
repository
=
repository
;
this
.
campusMessageClient
=
campusMessageClient
;
this
.
properties
=
properties
;
this
.
objectMapper
=
objectMapper
;
this
.
transactionTemplate
=
new
TransactionTemplate
(
transactionManager
);
}
public
MessageRelayReceipt
enqueueMessage
(
MingdaoMessageRequest
request
)
{
validateMessageIdentity
(
request
);
Integer
ucsType
=
CampusUcsType
.
fromMessageTypeV2
(
request
.
getMessageTypeV2
());
int
originalTargetCount
=
request
.
getTargets
()
==
null
?
0
:
request
.
getTargets
().
size
();
int
normalizedTargetCount
=
normalizeTargets
(
request
.
getTargets
()).
size
();
log
.
info
(
"Message relay enqueue validated: eventType={}, messageId={}, targetCount={}, uniqueTargetCount={}, "
+
"messageTypeV2={}, ucsType={}, contentType={}, hasRedirect={}"
,
EVENT_MESSAGE
,
safe
(
request
.
getMessageId
()),
originalTargetCount
,
normalizedTargetCount
,
request
.
getMessageTypeV2
(),
ucsType
,
contentType
(),
StringUtils
.
hasText
(
request
.
getRedirectUrl
()));
if
(
ucsType
==
null
)
{
log
.
info
(
"Message relay event ignored: eventType={}, messageId={}, messageTypeV2={}, reason=no-send-record"
,
EVENT_MESSAGE
,
safe
(
request
.
getMessageId
()),
request
.
getMessageTypeV2
());
return
new
MessageRelayReceipt
(
null
,
false
,
true
);
}
validateSendableMessage
(
request
);
return
saveTask
(
EVENT_MESSAGE
,
request
.
getMessageId
().
trim
(),
writeJson
(
request
),
RelayTaskStatus
.
RECEIVED
);
}
public
MessageRelayReceipt
recordTodoFinished
(
MingdaoTodoFinishedRequest
request
)
{
if
(
request
==
null
||
!
StringUtils
.
hasText
(
request
.
getMessageId
()))
{
throw
new
IllegalArgumentException
(
"messageId is required"
);
}
log
.
info
(
"Message relay completion event validated: eventType={}, messageId={}, projectId={}, processId={}"
,
EVENT_TODO_FINISHED
,
safe
(
request
.
getMessageId
()),
safe
(
request
.
getProjectId
()),
safe
(
request
.
getProcessId
()));
return
saveTask
(
EVENT_TODO_FINISHED
,
request
.
getMessageId
().
trim
(),
writeJson
(
request
),
RelayTaskStatus
.
COMPLETED
);
}
public
void
processTask
(
Long
taskId
)
{
long
taskStartNanos
=
System
.
nanoTime
();
ClaimedTask
task
=
claimTask
(
taskId
);
if
(
task
==
null
)
{
return
;
}
String
contentType
=
contentType
();
log
.
info
(
"Message relay task started: taskId={}, messageId={}, eventType={}, status={}, attempt={}, "
+
"contentType={}, processedRecipientCount={}, queuedMs={}"
,
task
.
id
,
safe
(
task
.
messageId
),
task
.
eventType
,
RelayTaskStatus
.
PROCESSING
,
task
.
retryCount
+
1
,
contentType
,
task
.
processedRecipientCount
,
elapsedSince
(
task
.
createdAt
));
try
{
MingdaoMessageRequest
request
=
objectMapper
.
readValue
(
task
.
payload
,
MingdaoMessageRequest
.
class
);
int
ucsType
=
CampusUcsType
.
fromMessageTypeV2
(
request
.
getMessageTypeV2
()).
intValue
();
List
<
String
>
targets
=
normalizeTargets
(
request
.
getTargets
());
int
batchSize
=
Math
.
max
(
1
,
properties
.
getRecipientBatchSize
());
int
processed
=
Math
.
min
(
task
.
processedRecipientCount
,
targets
.
size
());
int
totalBatches
=
targets
.
isEmpty
()
?
0
:
(
targets
.
size
()
+
batchSize
-
1
)
/
batchSize
;
log
.
info
(
"Message relay payload prepared: taskId={}, messageId={}, recipientCount={}, remainingCount={}, "
+
"batchSize={}, totalBatches={}, priorUcsCount={}, messageLength={}, contentType={}, "
+
"ucsType={}, hasRedirect={}"
,
task
.
id
,
safe
(
task
.
messageId
),
targets
.
size
(),
targets
.
size
()
-
processed
,
batchSize
,
totalBatches
,
splitValues
(
task
.
campusUcsIds
).
size
(),
request
.
getMessage
()
==
null
?
0
:
request
.
getMessage
().
length
(),
contentType
,
ucsType
,
StringUtils
.
hasText
(
request
.
getRedirectUrl
()));
for
(
int
start
=
processed
;
start
<
targets
.
size
();
start
+=
batchSize
)
{
List
<
String
>
batch
=
targets
.
subList
(
start
,
Math
.
min
(
start
+
batchSize
,
targets
.
size
()));
int
batchNumber
=
start
/
batchSize
+
1
;
long
batchStartNanos
=
System
.
nanoTime
();
log
.
info
(
"Message relay batch started: taskId={}, messageId={}, batch={}/{}, contentType={}, "
+
"recipientCount={}, range={}..{}"
,
task
.
id
,
safe
(
task
.
messageId
),
batchNumber
,
totalBatches
,
contentType
,
batch
.
size
(),
start
+
1
,
start
+
batch
.
size
());
CampusPushResult
result
=
campusMessageClient
.
send
(
batch
,
request
.
getMessage
(),
request
.
getRedirectUrl
(),
ucsType
,
task
.
id
,
task
.
messageId
,
batchNumber
,
totalBatches
);
BatchCheckpoint
checkpoint
=
persistBatch
(
task
.
id
,
task
.
processingToken
,
result
,
start
+
batch
.
size
());
log
.
info
(
"Message relay batch accepted: taskId={}, messageId={}, batch={}/{}, ucsId={}, "
+
"contentType={}, invalidCount={}, processedRecipientCount={}, elapsedMs={}"
,
task
.
id
,
safe
(
task
.
messageId
),
batchNumber
,
totalBatches
,
MessageRelayLogSupport
.
mask
(
result
.
getUcsId
()),
contentType
,
result
.
getErrorData
().
size
(),
checkpoint
.
processedRecipientCount
,
MessageRelayLogSupport
.
elapsedMs
(
batchStartNanos
));
}
CompletionResult
completion
=
completeTask
(
task
.
id
,
task
.
processingToken
);
log
.
info
(
"Message relay forwarding completed: taskId={}, messageId={}, newStatus={}, recipientCount={}, "
+
"contentType={}, batchCount={}, invalidCount={}, attempts={}, elapsedMs={}, deliveryStatus=pending-query"
,
task
.
id
,
safe
(
task
.
messageId
),
RelayTaskStatus
.
SENT
,
targets
.
size
(),
contentType
,
completion
.
ucsCount
,
completion
.
invalidCount
,
task
.
retryCount
+
1
,
MessageRelayLogSupport
.
elapsedMs
(
taskStartNanos
));
}
catch
(
TaskOwnershipLostException
ex
)
{
log
.
warn
(
"Message relay task ownership lost; worker stopped: taskId={}, messageId={}, contentType={}, elapsedMs={}"
,
task
.
id
,
safe
(
task
.
messageId
),
contentType
,
MessageRelayLogSupport
.
elapsedMs
(
taskStartNanos
));
}
catch
(
Exception
ex
)
{
log
.
warn
(
"Message relay task attempt failed: taskId={}, messageId={}, status={}, attempt={}, "
+
"contentType={}, processedRecipientCount={}, exceptionType={}, retryable={}, "
+
"tokenInvalid={}, elapsedMs={}"
,
task
.
id
,
safe
(
task
.
messageId
),
RelayTaskStatus
.
PROCESSING
,
task
.
retryCount
+
1
,
contentType
,
currentProcessedCount
(
task
.
id
),
ex
.
getClass
().
getSimpleName
(),
isRetryable
(
ex
),
isTokenInvalid
(
ex
),
MessageRelayLogSupport
.
elapsedMs
(
taskStartNanos
));
recordFailure
(
task
.
id
,
task
.
processingToken
,
ex
);
}
}
private
ClaimedTask
claimTask
(
final
Long
taskId
)
{
return
transactionTemplate
.
execute
(
status
->
{
Optional
<
MessageRelayTask
>
optional
=
repository
.
findLockedById
(
taskId
);
if
(!
optional
.
isPresent
())
{
log
.
warn
(
"Message relay task disappeared before processing: taskId={}"
,
taskId
);
return
null
;
}
MessageRelayTask
task
=
optional
.
get
();
Instant
now
=
Instant
.
now
();
boolean
retryDue
=
task
.
getStatus
()
==
RelayTaskStatus
.
RETRY
&&
task
.
getNextRetryAt
()
!=
null
&&
!
task
.
getNextRetryAt
().
isAfter
(
now
);
boolean
processingStale
=
task
.
getStatus
()
==
RelayTaskStatus
.
PROCESSING
&&
task
.
getProcessingStartedAt
()
!=
null
&&
!
task
.
getProcessingStartedAt
().
isAfter
(
now
.
minusSeconds
(
Math
.
max
(
1L
,
properties
.
getProcessingTimeoutSeconds
())));
if
(
task
.
getStatus
()
!=
RelayTaskStatus
.
RECEIVED
&&
!
retryDue
&&
!
processingStale
)
{
log
.
debug
(
"Message relay task skipped: taskId={}, messageId={}, status={}"
,
task
.
getId
(),
safe
(
task
.
getMessageId
()),
task
.
getStatus
());
return
null
;
}
if
(
processingStale
)
{
log
.
warn
(
"Message relay stale processing lease reclaimed: taskId={}, messageId={}, processedRecipientCount={}"
,
task
.
getId
(),
safe
(
task
.
getMessageId
()),
task
.
getProcessedRecipientCount
());
}
String
processingToken
=
UUID
.
randomUUID
().
toString
();
task
.
setStatus
(
RelayTaskStatus
.
PROCESSING
);
task
.
setProcessingToken
(
processingToken
);
task
.
setProcessingStartedAt
(
now
);
task
.
setNextRetryAt
(
null
);
repository
.
saveAndFlush
(
task
);
return
new
ClaimedTask
(
task
,
processingToken
);
});
}
private
BatchCheckpoint
persistBatch
(
final
Long
taskId
,
final
String
processingToken
,
final
CampusPushResult
result
,
final
int
processedRecipientCount
)
{
return
transactionTemplate
.
execute
(
status
->
{
MessageRelayTask
task
=
lockedOwnedTask
(
taskId
,
processingToken
);
Set
<
String
>
ucsIds
=
new
LinkedHashSet
<
String
>(
splitValues
(
task
.
getCampusUcsIds
()));
if
(
StringUtils
.
hasText
(
result
.
getUcsId
()))
{
ucsIds
.
add
(
result
.
getUcsId
().
trim
());
}
Set
<
String
>
invalidRecipients
=
new
LinkedHashSet
<
String
>(
splitValues
(
task
.
getInvalidRecipients
()));
invalidRecipients
.
addAll
(
normalizeTargets
(
result
.
getErrorData
()));
task
.
setCampusUcsIds
(
String
.
join
(
","
,
ucsIds
));
task
.
setInvalidRecipients
(
String
.
join
(
","
,
invalidRecipients
));
task
.
setProcessedRecipientCount
(
processedRecipientCount
);
task
.
setProcessingStartedAt
(
Instant
.
now
());
repository
.
saveAndFlush
(
task
);
return
new
BatchCheckpoint
(
task
.
getProcessedRecipientCount
());
});
}
private
CompletionResult
completeTask
(
final
Long
taskId
,
final
String
processingToken
)
{
return
transactionTemplate
.
execute
(
status
->
{
MessageRelayTask
task
=
lockedOwnedTask
(
taskId
,
processingToken
);
List
<
String
>
invalidRecipients
=
splitValues
(
task
.
getInvalidRecipients
());
task
.
setStatus
(
RelayTaskStatus
.
SENT
);
task
.
setNextRetryAt
(
null
);
task
.
setProcessingToken
(
null
);
task
.
setProcessingStartedAt
(
null
);
task
.
setErrorMessage
(
invalidRecipients
.
isEmpty
()
?
null
:
truncate
(
"School platform does not recognize work numbers: "
+
String
.
join
(
","
,
invalidRecipients
)));
repository
.
saveAndFlush
(
task
);
return
new
CompletionResult
(
splitValues
(
task
.
getCampusUcsIds
()).
size
(),
invalidRecipients
.
size
());
});
}
private
void
recordFailure
(
final
Long
taskId
,
final
String
processingToken
,
final
Exception
ex
)
{
transactionTemplate
.
executeWithoutResult
(
status
->
{
MessageRelayTask
task
=
lockedOwnedTask
(
taskId
,
processingToken
);
if
(
ex
instanceof
CampusApiException
&&
!((
CampusApiException
)
ex
).
isRetryable
())
{
markFailed
(
task
,
ex
);
}
else
{
scheduleRetry
(
task
,
ex
);
}
task
.
setProcessingToken
(
null
);
task
.
setProcessingStartedAt
(
null
);
repository
.
saveAndFlush
(
task
);
});
}
private
MessageRelayTask
lockedOwnedTask
(
Long
taskId
,
String
processingToken
)
{
MessageRelayTask
task
=
repository
.
findLockedById
(
taskId
)
.
orElseThrow
(()
->
new
TaskOwnershipLostException
());
if
(
task
.
getStatus
()
!=
RelayTaskStatus
.
PROCESSING
||
!
processingToken
.
equals
(
task
.
getProcessingToken
()))
{
throw
new
TaskOwnershipLostException
();
}
return
task
;
}
private
int
currentProcessedCount
(
Long
taskId
)
{
return
repository
.
findById
(
taskId
).
map
(
MessageRelayTask:
:
getProcessedRecipientCount
).
orElse
(-
1
);
}
private
MessageRelayReceipt
saveTask
(
String
eventType
,
String
messageId
,
String
payload
,
RelayTaskStatus
status
)
{
MessageRelayTask
task
=
new
MessageRelayTask
();
task
.
setEventType
(
eventType
);
task
.
setMessageId
(
messageId
);
task
.
setPayload
(
payload
);
task
.
setStatus
(
status
);
task
.
setRetryCount
(
0
);
try
{
task
=
repository
.
saveAndFlush
(
task
);
log
.
info
(
"Message relay event persisted: taskId={}, eventType={}, messageId={}, status={}"
,
task
.
getId
(),
eventType
,
safe
(
messageId
),
status
);
return
new
MessageRelayReceipt
(
task
.
getId
(),
false
);
}
catch
(
DataIntegrityViolationException
ex
)
{
MessageRelayTask
existing
=
repository
.
findByEventTypeAndMessageId
(
eventType
,
messageId
)
.
orElseThrow
(()
->
ex
);
log
.
info
(
"Message relay duplicate ignored: taskId={}, eventType={}, messageId={}, existingStatus={}, "
+
"retryCount={}, processedRecipientCount={}"
,
existing
.
getId
(),
eventType
,
safe
(
messageId
),
existing
.
getStatus
(),
existing
.
getRetryCount
(),
existing
.
getProcessedRecipientCount
());
return
new
MessageRelayReceipt
(
existing
.
getId
(),
true
);
}
}
private
void
validateMessageIdentity
(
MingdaoMessageRequest
request
)
{
if
(
request
==
null
)
{
throw
new
IllegalArgumentException
(
"request body is required"
);
}
if
(!
StringUtils
.
hasText
(
request
.
getMessageId
()))
{
throw
new
IllegalArgumentException
(
"messageId is required"
);
}
}
private
void
validateSendableMessage
(
MingdaoMessageRequest
request
)
{
if
(!
StringUtils
.
hasText
(
request
.
getMessage
()))
{
throw
new
IllegalArgumentException
(
"message is required"
);
}
if
(
normalizeTargets
(
request
.
getTargets
()).
isEmpty
())
{
throw
new
IllegalArgumentException
(
"targets is required"
);
}
}
private
List
<
String
>
normalizeTargets
(
List
<
String
>
targets
)
{
Set
<
String
>
result
=
new
LinkedHashSet
<
String
>();
if
(
targets
!=
null
)
{
for
(
String
target
:
targets
)
{
if
(
StringUtils
.
hasText
(
target
))
{
result
.
add
(
target
.
trim
());
}
}
}
return
new
ArrayList
<
String
>(
result
);
}
private
void
scheduleRetry
(
MessageRelayTask
task
,
Exception
ex
)
{
int
failures
=
task
.
getRetryCount
()
+
1
;
task
.
setRetryCount
(
failures
);
task
.
setErrorMessage
(
truncate
(
ex
.
getMessage
()
==
null
?
ex
.
getClass
().
getSimpleName
()
:
ex
.
getMessage
()));
if
(
failures
>=
Math
.
max
(
1
,
properties
.
getMaxAttempts
()))
{
task
.
setStatus
(
RelayTaskStatus
.
FAILED
);
task
.
setNextRetryAt
(
null
);
log
.
error
(
"Message relay permanently failed: taskId={}, messageId={}, newStatus={}, contentType={}, "
+
"attempts={}, reason={}"
,
task
.
getId
(),
safe
(
task
.
getMessageId
()),
task
.
getStatus
(),
contentType
(),
failures
,
safeError
(
task
.
getErrorMessage
()));
return
;
}
long
delay
=
RETRY_DELAYS_SECONDS
[
Math
.
min
(
failures
-
1
,
RETRY_DELAYS_SECONDS
.
length
-
1
)];
task
.
setStatus
(
RelayTaskStatus
.
RETRY
);
task
.
setNextRetryAt
(
Instant
.
now
().
plusSeconds
(
delay
));
log
.
warn
(
"Message relay will retry: taskId={}, messageId={}, newStatus={}, contentType={}, attempts={}, "
+
"nextRetryAt={}, reason={}"
,
task
.
getId
(),
safe
(
task
.
getMessageId
()),
task
.
getStatus
(),
contentType
(),
failures
,
task
.
getNextRetryAt
(),
safeError
(
task
.
getErrorMessage
()));
}
private
void
markFailed
(
MessageRelayTask
task
,
Exception
ex
)
{
task
.
setRetryCount
(
task
.
getRetryCount
()
+
1
);
task
.
setStatus
(
RelayTaskStatus
.
FAILED
);
task
.
setNextRetryAt
(
null
);
task
.
setErrorMessage
(
truncate
(
ex
.
getMessage
()
==
null
?
ex
.
getClass
().
getSimpleName
()
:
ex
.
getMessage
()));
log
.
error
(
"Message relay rejected without retry: taskId={}, messageId={}, newStatus={}, contentType={}, reason={}"
,
task
.
getId
(),
safe
(
task
.
getMessageId
()),
task
.
getStatus
(),
contentType
(),
safeError
(
task
.
getErrorMessage
()));
}
private
List
<
String
>
splitValues
(
String
value
)
{
List
<
String
>
result
=
new
ArrayList
<
String
>();
if
(!
StringUtils
.
hasText
(
value
))
{
return
result
;
}
for
(
String
item
:
value
.
split
(
","
))
{
if
(
StringUtils
.
hasText
(
item
))
{
result
.
add
(
item
.
trim
());
}
}
return
result
;
}
private
String
writeJson
(
Object
value
)
{
try
{
return
objectMapper
.
writeValueAsString
(
value
);
}
catch
(
Exception
ex
)
{
throw
new
IllegalArgumentException
(
"request body cannot be serialized"
,
ex
);
}
}
private
String
truncate
(
String
value
)
{
if
(
value
==
null
||
value
.
length
()
<=
1000
)
{
return
value
;
}
return
value
.
substring
(
0
,
1000
);
}
private
boolean
isRetryable
(
Exception
ex
)
{
return
!(
ex
instanceof
CampusApiException
)
||
((
CampusApiException
)
ex
).
isRetryable
();
}
private
boolean
isTokenInvalid
(
Exception
ex
)
{
return
ex
instanceof
CampusApiException
&&
((
CampusApiException
)
ex
).
isTokenInvalid
();
}
private
long
elapsedSince
(
Instant
value
)
{
if
(
value
==
null
)
{
return
-
1L
;
}
return
Math
.
max
(
0L
,
java
.
time
.
Duration
.
between
(
value
,
Instant
.
now
()).
toMillis
());
}
private
String
safe
(
String
value
)
{
return
MessageRelayLogSupport
.
safe
(
value
,
160
);
}
private
String
safeError
(
String
value
)
{
return
MessageRelayLogSupport
.
safe
(
value
,
500
);
}
private
String
contentType
()
{
String
value
=
properties
.
getContentType
();
return
value
==
null
?
"news"
:
value
.
trim
().
toLowerCase
(
Locale
.
ROOT
);
}
private
static
class
ClaimedTask
{
private
final
Long
id
;
private
final
String
eventType
;
private
final
String
messageId
;
private
final
String
payload
;
private
final
int
retryCount
;
private
final
int
processedRecipientCount
;
private
final
String
campusUcsIds
;
private
final
Instant
createdAt
;
private
final
String
processingToken
;
private
ClaimedTask
(
MessageRelayTask
task
,
String
processingToken
)
{
this
.
id
=
task
.
getId
();
this
.
eventType
=
task
.
getEventType
();
this
.
messageId
=
task
.
getMessageId
();
this
.
payload
=
task
.
getPayload
();
this
.
retryCount
=
task
.
getRetryCount
();
this
.
processedRecipientCount
=
task
.
getProcessedRecipientCount
();
this
.
campusUcsIds
=
task
.
getCampusUcsIds
();
this
.
createdAt
=
task
.
getCreatedAt
();
this
.
processingToken
=
processingToken
;
}
}
private
static
class
BatchCheckpoint
{
private
final
int
processedRecipientCount
;
private
BatchCheckpoint
(
int
processedRecipientCount
)
{
this
.
processedRecipientCount
=
processedRecipientCount
;
}
}
private
static
class
CompletionResult
{
private
final
int
ucsCount
;
private
final
int
invalidCount
;
private
CompletionResult
(
int
ucsCount
,
int
invalidCount
)
{
this
.
ucsCount
=
ucsCount
;
this
.
invalidCount
=
invalidCount
;
}
}
private
static
class
TaskOwnershipLostException
extends
RuntimeException
{
private
static
final
long
serialVersionUID
=
1L
;
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayStartupValidator.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.util.Locale
;
import
javax.annotation.PostConstruct
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.stereotype.Component
;
import
org.springframework.util.StringUtils
;
@Component
public
class
MessageRelayStartupValidator
{
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
MessageRelayStartupValidator
.
class
);
private
final
MessageRelayProperties
properties
;
public
MessageRelayStartupValidator
(
MessageRelayProperties
properties
)
{
this
.
properties
=
properties
;
}
@PostConstruct
public
void
validate
()
{
if
(!
properties
.
isEnabled
())
{
log
.
info
(
"Mingdao message relay is disabled"
);
return
;
}
require
(
properties
.
getWebhookToken
(),
"MESSAGE_RELAY_WEBHOOK_TOKEN"
);
require
(
properties
.
getCampusBaseUrl
(),
"CAMPUS_BASE_URL"
);
require
(
properties
.
getCampusAppId
(),
"CAMPUS_APP_ID"
);
require
(
properties
.
getCampusAppSecret
(),
"CAMPUS_APP_SECRET"
);
require
(
properties
.
getCampusWid
(),
"CAMPUS_WID"
);
String
contentType
=
properties
.
getContentType
()
==
null
?
""
:
properties
.
getContentType
().
trim
().
toLowerCase
(
Locale
.
ROOT
);
if
(!
"text"
.
equals
(
contentType
)
&&
!
"news"
.
equals
(
contentType
))
{
throw
new
IllegalStateException
(
"MESSAGE_RELAY_CONTENT_TYPE must be text or news"
);
}
if
(
properties
.
getRecipientBatchSize
()
<
1
)
{
throw
new
IllegalStateException
(
"MESSAGE_RELAY_RECIPIENT_BATCH_SIZE must be greater than zero"
);
}
if
(
properties
.
getMaxAttempts
()
<
1
)
{
throw
new
IllegalStateException
(
"MESSAGE_RELAY_MAX_ATTEMPTS must be greater than zero"
);
}
if
(
properties
.
getProcessingTimeoutSeconds
()
<
1L
)
{
throw
new
IllegalStateException
(
"MESSAGE_RELAY_PROCESSING_TIMEOUT_SECONDS must be greater than zero"
);
}
log
.
info
(
"Mingdao message relay is enabled: campusHost={}, campusAppId={}, campusWid={}, contentType={}, batchSize={}, "
+
"maxAttempts={}, scanIntervalMs={}, processingTimeoutSeconds={}, connectTimeoutMs={}, "
+
"readTimeoutMs={}, tokenSkewSeconds={}"
,
MessageRelayLogSupport
.
host
(
properties
.
getCampusBaseUrl
()),
MessageRelayLogSupport
.
mask
(
properties
.
getCampusAppId
()),
MessageRelayLogSupport
.
mask
(
properties
.
getCampusWid
()),
contentType
,
properties
.
getRecipientBatchSize
(),
properties
.
getMaxAttempts
(),
properties
.
getScanIntervalMs
(),
properties
.
getProcessingTimeoutSeconds
(),
properties
.
getConnectTimeoutMs
(),
properties
.
getReadTimeoutMs
(),
properties
.
getTokenSkewSeconds
());
}
private
void
require
(
String
value
,
String
name
)
{
if
(!
StringUtils
.
hasText
(
value
))
{
throw
new
IllegalStateException
(
name
+
" is required when message relay is enabled"
);
}
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayTask.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.time.Instant
;
import
javax.persistence.Column
;
import
javax.persistence.Entity
;
import
javax.persistence.EnumType
;
import
javax.persistence.Enumerated
;
import
javax.persistence.GeneratedValue
;
import
javax.persistence.GenerationType
;
import
javax.persistence.Id
;
import
javax.persistence.Index
;
import
javax.persistence.PrePersist
;
import
javax.persistence.PreUpdate
;
import
javax.persistence.Table
;
import
javax.persistence.UniqueConstraint
;
@Entity
@Table
(
name
=
"message_relay_task"
,
uniqueConstraints
=
@UniqueConstraint
(
name
=
"uk_relay_event_message"
,
columnNames
=
{
"event_type"
,
"message_id"
}),
indexes
=
@Index
(
name
=
"idx_relay_due"
,
columnList
=
"status,next_retry_at"
))
public
class
MessageRelayTask
{
@Id
@GeneratedValue
(
strategy
=
GenerationType
.
IDENTITY
)
private
Long
id
;
@Column
(
name
=
"event_type"
,
nullable
=
false
,
length
=
32
)
private
String
eventType
;
@Column
(
name
=
"message_id"
,
nullable
=
false
,
length
=
128
)
private
String
messageId
;
@Column
(
nullable
=
false
,
columnDefinition
=
"TEXT"
)
private
String
payload
;
@Enumerated
(
EnumType
.
STRING
)
@Column
(
nullable
=
false
,
length
=
20
)
private
RelayTaskStatus
status
;
@Column
(
name
=
"retry_count"
,
nullable
=
false
)
private
int
retryCount
;
@Column
(
name
=
"next_retry_at"
)
private
Instant
nextRetryAt
;
@Column
(
name
=
"campus_ucs_ids"
,
columnDefinition
=
"TEXT"
)
private
String
campusUcsIds
;
@Column
(
name
=
"processed_recipient_count"
,
nullable
=
false
)
private
int
processedRecipientCount
;
@Column
(
name
=
"invalid_recipients"
,
columnDefinition
=
"TEXT"
)
private
String
invalidRecipients
;
@Column
(
name
=
"processing_token"
,
length
=
36
)
private
String
processingToken
;
@Column
(
name
=
"processing_started_at"
)
private
Instant
processingStartedAt
;
@Column
(
name
=
"error_message"
,
length
=
1000
)
private
String
errorMessage
;
@Column
(
name
=
"created_at"
,
nullable
=
false
)
private
Instant
createdAt
;
@Column
(
name
=
"updated_at"
,
nullable
=
false
)
private
Instant
updatedAt
;
@PrePersist
public
void
prePersist
()
{
Instant
now
=
Instant
.
now
();
createdAt
=
now
;
updatedAt
=
now
;
}
@PreUpdate
public
void
preUpdate
()
{
updatedAt
=
Instant
.
now
();
}
public
Long
getId
()
{
return
id
;
}
public
void
setId
(
Long
id
)
{
this
.
id
=
id
;
}
public
String
getEventType
()
{
return
eventType
;
}
public
void
setEventType
(
String
eventType
)
{
this
.
eventType
=
eventType
;
}
public
String
getMessageId
()
{
return
messageId
;
}
public
void
setMessageId
(
String
messageId
)
{
this
.
messageId
=
messageId
;
}
public
String
getPayload
()
{
return
payload
;
}
public
void
setPayload
(
String
payload
)
{
this
.
payload
=
payload
;
}
public
RelayTaskStatus
getStatus
()
{
return
status
;
}
public
void
setStatus
(
RelayTaskStatus
status
)
{
this
.
status
=
status
;
}
public
int
getRetryCount
()
{
return
retryCount
;
}
public
void
setRetryCount
(
int
retryCount
)
{
this
.
retryCount
=
retryCount
;
}
public
Instant
getNextRetryAt
()
{
return
nextRetryAt
;
}
public
void
setNextRetryAt
(
Instant
nextRetryAt
)
{
this
.
nextRetryAt
=
nextRetryAt
;
}
public
String
getCampusUcsIds
()
{
return
campusUcsIds
;
}
public
void
setCampusUcsIds
(
String
campusUcsIds
)
{
this
.
campusUcsIds
=
campusUcsIds
;
}
public
int
getProcessedRecipientCount
()
{
return
processedRecipientCount
;
}
public
void
setProcessedRecipientCount
(
int
processedRecipientCount
)
{
this
.
processedRecipientCount
=
processedRecipientCount
;
}
public
String
getInvalidRecipients
()
{
return
invalidRecipients
;
}
public
void
setInvalidRecipients
(
String
invalidRecipients
)
{
this
.
invalidRecipients
=
invalidRecipients
;
}
public
String
getProcessingToken
()
{
return
processingToken
;
}
public
void
setProcessingToken
(
String
processingToken
)
{
this
.
processingToken
=
processingToken
;
}
public
Instant
getProcessingStartedAt
()
{
return
processingStartedAt
;
}
public
void
setProcessingStartedAt
(
Instant
processingStartedAt
)
{
this
.
processingStartedAt
=
processingStartedAt
;
}
public
String
getErrorMessage
()
{
return
errorMessage
;
}
public
void
setErrorMessage
(
String
errorMessage
)
{
this
.
errorMessage
=
errorMessage
;
}
public
Instant
getCreatedAt
()
{
return
createdAt
;
}
public
Instant
getUpdatedAt
()
{
return
updatedAt
;
}
}
src/main/java/com/jd/easywalk/casrelay/MessageRelayTaskRepository.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.time.Instant
;
import
java.util.List
;
import
java.util.Optional
;
import
javax.persistence.LockModeType
;
import
org.springframework.data.domain.Pageable
;
import
org.springframework.data.jpa.repository.JpaRepository
;
import
org.springframework.data.jpa.repository.Lock
;
import
org.springframework.data.jpa.repository.Query
;
import
org.springframework.data.repository.query.Param
;
public
interface
MessageRelayTaskRepository
extends
JpaRepository
<
MessageRelayTask
,
Long
>
{
Optional
<
MessageRelayTask
>
findByEventTypeAndMessageId
(
String
eventType
,
String
messageId
);
@Query
(
"select t.id from MessageRelayTask t where t.status = :received "
+
"or (t.status = :retry and t.nextRetryAt <= :now) "
+
"or (t.status = :processing and t.processingStartedAt <= :staleBefore) order by t.createdAt"
)
List
<
Long
>
findDueTaskIds
(
@Param
(
"received"
)
RelayTaskStatus
received
,
@Param
(
"retry"
)
RelayTaskStatus
retry
,
@Param
(
"processing"
)
RelayTaskStatus
processing
,
@Param
(
"now"
)
Instant
now
,
@Param
(
"staleBefore"
)
Instant
staleBefore
,
Pageable
pageable
);
@Lock
(
LockModeType
.
PESSIMISTIC_WRITE
)
@Query
(
"select t from MessageRelayTask t where t.id = :id"
)
Optional
<
MessageRelayTask
>
findLockedById
(
@Param
(
"id"
)
Long
id
);
}
src/main/java/com/jd/easywalk/casrelay/MingdaoMessageController.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.nio.charset.StandardCharsets
;
import
java.security.MessageDigest
;
import
java.util.LinkedHashMap
;
import
java.util.Map
;
import
javax.servlet.http.HttpServletRequest
;
import
org.slf4j.Logger
;
import
org.slf4j.LoggerFactory
;
import
org.springframework.http.HttpStatus
;
import
org.springframework.http.ResponseEntity
;
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.RequestHeader
;
import
org.springframework.web.bind.annotation.RequestMapping
;
import
org.springframework.web.bind.annotation.RestController
;
@RestController
@RequestMapping
(
"/webhooks/mingdao"
)
public
class
MingdaoMessageController
{
private
static
final
Logger
log
=
LoggerFactory
.
getLogger
(
MingdaoMessageController
.
class
);
private
final
MessageRelayProperties
properties
;
private
final
MessageRelayService
relayService
;
public
MingdaoMessageController
(
MessageRelayProperties
properties
,
MessageRelayService
relayService
)
{
this
.
properties
=
properties
;
this
.
relayService
=
relayService
;
}
@PostMapping
(
"/message"
)
public
ResponseEntity
<
Map
<
String
,
Object
>>
message
(
@RequestHeader
(
value
=
"Authorization"
,
required
=
false
)
String
authorization
,
@RequestBody
MingdaoMessageRequest
request
,
HttpServletRequest
httpRequest
)
{
String
remoteAddress
=
MessageRelayLogSupport
.
safe
(
httpRequest
.
getRemoteAddr
(),
80
);
log
.
info
(
"Mingdao webhook received: eventType={}, messageId={}, messageTypeV2={}, appId={}, projectId={}, "
+
"targetCount={}, attachmentCount={}, hasRedirect={}, remoteAddress={}"
,
MessageRelayService
.
EVENT_MESSAGE
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
request
==
null
?
null
:
request
.
getMessageTypeV2
(),
safe
(
request
==
null
?
null
:
request
.
getAppId
()),
safe
(
request
==
null
?
null
:
request
.
getProjectId
()),
size
(
request
==
null
?
null
:
request
.
getTargets
()),
size
(
request
==
null
?
null
:
request
.
getAttachments
()),
request
!=
null
&&
StringUtils
.
hasText
(
request
.
getRedirectUrl
()),
remoteAddress
);
ResponseEntity
<
Map
<
String
,
Object
>>
unavailable
=
authorize
(
authorization
,
remoteAddress
,
request
==
null
?
null
:
request
.
getMessageId
());
if
(
unavailable
!=
null
)
{
return
unavailable
;
}
try
{
log
.
info
(
"Mingdao webhook request payload: eventType={}, messageId={}, payload={}"
,
MessageRelayService
.
EVENT_MESSAGE
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
MessageRelayLogSupport
.
payload
(
messagePayload
(
request
),
3000
));
MessageRelayReceipt
receipt
=
relayService
.
enqueueMessage
(
request
);
log
.
info
(
"Mingdao webhook acknowledged: eventType={}, messageId={}, taskId={}, duplicate={}, ignored={}"
,
MessageRelayService
.
EVENT_MESSAGE
,
safeMessageId
(
request
.
getMessageId
()),
receipt
.
getTaskId
(),
receipt
.
isDuplicate
(),
receipt
.
isIgnored
());
return
accepted
(
receipt
);
}
catch
(
IllegalArgumentException
ex
)
{
log
.
warn
(
"Mingdao webhook rejected: eventType={}, messageId={}, reason={}"
,
MessageRelayService
.
EVENT_MESSAGE
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
MessageRelayLogSupport
.
safe
(
ex
.
getMessage
(),
300
));
return
response
(
HttpStatus
.
BAD_REQUEST
,
false
,
ex
.
getMessage
());
}
catch
(
RuntimeException
ex
)
{
log
.
error
(
"Mingdao webhook persistence failed: eventType={}, messageId={}, exceptionType={}"
,
MessageRelayService
.
EVENT_MESSAGE
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
ex
.
getClass
().
getSimpleName
(),
ex
);
return
response
(
HttpStatus
.
INTERNAL_SERVER_ERROR
,
false
,
"message could not be persisted"
);
}
}
@PostMapping
(
"/todo/finished"
)
public
ResponseEntity
<
Map
<
String
,
Object
>>
todoFinished
(
@RequestHeader
(
value
=
"Authorization"
,
required
=
false
)
String
authorization
,
@RequestBody
MingdaoTodoFinishedRequest
request
,
HttpServletRequest
httpRequest
)
{
String
remoteAddress
=
MessageRelayLogSupport
.
safe
(
httpRequest
.
getRemoteAddr
(),
80
);
log
.
info
(
"Mingdao webhook received: eventType={}, messageId={}, projectId={}, processId={}, remoteAddress={}"
,
MessageRelayService
.
EVENT_TODO_FINISHED
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
safe
(
request
==
null
?
null
:
request
.
getProjectId
()),
safe
(
request
==
null
?
null
:
request
.
getProcessId
()),
remoteAddress
);
ResponseEntity
<
Map
<
String
,
Object
>>
unavailable
=
authorize
(
authorization
,
remoteAddress
,
request
==
null
?
null
:
request
.
getMessageId
());
if
(
unavailable
!=
null
)
{
return
unavailable
;
}
try
{
log
.
info
(
"Mingdao webhook request payload: eventType={}, messageId={}, payload={}"
,
MessageRelayService
.
EVENT_TODO_FINISHED
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
MessageRelayLogSupport
.
payload
(
todoFinishedPayload
(
request
),
1600
));
MessageRelayReceipt
receipt
=
relayService
.
recordTodoFinished
(
request
);
log
.
info
(
"Mingdao webhook acknowledged: eventType={}, messageId={}, taskId={}, duplicate={}"
,
MessageRelayService
.
EVENT_TODO_FINISHED
,
safeMessageId
(
request
.
getMessageId
()),
receipt
.
getTaskId
(),
receipt
.
isDuplicate
());
return
accepted
(
receipt
);
}
catch
(
IllegalArgumentException
ex
)
{
log
.
warn
(
"Mingdao webhook rejected: eventType={}, messageId={}, reason={}"
,
MessageRelayService
.
EVENT_TODO_FINISHED
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
MessageRelayLogSupport
.
safe
(
ex
.
getMessage
(),
300
));
return
response
(
HttpStatus
.
BAD_REQUEST
,
false
,
ex
.
getMessage
());
}
catch
(
RuntimeException
ex
)
{
log
.
error
(
"Mingdao webhook persistence failed: eventType={}, messageId={}, exceptionType={}"
,
MessageRelayService
.
EVENT_TODO_FINISHED
,
safeMessageId
(
request
==
null
?
null
:
request
.
getMessageId
()),
ex
.
getClass
().
getSimpleName
(),
ex
);
return
response
(
HttpStatus
.
INTERNAL_SERVER_ERROR
,
false
,
"completion event could not be persisted"
);
}
}
private
ResponseEntity
<
Map
<
String
,
Object
>>
authorize
(
String
authorization
,
String
remoteAddress
,
String
messageId
)
{
if
(!
properties
.
isEnabled
())
{
log
.
warn
(
"Mingdao webhook unavailable: messageId={}, remoteAddress={}, reason=relay-disabled"
,
safeMessageId
(
messageId
),
remoteAddress
);
return
response
(
HttpStatus
.
SERVICE_UNAVAILABLE
,
false
,
"message relay is disabled"
);
}
if
(!
StringUtils
.
hasText
(
properties
.
getWebhookToken
()))
{
log
.
error
(
"Mingdao webhook unavailable: messageId={}, remoteAddress={}, reason=token-not-configured"
,
safeMessageId
(
messageId
),
remoteAddress
);
return
response
(
HttpStatus
.
SERVICE_UNAVAILABLE
,
false
,
"webhook token is not configured"
);
}
String
expected
=
"Bearer "
+
properties
.
getWebhookToken
();
if
(!
StringUtils
.
hasText
(
authorization
)
||
!
MessageDigest
.
isEqual
(
expected
.
getBytes
(
StandardCharsets
.
UTF_8
),
authorization
.
getBytes
(
StandardCharsets
.
UTF_8
)))
{
log
.
warn
(
"Mingdao webhook unauthorized: messageId={}, remoteAddress={}, hasAuthorizationHeader={}"
,
safeMessageId
(
messageId
),
remoteAddress
,
StringUtils
.
hasText
(
authorization
));
return
response
(
HttpStatus
.
UNAUTHORIZED
,
false
,
"unauthorized"
);
}
return
null
;
}
private
int
size
(
java
.
util
.
Collection
<?>
values
)
{
return
values
==
null
?
0
:
values
.
size
();
}
private
Map
<
String
,
Object
>
messagePayload
(
MingdaoMessageRequest
request
)
{
if
(
request
==
null
)
{
return
null
;
}
Map
<
String
,
Object
>
payload
=
new
LinkedHashMap
<
String
,
Object
>();
payload
.
put
(
"messageId"
,
request
.
getMessageId
());
payload
.
put
(
"message"
,
request
.
getMessage
());
payload
.
put
(
"messageType"
,
request
.
getMessageType
());
payload
.
put
(
"messageTypeV2"
,
request
.
getMessageTypeV2
());
payload
.
put
(
"appId"
,
request
.
getAppId
());
payload
.
put
(
"appName"
,
request
.
getAppName
());
payload
.
put
(
"attachments"
,
request
.
getAttachments
());
payload
.
put
(
"redirectUrl"
,
request
.
getRedirectUrl
());
payload
.
put
(
"targets"
,
request
.
getTargets
());
payload
.
put
(
"projectId"
,
request
.
getProjectId
());
payload
.
put
(
"createUserId"
,
request
.
getCreateUserId
());
payload
.
put
(
"processId"
,
request
.
getProcessId
());
return
payload
;
}
private
Map
<
String
,
Object
>
todoFinishedPayload
(
MingdaoTodoFinishedRequest
request
)
{
if
(
request
==
null
)
{
return
null
;
}
Map
<
String
,
Object
>
payload
=
new
LinkedHashMap
<
String
,
Object
>();
payload
.
put
(
"messageId"
,
request
.
getMessageId
());
payload
.
put
(
"projectId"
,
request
.
getProjectId
());
payload
.
put
(
"processId"
,
request
.
getProcessId
());
return
payload
;
}
private
String
safe
(
String
value
)
{
return
MessageRelayLogSupport
.
safe
(
value
,
100
);
}
private
String
safeMessageId
(
String
value
)
{
return
MessageRelayLogSupport
.
safe
(
value
,
128
);
}
private
ResponseEntity
<
Map
<
String
,
Object
>>
accepted
(
MessageRelayReceipt
receipt
)
{
Map
<
String
,
Object
>
body
=
new
LinkedHashMap
<
String
,
Object
>();
body
.
put
(
"success"
,
true
);
body
.
put
(
"accepted"
,
true
);
body
.
put
(
"duplicate"
,
receipt
.
isDuplicate
());
body
.
put
(
"ignored"
,
receipt
.
isIgnored
());
body
.
put
(
"taskId"
,
receipt
.
getTaskId
());
return
ResponseEntity
.
ok
(
body
);
}
private
ResponseEntity
<
Map
<
String
,
Object
>>
response
(
HttpStatus
status
,
boolean
success
,
String
message
)
{
Map
<
String
,
Object
>
body
=
new
LinkedHashMap
<
String
,
Object
>();
body
.
put
(
"success"
,
success
);
body
.
put
(
"message"
,
message
);
return
ResponseEntity
.
status
(
status
).
body
(
body
);
}
}
src/main/java/com/jd/easywalk/casrelay/MingdaoMessageRequest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
java.util.ArrayList
;
import
java.util.List
;
public
class
MingdaoMessageRequest
{
private
String
messageId
;
private
String
message
;
private
Integer
messageType
;
private
Integer
messageTypeV2
;
private
String
appId
;
private
String
appName
;
private
List
<
String
>
attachments
=
new
ArrayList
<
String
>();
private
String
redirectUrl
;
private
List
<
String
>
targets
=
new
ArrayList
<
String
>();
private
String
projectId
;
private
String
createUserId
;
private
String
processId
;
public
String
getMessageId
()
{
return
messageId
;
}
public
void
setMessageId
(
String
messageId
)
{
this
.
messageId
=
messageId
;
}
public
String
getMessage
()
{
return
message
;
}
public
void
setMessage
(
String
message
)
{
this
.
message
=
message
;
}
public
Integer
getMessageType
()
{
return
messageType
;
}
public
void
setMessageType
(
Integer
messageType
)
{
this
.
messageType
=
messageType
;
}
public
Integer
getMessageTypeV2
()
{
return
messageTypeV2
;
}
public
void
setMessageTypeV2
(
Integer
messageTypeV2
)
{
this
.
messageTypeV2
=
messageTypeV2
;
}
public
String
getAppId
()
{
return
appId
;
}
public
void
setAppId
(
String
appId
)
{
this
.
appId
=
appId
;
}
public
String
getAppName
()
{
return
appName
;
}
public
void
setAppName
(
String
appName
)
{
this
.
appName
=
appName
;
}
public
List
<
String
>
getAttachments
()
{
return
attachments
;
}
public
void
setAttachments
(
List
<
String
>
attachments
)
{
this
.
attachments
=
attachments
;
}
public
String
getRedirectUrl
()
{
return
redirectUrl
;
}
public
void
setRedirectUrl
(
String
redirectUrl
)
{
this
.
redirectUrl
=
redirectUrl
;
}
public
List
<
String
>
getTargets
()
{
return
targets
;
}
public
void
setTargets
(
List
<
String
>
targets
)
{
this
.
targets
=
targets
;
}
public
String
getProjectId
()
{
return
projectId
;
}
public
void
setProjectId
(
String
projectId
)
{
this
.
projectId
=
projectId
;
}
public
String
getCreateUserId
()
{
return
createUserId
;
}
public
void
setCreateUserId
(
String
createUserId
)
{
this
.
createUserId
=
createUserId
;
}
public
String
getProcessId
()
{
return
processId
;
}
public
void
setProcessId
(
String
processId
)
{
this
.
processId
=
processId
;
}
}
src/main/java/com/jd/easywalk/casrelay/MingdaoTodoFinishedRequest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
public
class
MingdaoTodoFinishedRequest
{
private
String
messageId
;
private
String
projectId
;
private
String
userId
;
private
String
processId
;
public
String
getMessageId
()
{
return
messageId
;
}
public
void
setMessageId
(
String
messageId
)
{
this
.
messageId
=
messageId
;
}
public
String
getProjectId
()
{
return
projectId
;
}
public
void
setProjectId
(
String
projectId
)
{
this
.
projectId
=
projectId
;
}
public
String
getUserId
()
{
return
userId
;
}
public
void
setUserId
(
String
userId
)
{
this
.
userId
=
userId
;
}
public
String
getProcessId
()
{
return
processId
;
}
public
void
setProcessId
(
String
processId
)
{
this
.
processId
=
processId
;
}
}
src/main/java/com/jd/easywalk/casrelay/RelayTaskStatus.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
public
enum
RelayTaskStatus
{
RECEIVED
,
RETRY
,
PROCESSING
,
SENT
,
FAILED
,
COMPLETED
}
src/main/java/com/jd/easywalk/casrelay/TeacherDirectoryService.java
View file @
1a77b4ba
...
...
@@ -39,7 +39,13 @@ public class TeacherDirectoryService {
log
.
info
(
"Mingdao teacher directory loading is disabled"
);
return
;
}
try
{
refresh
();
}
catch
(
Exception
ex
)
{
// An unavailable directory must not prevent the relay and other endpoints from starting.
// The scheduled refresh will retry while preserving any previously loaded cache.
log
.
error
(
"Initial Mingdao teacher directory load failed; application will continue and retry later"
,
ex
);
}
}
public
synchronized
void
refresh
()
{
...
...
@@ -77,7 +83,6 @@ public class TeacherDirectoryService {
teachersByMobile
.
clear
();
teachersByMobile
.
putAll
(
loadedByMobile
);
log
.
info
(
"Loaded {} teachers from Mingdao worksheet"
,
teachersByWorkNo
.
size
());
logTeacherDetails
();
}
public
TeacherInfo
findByWorkNo
(
String
workNo
)
{
...
...
@@ -196,10 +201,4 @@ public class TeacherDirectoryService {
}
}
private
void
logTeacherDetails
()
{
for
(
TeacherInfo
teacher
:
teachersByWorkNo
.
values
())
{
log
.
info
(
"Cached teacher: workNo={}, name={}, email={}, mobile={}"
,
teacher
.
getWorkNo
(),
teacher
.
getName
(),
teacher
.
getEmail
(),
teacher
.
getMobile
());
}
}
}
src/main/resources/application.yml
View file @
1a77b4ba
server
:
port
:
18080
spring
:
datasource
:
url
:
${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/cas_relay}
username
:
${SPRING_DATASOURCE_USERNAME:cas_relay}
password
:
${SPRING_DATASOURCE_PASSWORD:}
jpa
:
open-in-view
:
false
hibernate
:
ddl-auto
:
update
properties
:
hibernate
:
jdbc
:
time_zone
:
UTC
cas
:
server-url-prefix
:
https://ids.xidian.edu.cn/authserver
service-validate-path
:
/serviceValidate
...
...
@@ -17,8 +31,8 @@ mingdao:
refresh-enabled
:
${MINGDAO_REFRESH_ENABLED:
true
}
refresh-interval-ms
:
${MINGDAO_REFRESH_INTERVAL_MS:600000}
api-url
:
https://nercwbs-lms.xidian.edu.cn/api/v2/open/worksheet/getFilterRows
app-key
:
${MINGDAO_APP_KEY:
3ed6154939e6aa92
}
sign
:
${MINGDAO_SIGN:
MDM0ZWU1YWM4NzM0ODljZTZiNDJlYmUwYzRmODU4OTNlN2ZkYzIwMjI1ZjBhNzIwZjA4OWU3YzQ1NTdiNGM0Mg==
}
app-key
:
${MINGDAO_APP_KEY:}
sign
:
${MINGDAO_SIGN:}
worksheet-id
:
680b5fd8d20405006c858262
view-id
:
"
"
page-size
:
1000
...
...
@@ -32,8 +46,8 @@ mingdao:
oauth-relay
:
enabled
:
${OAUTH_RELAY_ENABLED:
true
}
client-id
:
${OAUTH_CLIENT_ID:
1506958348348624896
}
client-secret
:
${OAUTH_CLIENT_SECRET:
19E483DC4D272E4JD5KD
}
client-id
:
${OAUTH_CLIENT_ID:}
client-secret
:
${OAUTH_CLIENT_SECRET:}
authorization-uri
:
${OAUTH_AUTHORIZATION_URI:https://ids.xidian.edu.cn/authserver/oauth2.0/authorize}
token-uri
:
${OAUTH_TOKEN_URI:https://ids.xidian.edu.cn/authserver/oauth2.0/accessToken}
user-info-uri
:
${OAUTH_USER_INFO_URI:https://ids.xidian.edu.cn/authserver/oauthApi/user/profile}
...
...
@@ -45,7 +59,7 @@ oauth-relay:
wecom-relay
:
enabled
:
${WECOM_RELAY_ENABLED:
false
}
app-id
:
${WECOM_APP_ID:
200260525100013951
}
app-id
:
${WECOM_APP_ID:}
app-secret
:
${WECOM_APP_SECRET:}
platform-domain
:
${WECOM_PLATFORM_DOMAIN:xxcapp.xidian.edu.cn}
corp-id
:
${WECOM_CORP_ID:}
...
...
@@ -84,3 +98,19 @@ wecom-relay:
mock-name
:
${WECOM_MOCK_NAME:MockUser}
mock-email
:
${WECOM_MOCK_EMAIL:20240001@xidian.edu.cn}
mock-mobile
:
${WECOM_MOCK_MOBILE:13800000000}
message-relay
:
enabled
:
${MESSAGE_RELAY_ENABLED:
false
}
webhook-token
:
${MESSAGE_RELAY_WEBHOOK_TOKEN:}
campus-base-url
:
${CAMPUS_BASE_URL:}
campus-app-id
:
${CAMPUS_APP_ID:}
campus-app-secret
:
${CAMPUS_APP_SECRET:}
campus-wid
:
${CAMPUS_WID:}
content-type
:
${MESSAGE_RELAY_CONTENT_TYPE:news}
token-skew-seconds
:
${MESSAGE_RELAY_TOKEN_SKEW_SECONDS:300}
connect-timeout-ms
:
${MESSAGE_RELAY_CONNECT_TIMEOUT_MS:5000}
read-timeout-ms
:
${MESSAGE_RELAY_READ_TIMEOUT_MS:10000}
recipient-batch-size
:
${MESSAGE_RELAY_RECIPIENT_BATCH_SIZE:100}
max-attempts
:
${MESSAGE_RELAY_MAX_ATTEMPTS:5}
scan-interval-ms
:
${MESSAGE_RELAY_SCAN_INTERVAL_MS:30000}
processing-timeout-seconds
:
${MESSAGE_RELAY_PROCESSING_TIMEOUT_SECONDS:300}
src/test/java/com/jd/easywalk/casrelay/CampusNewsFormTest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
static
org
.
assertj
.
core
.
api
.
Assertions
.
assertThat
;
import
static
org
.
mockito
.
Mockito
.
mock
;
import
java.util.Arrays
;
import
org.junit.jupiter.api.Test
;
import
org.springframework.boot.web.client.RestTemplateBuilder
;
import
org.springframework.util.MultiValueMap
;
class
CampusNewsFormTest
{
@Test
void
buildsSingleNewsArticleFromMingdaoMessage
()
{
MessageRelayProperties
properties
=
new
MessageRelayProperties
();
properties
.
setCampusWid
(
"126"
);
CampusMessageClient
client
=
new
CampusMessageClient
(
properties
,
mock
(
CampusTokenService
.
class
),
new
RestTemplateBuilder
());
MultiValueMap
<
String
,
String
>
form
=
client
.
buildConfiguredForm
(
Arrays
.
asList
(
"20260001"
,
"20260002"
),
"明道云消息内容"
,
"https://example.edu/todo/1"
,
"access-token"
,
CampusUcsType
.
WORKFLOW
);
assertThat
(
form
.
getFirst
(
"content[msgtype]"
)).
isEqualTo
(
"news"
);
assertThat
(
form
.
getFirst
(
"content[news][articles][0][title]"
)).
isEqualTo
(
"国工实验室综合管理平台"
);
assertThat
(
form
.
getFirst
(
"content[news][articles][0][description]"
)).
isEqualTo
(
"明道云消息内容"
);
assertThat
(
form
.
getFirst
(
"content[news][articles][0][url]"
)).
isEqualTo
(
"https://example.edu/todo/1"
);
assertThat
(
form
.
getFirst
(
"content[news][articles][0][picurl]"
)).
isEmpty
();
assertThat
(
form
.
get
(
"numbers[]"
)).
containsExactly
(
"20260001"
,
"20260002"
);
assertThat
(
form
.
getFirst
(
"isall"
)).
isEqualTo
(
"0"
);
assertThat
(
form
.
getFirst
(
"wid"
)).
isEqualTo
(
"126"
);
assertThat
(
form
.
getFirst
(
"ucs_type"
)).
isEqualTo
(
"4"
);
}
@Test
void
buildsTextMessageWhenConfigured
()
{
MessageRelayProperties
properties
=
new
MessageRelayProperties
();
properties
.
setCampusWid
(
"126"
);
properties
.
setContentType
(
"text"
);
CampusMessageClient
client
=
new
CampusMessageClient
(
properties
,
mock
(
CampusTokenService
.
class
),
new
RestTemplateBuilder
());
MultiValueMap
<
String
,
String
>
form
=
client
.
buildConfiguredForm
(
Arrays
.
asList
(
"20260001"
),
"明道云消息内容"
,
"https://example.edu/todo/1"
,
"access-token"
,
CampusUcsType
.
REMINDER
);
assertThat
(
form
.
getFirst
(
"content[msgtype]"
)).
isEqualTo
(
"text"
);
assertThat
(
form
.
getFirst
(
"content[text][content]"
)).
isEqualTo
(
"明道云消息内容\n<a href=\"https://example.edu/todo/1\">点击查看详情</a>"
);
assertThat
(
form
).
doesNotContainKey
(
"content[news][articles][0][title]"
);
assertThat
(
form
.
getFirst
(
"content[safe]"
)).
isEqualTo
(
"0"
);
assertThat
(
form
.
getFirst
(
"ucs_type"
)).
isEqualTo
(
"2"
);
}
}
src/test/java/com/jd/easywalk/casrelay/CampusResponseValidationTest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
static
org
.
assertj
.
core
.
api
.
Assertions
.
assertThatThrownBy
;
import
static
org
.
mockito
.
Mockito
.
mock
;
import
java.util.Collections
;
import
java.util.HashMap
;
import
java.util.Map
;
import
org.junit.jupiter.api.Test
;
import
org.springframework.boot.web.client.RestTemplateBuilder
;
class
CampusResponseValidationTest
{
@Test
void
messageResponseRequiresResultCode
()
{
CampusMessageClient
client
=
new
CampusMessageClient
(
new
MessageRelayProperties
(),
mock
(
CampusTokenService
.
class
),
new
RestTemplateBuilder
());
assertThatThrownBy
(()
->
client
.
assertSuccess
(
Collections
.<
String
,
Object
>
emptyMap
()))
.
isInstanceOf
(
CampusApiException
.
class
)
.
hasMessageContaining
(
"result code"
);
}
@Test
void
tokenResponseRequiresResultCode
()
{
CampusTokenService
service
=
new
CampusTokenService
(
new
MessageRelayProperties
(),
new
RestTemplateBuilder
());
assertThatThrownBy
(()
->
service
.
assertSuccess
(
Collections
.<
String
,
Object
>
emptyMap
(),
"get token"
))
.
isInstanceOf
(
CampusApiException
.
class
)
.
hasMessageContaining
(
"result code"
);
}
@Test
void
successfulMessageResponseRequiresUcsId
()
{
CampusMessageClient
client
=
new
CampusMessageClient
(
new
MessageRelayProperties
(),
mock
(
CampusTokenService
.
class
),
new
RestTemplateBuilder
());
Map
<
String
,
Object
>
body
=
new
HashMap
<
String
,
Object
>();
body
.
put
(
"e"
,
0
);
body
.
put
(
"d"
,
Collections
.<
String
,
Object
>
emptyMap
());
assertThatThrownBy
(()
->
client
.
parseSuccessBody
(
body
))
.
isInstanceOf
(
CampusApiException
.
class
)
.
hasMessageContaining
(
"ucs_id"
);
}
}
src/test/java/com/jd/easywalk/casrelay/CampusUcsTypeTest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
static
org
.
assertj
.
core
.
api
.
Assertions
.
assertThat
;
import
static
org
.
assertj
.
core
.
api
.
Assertions
.
assertThatThrownBy
;
import
org.junit.jupiter.api.Test
;
class
CampusUcsTypeTest
{
@Test
void
mapsMingdaoMessageTypesToCampusUcsTypes
()
{
assertThat
(
CampusUcsType
.
fromMessageTypeV2
(
11
)).
isEqualTo
(
CampusUcsType
.
NOTICE
);
assertThat
(
CampusUcsType
.
fromMessageTypeV2
(
12
)).
isEqualTo
(
CampusUcsType
.
NOTICE
);
assertThat
(
CampusUcsType
.
fromMessageTypeV2
(
13
)).
isEqualTo
(
CampusUcsType
.
REMINDER
);
assertThat
(
CampusUcsType
.
fromMessageTypeV2
(
14
)).
isEqualTo
(
CampusUcsType
.
WORKFLOW
);
assertThat
(
CampusUcsType
.
fromMessageTypeV2
(
15
)).
isNull
();
assertThat
(
CampusUcsType
.
fromMessageTypeV2
(
16
)).
isEqualTo
(
CampusUcsType
.
WORKFLOW
);
}
@Test
void
rejectsMissingOrUnsupportedMessageTypes
()
{
assertThatThrownBy
(()
->
CampusUcsType
.
fromMessageTypeV2
(
null
))
.
isInstanceOf
(
IllegalArgumentException
.
class
)
.
hasMessageContaining
(
"required"
);
assertThatThrownBy
(()
->
CampusUcsType
.
fromMessageTypeV2
(
99
))
.
isInstanceOf
(
IllegalArgumentException
.
class
)
.
hasMessageContaining
(
"unsupported"
);
}
}
src/test/java/com/jd/easywalk/casrelay/MessageRelayLogSupportTest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
static
org
.
assertj
.
core
.
api
.
Assertions
.
assertThat
;
import
java.util.Arrays
;
import
java.util.LinkedHashMap
;
import
java.util.Map
;
import
org.junit.jupiter.api.Test
;
class
MessageRelayLogSupportTest
{
@Test
void
redactsCredentialsAndPersonalIdentifiersFromPayloads
()
{
Map
<
String
,
Object
>
data
=
new
LinkedHashMap
<
String
,
Object
>();
data
.
put
(
"access_token"
,
"top-secret-token"
);
data
.
put
(
"error_data"
,
Arrays
.
asList
(
"20260001"
,
"20260002"
));
Map
<
String
,
Object
>
body
=
new
LinkedHashMap
<
String
,
Object
>();
body
.
put
(
"d"
,
data
);
String
logged
=
MessageRelayLogSupport
.
payload
(
body
,
1000
);
assertThat
(
logged
).
doesNotContain
(
"top-secret-token"
,
"20260001"
,
"20260002"
);
assertThat
(
logged
).
contains
(
"top***ken"
,
"202***001"
,
"202***002"
);
}
}
src/test/java/com/jd/easywalk/casrelay/MessageRelayServiceTest.java
0 → 100644
View file @
1a77b4ba
package
com
.
jd
.
easywalk
.
casrelay
;
import
static
org
.
assertj
.
core
.
api
.
Assertions
.
assertThat
;
import
static
org
.
mockito
.
ArgumentMatchers
.
any
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyInt
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyList
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyLong
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyString
;
import
static
org
.
mockito
.
Mockito
.
mock
;
import
static
org
.
mockito
.
Mockito
.
reset
;
import
static
org
.
mockito
.
Mockito
.
when
;
import
java.time.Instant
;
import
java.util.Arrays
;
import
java.util.Collections
;
import
java.util.concurrent.atomic.AtomicInteger
;
import
com.fasterxml.jackson.databind.ObjectMapper
;
import
org.junit.jupiter.api.Test
;
import
org.springframework.beans.factory.annotation.Autowired
;
import
org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
;
import
org.springframework.transaction.PlatformTransactionManager
;
import
org.springframework.transaction.annotation.Propagation
;
import
org.springframework.transaction.annotation.Transactional
;
import
org.springframework.transaction.support.TransactionSynchronizationManager
;
@DataJpaTest
@Transactional
(
propagation
=
Propagation
.
NOT_SUPPORTED
)
class
MessageRelayServiceTest
{
@Autowired
private
MessageRelayTaskRepository
repository
;
@Autowired
private
PlatformTransactionManager
transactionManager
;
@Test
void
commitsEachAcceptedBatchAndKeepsInvalidRecipientsAcrossRetry
()
{
MessageRelayProperties
properties
=
new
MessageRelayProperties
();
properties
.
setRecipientBatchSize
(
1
);
properties
.
setMaxAttempts
(
3
);
CampusMessageClient
client
=
mock
(
CampusMessageClient
.
class
);
MessageRelayService
service
=
new
MessageRelayService
(
repository
,
client
,
properties
,
new
ObjectMapper
(),
transactionManager
);
MingdaoMessageRequest
request
=
new
MingdaoMessageRequest
();
request
.
setMessageId
(
"message-1"
);
request
.
setMessage
(
"hello"
);
request
.
setMessageTypeV2
(
11
);
request
.
setTargets
(
Arrays
.
asList
(
"user-1"
,
"user-2"
));
Long
taskId
=
service
.
enqueueMessage
(
request
).
getTaskId
();
AtomicInteger
call
=
new
AtomicInteger
();
when
(
client
.
send
(
anyList
(),
anyString
(),
any
(),
anyInt
(),
anyLong
(),
anyString
(),
anyInt
(),
anyInt
()))
.
thenAnswer
(
invocation
->
{
assertThat
(
TransactionSynchronizationManager
.
isActualTransactionActive
()).
isFalse
();
assertThat
(
invocation
.
getArgument
(
3
,
Integer
.
class
)).
isEqualTo
(
CampusUcsType
.
NOTICE
);
if
(
call
.
getAndIncrement
()
==
0
)
{
CampusPushResult
result
=
new
CampusPushResult
();
result
.
setUcsId
(
"ucs-1"
);
result
.
setErrorData
(
Collections
.
singletonList
(
"bad-1"
));
return
result
;
}
MessageRelayTask
checkpoint
=
repository
.
findById
(
taskId
).
orElseThrow
(
AssertionError:
:
new
);
assertThat
(
checkpoint
.
getProcessedRecipientCount
()).
isEqualTo
(
1
);
assertThat
(
checkpoint
.
getInvalidRecipients
()).
isEqualTo
(
"bad-1"
);
throw
new
CampusApiException
(
"temporary failure"
,
false
,
true
);
});
service
.
processTask
(
taskId
);
MessageRelayTask
retry
=
repository
.
findById
(
taskId
).
orElseThrow
(
AssertionError:
:
new
);
assertThat
(
retry
.
getStatus
()).
isEqualTo
(
RelayTaskStatus
.
RETRY
);
assertThat
(
retry
.
getProcessedRecipientCount
()).
isEqualTo
(
1
);
assertThat
(
retry
.
getInvalidRecipients
()).
isEqualTo
(
"bad-1"
);
retry
.
setNextRetryAt
(
Instant
.
now
().
minusSeconds
(
1
));
repository
.
saveAndFlush
(
retry
);
CampusPushResult
secondResult
=
new
CampusPushResult
();
secondResult
.
setUcsId
(
"ucs-2"
);
secondResult
.
setErrorData
(
Collections
.
singletonList
(
"bad-2"
));
reset
(
client
);
when
(
client
.
send
(
anyList
(),
anyString
(),
any
(),
anyInt
(),
anyLong
(),
anyString
(),
anyInt
(),
anyInt
()))
.
thenReturn
(
secondResult
);
service
.
processTask
(
taskId
);
MessageRelayTask
sent
=
repository
.
findById
(
taskId
).
orElseThrow
(
AssertionError:
:
new
);
assertThat
(
sent
.
getStatus
()).
isEqualTo
(
RelayTaskStatus
.
SENT
);
assertThat
(
sent
.
getProcessedRecipientCount
()).
isEqualTo
(
2
);
assertThat
(
sent
.
getCampusUcsIds
()).
isEqualTo
(
"ucs-1,ucs-2"
);
assertThat
(
sent
.
getInvalidRecipients
()).
isEqualTo
(
"bad-1,bad-2"
);
assertThat
(
sent
.
getErrorMessage
()).
contains
(
"bad-1"
).
contains
(
"bad-2"
);
}
@Test
void
ignoresMessageTypeThatDoesNotSendRecords
()
{
MessageRelayProperties
properties
=
new
MessageRelayProperties
();
CampusMessageClient
client
=
mock
(
CampusMessageClient
.
class
);
MessageRelayService
service
=
new
MessageRelayService
(
repository
,
client
,
properties
,
new
ObjectMapper
(),
transactionManager
);
MingdaoMessageRequest
request
=
new
MingdaoMessageRequest
();
request
.
setMessageId
(
"message-ignored"
);
request
.
setMessageTypeV2
(
15
);
long
taskCountBefore
=
repository
.
count
();
MessageRelayReceipt
receipt
=
service
.
enqueueMessage
(
request
);
assertThat
(
receipt
.
isIgnored
()).
isTrue
();
assertThat
(
receipt
.
getTaskId
()).
isNull
();
assertThat
(
repository
.
count
()).
isEqualTo
(
taskCountBefore
);
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment