Commit 1a77b4ba authored by jiatao's avatar jiatao

添加消息对接

parent f288e28a
target/*
!target/cas-relay-1.0.0.jar
.git/
.m2/
*.log
cas-relay.log
......
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
......
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
FROM eclipse-temurin:8-jre
ARG BASE_IMAGE=eclipse-temurin:8-jre
FROM ${BASE_IMAGE}
WORKDIR /app
......
......@@ -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)
......
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:
# 明道云消息转发
消息链路:明道云 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`
调用学校的发送结果查询接口确认。
# 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` 的来源限制为学校代理地址。
......@@ -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>
# 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=10s;
keepalive 32;
}
upstream cas_relay_local {
server 127.0.0.1:18080 max_fails=3 fail_timeout=10s;
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 2048m;
client_body_timeout 60s;
client_header_timeout 60s;
keepalive_timeout 65s;
send_timeout 1800s;
reset_timedout_connection on;
proxy_http_version 1.1;
proxy_connect_timeout 10s;
proxy_send_timeout 1800s;
proxy_read_timeout 1800s;
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 64k;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
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 64k;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
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 64k;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
limit_except GET {
deny all;
}
proxy_pass http://cas_relay_local/wecom/oauth2/userinfo;
}
location = /cas-relay/wecom/oauth2/logout {
client_max_body_size 64k;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
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 3600s;
proxy_send_timeout 3600s;
proxy_pass http://hap_local;
}
# All remaining HAP pages, APIs, uploads, and downloads.
location / {
proxy_request_buffering off;
proxy_pass http://hap_local;
}
}
......@@ -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>
......
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; }
}
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; }
}
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); }
}
}
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);
}
}
}
......@@ -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) {
......
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;
}
}
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; }
}
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; }
}
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);
}
}
}
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");
}
}
}
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; }
}
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);
}
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; }
}
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; }
}
package com.jd.easywalk.casrelay;
public enum RelayTaskStatus {
RECEIVED,
RETRY,
PROCESSING,
SENT,
FAILED,
COMPLETED
}
......@@ -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());
}
}
}
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}
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");
}
}
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");
}
}
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");
}
}
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");
}
}
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);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment