feat(open-platform): 新增用户分页查询接口并更新订单权限及时间格式
- 添加 OpenUserController 支持分页查询本租户用户,权限为 sys:user:list - 引入 OpenUserPageParam 作为用户查询白名单参数类,过滤敏感字段 - 定义 OpenUserVO,确保响应脱敏手机号、邮箱且不包含密码字段 - 修正订单接口权限 scope 由 order:read 改为 shop:shopOrder:list - 订单及退款时间字段增加 JsonFormat 注解,统一时间格式为 yyyy-MM-dd HH:mm:ss - 更新权限常量 OpenScopes,新增 SHOP_ORDER_LIST 和 SYS_USER_LIST - 增加 OpenUserVO 单元测试,确保敏感字段不被泄露且脱敏逻辑正确 - 新增开放平台调用验证脚本 scripts/verify-open-api.sh,覆盖令牌获取及接口调用验证 - 修改接口文档与示例,明确权限 scope 和时间格式要求 - 修复 TenantController 权限校验问题,确保租户字段来源于令牌且不可伪造 - 禁用伪造 tenantId 请求头修改数据范围,保证租户数据隔离安全
This commit is contained in:
@@ -73,8 +73,11 @@ com.gxwebsoft.openplatform
|
|||||||
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
||||||
├─ constant/OpenScopes.java scope 常量
|
├─ constant/OpenScopes.java scope 常量
|
||||||
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
|
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
|
||||||
|
├─ param/OpenUserPageParam.java 用户查询入参白名单
|
||||||
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
|
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
|
||||||
└─ controller/OpenOrderController.java /api/open/v1/order/page
|
├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段
|
||||||
|
├─ controller/OpenOrderController.java /api/open/v1/order/page
|
||||||
|
└─ controller/OpenUserController.java /api/open/v1/user/page
|
||||||
```
|
```
|
||||||
|
|
||||||
既有文件改动:
|
既有文件改动:
|
||||||
@@ -115,15 +118,18 @@ open-platform:
|
|||||||
|
|
||||||
## 5. 接口契约
|
## 5. 接口契约
|
||||||
|
|
||||||
|
### 5.1 订单列表
|
||||||
|
|
||||||
`GET /api/open/v1/order/page`
|
`GET /api/open/v1/order/page`
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||||||
| 权限 | scope 含 `order:read`,否则 403 |
|
| 权限 | scope 含 `shop:shopOrder:list`,否则 403 |
|
||||||
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
|
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
|
||||||
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||||||
| 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` |
|
| 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` |
|
||||||
|
| 时间格式 | `yyyy-MM-dd HH:mm:ss`,输出库中存储的挂钟时间,不做时区换算 |
|
||||||
|
|
||||||
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
|
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
|
||||||
|
|
||||||
@@ -155,9 +161,36 @@ open-platform:
|
|||||||
| code | message | 触发条件 |
|
| code | message | 触发条件 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
|
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
|
||||||
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `order:read` |
|
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `shop:shopOrder:list` |
|
||||||
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
|
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
|
||||||
|
|
||||||
|
### 5.2 用户列表
|
||||||
|
|
||||||
|
`GET /api/open/v1/user/page`
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||||||
|
| 权限 | scope 含 `sys:user:list`,否则 403 |
|
||||||
|
| 数据范围 | 令牌 `tenant_id` 对应租户的用户 |
|
||||||
|
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||||||
|
| 过滤 | `username`(模糊)、`nickname`(模糊)、`type`、`status`、`createTimeStart`、`createTimeEnd` |
|
||||||
|
|
||||||
|
出参只包含 `userId` / `userCode` / `username` / `nickname` / `realName` / `type` / `sex` /
|
||||||
|
`sexName` / `phone` / `email` / `emailVerified` / `organizationId` / `organizationName` /
|
||||||
|
`status` / `auditStatus` / `createTime`。
|
||||||
|
|
||||||
|
> **为什么必须用独立 VO**:`User` 实体对 `password`、`payPassword` 都没有 `@JsonIgnore`,
|
||||||
|
> 而 `UserMapper` 用的是 `SELECT a.*`。内部接口 `/api/system/user/page` 直接把实体返回,
|
||||||
|
> 响应里带着密码哈希;开放接口如果照抄这个写法就会把凭证交给第三方。
|
||||||
|
> `OpenUserVOTest` 里有一条断言专门防止这种回归。
|
||||||
|
|
||||||
|
> **scope 命名说明**:`shop:shopOrder:list` 取自 `sys_menu.authority` 里已有的权限点
|
||||||
|
> (菜单 157795「查询」、182274「项目订单」),与内部权限体系保持一致。
|
||||||
|
> 但注意 `gxwebsoft_core` 库里**没有 `shop_*` 表**(只有 `sys_order` / `sys_order_goods`),
|
||||||
|
> 当前该 scope 守卫的是 `sys_order` 的数据。若后续要对外开放的是商城订单,
|
||||||
|
> 需要把接口指向商城所在的服务与库,而不是复用本 controller。
|
||||||
|
|
||||||
## 6. 随本次改动修复的既有问题
|
## 6. 随本次改动修复的既有问题
|
||||||
|
|
||||||
`TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`,
|
`TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`,
|
||||||
@@ -171,6 +204,10 @@ open-platform:
|
|||||||
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
|
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
|
||||||
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
|
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
|
||||||
> 影响面较大,建议单独排期。
|
> 影响面较大,建议单独排期。
|
||||||
|
>
|
||||||
|
> 已知同一族的两个问题(本次未动,因为属于内部接口行为变更):
|
||||||
|
> `GET /api/system/user/withoutAuth` 免登录且可返回用户实体(含密码哈希),
|
||||||
|
> `GET /api/system/user/page` 等接口返回的实体也带密码字段——两者都应该改为按需 VO。
|
||||||
|
|
||||||
## 7. 如何新增一个开放接口
|
## 7. 如何新增一个开放接口
|
||||||
|
|
||||||
@@ -194,15 +231,28 @@ open-platform:
|
|||||||
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
|
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
|
||||||
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
|
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
|
||||||
|
|
||||||
端到端(本地自建 JWKS,无需 base-api 凭证):
|
端到端脚本 `scripts/verify-open-api.sh`,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」:
|
||||||
|
|
||||||
签发 `tenant_id` 分别为 A / B / 不存在的租户三枚令牌调用接口,
|
```bash
|
||||||
预期返回条数与数据库 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?`
|
# 真实链路(需要应用凭证)
|
||||||
完全一致,且伪造 `tenantId` 请求头不改变结果。
|
OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
|
||||||
|
|
||||||
|
# 已有令牌时跳过换令牌
|
||||||
|
OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 TENANT_EXPECT=6 ./scripts/verify-open-api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
判定标准:
|
||||||
|
|
||||||
|
1. 令牌 `scope` 含 `shop:shopOrder:list`、含 `tenant_id`;
|
||||||
|
2. 订单列表 `code=0`,`total` 与 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?` 一致;
|
||||||
|
3. 不带令牌返回 401;
|
||||||
|
4. 伪造 `tenantId` 请求头不改变返回结果。
|
||||||
|
|
||||||
|
本地自建 JWKS(不依赖 base-api)的联调方式见第 4 节。
|
||||||
|
|
||||||
## 9. 待办
|
## 9. 待办
|
||||||
|
|
||||||
- [ ] `base-api` 的权限字典补充 `order:read` 等业务 scope,并在应用管理页可勾选;
|
- [ ] `base-api` 的权限字典补充 `shop:shopOrder:list` 等业务 scope,并在应用管理页可勾选;
|
||||||
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
|
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
|
||||||
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
|
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
|
||||||
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
|
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
|
||||||
|
|||||||
Executable
+180
@@ -0,0 +1,180 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# 开放平台接入验证脚本
|
||||||
|
#
|
||||||
|
# 作用:验证「应用凭证 → 换取令牌 → 携带令牌调用业务接口 → 租户隔离与权限生效」整条链路。
|
||||||
|
#
|
||||||
|
# 用法一(真实链路,需要应用凭证):
|
||||||
|
# OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
|
||||||
|
#
|
||||||
|
# 用法二(已有令牌,跳过换令牌,用于本地/联调):
|
||||||
|
# OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 ./scripts/verify-open-api.sh
|
||||||
|
#
|
||||||
|
# 可选环境变量:
|
||||||
|
# OPEN_BASE_URL 开放平台地址,默认 https://base-api.websoft.top/api
|
||||||
|
# BUSINESS_BASE_URL 业务服务地址,默认 http://127.0.0.1:8000
|
||||||
|
# OPEN_SCOPE 期望的权限标识,默认 shop:shopOrder:list
|
||||||
|
# OPEN_PATH 被测接口路径,默认 /api/open/v1/order/page
|
||||||
|
# 测用户列表:OPEN_PATH=/api/open/v1/user/page OPEN_SCOPE=sys:user:list
|
||||||
|
# TENANT_EXPECT 期望命中本租户的记录数,留空则不校验数量(需与数据库核对后填写)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
|
||||||
|
OPEN_BASE_URL="${OPEN_BASE_URL:-https://base-api.websoft.top/api}"
|
||||||
|
BUSINESS_BASE_URL="${BUSINESS_BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
OPEN_SCOPE="${OPEN_SCOPE:-shop:shopOrder:list}"
|
||||||
|
OPEN_PATH="${OPEN_PATH:-/api/open/v1/order/page}"
|
||||||
|
|
||||||
|
ok() { echo -e "${GREEN}✔${NC} $1"; }
|
||||||
|
bad() { echo -e "${RED}✘${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}!${NC} $1"; }
|
||||||
|
step() { echo -e "\n${BLUE}== $1 ==${NC}"; }
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || { bad "缺少命令:$1"; exit 1; }
|
||||||
|
}
|
||||||
|
need_cmd curl
|
||||||
|
need_cmd python3
|
||||||
|
|
||||||
|
# 只解包 ApiResult,不校验业务语义
|
||||||
|
json_field() {
|
||||||
|
python3 -c "
|
||||||
|
import json,sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
print('')
|
||||||
|
sys.exit(0)
|
||||||
|
cur = data
|
||||||
|
for part in sys.argv[1].split('.'):
|
||||||
|
if isinstance(cur, dict):
|
||||||
|
cur = cur.get(part)
|
||||||
|
else:
|
||||||
|
cur = None
|
||||||
|
break
|
||||||
|
print('' if cur is None else cur)
|
||||||
|
" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_jwt_payload() {
|
||||||
|
python3 -c "
|
||||||
|
import base64, json, sys
|
||||||
|
token = sys.argv[1].strip()
|
||||||
|
parts = token.split('.')
|
||||||
|
if len(parts) < 2:
|
||||||
|
print('{}')
|
||||||
|
sys.exit(0)
|
||||||
|
payload = parts[1].replace('-', '+').replace('_', '/')
|
||||||
|
payload += '=' * ((4 - len(payload) % 4) % 4)
|
||||||
|
try:
|
||||||
|
print(json.dumps(json.loads(base64.b64decode(payload)), ensure_ascii=False, indent=2))
|
||||||
|
except Exception as e:
|
||||||
|
print('{}')
|
||||||
|
" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "0. 目标地址"
|
||||||
|
echo " 开放平台:$OPEN_BASE_URL"
|
||||||
|
echo " 业务服务:$BUSINESS_BASE_URL"
|
||||||
|
echo " 期望权限:$OPEN_SCOPE"
|
||||||
|
echo " 接口路径:$OPEN_PATH"
|
||||||
|
|
||||||
|
step "1. 获取 access token"
|
||||||
|
if [ -n "${OPEN_ACCESS_TOKEN:-}" ]; then
|
||||||
|
ACCESS_TOKEN="$OPEN_ACCESS_TOKEN"
|
||||||
|
ok "使用已提供的 OPEN_ACCESS_TOKEN"
|
||||||
|
else
|
||||||
|
if [ -z "${OPEN_CLIENT_ID:-}" ] || [ -z "${OPEN_CLIENT_SECRET:-}" ]; then
|
||||||
|
bad "请设置 OPEN_CLIENT_ID / OPEN_CLIENT_SECRET,或直接提供 OPEN_ACCESS_TOKEN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TOKEN_RESPONSE=$(curl -s -m 20 -X POST "$OPEN_BASE_URL/v1/oauth/token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"grantType\":\"client_credentials\",\"clientId\":\"$OPEN_CLIENT_ID\",\"clientSecret\":\"$OPEN_CLIENT_SECRET\"}")
|
||||||
|
echo "$TOKEN_RESPONSE" | python3 -m json.tool 2>/dev/null | head -20 || echo "$TOKEN_RESPONSE"
|
||||||
|
CODE=$(echo "$TOKEN_RESPONSE" | json_field code)
|
||||||
|
if [ "$CODE" != "0" ]; then
|
||||||
|
bad "换取令牌失败:$(echo "$TOKEN_RESPONSE" | json_field message)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | json_field data.accessToken)
|
||||||
|
ok "换取令牌成功"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ACCESS_TOKEN" ]; then
|
||||||
|
bad "accessToken 为空"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "2. 解析令牌声明(仅本地解码,不代表已验签)"
|
||||||
|
decode_jwt_payload "$ACCESS_TOKEN"
|
||||||
|
|
||||||
|
CLAIMS=$(decode_jwt_payload "$ACCESS_TOKEN")
|
||||||
|
TOKEN_SCOPE=$(echo "$CLAIMS" | json_field scope)
|
||||||
|
TOKEN_TENANT=$(echo "$CLAIMS" | json_field tenant_id)
|
||||||
|
|
||||||
|
if echo " $TOKEN_SCOPE " | grep -q " $OPEN_SCOPE "; then
|
||||||
|
ok "令牌包含权限 $OPEN_SCOPE"
|
||||||
|
else
|
||||||
|
bad "令牌不含 $OPEN_SCOPE,当前 scope:${TOKEN_SCOPE:-(空)}"
|
||||||
|
warn "请确认:1) base-api 权限字典已登记该 scope;2) 已在应用管理里勾选;3) 重新换过令牌"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then
|
||||||
|
ok "令牌携带租户 tenant_id=$TOKEN_TENANT(业务侧据此隔离数据)"
|
||||||
|
else
|
||||||
|
bad "令牌没有 tenant_id,业务接口会返回 403"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "3. 携带令牌调用开放接口"
|
||||||
|
RESPONSE=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||||
|
"$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5")
|
||||||
|
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null | head -40 || echo "$RESPONSE"
|
||||||
|
|
||||||
|
CODE=$(echo "$RESPONSE" | json_field code)
|
||||||
|
if [ "$CODE" != "0" ]; then
|
||||||
|
bad "调用失败:code=$CODE message=$(echo "$RESPONSE" | json_field message)"
|
||||||
|
warn "404 说明业务服务未部署该开放接口;403 说明权限或租户不满足"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "接口调用成功,total=$(echo "$RESPONSE" | json_field data.total)"
|
||||||
|
|
||||||
|
if [ -n "${TENANT_EXPECT:-}" ]; then
|
||||||
|
TOTAL=$(echo "$RESPONSE" | json_field data.total)
|
||||||
|
if [ "$TOTAL" = "$TENANT_EXPECT" ]; then
|
||||||
|
ok "条数与预期一致($TOTAL)"
|
||||||
|
else
|
||||||
|
bad "条数不符:接口返回 $TOTAL,预期 $TENANT_EXPECT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "4. 负向用例"
|
||||||
|
|
||||||
|
NO_TOKEN=$(curl -s -m 20 "$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=1")
|
||||||
|
NO_TOKEN_CODE=$(echo "$NO_TOKEN" | json_field code)
|
||||||
|
if [ "$NO_TOKEN_CODE" = "401" ]; then
|
||||||
|
ok "未携带令牌被拒绝(code=401)"
|
||||||
|
else
|
||||||
|
bad "未携带令牌竟然返回 code=$NO_TOKEN_CODE,开放接口可能未走独立安全链"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then
|
||||||
|
SPOOF=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" -H "tenantId: 1" \
|
||||||
|
"$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5")
|
||||||
|
SPOOF_TOTAL=$(echo "$SPOOF" | json_field data.total)
|
||||||
|
BASE_TOTAL=$(echo "$RESPONSE" | json_field data.total)
|
||||||
|
if [ "$SPOOF_TOTAL" = "$BASE_TOTAL" ]; then
|
||||||
|
ok "伪造 tenantId 请求头无效(仍返回本租户数据)"
|
||||||
|
else
|
||||||
|
bad "伪造 tenantId 改变了结果($BASE_TOTAL -> $SPOOF_TOTAL),存在越权风险"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
ok "验证完成"
|
||||||
@@ -9,8 +9,20 @@ package com.gxwebsoft.openplatform.constant;
|
|||||||
*/
|
*/
|
||||||
public final class OpenScopes {
|
public final class OpenScopes {
|
||||||
|
|
||||||
/** 查询订单 */
|
/**
|
||||||
public static final String ORDER_READ = "order:read";
|
* 查询项目订单。
|
||||||
|
*
|
||||||
|
* <p>与 sys_menu.authority 里的既有权限点同名(菜单 157795「查询」、182274「项目订单」),
|
||||||
|
* 便于与内部权限体系对照。</p>
|
||||||
|
*/
|
||||||
|
public static final String SHOP_ORDER_LIST = "shop:shopOrder:list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户列表。
|
||||||
|
*
|
||||||
|
* <p>与 sys_menu.authority 里的既有权限点同名(菜单 3561 等「查询」)。</p>
|
||||||
|
*/
|
||||||
|
public static final String SYS_USER_LIST = "sys:user:list";
|
||||||
|
|
||||||
private OpenScopes() {
|
private OpenScopes() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class OpenOrderController {
|
|||||||
@Resource
|
@Resource
|
||||||
private OpenPlatformProperties properties;
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.ORDER_READ + "')")
|
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SHOP_ORDER_LIST + "')")
|
||||||
@Operation(summary = "分页查询本租户订单")
|
@Operation(summary = "分页查询本租户订单")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public ApiResult<OpenPageResult<OpenOrderVO>> page(@AuthenticationPrincipal Jwt jwt,
|
public ApiResult<OpenPageResult<OpenOrderVO>> page(@AuthenticationPrincipal Jwt jwt,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.gxwebsoft.openplatform.controller;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import com.gxwebsoft.common.system.param.UserParam;
|
||||||
|
import com.gxwebsoft.common.system.service.UserService;
|
||||||
|
import com.gxwebsoft.openplatform.config.OpenPlatformProperties;
|
||||||
|
import com.gxwebsoft.openplatform.constant.OpenScopes;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.param.OpenUserPageParam;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenUserVO;
|
||||||
|
import com.gxwebsoft.openplatform.web.OpenPageResult;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台 · 用户接口。
|
||||||
|
*
|
||||||
|
* <p>{@code sys_user} 不在 {@code MybatisPlusConfig} 的 {@code ignoreTable} 白名单里,
|
||||||
|
* 多租户插件会自动加上 {@code tenant_id} 条件;这里仍显式设置一次,
|
||||||
|
* 两个来源都指向令牌里的租户({@code OpenTenantContext}),属于双保险。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Tag(name = "开放接口-用户")
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
@RequestMapping("/api/open/v1/user")
|
||||||
|
public class OpenUserController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserService userService;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SYS_USER_LIST + "')")
|
||||||
|
@Operation(summary = "分页查询本租户用户")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ApiResult<OpenPageResult<OpenUserVO>> page(OpenUserPageParam query) {
|
||||||
|
// 租户只认令牌,拦截器已校验 tenant_id 存在
|
||||||
|
Integer tenantId = OpenTenantContext.getTenantId();
|
||||||
|
|
||||||
|
UserParam param = new UserParam();
|
||||||
|
param.setPage(normalizePage(query.getPage()));
|
||||||
|
param.setLimit(normalizeLimit(query.getLimit()));
|
||||||
|
param.setTenantId(tenantId);
|
||||||
|
param.setUsername(StrUtil.trimToNull(query.getUsername()));
|
||||||
|
param.setNickname(StrUtil.trimToNull(query.getNickname()));
|
||||||
|
param.setType(query.getType());
|
||||||
|
param.setStatus(query.getStatus());
|
||||||
|
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
|
||||||
|
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
|
||||||
|
|
||||||
|
PageResult<User> result = userService.pageRel(param);
|
||||||
|
List<OpenUserVO> list = OpenUserVO.from(result.getList(), properties.isMaskSensitive());
|
||||||
|
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG,
|
||||||
|
new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizePage(Long page) {
|
||||||
|
return (page == null || page < 1L) ? 1L : page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizeLimit(Long limit) {
|
||||||
|
if (limit == null || limit < 1L) {
|
||||||
|
return 20L;
|
||||||
|
}
|
||||||
|
return Math.min(limit, OpenUserPageParam.MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.gxwebsoft.openplatform.param;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口用户分页查询入参。
|
||||||
|
*
|
||||||
|
* <p>白名单字段,刻意不含 {@code password}、{@code tenantId} 等内部可构造字段。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenUserPageParam", description = "开放接口用户分页查询参数")
|
||||||
|
public class OpenUserPageParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 每页最大条数 */
|
||||||
|
public static final long MAX_LIMIT = 100L;
|
||||||
|
|
||||||
|
@Schema(description = "页码,从 1 开始", example = "1")
|
||||||
|
private Long page = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "每页数量,最大 100", example = "20")
|
||||||
|
private Long limit = 20L;
|
||||||
|
|
||||||
|
@Schema(description = "账号,模糊匹配")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "昵称,模糊匹配")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "用户类型")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "用户状态")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Schema(description = "注册时间起始,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeStart;
|
||||||
|
|
||||||
|
@Schema(description = "注册时间结束,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeEnd;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft.openplatform.vo;
|
package com.gxwebsoft.openplatform.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.gxwebsoft.common.system.entity.Order;
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -71,13 +72,19 @@ public class OpenOrderVO implements Serializable {
|
|||||||
@Schema(description = "备注")
|
@Schema(description = "备注")
|
||||||
private String comments;
|
private String comments;
|
||||||
|
|
||||||
@Schema(description = "支付时间")
|
// 与 Order 实体的序列化保持一致:输出库中存储的挂钟时间,不做时区换算。
|
||||||
|
// 注意 JacksonConfig 里的 @Primary ObjectMapper 是新建的,不加载 spring.jackson.* 配置,
|
||||||
|
// 不显式声明格式会退化成 ISO-8601(2024-11-12T21:03:31.000+00:00)。
|
||||||
|
@Schema(description = "支付时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date payTime;
|
private Date payTime;
|
||||||
|
|
||||||
@Schema(description = "退款时间")
|
@Schema(description = "退款时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date refundTime;
|
private Date refundTime;
|
||||||
|
|
||||||
@Schema(description = "下单时间")
|
@Schema(description = "下单时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.gxwebsoft.openplatform.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的用户出参。
|
||||||
|
*
|
||||||
|
* <p><b>安全要点</b>:{@code User} 实体没有对 {@code password} 做 {@code @JsonIgnore},
|
||||||
|
* 且 UserMapper 用的是 {@code SELECT a.*},直接把实体返回给第三方会泄露密码哈希与支付密码。
|
||||||
|
* 这里只挑出必要字段,密码类字段一律不出现。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenUser", description = "开放接口用户")
|
||||||
|
public class OpenUserVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "用户id")
|
||||||
|
private Integer userId;
|
||||||
|
|
||||||
|
@Schema(description = "用户编码")
|
||||||
|
private String userCode;
|
||||||
|
|
||||||
|
@Schema(description = "账号")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "昵称")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "真实姓名")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "用户类型")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "性别字典值")
|
||||||
|
private String sex;
|
||||||
|
|
||||||
|
@Schema(description = "性别名称")
|
||||||
|
private String sexName;
|
||||||
|
|
||||||
|
@Schema(description = "手机号(按配置脱敏)")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱(按配置脱敏)")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "邮箱是否验证,0否 1是")
|
||||||
|
private Integer emailVerified;
|
||||||
|
|
||||||
|
@Schema(description = "机构id")
|
||||||
|
private Integer organizationId;
|
||||||
|
|
||||||
|
@Schema(description = "机构名称")
|
||||||
|
private String organizationName;
|
||||||
|
|
||||||
|
@Schema(description = "状态")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Schema(description = "审核状态:0待审核 1已通过 2已拒绝")
|
||||||
|
private Integer auditStatus;
|
||||||
|
|
||||||
|
@Schema(description = "注册时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体转出参。
|
||||||
|
*
|
||||||
|
* @param users 用户列表
|
||||||
|
* @param maskSensitive 是否对手机号、邮箱脱敏
|
||||||
|
*/
|
||||||
|
public static List<OpenUserVO> from(List<User> users, boolean maskSensitive) {
|
||||||
|
if (users == null || users.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<OpenUserVO> list = new ArrayList<>(users.size());
|
||||||
|
for (User user : users) {
|
||||||
|
OpenUserVO vo = new OpenUserVO();
|
||||||
|
vo.setUserId(user.getUserId());
|
||||||
|
vo.setUserCode(user.getUserCode());
|
||||||
|
vo.setUsername(user.getUsername());
|
||||||
|
vo.setNickname(user.getNickname());
|
||||||
|
vo.setRealName(user.getRealName());
|
||||||
|
vo.setType(user.getType());
|
||||||
|
vo.setSex(user.getSex());
|
||||||
|
vo.setSexName(user.getSexName());
|
||||||
|
vo.setPhone(maskSensitive ? maskPhone(user.getPhone()) : user.getPhone());
|
||||||
|
vo.setEmail(maskSensitive ? maskEmail(user.getEmail()) : user.getEmail());
|
||||||
|
vo.setEmailVerified(user.getEmailVerified());
|
||||||
|
vo.setOrganizationId(user.getOrganizationId());
|
||||||
|
vo.setOrganizationName(user.getOrganizationName());
|
||||||
|
vo.setStatus(user.getStatus());
|
||||||
|
vo.setAuditStatus(user.getAuditStatus());
|
||||||
|
vo.setCreateTime(user.getCreateTime());
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhone(String phone) {
|
||||||
|
if (phone == null || phone.length() < 7) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskEmail(String email) {
|
||||||
|
if (email == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int at = email.indexOf('@');
|
||||||
|
if (at <= 0) {
|
||||||
|
// 不是正常的邮箱格式,原样返回,不臆造
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
// 统一保留局部名首字符与完整域名:zhangsan@example.com -> z***@example.com
|
||||||
|
return email.charAt(0) + "***" + email.substring(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ class OpenPlatformJwtValidationTest {
|
|||||||
.subject("demo-app")
|
.subject("demo-app")
|
||||||
.claim("client_id", "demo-app")
|
.claim("client_id", "demo-app")
|
||||||
.claim("tenant_id", 1001)
|
.claim("tenant_id", 1001)
|
||||||
.claim("scope", "order:read")
|
.claim("scope", "shop:shopOrder:list")
|
||||||
.issueTime(Date.from(Instant.now().minusSeconds(30)))
|
.issueTime(Date.from(Instant.now().minusSeconds(30)))
|
||||||
.expirationTime(Date.from(expiresAt))
|
.expirationTime(Date.from(expiresAt))
|
||||||
.build();
|
.build();
|
||||||
@@ -89,7 +89,7 @@ class OpenPlatformJwtValidationTest {
|
|||||||
assertNotNull(jwt);
|
assertNotNull(jwt);
|
||||||
assertEquals("demo-app", jwt.getClaimAsString("client_id"));
|
assertEquals("demo-app", jwt.getClaimAsString("client_id"));
|
||||||
assertEquals(1001, ((Number) jwt.getClaim("tenant_id")).intValue());
|
assertEquals(1001, ((Number) jwt.getClaim("tenant_id")).intValue());
|
||||||
assertEquals("order:read", jwt.getClaimAsString("scope"));
|
assertEquals("shop:shopOrder:list", jwt.getClaimAsString("scope"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class OpenTenantBindingTest {
|
|||||||
Jwt.Builder builder = Jwt.withTokenValue("test-token")
|
Jwt.Builder builder = Jwt.withTokenValue("test-token")
|
||||||
.header("alg", "RS256")
|
.header("alg", "RS256")
|
||||||
.claim("client_id", "demo-app")
|
.claim("client_id", "demo-app")
|
||||||
.claim("scope", "order:read order:write");
|
.claim("scope", "shop:shopOrder:list shop:shopOrder:save");
|
||||||
if (tenantId != null) {
|
if (tenantId != null) {
|
||||||
builder.claim("tenant_id", tenantId);
|
builder.claim("tenant_id", tenantId);
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,8 @@ class OpenTenantBindingTest {
|
|||||||
|
|
||||||
assertEquals(1001, OpenTenantContext.getTenantId());
|
assertEquals(1001, OpenTenantContext.getTenantId());
|
||||||
assertEquals("demo-app", OpenTenantContext.get().getClientId());
|
assertEquals("demo-app", OpenTenantContext.get().getClientId());
|
||||||
assertEquals(List.of("order:read", "order:write"), OpenTenantContext.get().getScopes());
|
assertEquals(List.of("shop:shopOrder:list", "shop:shopOrder:save"),
|
||||||
|
OpenTenantContext.get().getScopes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.gxwebsoft.openplatform;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenUserVO;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口用户出参测试。
|
||||||
|
*
|
||||||
|
* <p>重点是<b>不能泄露密码</b>:User 实体没有 @JsonIgnore,UserMapper 又用 SELECT a.*,
|
||||||
|
* 所以这里直接序列化结果做断言,防止以后有人图省事把实体返回出去。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
class OpenUserVOTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private User user() {
|
||||||
|
User user = new User();
|
||||||
|
user.setUserId(1);
|
||||||
|
user.setUsername("zhangsan");
|
||||||
|
user.setNickname("张三");
|
||||||
|
user.setPhone("13800000009");
|
||||||
|
user.setEmail("zhangsan@example.com");
|
||||||
|
// 敏感字段:绝不能出现在开放接口响应里
|
||||||
|
user.setPassword("$2a$10$abcdefghijklmnopqrstuv");
|
||||||
|
user.setPayPassword("$2a$10$paypaypaypaypaypaypayp");
|
||||||
|
user.setIdCard("450102199001011234");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("序列化结果里不含密码、支付密码、身份证号")
|
||||||
|
void neverExposesCredentials() throws Exception {
|
||||||
|
List<OpenUserVO> list = OpenUserVO.from(List.of(user()), true);
|
||||||
|
|
||||||
|
String json = MAPPER.writeValueAsString(list);
|
||||||
|
|
||||||
|
assertFalse(json.contains("password"), "响应不应包含 password / payPassword 字段");
|
||||||
|
assertFalse(json.contains("payPassword"), "响应不应包含 payPassword 字段");
|
||||||
|
assertFalse(json.contains("$2a$10$"), "响应不应包含密码哈希");
|
||||||
|
assertFalse(json.contains("idCard"), "响应不应包含身份证号字段");
|
||||||
|
assertTrue(json.contains("zhangsan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("手机号与邮箱按开关脱敏")
|
||||||
|
void masksContactInfo() {
|
||||||
|
OpenUserVO masked = OpenUserVO.from(List.of(user()), true).get(0);
|
||||||
|
assertEquals("138****0009", masked.getPhone());
|
||||||
|
assertEquals("z***@example.com", masked.getEmail());
|
||||||
|
|
||||||
|
OpenUserVO plain = OpenUserVO.from(List.of(user()), false).get(0);
|
||||||
|
assertEquals("13800000009", plain.getPhone());
|
||||||
|
assertEquals("zhangsan@example.com", plain.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("异常联系方式的兜底处理")
|
||||||
|
void handlesUnusualContactInfo() {
|
||||||
|
User user = user();
|
||||||
|
|
||||||
|
user.setPhone("123");
|
||||||
|
user.setEmail("a@b.com");
|
||||||
|
OpenUserVO vo = OpenUserVO.from(List.of(user), true).get(0);
|
||||||
|
assertEquals("123", vo.getPhone());
|
||||||
|
assertEquals("a***@b.com", vo.getEmail());
|
||||||
|
|
||||||
|
// 非法格式不臆造,原样返回
|
||||||
|
user.setEmail("@example.com");
|
||||||
|
assertEquals("@example.com", OpenUserVO.from(List.of(user), true).get(0).getEmail());
|
||||||
|
user.setEmail("no-at-sign");
|
||||||
|
assertEquals("no-at-sign", OpenUserVO.from(List.of(user), true).get(0).getEmail());
|
||||||
|
|
||||||
|
user.setPhone(null);
|
||||||
|
user.setEmail(null);
|
||||||
|
vo = OpenUserVO.from(List.of(user), true).get(0);
|
||||||
|
assertNull(vo.getPhone());
|
||||||
|
assertNull(vo.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("空列表返回空集合")
|
||||||
|
void handlesEmptyList() {
|
||||||
|
assertTrue(OpenUserVO.from(null, true).isEmpty());
|
||||||
|
assertTrue(OpenUserVO.from(List.of(), true).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user