Compare commits
21 Commits
280224e646
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 397c3c4b08 | |||
| a705539e77 | |||
| c3e46f4bc8 | |||
| 86e73b68b7 | |||
| 0923635575 | |||
| ea5021c3f0 | |||
| efccb786fe | |||
| 792d35c1c0 | |||
| e7a12d8828 | |||
| c88b41fd46 | |||
| f751d17771 | |||
| 539819d474 | |||
| d93c4684ee | |||
| 58972885ad | |||
| c585647dec | |||
| 477b028e82 | |||
| 0fda607612 | |||
| 70bd81c45d | |||
| 262a9e0183 | |||
| 4a827abe9b | |||
| 4db7a11624 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
|
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-08-10 工作日志
|
||||||
|
|
||||||
|
## 邮件模板风格统一:注册成功邮件对齐验证码邮件
|
||||||
|
|
||||||
|
**背景**:用户看到两封 WebSoft 邮件风格不统一(企业官网开通成功 vs 邮箱验证码),要求把注册成功模板改成验证码风格。
|
||||||
|
|
||||||
|
**关键发现(重要)**:
|
||||||
|
- `register-success.html` 模板此前**并未被真实开通流程使用**。`TenantServiceImpl.java` 的租户开通逻辑(约 650-673 行)走的是纯文本 `emailRecordService.sendEmail(...)`,且构建的 content 是拼接文本,根本没用 HTML 模板。
|
||||||
|
- `EmailTemplateUtil.sendRegisterSuccessEmail` 原本签名是 `(username, phone, password, email, tenantId)`,只传了这些变量,和模板里的 `siteName/siteUrl/adminUrl/account` 对不上,所以旧模板这些字段一直为空。
|
||||||
|
- `MainController.java:1428` 的"注册网站平台会员"分支也调用了该方法,属于账号注册场景误用"企业官网开通"模板的历史错配(旧逻辑下邮件大半空白)。
|
||||||
|
|
||||||
|
**改动**:
|
||||||
|
1. 重写 `src/main/resources/templates/register-success.html`:对齐 `captcha.html` 的视觉系统(图片 Logo、`.card` 圆角灰卡、`#1d1d1f` 文字、`#06c` 链接、黑色 pill 登录按钮、发送时间、统一页脚)。变量:`siteName, siteUrl, adminUrl, account, password, helpUrl, sendTime`。
|
||||||
|
2. `TenantServiceImpl`:开通邮件从 `sendEmail`(纯文本)改为 `sendHtmlEmail("register-success.html", data, ...)`,补传 `sendTime`(用 `DateUtil.now()`),并加 try-catch 降级为纯文本(`sendHtmlEmail` 抛受检异常 `MessagingException`)。
|
||||||
|
3. `EmailTemplateUtil.sendRegisterSuccessEmail`:签名改为 `(siteName, siteUrl, adminUrl, account, password, email, tenantId)`,正确传模板变量 + `sendTime`。
|
||||||
|
4. `EmailTestController` 的 `/register-success` 测试端点:更新为传新签名的示例数据。
|
||||||
|
5. `MainController:1428`:用兜底值(`siteUrl/adminUrl = https://websoft.top`、`siteName = username`)修复编译,但语义上仍属错配,待产品决策。
|
||||||
|
|
||||||
|
**验证**:`./mvnw -o -q compile` 通过。
|
||||||
|
|
||||||
|
**待用户决策**:`MainController` 的"注册网站平台会员"分支语义是账号注册,是否应为它单独做"账号注册成功"邮件(可能需专用模板),而非复用企业官网开通模板。
|
||||||
|
|
||||||
|
**测试方式**:重新构建启动后,用 `POST /api/email-test/register-success?email=xxx` 预览真实渲染效果。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 2026-09-01 工作记录
|
||||||
|
|
||||||
|
## 修复 getOrderQRCodeUnlimited scene 参数 bug
|
||||||
|
- 文件:`src/main/java/com/gxwebsoft/common/system/controller/WxLoginController.java`
|
||||||
|
- 问题:后台管理端调用 `/getOrderQRCodeUnlimited/e9?page=pages/event/detail/index` 时,因未传 `scene`,回退逻辑生成 `scene=orderNo=e9`,而 `pages/event/detail/index` 页面期望 `scene=e9`(直接读 options.scene)。
|
||||||
|
- 修复:scene 取值优先级改为 `显式 scene > 自定义页面用原始 orderNo > 兼容原订单核销(orderNo=xxx)`;page 无参时默认 `package/admin/order-scan`。
|
||||||
|
- 兼容性:原订单核销用法(不带参数)行为不变;自定义页面默认用原始 orderNo;需 `orderNo=` 格式可显式传 `?scene=orderNo=e9`。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 2026-09-03
|
||||||
|
|
||||||
|
## 修复:素材管理搜索报 Unknown column 'b.name'
|
||||||
|
|
||||||
|
- 文件:`src/main/java/com/gxwebsoft/common/system/mapper/xml/FileRecordMapper.xml`
|
||||||
|
- 根因:`selectSql` 的 `keywords` 模糊搜索块(2024-11-25 提交 cf4f010 引入,疑似从 UserVerifyMapper 复制过来未改别名)写成了 `b.name` / `b.comments`。
|
||||||
|
该块中 `a` = sys_file_record,`b` = sys_user;sys_user **没有** `name` 字段(只有 username / nickname / real_name / phone),故 MySQL 报 `Unknown column 'b.name' in 'where clause'`。
|
||||||
|
- 修复:关键词改为搜索文件侧字段 `a.name` / `a.path` / `a.comments`,加上传者 `b.username` / `b.nickname` / `b.real_name`;
|
||||||
|
`a.create_user_id = #{param.keywords}` 用 `<if test="param.keywords.matches('[0-9]+')">` 包住,避免非数字关键词被 MySQL 隐式转成 0。
|
||||||
|
- 教训:跨 mapper 复制动态 SQL 片段时,务必核对表别名与目标表字段(sys_user 无 name 列)。
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
# 开放平台对接说明(server-api / gxwebsoft_core)
|
||||||
|
|
||||||
|
> 记录日期:2026-09-22 | 分支:`codex/open-platform-integration`
|
||||||
|
> 上游控制面:`base-api`(开放平台,负责应用、凭证、权限与令牌签发)
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
开放平台(`base-api`)只负责签发令牌,业务数据仍在业务服务里。第三方应用拿着
|
||||||
|
`base-api` 签发的 RS256 access token 调用业务接口时,业务服务需要自行完成:
|
||||||
|
|
||||||
|
1. 用 JWKS 公钥验签,校验 `iss` / `aud` / `exp`;
|
||||||
|
2. 校验令牌的 `scope` 是否包含接口所需权限;
|
||||||
|
3. 从令牌的 `tenant_id` 取租户,作为数据隔离条件。
|
||||||
|
|
||||||
|
本服务(`server.websoft.top`)在此之上新增了一条独立的**开放接口链路**,
|
||||||
|
对内控制台链路零影响。
|
||||||
|
|
||||||
|
## 2. 关键设计
|
||||||
|
|
||||||
|
### 2.1 两条安全链互不干扰
|
||||||
|
|
||||||
|
本服务原有 `SecurityConfig` 有两个特点,都不能被开放接口继承:
|
||||||
|
|
||||||
|
| 原有行为 | 位置 | 对开放接口的风险 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `antMatchers(GET, "/**").permitAll()`,即所有 GET 在框架层放行 | `SecurityConfig` | 开放接口挂上去默认就是公开的 |
|
||||||
|
| 内部令牌是 HS256 对称密钥,由全局 `JwtAuthenticationFilter` 解析 | `JwtAuthenticationFilter` | 用内部密钥解析 RS256 令牌会抛 `UnsupportedJwtException` |
|
||||||
|
|
||||||
|
因此新增 `OpenPlatformSecurityConfig`(`@Order(1)`),只匹配 `/api/open/**`,
|
||||||
|
`anyRequest().authenticated()` 且无任何 `permitAll`;原 `SecurityConfig` 降为 `@Order(2)`。
|
||||||
|
`JwtAuthenticationFilter` 增加 `shouldNotFilter` 跳过 `/api/open/**`
|
||||||
|
(该过滤器是 `@Component`,会被 Spring Boot 额外注册为普通 Servlet Filter,
|
||||||
|
仅靠安全链隔离并不够)。
|
||||||
|
|
||||||
|
### 2.2 租户只来自令牌
|
||||||
|
|
||||||
|
原有租户取值顺序是「请求头 `tenantId` → 请求头 `Domain` → 登录用户」
|
||||||
|
(见 `MybatisPlusConfig` 与 `BaseController.getTenantId`),
|
||||||
|
意味着客户端可以用一个请求头指定租户。开放链路必须堵死这一点:
|
||||||
|
|
||||||
|
`OpenTenantInterceptor` 在请求进入 controller 前,从已验签的 JWT 取 `tenant_id`
|
||||||
|
写入 `OpenTenantContext`;`MybatisPlusConfig.getTenantId()` 以它作为**最高优先级**,
|
||||||
|
不再读取请求头。令牌没有 `tenant_id` 时直接返回 403,不回退。
|
||||||
|
|
||||||
|
### 2.3 订单表需要显式租户条件
|
||||||
|
|
||||||
|
`sys_order` 在 `MybatisPlusConfig` 的 `ignoreTable` 白名单里,**不受多租户插件管辖**,
|
||||||
|
内部接口是靠「非平台租户强制 `userId = 登录用户`」实现隔离的(按人而非按租户)。
|
||||||
|
因此 `OrderMapper.xml` 补了一条显式条件:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<if test="param.tenantId != null">
|
||||||
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
</if>
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageParam` 会跳过名为 `tenantId` 的字段,该参数此前无人设置,所以对既有调用无影响。
|
||||||
|
|
||||||
|
## 3. 代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
本服务的业务侧(留在本仓库)
|
||||||
|
com.gxwebsoft.openplatform
|
||||||
|
├─ constant/OpenScopes.java scope 常量
|
||||||
|
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
|
||||||
|
├─ param/OpenUserPageParam.java 用户查询入参白名单
|
||||||
|
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
|
||||||
|
├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段
|
||||||
|
├─ controller/OpenOrderController.java /api/open/v1/order/page
|
||||||
|
└─ controller/OpenUserController.java /api/open/v1/user/page
|
||||||
|
|
||||||
|
平台层(已迁出到独立仓库 websoft-platform,作为构件依赖引入)
|
||||||
|
com.gxwebsoft.platform.openapi
|
||||||
|
├─ OpenApi 标记注解,异常处理器按它匹配
|
||||||
|
├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
|
||||||
|
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
|
||||||
|
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
|
||||||
|
├─ config/OpenPlatformWebMvcConfig.java 给 /api/open/** 挂租户拦截器
|
||||||
|
├─ context/OpenCaller.java 调用方身份(全部来自令牌)
|
||||||
|
├─ context/OpenTenantContext.java 开放链路租户 ThreadLocal
|
||||||
|
├─ web/OpenTenantInterceptor.java 绑定与清理租户上下文
|
||||||
|
├─ web/OpenPlatformAuthenticationEntryPoint.java 未认证响应(不含 error 字段)
|
||||||
|
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
|
||||||
|
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
|
||||||
|
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
||||||
|
└─ web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **迁移说明(2026-09-22)**:上述平台层文件原先以源码形式放在本仓库的
|
||||||
|
> `com.gxwebsoft.openplatform.{config,context,web}` 下,现已抽到独立仓库
|
||||||
|
> `websoft-platform` 并作为 `websoft-platform-openapi` 构件依赖引入。
|
||||||
|
> 内核 `Constants` / `ApiResult` / `PageResult` / `BusinessException` 同样已迁移,
|
||||||
|
> 但**包名保持不变**,所以本服务其余代码一行 import 都不用改。
|
||||||
|
>
|
||||||
|
> 对外开放的 controller 必须加 `@OpenApi` 注解——平台层的异常处理器按注解匹配,
|
||||||
|
> 漏加会导致第三方收到带 `error` 字段(内部异常信息)的响应。
|
||||||
|
>
|
||||||
|
> 详见 `websoft-platform/docs/PLATFORM_LAYER.md`。
|
||||||
|
|
||||||
|
既有文件改动:
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pom.xml` | 新增 `spring-boot-starter-oauth2-resource-server` |
|
||||||
|
| `SecurityConfig` | 加 `@Order(2)` 让位给开放链 |
|
||||||
|
| `JwtAuthenticationFilter` | 加 `shouldNotFilter`,跳过 `/api/open/**` |
|
||||||
|
| `MybatisPlusConfig` | 租户取值优先读 `OpenTenantContext` |
|
||||||
|
| `OrderMapper.xml` | 补 `tenant_id` 查询条件 |
|
||||||
|
| `TenantController.page` | **安全修复**:未登录直接拒绝,见第 6 节 |
|
||||||
|
| `application.yml` | 新增 `open-platform.*` 配置块 |
|
||||||
|
|
||||||
|
## 4. 配置项
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
open-platform:
|
||||||
|
enabled: true
|
||||||
|
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
|
||||||
|
issuer: https://base-api.websoft.top/api
|
||||||
|
audience: websoft-open-platform
|
||||||
|
path-prefix: /api/open
|
||||||
|
mask-sensitive: true
|
||||||
|
```
|
||||||
|
|
||||||
|
> **不要改用 `spring.security.oauth2.resourceserver.jwt.issuer-uri`**。
|
||||||
|
> 配置该项后 Spring Security 会在启动时请求 `{issuer}/.well-known/openid-configuration`
|
||||||
|
> 做 OIDC 发现,而 `base-api` 未提供该文档(实测返回 `{"code":401,"message":"请先登录"}`),
|
||||||
|
> 会导致启动直接失败。这里只配 JWKS 地址,`iss` / `aud` 用显式校验器声明。
|
||||||
|
|
||||||
|
本地联调时可临时指向自建 JWKS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -Dspring-boot.run.arguments="\
|
||||||
|
--open-platform.jwk-set-uri=http://127.0.0.1:18999/jwks.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 接口契约
|
||||||
|
|
||||||
|
### 5.1 订单列表
|
||||||
|
|
||||||
|
`GET /api/open/v1/order/page`
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||||||
|
| 权限 | scope 含 `shop:shopOrder:list`,否则 403 |
|
||||||
|
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
|
||||||
|
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||||||
|
| 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` |
|
||||||
|
| 时间格式 | `yyyy-MM-dd HH:mm:ss`,输出库中存储的挂钟时间,不做时区换算 |
|
||||||
|
|
||||||
|
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"orderId": 1,
|
||||||
|
"orderNo": "1856321928276439040",
|
||||||
|
"orderStatus": 1,
|
||||||
|
"payStatus": true,
|
||||||
|
"payPrice": 100.00,
|
||||||
|
"phone": "138****0316",
|
||||||
|
"createTime": "2026-09-01 10:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 6,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
典型错误:
|
||||||
|
|
||||||
|
| code | message | 触发条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
|
||||||
|
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `shop:shopOrder:list` |
|
||||||
|
| 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. 随本次改动修复的既有问题
|
||||||
|
|
||||||
|
`TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`,
|
||||||
|
而 `SecurityConfig` 对所有 GET 放行;未登录且未指定 `userId` 时会走「无过滤」分支,
|
||||||
|
**匿名即可分页读取全部租户**(含租户名称、编码、手机号)。已补登录校验:
|
||||||
|
未登录返回 `{"code":401,"message":"请先登录"}`。
|
||||||
|
|
||||||
|
登录页的多租户选择来自登录响应里的 `tenants` 列表(`loginBySelectTenant` 流程),
|
||||||
|
不依赖该接口,因此不影响登录。
|
||||||
|
|
||||||
|
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
|
||||||
|
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
|
||||||
|
> 影响面较大,建议单独排期。
|
||||||
|
|
||||||
|
### 6.1 用户密码哈希可通过免登录接口读取(已修复)
|
||||||
|
|
||||||
|
`GET /api/system/user/getByUserId/{userId}` 在 `SecurityConfig` 的 GET 白名单里,
|
||||||
|
实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体,
|
||||||
|
而响应里包含 `password` 与 `payPassword` 两个 BCrypt 哈希字段:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'tenantId: <任意租户ID>' 'https://server.websoft.top/api/system/user/getByUserId/<用户ID>'
|
||||||
|
# => {"code":0,"data":{"userId":..., "password":"$2a$10$...", "payPassword":"..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
根因有两点叠加:`User` 实体对 `password` / `payPassword` 没有做序列化限制,
|
||||||
|
且 `UserMapper` 使用 `SELECT a.*`;同时该接口无 `@PreAuthorize` 又落在 GET 白名单里。
|
||||||
|
`GET /api/system/user/withoutAuth` 属于同一族(免登录 + 返回实体)。
|
||||||
|
|
||||||
|
修复方式是在实体上把这两个字段设为只写:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
|
private String password;
|
||||||
|
```
|
||||||
|
|
||||||
|
只影响序列化(不再输出),不影响任何依赖反序列化写入密码的流程。
|
||||||
|
`password` 与 `payPassword` 都已加上该注解,因此所有返回 `User` 实体的接口一并收敛,
|
||||||
|
包括 `getByUserId`、`withoutAuth`、`getByPhone`、`getByUnionid`、`list`、`page` 等。
|
||||||
|
|
||||||
|
> **同步报文已显式保留**:`RabbitMQSyncProducer.sendUserSyncMessage` 把 User 转成 Map 时
|
||||||
|
> 用的是同一个 ObjectMapper,实体改成只写后密码会从报文里消失。为避免悄悄改变
|
||||||
|
> websopy 侧收到的内容,生产者在转换后显式补回 `password` / `payPassword`。
|
||||||
|
> 是否继续在 MQ 报文里带密码哈希,需要 websopy 侧确认后另行决定。
|
||||||
|
> `UserCredentialSerializationTest` 用三个用例锁住了「HTTP 不输出、反序列化可写入、MQ 报文不变」。
|
||||||
|
|
||||||
|
### 6.2 同一批免登录接口仍存在的问题(未修复,需决策)
|
||||||
|
|
||||||
|
修掉密码后,这一族接口仍然免登录返回完整用户实体,且租户由 `tenantId` 请求头指定:
|
||||||
|
|
||||||
|
| 接口 | 问题 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/system/user/getByUserId/{userId}` | 免登录返回用户详情,含 `idCard`(完整 18 位身份证号)、手机号、邮箱、余额 |
|
||||||
|
| `GET /api/system/user/withoutAuth` | 免登录,一次返回该租户**全部**用户(不受 page/limit 限制,实测租户 5 返回 163 条 × 107 字段) |
|
||||||
|
| `GET /api/system/user/getByPhone/{phone}` | 免登录按手机号查用户,可被枚举(`mp-react-nextjs` 在用) |
|
||||||
|
| `GET /api/system/user/getByUnionid/{unionid}` | 免登录 |
|
||||||
|
| `PUT /api/system/user/updateWithoutLogin` | **完全没有校验**,任何人都能改用户资料 |
|
||||||
|
| `POST /api/system/user/batchBackUserId` | **完全没有校验**,可批量建用户(`mp-react-nextjs` 在用) |
|
||||||
|
|
||||||
|
另有 `updateUserBalanceWithoutLogin`、`addUserBalanceWithoutLogin`、`getUserWithoutLogin`、
|
||||||
|
`updateUserOfficeOpenidWithoutLogin` 使用硬编码常量 `authCode == "1700083"` 作为唯一凭证,
|
||||||
|
该常量对所有租户相同且写在源码里,等同于弱口令。
|
||||||
|
|
||||||
|
`id_card` 列存的是**完整 18 位身份证号**(库里 3173 条有值),与实体上的
|
||||||
|
「身份证号(脱敏)」注释不符,需要单独决定是脱敏还是仅对管理员可见——会直接影响
|
||||||
|
租户后台的实名审核页面,因此未擅自改动。
|
||||||
|
|
||||||
|
## 7. 如何新增一个开放接口
|
||||||
|
|
||||||
|
1. 在 `OpenScopes` 增加 scope 常量,并确保 `base-api` 的权限字典里有同名 scope;
|
||||||
|
2. 在 `controller` 下新建对外 controller,路径以 `/api/open/v1/` 开头;
|
||||||
|
3. 方法上加 `@PreAuthorize("hasAuthority('SCOPE_xxx')")`;
|
||||||
|
4. 入参使用**独立的白名单 DTO**,不要直接暴露内部 `XxxParam`;
|
||||||
|
5. 需要租户时用 `OpenTenantContext.getTenantId()`,不要从参数或请求头取;
|
||||||
|
6. 出参使用独立的 VO,不要直接返回实体;
|
||||||
|
7. 若目标表在 `MybatisPlusConfig` 的 `ignoreTable` 名单里(如 `sys_order`),
|
||||||
|
必须在 Mapper 里显式加租户条件。
|
||||||
|
|
||||||
|
## 8. 验证方式
|
||||||
|
|
||||||
|
单元测试(不依赖 base-api 在线):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw -o test -Dtest='OpenPlatformJwtValidationTest,OpenTenantBindingTest,OpenOrderVOTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
|
||||||
|
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
|
||||||
|
|
||||||
|
端到端脚本 `scripts/verify-open-api.sh`,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」:
|
||||||
|
|
||||||
|
```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 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. 待办
|
||||||
|
|
||||||
|
- [ ] `base-api` 的权限字典补充 `shop:shopOrder:list` 等业务 scope,并在应用管理页可勾选;
|
||||||
|
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
|
||||||
|
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
|
||||||
|
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
|
||||||
|
- [ ] 第二个业务服务出现时,把 `openplatform` 配置与拦截器抽成共享 starter。
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<groupId>com.gxwebsoft</groupId>
|
<groupId>com.gxwebsoft</groupId>
|
||||||
<artifactId>server-api</artifactId>
|
<artifactId>server-api</artifactId>
|
||||||
<version>1.0</version>
|
<version>2.0</version>
|
||||||
|
|
||||||
<name>server-api</name>
|
<name>server-api</name>
|
||||||
<description>WebSoftApi project for Spring Boot</description>
|
<description>WebSoftApi project for Spring Boot</description>
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<!-- 平台层版本:升级平台能力只改这一处 -->
|
||||||
|
<websoft-platform.version>1.0.1</websoft-platform.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -203,6 +205,21 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
平台层:内核(统一返回/状态码/业务异常)+ 开放平台资源服务能力。
|
||||||
|
|
||||||
|
这些类原先以源码形式散在本仓库里,每个业务仓库各复制一份,改一处要改 40 处。
|
||||||
|
现在改为依赖构件,升级只要改下面的版本号。
|
||||||
|
|
||||||
|
配套约定:BOM 只管理 com.gxwebsoft 的构件,不接管 Spring Boot 版本,
|
||||||
|
所以本服务仍可自行决定 Boot 版本。
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.gxwebsoft</groupId>
|
||||||
|
<artifactId>websoft-platform-openapi</artifactId>
|
||||||
|
<version>${websoft-platform.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- jjwt - 升级到安全版本 -->
|
<!-- jjwt - 升级到安全版本 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
|||||||
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 "验证完成"
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 系统常量
|
|
||||||
* Created by WebSoft on 2019-10-29 15:55
|
|
||||||
*/
|
|
||||||
public class Constants {
|
|
||||||
/**
|
|
||||||
* 默认成功码
|
|
||||||
*/
|
|
||||||
public static final int RESULT_OK_CODE = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认失败码
|
|
||||||
*/
|
|
||||||
public static final int RESULT_ERROR_CODE = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认成功信息
|
|
||||||
*/
|
|
||||||
public static final String RESULT_OK_MSG = "操作成功";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认失败信息
|
|
||||||
*/
|
|
||||||
public static final String RESULT_ERROR_MSG = "操作失败";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无权限错误码
|
|
||||||
*/
|
|
||||||
public static final int UNAUTHORIZED_CODE = 403;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无权限提示信息
|
|
||||||
*/
|
|
||||||
public static final String UNAUTHORIZED_MSG = "没有访问权限";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 未认证错误码
|
|
||||||
*/
|
|
||||||
public static final int UNAUTHENTICATED_CODE = 401;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 未认证提示信息
|
|
||||||
*/
|
|
||||||
public static final String UNAUTHENTICATED_MSG = "请先登录";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录过期错误码
|
|
||||||
*/
|
|
||||||
public static final int TOKEN_EXPIRED_CODE = 401;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录过期提示信息
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_EXPIRED_MSG = "登录已过期";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 非法token错误码
|
|
||||||
*/
|
|
||||||
public static final int BAD_CREDENTIALS_CODE = 401;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 非法token提示信息
|
|
||||||
*/
|
|
||||||
public static final String BAD_CREDENTIALS_MSG = "请退出重新登录";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表示升序的值
|
|
||||||
*/
|
|
||||||
public static final String ORDER_ASC_VALUE = "asc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表示降序的值
|
|
||||||
*/
|
|
||||||
public static final String ORDER_DESC_VALUE = "desc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* token通过header传递的名称
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_HEADER_NAME = "Authorization";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* token通过参数传递的名称
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_PARAM_NAME = "access_token";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* token认证类型
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_TYPE = "Bearer";
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -97,6 +97,11 @@ public class ConfigProperties {
|
|||||||
*/
|
*/
|
||||||
private String websopyUrl;
|
private String websopyUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部服务调用密钥(调用 websopy 等内部接口时使用)
|
||||||
|
*/
|
||||||
|
private String internalKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信扫码H5页面访问地址(用于微信扫码登录跳转)
|
* 微信扫码H5页面访问地址(用于微信扫码登录跳转)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
|||||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
|
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import net.sf.jsqlparser.expression.Expression;
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
import net.sf.jsqlparser.expression.LongValue;
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
@@ -40,6 +41,12 @@ public class MybatisPlusConfig {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Expression getTenantId() {
|
public Expression getTenantId() {
|
||||||
|
// 0 开放平台链路:租户只来自令牌,优先级最高
|
||||||
|
// 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离
|
||||||
|
Integer openTenantId = OpenTenantContext.getTenantId();
|
||||||
|
if (openTenantId != null) {
|
||||||
|
return new LongValue(openTenantId);
|
||||||
|
}
|
||||||
String tenantId;
|
String tenantId;
|
||||||
// 从请求头拿ID
|
// 从请求头拿ID
|
||||||
tenantId = request.getHeader("tenantId");
|
tenantId = request.getHeader("tenantId");
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core.exception;
|
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.Constants;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义业务异常
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
* @since 2018-02-22 11:29:28
|
|
||||||
*/
|
|
||||||
public class BusinessException extends RuntimeException {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private Integer code;
|
|
||||||
|
|
||||||
public BusinessException() {
|
|
||||||
this(Constants.RESULT_ERROR_MSG);
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(String message) {
|
|
||||||
this(Constants.RESULT_ERROR_CODE, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(Integer code, String message) {
|
|
||||||
super(message);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(Integer code, String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BusinessException(Integer code, String message, Throwable cause,
|
|
||||||
boolean enableSuppression, boolean writableStackTrace) {
|
|
||||||
super(message, cause, enableSuppression, writableStackTrace);
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCode(Integer code) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -47,6 +47,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoginRecordService loginRecordService;
|
private LoginRecordService loginRecordService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台接口走独立的安全链与 RS256 验签,不能被本过滤器用内部 HS256 密钥解析。
|
||||||
|
*
|
||||||
|
* <p>否则请求会在这里抛出
|
||||||
|
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures}
|
||||||
|
* 并被包装成「请退出重新登录」。</p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
return uri != null && uri.startsWith("/api/open/");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@@ -20,6 +21,7 @@ import javax.annotation.Resource;
|
|||||||
* @since 2020-03-23 18:04:52
|
* @since 2020-03-23 18:04:52
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@Order(2)
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||||
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import cn.hutool.extra.qrcode.QrConfig;
|
|||||||
|
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
|
||||||
import static com.gxwebsoft.common.core.constants.QRCodeConstants.*;
|
import static com.gxwebsoft.common.core.constants.QRCodeConstants.*;
|
||||||
@@ -77,4 +79,17 @@ public class MyQrCodeUtil {
|
|||||||
QrCodeUtil.generate(content, config, FileUtil.file(filePath));
|
QrCodeUtil.generate(content, config, FileUtil.file(filePath));
|
||||||
return qrcodeUrl;
|
return qrcodeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成二维码并返回 base64(data:image/png;base64,...),用于前端直接展示,无需写文件
|
||||||
|
* @param content 二维码内容(如微信支付 code_url)
|
||||||
|
* @return base64 图片字符串
|
||||||
|
*/
|
||||||
|
public static String generateBase64(String content) throws IOException {
|
||||||
|
QrConfig config = new QrConfig(300, 300);
|
||||||
|
config.setMargin(1);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
QrCodeUtil.generate(content, config, "PNG", out);
|
||||||
|
return "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core.web;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回结果
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
* @since 2017-06-10 10:10:50
|
|
||||||
*/
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class ApiResult<T> implements Serializable {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Schema(description = "状态码")
|
|
||||||
private Integer code;
|
|
||||||
|
|
||||||
@Schema(description = "状态信息")
|
|
||||||
private String message;
|
|
||||||
|
|
||||||
@Schema(description = "返回数据")
|
|
||||||
private T data;
|
|
||||||
|
|
||||||
@Schema(description = "错误信息")
|
|
||||||
private String error;
|
|
||||||
|
|
||||||
public ApiResult() {}
|
|
||||||
|
|
||||||
public ApiResult(Integer code) {
|
|
||||||
this(code, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult(Integer code, String message) {
|
|
||||||
this(code, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult(Integer code, String message, T data) {
|
|
||||||
this(code, message, data, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult(Integer code, String message, T data, String error) {
|
|
||||||
setCode(code);
|
|
||||||
setMessage(message);
|
|
||||||
setData(data);
|
|
||||||
setError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getCode() {
|
|
||||||
return this.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setCode(Integer code) {
|
|
||||||
this.code = code;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMessage() {
|
|
||||||
return this.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setMessage(String message) {
|
|
||||||
this.message = message;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public T getData() {
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setData(T data) {
|
|
||||||
this.data = data;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getError() {
|
|
||||||
return this.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ApiResult<T> setError(String error) {
|
|
||||||
this.error = error;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.gxwebsoft.common.core.web;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页查询返回结果
|
|
||||||
*
|
|
||||||
* @author WebSoft
|
|
||||||
* @since 2017-06-10 10:10:02
|
|
||||||
*/
|
|
||||||
public class PageResult<T> implements Serializable {
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Schema(description = "当前页数据")
|
|
||||||
private List<T> list;
|
|
||||||
|
|
||||||
@Schema(description = "总数量")
|
|
||||||
private Long count;
|
|
||||||
|
|
||||||
public PageResult() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public PageResult(List<T> list) {
|
|
||||||
this(list, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PageResult(List<T> list, Long count) {
|
|
||||||
setList(list);
|
|
||||||
setCount(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> getList() {
|
|
||||||
return this.list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setList(List<T> list) {
|
|
||||||
this.list = list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getCount() {
|
|
||||||
return this.count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCount(Long count) {
|
|
||||||
this.count = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
||||||
import com.gxwebsoft.common.mq.message.SyncMessage;
|
import com.gxwebsoft.common.mq.message.SyncMessage;
|
||||||
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
|
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.amqp.core.Message;
|
import org.springframework.amqp.core.Message;
|
||||||
import org.springframework.amqp.core.MessageProperties;
|
import org.springframework.amqp.core.MessageProperties;
|
||||||
@@ -108,6 +109,19 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate
|
|||||||
dataMap = objectMapper.convertValue(userData, Map.class);
|
dataMap = objectMapper.convertValue(userData, Map.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User 实体把 password / payPassword 标记为 WRITE_ONLY(不再出现在任何 HTTP 响应里),
|
||||||
|
// 但用户同步到 websopy 的消息历来包含这两个字段。这里显式补回,保证本次安全修复
|
||||||
|
// 不会悄悄改变对端收到的报文;是否保留由 websopy 侧确认后另行决定。
|
||||||
|
if (userData instanceof User) {
|
||||||
|
User user = (User) userData;
|
||||||
|
if (user.getPassword() != null) {
|
||||||
|
dataMap.put("password", user.getPassword());
|
||||||
|
}
|
||||||
|
if (user.getPayPassword() != null) {
|
||||||
|
dataMap.put("payPassword", user.getPayPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SyncMessage message = new SyncMessage("USER_SYNC", eventType, targetSystem, dataMap);
|
SyncMessage message = new SyncMessage("USER_SYNC", eventType, targetSystem, dataMap);
|
||||||
sendSyncMessage(message);
|
sendSyncMessage(message);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ public class EmailTestController extends BaseController {
|
|||||||
public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) {
|
public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) {
|
||||||
try {
|
try {
|
||||||
emailTemplateUtil.sendRegisterSuccessEmail(
|
emailTemplateUtil.sendRegisterSuccessEmail(
|
||||||
"测试用户",
|
"广西河马科技有限公司",
|
||||||
"13800138000",
|
"https://site-10654.sitelnk.cn",
|
||||||
"TestPassword123",
|
"https://site.websoft.top",
|
||||||
email,
|
email,
|
||||||
|
"F!huhtL3fqD8",
|
||||||
|
email,
|
||||||
getTenantId()
|
getTenantId()
|
||||||
);
|
);
|
||||||
return success("注册成功邮件发送成功");
|
return success("注册成功邮件发送成功");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.transaction.annotation.Isolation;
|
import org.springframework.transaction.annotation.Isolation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
@@ -69,6 +70,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
@@ -90,6 +92,8 @@ public class MainController extends BaseController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ConfigProperties configProperties;
|
private ConfigProperties configProperties;
|
||||||
@Resource
|
@Resource
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
@Resource
|
@Resource
|
||||||
private UserMapper userMapper;
|
private UserMapper userMapper;
|
||||||
@@ -1285,6 +1289,31 @@ public class MainController extends BaseController {
|
|||||||
return access_token;
|
return access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 websopy 初始化租户默认域名并标记为已生效
|
||||||
|
*/
|
||||||
|
private void initDefaultDomain(Integer tenantId, String appCode) {
|
||||||
|
String websopyUrl = configProperties.getWebsopyUrl();
|
||||||
|
if (StrUtil.isBlank(websopyUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String prefix = "site";
|
||||||
|
String suffix = "sitelnk.cn";
|
||||||
|
if ("mp".equals(appCode)) {
|
||||||
|
prefix = "mp";
|
||||||
|
suffix = "shoplnk.cn";
|
||||||
|
} else if ("shop".equals(appCode)) {
|
||||||
|
prefix = "shop";
|
||||||
|
suffix = "shoplnk.cn";
|
||||||
|
}
|
||||||
|
String domain = prefix + "-" + tenantId + "." + suffix;
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("tenantId", tenantId);
|
||||||
|
body.put("domain", domain);
|
||||||
|
body.put("internalKey", configProperties.getInternalKey());
|
||||||
|
restTemplate.postForObject(websopyUrl + "/api/app/user-sync/initDomain", body, String.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "开发者短信验证码登录")
|
@Operation(summary = "开发者短信验证码登录")
|
||||||
@PostMapping("/loginByDeveloperSms")
|
@PostMapping("/loginByDeveloperSms")
|
||||||
public ApiResult<LoginResult> loginByDeveloperSms(@RequestBody LoginParam param, HttpServletRequest request) {
|
public ApiResult<LoginResult> loginByDeveloperSms(@RequestBody LoginParam param, HttpServletRequest request) {
|
||||||
@@ -1396,7 +1425,7 @@ public class MainController extends BaseController {
|
|||||||
tokenExpireTime, configProperties.getTokenKey());
|
tokenExpireTime, configProperties.getTokenKey());
|
||||||
// 发送邮件通知
|
// 发送邮件通知
|
||||||
if(ObjectUtil.isNotEmpty(addUser) && email != null){
|
if(ObjectUtil.isNotEmpty(addUser) && email != null){
|
||||||
emailTemplateUtil.sendRegisterSuccessEmail(username, phone, password, email, addUser.getTenantId());
|
emailTemplateUtil.sendRegisterSuccessEmail(username, "https://websoft.top", "https://websoft.top", email, password, email, addUser.getTenantId());
|
||||||
}
|
}
|
||||||
return success("注册成功", new LoginResult(access_token, addUser));
|
return success("注册成功", new LoginResult(access_token, addUser));
|
||||||
}
|
}
|
||||||
@@ -1448,9 +1477,6 @@ public class MainController extends BaseController {
|
|||||||
userParam.setTemplateId(user.getTemplateId());
|
userParam.setTemplateId(user.getTemplateId());
|
||||||
userParam.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
|
userParam.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
|
||||||
final User adminByPhone = userService.getAdminByPhone(userParam);
|
final User adminByPhone = userService.getAdminByPhone(userParam);
|
||||||
// 注册即开通:标记该超管账号已安装(initialized)
|
|
||||||
adminByPhone.setInstalled(true);
|
|
||||||
userService.updateById(adminByPhone);
|
|
||||||
|
|
||||||
// 设置过期时间
|
// 设置过期时间
|
||||||
Long tokenExpireTime = configProperties.getTokenExpireTime();
|
Long tokenExpireTime = configProperties.getTokenExpireTime();
|
||||||
@@ -1632,9 +1658,14 @@ public class MainController extends BaseController {
|
|||||||
userParam1.setTemplateId(user.getTemplateId());
|
userParam1.setTemplateId(user.getTemplateId());
|
||||||
userParam1.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
|
userParam1.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
|
||||||
final User adminByPhone = userService.getAdminByPhone(userParam1);
|
final User adminByPhone = userService.getAdminByPhone(userParam1);
|
||||||
// 注册即开通:标记该超管账号已安装(initialized)
|
|
||||||
adminByPhone.setInstalled(true);
|
// 在 websopy 初始化默认域名(site-租户ID.sitelnk.cn)并标记为已生效
|
||||||
userService.updateById(adminByPhone);
|
try {
|
||||||
|
initDefaultDomain(addCompany.getTenantId(), "site");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("注册后初始化默认域名失败: tenantId={}", addCompany.getTenantId(), e);
|
||||||
|
// 域名初始化失败不阻断注册流程
|
||||||
|
}
|
||||||
|
|
||||||
// 设置过期时间
|
// 设置过期时间
|
||||||
Long tokenExpireTime = configProperties.getTokenExpireTime();
|
Long tokenExpireTime = configProperties.getTokenExpireTime();
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ public class SettingController extends BaseController {
|
|||||||
// 更新系统设置信息到缓存 key = ""
|
// 更新系统设置信息到缓存 key = ""
|
||||||
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
|
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
|
||||||
redisUtil.set(key, JSON.parseObject(setting.getContent()));
|
redisUtil.set(key, JSON.parseObject(setting.getContent()));
|
||||||
|
// 上传配置变更后需同步清除上传缓存,避免 /api/oss/upload 继续命中旧配置
|
||||||
|
if ("upload".equals(setting.getSettingKey())) {
|
||||||
|
redisUtil.delete("Upload:" + loginUser.getTenantId());
|
||||||
|
}
|
||||||
// 更新租户信息
|
// 更新租户信息
|
||||||
if (setting.getSettingKey().equals("setting")) {
|
if (setting.getSettingKey().equals("setting")) {
|
||||||
final String content = setting.getContent();
|
final String content = setting.getContent();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.controller;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
@@ -58,13 +59,17 @@ public class TenantController extends BaseController {
|
|||||||
@Operation(summary = "分页查询租户")
|
@Operation(summary = "分页查询租户")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public ApiResult<PageResult<Tenant>> page(TenantParam param) {
|
public ApiResult<PageResult<Tenant>> page(TenantParam param) {
|
||||||
|
// 安全修复:SecurityConfig 对所有 GET 放行,本方法原先在未登录且未指定 userId 时
|
||||||
|
// 会走「无过滤」分支返回全部租户(含租户名称、编码、手机号)。这里补上登录校验。
|
||||||
|
// 登录页的多租户选择走登录响应里的 tenants 列表,不依赖本接口,因此不影响登录流程。
|
||||||
|
final User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return new ApiResult<>(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
|
||||||
|
}
|
||||||
// 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId
|
// 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId
|
||||||
if (param.getAll() == null || !param.getAll()) {
|
if (param.getAll() == null || !param.getAll()) {
|
||||||
if (param.getUserId() == null) {
|
if (param.getUserId() == null && loginUser.getUserId() != null) {
|
||||||
final User loginUser = getLoginUser();
|
param.setUserId(loginUser.getUserId());
|
||||||
if (loginUser != null && loginUser.getUserId() != null) {
|
|
||||||
param.setUserId(loginUser.getUserId());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PageResult<Tenant> result = tenantService.pageRel(param);
|
PageResult<Tenant> result = tenantService.pageRel(param);
|
||||||
|
|||||||
+107
-1
@@ -4,17 +4,25 @@ import cn.hutool.core.util.IdUtil;
|
|||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import com.gxwebsoft.common.core.Constants;
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.utils.MyQrCodeUtil;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import com.gxwebsoft.common.system.entity.Order;
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import com.gxwebsoft.common.system.entity.TenantPackage;
|
||||||
|
import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import com.gxwebsoft.common.system.param.SubscriptionOrderParam;
|
import com.gxwebsoft.common.system.param.SubscriptionOrderParam;
|
||||||
import com.gxwebsoft.common.system.result.SubscriptionOrderCreateResult;
|
import com.gxwebsoft.common.system.result.SubscriptionOrderCreateResult;
|
||||||
import com.gxwebsoft.common.system.result.SubscriptionOrderPayResult;
|
import com.gxwebsoft.common.system.result.SubscriptionOrderPayResult;
|
||||||
import com.gxwebsoft.common.system.result.SubscriptionPriceResult;
|
import com.gxwebsoft.common.system.result.SubscriptionPriceResult;
|
||||||
import com.gxwebsoft.common.system.service.SettingService;
|
import com.gxwebsoft.common.system.service.SettingService;
|
||||||
|
import com.gxwebsoft.common.system.service.TenantPackageService;
|
||||||
|
import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -29,6 +37,7 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 订阅订单接口
|
* 订阅订单接口
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Tag(name = "订阅订单")
|
@Tag(name = "订阅订单")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/system/subscription-order")
|
@RequestMapping("/api/system/subscription-order")
|
||||||
@@ -38,6 +47,10 @@ public class TenantSubscriptionOrderController extends BaseController {
|
|||||||
private SettingService settingService;
|
private SettingService settingService;
|
||||||
@Resource
|
@Resource
|
||||||
private WxNativePayController wxNativePayController;
|
private WxNativePayController wxNativePayController;
|
||||||
|
@Resource
|
||||||
|
private TenantSubscriptionOrderService subscriptionOrderService;
|
||||||
|
@Resource
|
||||||
|
private TenantPackageService tenantPackageService;
|
||||||
|
|
||||||
@Operation(summary = "计算订阅订单价格")
|
@Operation(summary = "计算订阅订单价格")
|
||||||
@PostMapping("/calculate-price")
|
@PostMapping("/calculate-price")
|
||||||
@@ -88,7 +101,6 @@ public class TenantSubscriptionOrderController extends BaseController {
|
|||||||
|
|
||||||
JSONObject config = loadSubscriptionConfig();
|
JSONObject config = loadSubscriptionConfig();
|
||||||
final SubscriptionPriceResult price = buildPriceResult(param, config);
|
final SubscriptionPriceResult price = buildPriceResult(param, config);
|
||||||
price.setPayPrice(new BigDecimal("0.01"));
|
|
||||||
if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) {
|
if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
return fail("支付金额必须大于0", null);
|
return fail("支付金额必须大于0", null);
|
||||||
}
|
}
|
||||||
@@ -114,6 +126,100 @@ public class TenantSubscriptionOrderController extends BaseController {
|
|||||||
return success(result);
|
return success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建官网开通订单并直接生成微信Native支付二维码。
|
||||||
|
* 用于 /register 最后一步「支付开通」:建真实订单(TenantSubscriptionOrder)→ 调 Native 预下单
|
||||||
|
* (沿用已落库的 orderNo,保证回调查询一致)→ 将 code_url 编码为 base64 二维码返回。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "创建开通订单并生成微信Native支付二维码")
|
||||||
|
@PostMapping("/create-and-pay")
|
||||||
|
public ApiResult<SubscriptionOrderPayResult> createAndPay(@RequestBody SubscriptionOrderParam param) {
|
||||||
|
final User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return fail("请先登录", null);
|
||||||
|
}
|
||||||
|
Integer packageId = param.getPackageId();
|
||||||
|
if (packageId == null) {
|
||||||
|
// 默认基础版(version=10)
|
||||||
|
TenantPackage basic = tenantPackageService.getByVersion(10);
|
||||||
|
if (basic == null) {
|
||||||
|
return fail("未找到基础版套餐,请联系管理员配置", null);
|
||||||
|
}
|
||||||
|
packageId = basic.getPackageId();
|
||||||
|
}
|
||||||
|
Integer payType = param.getPayType() != null ? param.getPayType() : 12; // 默认年付
|
||||||
|
|
||||||
|
// 1) 落库真实订单(状态 0 待支付)
|
||||||
|
TenantSubscriptionOrder order;
|
||||||
|
try {
|
||||||
|
order = subscriptionOrderService.createOrder(packageId, payType, loginUser.getTenantId(), loginUser.getUserId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fail(e.getMessage(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 用已落库的订单号生成支付二维码(关键:不能重新生成随机号)
|
||||||
|
Order payOrder = new Order();
|
||||||
|
payOrder.setOrderNo(order.getOrderNo());
|
||||||
|
payOrder.setPayPrice(order.getActualPrice());
|
||||||
|
payOrder.setTotalPrice(order.getActualPrice());
|
||||||
|
payOrder.setComments("官网基础版开通-" + order.getOrderNo());
|
||||||
|
payOrder.setPayType(payType);
|
||||||
|
payOrder.setUserId(loginUser.getUserId());
|
||||||
|
payOrder.setTenantId(loginUser.getTenantId());
|
||||||
|
|
||||||
|
ApiResult<?> payResp = wxNativePayController.getCodeUrl(payOrder);
|
||||||
|
if (payResp.getCode() == null || !payResp.getCode().equals(Constants.RESULT_OK_CODE)) {
|
||||||
|
return fail(payResp.getMessage(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String codeUrl = String.valueOf(payResp.getData());
|
||||||
|
String qrImage = null;
|
||||||
|
try {
|
||||||
|
qrImage = MyQrCodeUtil.generateBase64(codeUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("生成支付二维码图片失败,前端将改用 codeUrl 渲染: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionOrderPayResult result = new SubscriptionOrderPayResult();
|
||||||
|
result.setOrderNo(order.getOrderNo());
|
||||||
|
result.setCodeUrl(codeUrl);
|
||||||
|
result.setQrImage(qrImage);
|
||||||
|
result.setOrderStatus(order.getOrderStatus());
|
||||||
|
result.setPrice(buildPriceFromOrder(order));
|
||||||
|
return success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询订单支付状态(前端支付页轮询用)。
|
||||||
|
* 状态由微信回调(wx-native-pay/notify)在服务端验签解密后翻转:0待支付→1已支付→2已激活。
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询开通订单支付状态")
|
||||||
|
@GetMapping("/status/{orderNo}")
|
||||||
|
public ApiResult<SubscriptionOrderPayResult> getOrderStatus(@PathVariable("orderNo") String orderNo) {
|
||||||
|
TenantSubscriptionOrder order = subscriptionOrderService.getByOrderNo(orderNo);
|
||||||
|
if (order == null) {
|
||||||
|
return fail("订单不存在", null);
|
||||||
|
}
|
||||||
|
SubscriptionOrderPayResult result = new SubscriptionOrderPayResult();
|
||||||
|
result.setOrderNo(order.getOrderNo());
|
||||||
|
result.setOrderStatus(order.getOrderStatus());
|
||||||
|
result.setPrice(buildPriceFromOrder(order));
|
||||||
|
return success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由已落库订单构造前端所需的价格结果
|
||||||
|
*/
|
||||||
|
private SubscriptionPriceResult buildPriceFromOrder(TenantSubscriptionOrder order) {
|
||||||
|
SubscriptionPriceResult price = new SubscriptionPriceResult();
|
||||||
|
price.setPackageId(order.getPackageId());
|
||||||
|
price.setOriginalPrice(order.getOriginalPrice() == null ? null : order.getOriginalPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
price.setTotalPrice(order.getOriginalPrice() == null ? null : order.getOriginalPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
price.setPayPrice(order.getActualPrice() == null ? null : order.getActualPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
price.setDiscountAmount(order.getDiscountPrice() == null ? null : order.getDiscountPrice().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从配置表读取订阅套餐配置(尝试多种key以兼容历史数据)
|
* 从配置表读取订阅套餐配置(尝试多种key以兼容历史数据)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -965,33 +967,44 @@ public class WxLoginController extends BaseController {
|
|||||||
return fail("获取失败", null);
|
return fail("获取失败", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取微信小程序码-订单核销码-数量极多的业务场景")
|
@Operation(summary = "获取微信小程序码-数量极多的业务场景(直接返回图片字节,支持自定义页面与场景值)")
|
||||||
@GetMapping("/getOrderQRCodeUnlimited/{orderNo}")
|
@GetMapping("/getOrderQRCodeUnlimited/{orderNo}")
|
||||||
public ApiResult<?> getOrderQRCodeUnlimited(@PathVariable("orderNo") String orderNo) {
|
public void getOrderQRCodeUnlimited(@PathVariable("orderNo") String orderNo,
|
||||||
|
@RequestParam(required = false) String page,
|
||||||
|
@RequestParam(required = false) String scene,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
final User loginUser = getLoginUser();
|
final User loginUser = getLoginUser();
|
||||||
if(loginUser == null){
|
if(loginUser == null){
|
||||||
return fail("请先登录");
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "请先登录");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + getAccessToken(loginUser.getTenantId());
|
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + getAccessToken(loginUser.getTenantId());
|
||||||
final HashMap<String, Object> map = new HashMap<>();
|
final HashMap<String, Object> map = new HashMap<>();
|
||||||
map.put("scene", "orderNo=".concat(orderNo));
|
// scene 优先级:显式 scene > 自定义页面用原始 orderNo > 兼容原订单核销(orderNo=xxx)
|
||||||
map.put("page", "package/admin/order-scan");
|
String sceneValue;
|
||||||
|
if (StrUtil.isNotBlank(scene)) {
|
||||||
|
sceneValue = scene;
|
||||||
|
} else if (StrUtil.isNotBlank(page)) {
|
||||||
|
// 自定义页面场景:小程序端通过 options.scene 直接读取业务值(如活动/订单号)
|
||||||
|
sceneValue = orderNo;
|
||||||
|
} else {
|
||||||
|
// 兼容原订单核销场景:scene=orderNo=xxx,默认跳转 package/admin/order-scan
|
||||||
|
sceneValue = "orderNo=".concat(orderNo);
|
||||||
|
}
|
||||||
|
map.put("scene", sceneValue);
|
||||||
|
// page 优先用入参,否则回退兼容原订单核销页面
|
||||||
|
map.put("page", StrUtil.isNotBlank(page) ? page : "package/admin/order-scan");
|
||||||
map.put("env_version", "trial");
|
map.put("env_version", "trial");
|
||||||
// 获取图片 Buffer
|
// 获取图片 Buffer
|
||||||
byte[] qrCode = HttpRequest.post(apiUrl)
|
byte[] qrCode = HttpRequest.post(apiUrl)
|
||||||
.body(JSON.toJSONString(map))
|
.body(JSON.toJSONString(map))
|
||||||
.execute().bodyBytes();
|
.execute().bodyBytes();
|
||||||
System.out.println("qrCode = " + qrCode);
|
|
||||||
|
|
||||||
// 保存的文件名称
|
// 直接将小程序码图片字节写回响应,不依赖 file.websoft.top 文件服务器
|
||||||
final String fileName = CommonUtil.randomUUID8().concat(".png");
|
response.setContentType("image/png");
|
||||||
// 保存路径
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
String filePath = getUploadDir().concat("qrcode/") + fileName;
|
response.getOutputStream().write(qrCode);
|
||||||
File file = FileUtil.writeBytes(qrCode, filePath);
|
response.getOutputStream().flush();
|
||||||
if (file != null) {
|
|
||||||
return success(config.getFileServer().concat("/qrcode/").concat(fileName));
|
|
||||||
}
|
|
||||||
return fail("获取失败", null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,22 @@ import com.gxwebsoft.common.core.web.ApiResult;
|
|||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.common.system.entity.Order;
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
import com.gxwebsoft.common.system.entity.Payment;
|
import com.gxwebsoft.common.system.entity.Payment;
|
||||||
|
import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder;
|
||||||
import com.gxwebsoft.common.system.param.SettingParam;
|
import com.gxwebsoft.common.system.param.SettingParam;
|
||||||
import com.gxwebsoft.common.system.service.OrderService;
|
import com.gxwebsoft.common.system.service.OrderService;
|
||||||
import com.gxwebsoft.common.system.service.SettingService;
|
import com.gxwebsoft.common.system.service.SettingService;
|
||||||
|
import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService;
|
||||||
import com.wechat.pay.java.core.Config;
|
import com.wechat.pay.java.core.Config;
|
||||||
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
||||||
|
import com.wechat.pay.java.core.notification.NotificationConfig;
|
||||||
|
import com.wechat.pay.java.core.notification.NotificationParser;
|
||||||
|
import com.wechat.pay.java.core.notification.RSANotificationConfig;
|
||||||
|
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.NativePayService;
|
import com.wechat.pay.java.service.payments.nativepay.NativePayService;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.model.Amount;
|
import com.wechat.pay.java.service.payments.nativepay.model.Amount;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
|
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
|
||||||
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
|
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -62,6 +69,8 @@ public class WxNativePayController extends BaseController {
|
|||||||
private CertificateService certificateService;
|
private CertificateService certificateService;
|
||||||
@Resource
|
@Resource
|
||||||
private CertificateProperties certificateProperties;
|
private CertificateProperties certificateProperties;
|
||||||
|
@Resource
|
||||||
|
private TenantSubscriptionOrderService subscriptionOrderService;
|
||||||
|
|
||||||
|
|
||||||
@Operation(summary = "生成付款码")
|
@Operation(summary = "生成付款码")
|
||||||
@@ -92,7 +101,10 @@ public class WxNativePayController extends BaseController {
|
|||||||
PrepayRequest request = new PrepayRequest();
|
PrepayRequest request = new PrepayRequest();
|
||||||
// 计算金额
|
// 计算金额
|
||||||
order.setMoney(new BigDecimal(order.getPayPrice().toString()));
|
order.setMoney(new BigDecimal(order.getPayPrice().toString()));
|
||||||
order.setOrderNo(CommonUtil.createOrderNo());
|
// 若调用方已指定订单号(如订阅订单),则保留以关联回调查询;否则生成随机号
|
||||||
|
if (order.getOrderNo() == null || order.getOrderNo().trim().isEmpty()) {
|
||||||
|
order.setOrderNo(CommonUtil.createOrderNo());
|
||||||
|
}
|
||||||
BigDecimal decimal = order.getMoney();
|
BigDecimal decimal = order.getMoney();
|
||||||
final BigDecimal multiply = decimal.multiply(new BigDecimal(100));
|
final BigDecimal multiply = decimal.multiply(new BigDecimal(100));
|
||||||
// 将 BigDecimal 转换为 Integer
|
// 将 BigDecimal 转换为 Integer
|
||||||
@@ -229,20 +241,67 @@ public class WxNativePayController extends BaseController {
|
|||||||
@Schema(description = "异步通知")
|
@Schema(description = "异步通知")
|
||||||
@PostMapping("/notify/{tenantId}")
|
@PostMapping("/notify/{tenantId}")
|
||||||
public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) {
|
public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) {
|
||||||
System.out.println("异步通知*************** = ");
|
log.info("微信Native支付异步通知, tenantId={}", tenantId);
|
||||||
System.out.println("request header = " + header);
|
try {
|
||||||
System.out.println("request body = " + body);
|
// 1) 获取支付配置用于验签/解密(优先 Native 支付配置,兜底会员支付配置,再兜底静态测试商户)
|
||||||
System.out.println("tenantId = " + tenantId);
|
Payment payment = redisUtil.get("Payment:wxPay:".concat(tenantId.toString()), Payment.class);
|
||||||
|
if (payment == null) {
|
||||||
|
payment = redisUtil.get("Payment:1:".concat(tenantId.toString()), Payment.class);
|
||||||
|
}
|
||||||
|
String apiV3Key;
|
||||||
|
String apiclientCert;
|
||||||
|
if (payment != null) {
|
||||||
|
apiV3Key = payment.getApiKey();
|
||||||
|
apiclientCert = config.getUploadPath().concat("/file").concat(payment.getApiclientCert());
|
||||||
|
} else {
|
||||||
|
apiV3Key = WxNativePayController.apiV3Key;
|
||||||
|
apiclientCert = config.getUploadPath().concat("/file/wxpay/apiclient_cert.pem");
|
||||||
|
}
|
||||||
|
|
||||||
// 推送微信官方支付结果(携带租户ID的POST请求)
|
com.wechat.pay.java.core.notification.RequestParam requestParam = new com.wechat.pay.java.core.notification.RequestParam.Builder()
|
||||||
// final String string = requestUtil.pushWxPayNotify(transaction, payment);
|
.serialNumber(header.get("wechatpay-serial"))
|
||||||
|
.nonce(header.get("wechatpay-nonce"))
|
||||||
|
.signature(header.get("wechatpay-signature"))
|
||||||
|
.timestamp(header.get("wechatpay-timestamp"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
|
||||||
// 获取支付配置信息用于解密
|
NotificationConfig notificationConfig = new RSANotificationConfig.Builder()
|
||||||
final SettingParam param = new SettingParam();
|
.apiV3Key(apiV3Key)
|
||||||
param.setSettingKey("payment");
|
.certificatesFromPath(apiclientCert)
|
||||||
param.setTenantId(tenantId);
|
.build();
|
||||||
final String uploadPath = config.getUploadPath(); // 服务器本地路径
|
|
||||||
|
|
||||||
return "fail";
|
NotificationParser parser = new NotificationParser(notificationConfig);
|
||||||
|
|
||||||
|
// 2) 验签 + 解密,拿到 Transaction
|
||||||
|
Transaction transaction = parser.parse(requestParam, Transaction.class);
|
||||||
|
if (transaction == null) {
|
||||||
|
log.warn("微信Native支付回调解析失败: transaction 为空");
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
|
if (transaction.getTradeState() != Transaction.TradeStateEnum.SUCCESS) {
|
||||||
|
log.info("微信Native支付回调:交易状态非成功, state={}", transaction.getTradeState());
|
||||||
|
return "SUCCESS";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 按 out_trade_no(=订阅订单号)翻订单状态:0待支付→1已支付→2已激活
|
||||||
|
String outTradeNo = transaction.getOutTradeNo();
|
||||||
|
TenantSubscriptionOrder order = subscriptionOrderService.getByOrderNo(outTradeNo);
|
||||||
|
if (order == null) {
|
||||||
|
log.warn("微信Native支付回调:订单不存在, outTradeNo={}", outTradeNo);
|
||||||
|
return "SUCCESS";
|
||||||
|
}
|
||||||
|
if (order.getOrderStatus() == 0) {
|
||||||
|
subscriptionOrderService.payOrder(outTradeNo, "wechat", transaction.getTransactionId());
|
||||||
|
subscriptionOrderService.activateOrder(outTradeNo);
|
||||||
|
log.info("订阅订单支付成功并激活, orderNo={}, tenantId={}", outTradeNo, tenantId);
|
||||||
|
} else {
|
||||||
|
log.info("订阅订单已处理(幂等),orderNo={}, status={}", outTradeNo, order.getOrderStatus());
|
||||||
|
}
|
||||||
|
return "SUCCESS";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("微信Native支付回调处理失败: {}", e.getMessage(), e);
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
|
|||||||
import cn.hutool.core.util.DesensitizedUtil;
|
import cn.hutool.core.util.DesensitizedUtil;
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -40,7 +41,14 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "账号")
|
@Schema(description = "账号")
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录密码(BCrypt 哈希)。
|
||||||
|
*
|
||||||
|
* <p>只写不读:序列化时永远不输出,避免任何接口把密码哈希返回给调用方;
|
||||||
|
* 反序列化不受影响,注册 / 改密 / 批量导入仍可正常写入。</p>
|
||||||
|
*/
|
||||||
@Schema(description = "密码")
|
@Schema(description = "密码")
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@Schema(description = "昵称")
|
@Schema(description = "昵称")
|
||||||
@@ -86,7 +94,11 @@ public class User implements UserDetails {
|
|||||||
@Schema(description = "特长")
|
@Schema(description = "特长")
|
||||||
private String speciality;
|
private String speciality;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付密码(BCrypt 哈希),与登录密码同样只写不读。
|
||||||
|
*/
|
||||||
@Schema(description = "支付密码")
|
@Schema(description = "支付密码")
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
private String payPassword;
|
private String payPassword;
|
||||||
|
|
||||||
@Schema(description = "职务")
|
@Schema(description = "职务")
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
import com.gxwebsoft.common.system.entity.Setting;
|
import com.gxwebsoft.common.system.entity.Setting;
|
||||||
import com.gxwebsoft.common.system.param.SettingParam;
|
import com.gxwebsoft.common.system.param.SettingParam;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -37,4 +39,20 @@ public interface SettingMapper extends BaseMapper<Setting> {
|
|||||||
|
|
||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
Setting getBySettingKeyIgnore(@Param("param") SettingParam param);
|
Setting getBySettingKeyIgnore(@Param("param") SettingParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定租户下某 setting_key 的记录数(绕过多租户插件,自行按 tenant_id 过滤)。
|
||||||
|
* 用于初始化时幂等判断,避免被上下文租户覆盖。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("SELECT COUNT(1) FROM sys_setting WHERE setting_key = #{settingKey} AND tenant_id = #{tenantId} AND deleted = 0")
|
||||||
|
int countByKeyAndTenant(@Param("settingKey") String settingKey, @Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式插入系统设置(绕过多租户插件,tenant_id 由实体携带,确保写入目标租户)。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Insert("INSERT INTO sys_setting (setting_key, content, sort_number, comments, tenant_id, create_time, update_time, deleted) "
|
||||||
|
+ "VALUES (#{settingKey}, #{content}, #{sortNumber}, #{comments}, #{tenantId}, #{createTime}, #{updateTime}, 0)")
|
||||||
|
int insertSetting(Setting setting);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,19 @@
|
|||||||
<if test="param.contentType != null">
|
<if test="param.contentType != null">
|
||||||
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
|
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
|
||||||
</if>
|
</if>
|
||||||
<if test="param.keywords != null">
|
<if test="param.keywords != null and param.keywords != ''">
|
||||||
AND (
|
AND (
|
||||||
a.create_user_id = #{param.keywords}
|
a.`name` LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
OR b.name LIKE CONCAT('%', #{param.keywords}, '%')
|
OR a.path LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
OR b.comments LIKE CONCAT('%', #{param.keywords}, '%')
|
OR a.comments LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
)
|
OR b.username LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
</if>
|
OR b.nickname LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
OR b.real_name LIKE CONCAT('%', #{param.keywords}, '%')
|
||||||
|
<if test="param.keywords.matches('[0-9]+')">
|
||||||
|
OR a.create_user_id = #{param.keywords}
|
||||||
|
</if>
|
||||||
|
)
|
||||||
|
</if>
|
||||||
</where>
|
</where>
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
FROM sys_order a
|
FROM sys_order a
|
||||||
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
||||||
<where>
|
<where>
|
||||||
|
<!-- 租户条件:sys_order 在 MybatisPlusConfig 的 ignoreTable 白名单里,
|
||||||
|
不受多租户插件管辖,必须在这里显式过滤。目前只有开放平台链路会设置该参数,
|
||||||
|
内部调用不传即保持原有行为。 -->
|
||||||
|
<if test="param.tenantId != null">
|
||||||
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
</if>
|
||||||
<if test="param.orderId != null">
|
<if test="param.orderId != null">
|
||||||
AND a.order_id = #{param.orderId}
|
AND a.order_id = #{param.orderId}
|
||||||
</if>
|
</if>
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class SubscriptionOrderPayResult {
|
|||||||
@Schema(description = "支付二维码链接")
|
@Schema(description = "支付二维码链接")
|
||||||
private String codeUrl;
|
private String codeUrl;
|
||||||
|
|
||||||
|
@Schema(description = "支付二维码图片(base64),前端可直接展示")
|
||||||
|
private String qrImage;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态 0待支付 1已支付 2已激活 3已取消")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
@Schema(description = "价格信息")
|
@Schema(description = "价格信息")
|
||||||
private SubscriptionPriceResult price;
|
private SubscriptionPriceResult price;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,15 @@ public class SettingServiceImpl extends ServiceImpl<SettingMapper, Setting> impl
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean updateByKey(Setting setting) {
|
public boolean updateByKey(Setting setting) {
|
||||||
return update(setting, new QueryWrapper<Setting>().eq("setting_key", setting.getSettingKey()));
|
boolean ok = update(setting, new QueryWrapper<Setting>().eq("setting_key", setting.getSettingKey()));
|
||||||
|
// 上传配置变更后必须清缓存,否则 /api/oss/upload 仍读到旧的 Upload:{tenantId} 缓存
|
||||||
|
if (ok && "upload".equals(setting.getSettingKey())) {
|
||||||
|
Integer tenantId = setting.getTenantId();
|
||||||
|
if (tenantId != null) {
|
||||||
|
redisUtil.delete("Upload:" + tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
|||||||
import com.gxwebsoft.common.core.Constants;
|
import com.gxwebsoft.common.core.Constants;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
import com.gxwebsoft.common.core.utils.DomainUtil;
|
import com.gxwebsoft.common.core.utils.DomainUtil;
|
||||||
|
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
import com.gxwebsoft.common.system.entity.*;
|
import com.gxwebsoft.common.system.entity.*;
|
||||||
import com.gxwebsoft.common.system.mapper.MenuMapper;
|
import com.gxwebsoft.common.system.mapper.MenuMapper;
|
||||||
import com.gxwebsoft.common.system.mapper.RoleMapper;
|
import com.gxwebsoft.common.system.mapper.RoleMapper;
|
||||||
import com.gxwebsoft.common.system.mapper.RoleMenuMapper;
|
import com.gxwebsoft.common.system.mapper.RoleMenuMapper;
|
||||||
|
import com.gxwebsoft.common.system.mapper.SettingMapper;
|
||||||
import com.gxwebsoft.common.system.mapper.UserRoleMapper;
|
import com.gxwebsoft.common.system.mapper.UserRoleMapper;
|
||||||
import com.gxwebsoft.common.system.mapper.TenantMapper;
|
import com.gxwebsoft.common.system.mapper.TenantMapper;
|
||||||
import com.gxwebsoft.common.system.param.MenuParam;
|
import com.gxwebsoft.common.system.param.MenuParam;
|
||||||
@@ -21,6 +23,7 @@ import com.gxwebsoft.common.system.param.TenantParam;
|
|||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.PageParam;
|
import com.gxwebsoft.common.core.web.PageParam;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
@@ -28,6 +31,7 @@ import org.springframework.util.CollectionUtils;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -73,6 +77,8 @@ import java.util.stream.Collectors;
|
|||||||
private UserService userService;
|
private UserService userService;
|
||||||
@Resource
|
@Resource
|
||||||
private RedisUtil redisUtil;
|
private RedisUtil redisUtil;
|
||||||
|
@Resource
|
||||||
|
private SettingMapper settingMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<Tenant> pageRel(TenantParam param) {
|
public PageResult<Tenant> pageRel(TenantParam param) {
|
||||||
@@ -146,6 +152,7 @@ import java.util.stream.Collectors;
|
|||||||
superAdmin.setPassword(userService.encodePassword(company.getPassword()));
|
superAdmin.setPassword(userService.encodePassword(company.getPassword()));
|
||||||
}
|
}
|
||||||
superAdmin.setTenantId(company.getTid());
|
superAdmin.setTenantId(company.getTid());
|
||||||
|
superAdmin.setInstalled(true);
|
||||||
if(company.getTemplateId() != null){
|
if(company.getTemplateId() != null){
|
||||||
superAdmin.setTemplateId(company.getTemplateId());
|
superAdmin.setTemplateId(company.getTemplateId());
|
||||||
}
|
}
|
||||||
@@ -615,6 +622,12 @@ import java.util.stream.Collectors;
|
|||||||
// 添加菜单ID到超级管理员所属角色ID
|
// 添加菜单ID到超级管理员所属角色ID
|
||||||
if (resultMenu) {
|
if (resultMenu) {
|
||||||
saveRedis(company);
|
saveRedis(company);
|
||||||
|
// 初始化默认 MinIO 云存储配置(oss-{tenantId} 桶 + 自动建桶/策略),失败不阻断开通
|
||||||
|
try {
|
||||||
|
initDefaultUploadSetting(company.getTid());
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[Tenant] 初始化默认上传配置失败(不影响开通): " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,22 +648,23 @@ import java.util.stream.Collectors;
|
|||||||
final String siteUrl = "https://" + sitePrefix + "-" + company.getTid() + "." + siteDomain;
|
final String siteUrl = "https://" + sitePrefix + "-" + company.getTid() + "." + siteDomain;
|
||||||
final String adminUrl = "https://" + adminHost;
|
final String adminUrl = "https://" + adminHost;
|
||||||
final String title = "您的企业官网已开通成功";
|
final String title = "您的企业官网已开通成功";
|
||||||
final StringBuilder content = new StringBuilder();
|
// 发送 HTML 邮件通知(模板风格与验证码邮件统一)
|
||||||
content.append("尊敬的用户:\r\n\r\n");
|
|
||||||
content.append("恭喜!您的企业官网「").append(company.getShortName()).append("」已开通成功。\r\n\r\n");
|
|
||||||
content.append("您可以使用以下信息登录管理后台,继续完善站点内容:\r\n\r\n");
|
|
||||||
content.append("• 官网地址:").append(siteUrl).append("\r\n");
|
|
||||||
content.append("• 后台地址:").append(adminUrl).append("\r\n");
|
|
||||||
content.append("• 登录账号:").append(company.getEmail()).append("\r\n");
|
|
||||||
content.append("• 登录密码:").append(company.getPassword()).append("\r\n\r\n");
|
|
||||||
content.append("安全提示:\r\n");
|
|
||||||
content.append("1. 请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。\r\n");
|
|
||||||
content.append("2. 建议首次登录后立即进入「个人中心 > 修改密码」重置密码。\r\n\r\n");
|
|
||||||
content.append("点击登录后台:").append(adminUrl).append("\r\n\r\n");
|
|
||||||
content.append("如有疑问,请联系客服或访问帮助中心。");
|
|
||||||
// 发送邮件通知
|
|
||||||
if (company.getEmail() != null) {
|
if (company.getEmail() != null) {
|
||||||
emailRecordService.sendEmail(title, content.toString(), company.getEmail(), company.getTid());
|
final Map<String, Object> data = new HashMap<>(8);
|
||||||
|
data.put("siteName", company.getShortName());
|
||||||
|
data.put("siteUrl", siteUrl);
|
||||||
|
data.put("adminUrl", adminUrl);
|
||||||
|
data.put("account", company.getEmail());
|
||||||
|
data.put("password", company.getPassword());
|
||||||
|
data.put("sendTime", DateUtil.now());
|
||||||
|
try {
|
||||||
|
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{company.getEmail()});
|
||||||
|
} catch (Exception e) {
|
||||||
|
// HTML 邮件发送失败时降级为纯文本
|
||||||
|
String plain = "恭喜!您的企业官网「" + company.getShortName() + "」已开通成功\r\n官网地址:" + siteUrl
|
||||||
|
+ "\r\n后台地址:" + adminUrl + "\r\n登录账号:" + company.getEmail() + "\r\n登录密码:" + company.getPassword();
|
||||||
|
emailRecordService.sendEmail(title, plain, company.getEmail(), company.getTid());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
@@ -666,6 +680,52 @@ import java.util.stream.Collectors;
|
|||||||
redisUtil.set(key, tenant);
|
redisUtil.set(key, tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为新开通的租户写入默认 MinIO 上传配置,并自动建桶 + 匿名只读策略。
|
||||||
|
* 幂等:若已存在 upload 配置则跳过。
|
||||||
|
*/
|
||||||
|
private void initDefaultUploadSetting(Integer tenantId) {
|
||||||
|
// 绕过多租户插件,按目标租户精确计数,避免被上下文租户覆盖
|
||||||
|
int exists = settingMapper.countByKeyAndTenant("upload", tenantId);
|
||||||
|
if (exists > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String bucketName = "oss-" + tenantId;
|
||||||
|
final String endpoint = "https://minio.sitelnk.cn";
|
||||||
|
final String accessKeyId = "ZB1RK0ARI4XPF0CWA2FF";
|
||||||
|
final String accessKeySecret = "QPnFBGLmXxto1lBEiaTyXgf4E4H46e8TOR4sYyiY";
|
||||||
|
|
||||||
|
JSONObject content = new JSONObject();
|
||||||
|
content.put("settingKey", "upload");
|
||||||
|
content.put("uploadMethod", "minio");
|
||||||
|
content.put("bucketName", bucketName);
|
||||||
|
content.put("bucketEndpoint", endpoint);
|
||||||
|
content.put("accessKeyId", accessKeyId);
|
||||||
|
content.put("accessKeySecret", accessKeySecret);
|
||||||
|
content.put("bucketDomain", endpoint + "/" + bucketName);
|
||||||
|
|
||||||
|
Setting setting = new Setting();
|
||||||
|
setting.setSettingKey("upload");
|
||||||
|
setting.setContent(content.toJSONString());
|
||||||
|
setting.setSortNumber(0);
|
||||||
|
setting.setComments("默认MinIO存储");
|
||||||
|
setting.setTenantId(tenantId);
|
||||||
|
setting.setDeleted(0);
|
||||||
|
setting.setCreateTime(new Date());
|
||||||
|
setting.setUpdateTime(new Date());
|
||||||
|
settingMapper.insertSetting(setting);
|
||||||
|
|
||||||
|
// 预热上传配置缓存,避免新租户首次读取命中空缓存
|
||||||
|
redisUtil.set("Upload:" + tenantId, content);
|
||||||
|
|
||||||
|
// 自动建桶 + 匿名只读策略(失败不阻断开通)
|
||||||
|
try {
|
||||||
|
MinioUtil.ensureBucketReady(MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret), endpoint, bucketName);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[Tenant] 初始化时自动建桶失败(忽略): " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean destructionAll(Integer tenantId){
|
public boolean destructionAll(Integer tenantId){
|
||||||
if (baseMapper.destructionAll(tenantId)) {
|
if (baseMapper.destructionAll(tenantId)) {
|
||||||
|
|||||||
@@ -30,22 +30,23 @@ public class EmailTemplateUtil {
|
|||||||
* @param email 邮箱
|
* @param email 邮箱
|
||||||
* @param tenantId 租户ID
|
* @param tenantId 租户ID
|
||||||
*/
|
*/
|
||||||
public void sendRegisterSuccessEmail(String username, String phone, String password, String email, Integer tenantId) {
|
public void sendRegisterSuccessEmail(String siteName, String siteUrl, String adminUrl, String account, String password, String email, Integer tenantId) {
|
||||||
try {
|
try {
|
||||||
String title = "恭喜!您的账号已注册成功";
|
String title = "您的企业官网已开通成功";
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>(8);
|
||||||
data.put("username", username);
|
data.put("siteName", siteName);
|
||||||
data.put("phone", phone);
|
data.put("siteUrl", siteUrl);
|
||||||
|
data.put("adminUrl", adminUrl);
|
||||||
|
data.put("account", account);
|
||||||
data.put("password", password);
|
data.put("password", password);
|
||||||
if (email != null && !email.trim().isEmpty()) {
|
data.put("sendTime", DateUtil.now());
|
||||||
data.put("email", email);
|
|
||||||
}
|
|
||||||
|
|
||||||
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{email});
|
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{email});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 如果HTML邮件发送失败,降级为文本邮件
|
// 如果HTML邮件发送失败,降级为文本邮件
|
||||||
String content = "恭喜!您的WebSoft账号已注册成功\r\n用户名:" + username + "\r\n手机号码:" + phone + "\r\n密码:" + password;
|
String content = "恭喜!您的企业官网「" + siteName + "」已开通成功\r\n官网地址:" + siteUrl
|
||||||
emailRecordService.sendEmail("恭喜!您的WebSoft账号已注册成功", content, email, tenantId);
|
+ "\r\n后台地址:" + adminUrl + "\r\n登录账号:" + account + "\r\n登录密码:" + password;
|
||||||
|
emailRecordService.sendEmail("您的企业官网已开通成功", content, email, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.gxwebsoft.openplatform.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台权限标识(scope)。
|
||||||
|
*
|
||||||
|
* <p>与 base-api 的权限字典、以及在管理后台勾选的权限一一对应。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
public final class OpenScopes {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询项目订单。
|
||||||
|
*
|
||||||
|
* <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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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.Order;
|
||||||
|
import com.gxwebsoft.common.system.param.OrderParam;
|
||||||
|
import com.gxwebsoft.common.system.service.OrderService;
|
||||||
|
import com.gxwebsoft.platform.openapi.OpenApi;
|
||||||
|
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
|
||||||
|
import com.gxwebsoft.openplatform.constant.OpenScopes;
|
||||||
|
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.param.OpenOrderPageParam;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
|
||||||
|
import com.gxwebsoft.platform.openapi.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.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
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 /api/open/v1/}:对外接口是长期契约,发布后只增不改。</p>
|
||||||
|
*
|
||||||
|
* <p>鉴权分两层:框架层由 {@code OpenPlatformSecurityConfig} 校验 RS256 令牌的签名与 iss/aud/exp;
|
||||||
|
* 权限层由 {@code @PreAuthorize} 校验 scope。租户由 {@code OpenTenantInterceptor} 从令牌落到上下文。</p>
|
||||||
|
*
|
||||||
|
* <p>随 {@code open-platform.enabled} 一起装配:开关关闭时接口整体下线,
|
||||||
|
* 而不是退化到内部控制台的安全链(那条链对所有 GET 放行)。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Tag(name = "开放接口-订单")
|
||||||
|
@OpenApi
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
@RequestMapping("/api/open/v1/order")
|
||||||
|
public class OpenOrderController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OrderService orderService;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SHOP_ORDER_LIST + "')")
|
||||||
|
@Operation(summary = "分页查询本租户订单")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ApiResult<OpenPageResult<OpenOrderVO>> page(@AuthenticationPrincipal Jwt jwt,
|
||||||
|
OpenOrderPageParam query) {
|
||||||
|
// 租户只认令牌:拦截器已校验过 tenant_id 存在,这里不会拿到请求头里的值
|
||||||
|
Integer tenantId = OpenTenantContext.getTenantId();
|
||||||
|
|
||||||
|
OrderParam param = new OrderParam();
|
||||||
|
param.setPage(normalizePage(query.getPage()));
|
||||||
|
param.setLimit(normalizeLimit(query.getLimit()));
|
||||||
|
param.setTenantId(tenantId);
|
||||||
|
param.setOrderNo(StrUtil.trimToNull(query.getOrderNo()));
|
||||||
|
param.setType(query.getType());
|
||||||
|
param.setOrderStatus(query.getOrderStatus());
|
||||||
|
param.setPayStatus(query.getPayStatus());
|
||||||
|
param.setPayType(query.getPayType());
|
||||||
|
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
|
||||||
|
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
|
||||||
|
|
||||||
|
PageResult<Order> result = orderService.pageRel(param);
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.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, OpenOrderPageParam.MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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.platform.openapi.OpenApi;
|
||||||
|
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
|
||||||
|
import com.gxwebsoft.openplatform.constant.OpenScopes;
|
||||||
|
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.param.OpenUserPageParam;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenUserVO;
|
||||||
|
import com.gxwebsoft.platform.openapi.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 = "开放接口-用户")
|
||||||
|
@OpenApi
|
||||||
|
@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,52 @@
|
|||||||
|
package com.gxwebsoft.openplatform.param;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口订单分页查询入参。
|
||||||
|
*
|
||||||
|
* <p>刻意不复用内部的 {@code OrderParam}:后者带着 {@code userId}、{@code keywords}、{@code deleted}
|
||||||
|
* 等可被构造的字段,直接暴露给第三方等于把内部查询能力整体开放。这里只保留白名单字段,
|
||||||
|
* 且<b>不提供 tenantId</b>——租户只来自令牌。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenOrderPageParam", description = "开放接口订单分页查询参数")
|
||||||
|
public class OpenOrderPageParam 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 orderNo;
|
||||||
|
|
||||||
|
@Schema(description = "订单类型,0产品 1插件")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态,0未完成 1已完成 2已取消 ...")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
|
@Schema(description = "是否已付款")
|
||||||
|
private Boolean payStatus;
|
||||||
|
|
||||||
|
@Schema(description = "支付方式")
|
||||||
|
private Integer payType;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间起始,闭区间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeStart;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间结束,闭区间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeEnd;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.gxwebsoft.openplatform.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的订单出参。
|
||||||
|
*
|
||||||
|
* <p>只暴露第三方确实需要的字段,避免把内部实体(含 adminUrl、menuParam、deleted 等)整体吐出去。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenOrder", description = "开放接口订单")
|
||||||
|
public class OpenOrderVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "订单id")
|
||||||
|
private Integer orderId;
|
||||||
|
|
||||||
|
@Schema(description = "订单编号")
|
||||||
|
private String orderNo;
|
||||||
|
|
||||||
|
@Schema(description = "订单类型,0产品 1插件")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "下单渠道,0网站 1小程序 2其他")
|
||||||
|
private Integer channel;
|
||||||
|
|
||||||
|
@Schema(description = "订单总额")
|
||||||
|
private BigDecimal totalPrice;
|
||||||
|
|
||||||
|
@Schema(description = "优惠金额")
|
||||||
|
private BigDecimal reducePrice;
|
||||||
|
|
||||||
|
@Schema(description = "实际付款金额")
|
||||||
|
private BigDecimal payPrice;
|
||||||
|
|
||||||
|
@Schema(description = "退款金额")
|
||||||
|
private BigDecimal refundMoney;
|
||||||
|
|
||||||
|
@Schema(description = "购买数量")
|
||||||
|
private Integer totalNum;
|
||||||
|
|
||||||
|
@Schema(description = "支付方式")
|
||||||
|
private Integer payType;
|
||||||
|
|
||||||
|
@Schema(description = "是否已付款")
|
||||||
|
private Boolean payStatus;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
|
@Schema(description = "第三方支付订单号")
|
||||||
|
private String transactionId;
|
||||||
|
|
||||||
|
@Schema(description = "下单人姓名")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "下单人手机号")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String comments;
|
||||||
|
|
||||||
|
// 与 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;
|
||||||
|
|
||||||
|
@Schema(description = "退款时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date refundTime;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体转出参。
|
||||||
|
*
|
||||||
|
* @param orders 订单列表
|
||||||
|
* @param maskSensitive 是否对手机号脱敏(默认开启,避免把终端用户信息直接交给第三方)
|
||||||
|
*/
|
||||||
|
public static List<OpenOrderVO> from(List<Order> orders, boolean maskSensitive) {
|
||||||
|
if (orders == null || orders.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<OpenOrderVO> list = new ArrayList<>(orders.size());
|
||||||
|
for (Order order : orders) {
|
||||||
|
OpenOrderVO vo = new OpenOrderVO();
|
||||||
|
vo.setOrderId(order.getOrderId());
|
||||||
|
vo.setOrderNo(order.getOrderNo());
|
||||||
|
vo.setType(order.getType());
|
||||||
|
vo.setChannel(order.getChannel());
|
||||||
|
vo.setTotalPrice(order.getTotalPrice());
|
||||||
|
vo.setReducePrice(order.getReducePrice());
|
||||||
|
vo.setPayPrice(order.getPayPrice());
|
||||||
|
vo.setRefundMoney(order.getRefundMoney());
|
||||||
|
vo.setTotalNum(order.getTotalNum());
|
||||||
|
vo.setPayType(order.getPayType());
|
||||||
|
vo.setPayStatus(order.getPayStatus());
|
||||||
|
vo.setOrderStatus(order.getOrderStatus());
|
||||||
|
vo.setTransactionId(order.getTransactionId());
|
||||||
|
vo.setRealName(order.getRealName());
|
||||||
|
vo.setPhone(maskSensitive ? maskPhone(order.getPhone()) : order.getPhone());
|
||||||
|
vo.setComments(order.getComments());
|
||||||
|
vo.setPayTime(order.getPayTime());
|
||||||
|
vo.setRefundTime(order.getRefundTime());
|
||||||
|
vo.setCreateTime(order.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://1Panel-mysql-HZz5:3306/gxwebsoft_core?useSSL=false&serverTimezone=UTC
|
||||||
|
username: gxwebsoft_core
|
||||||
|
password: jdj7HYEdYHnYEFBy
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
redis:
|
||||||
|
database: 0
|
||||||
|
host: 1Panel-redis-5odv
|
||||||
|
port: 6379
|
||||||
|
password: redis_WSDb88
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: websoft-core.log
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
com.gxwebsoft: ERROR
|
||||||
|
com.baomidou.mybatisplus: ERROR
|
||||||
|
|
||||||
|
socketio:
|
||||||
|
host: 0.0.0.0 #IP地址
|
||||||
|
|
||||||
|
knife4j:
|
||||||
|
# 开启knife4j增强
|
||||||
|
enable: true
|
||||||
|
# 开启生产环境屏蔽,一定要先开启knife4j增强才会生效
|
||||||
|
production: false
|
||||||
|
|
||||||
|
# 框架配置
|
||||||
|
config:
|
||||||
|
# 生产环境接口
|
||||||
|
server-url: https://paopao-server.websoft.top/api
|
||||||
|
upload-path: /www/wwwroot/file.ws
|
||||||
|
|
||||||
|
# 阿里云OSS云存储
|
||||||
|
endpoint: https://oss-cn-shenzhen.aliyuncs.com
|
||||||
|
accessKeyId: LTAI4GKGZ9Z2Z8JZ77c3GNZP
|
||||||
|
accessKeySecret: BiDkpS7UXj72HWwDWaFZxiXjNFBNCM
|
||||||
|
bucketName: oss-gxwebsoft
|
||||||
|
bucketDomain: https://oss.wsdns.cn
|
||||||
|
aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com
|
||||||
|
|
||||||
|
# 生产环境证书配置
|
||||||
|
certificate:
|
||||||
|
# 生产环境使用挂载卷模式
|
||||||
|
load-mode: VOLUME
|
||||||
|
cert-root-path: /app/certs
|
||||||
@@ -53,6 +53,9 @@ config:
|
|||||||
# websopy 服务地址(用于同步用户数据)
|
# websopy 服务地址(用于同步用户数据)
|
||||||
websopyUrl: https://websopy-api.websoft.top
|
websopyUrl: https://websopy-api.websoft.top
|
||||||
|
|
||||||
|
# 内部服务调用密钥(调用 websopy 等内部接口时使用,请务必与 websopy 端保持一致)
|
||||||
|
internal-key: ${INTERNAL_KEY:websopy-internal-2025}
|
||||||
|
|
||||||
# 生产环境证书配置
|
# 生产环境证书配置
|
||||||
certificate:
|
certificate:
|
||||||
# 生产环境使用挂载卷模式
|
# 生产环境使用挂载卷模式
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
|
||||||
|
# 数据源配置
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://1Panel-mysql-0NG1:3306/core?useSSL=false&serverTimezone=UTC
|
||||||
|
username: core
|
||||||
|
password: JRC4nTDbMGD7mBxa
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
redis:
|
||||||
|
database: 0
|
||||||
|
host: 1Panel-redis-gc1W
|
||||||
|
port: 6379
|
||||||
|
password: redis_Sr8d87
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: websoft-core.log
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
com.gxwebsoft: ERROR
|
||||||
|
com.baomidou.mybatisplus: ERROR
|
||||||
|
|
||||||
|
socketio:
|
||||||
|
host: 0.0.0.0 #IP地址
|
||||||
|
|
||||||
|
knife4j:
|
||||||
|
# 开启knife4j增强
|
||||||
|
enable: true
|
||||||
|
# 开启生产环境屏蔽,一定要先开启knife4j增强才会生效
|
||||||
|
production: false
|
||||||
|
|
||||||
|
# 框架配置
|
||||||
|
config:
|
||||||
|
# 生产环境接口
|
||||||
|
server-url: https://server.guiletao.com/api
|
||||||
|
upload-path: /www/wwwroot/file.ws
|
||||||
|
|
||||||
|
# 阿里云OSS云存储
|
||||||
|
endpoint: https://oss-cn-shenzhen.aliyuncs.com
|
||||||
|
accessKeyId: LTAI4GKGZ9Z2Z8JZ77c3GNZP
|
||||||
|
accessKeySecret: BiDkpS7UXj72HWwDWaFZxiXjNFBNCM
|
||||||
|
bucketName: oss-gxwebsoft
|
||||||
|
bucketDomain: https://oss.wsdns.cn
|
||||||
|
aliyunDomain: https://oss-gxwebsoft.oss-cn-shenzhen.aliyuncs.com
|
||||||
|
|
||||||
|
# 生产环境证书配置
|
||||||
|
certificate:
|
||||||
|
# 生产环境使用挂载卷模式
|
||||||
|
load-mode: VOLUME
|
||||||
|
cert-root-path: /app/certs
|
||||||
@@ -119,6 +119,9 @@ config:
|
|||||||
# websopy 服务地址(用于同步用户数据)
|
# websopy 服务地址(用于同步用户数据)
|
||||||
websopyUrl: https://websopy-api.websoft.top
|
websopyUrl: https://websopy-api.websoft.top
|
||||||
|
|
||||||
|
# 内部服务调用密钥(调用 websopy 等内部接口时使用,请务必与 websopy 端保持一致)
|
||||||
|
internal-key: ${INTERNAL_KEY:websopy-internal-2025}
|
||||||
|
|
||||||
# 阿里云OSS云存储
|
# 阿里云OSS云存储
|
||||||
endpoint: https://oss-cn-shenzhen.aliyuncs.com
|
endpoint: https://oss-cn-shenzhen.aliyuncs.com
|
||||||
accessKeyId: LTAI5tGXuJku8MK7TA6gQMZw
|
accessKeyId: LTAI5tGXuJku8MK7TA6gQMZw
|
||||||
@@ -165,3 +168,19 @@ certificate:
|
|||||||
alipay-cert-public-key-file: "alipayCertPublicKey.crt"
|
alipay-cert-public-key-file: "alipayCertPublicKey.crt"
|
||||||
alipay-root-cert-file: "alipayRootCert.crt"
|
alipay-root-cert-file: "alipayRootCert.crt"
|
||||||
|
|
||||||
|
# 开放平台(base-api)对接配置
|
||||||
|
# 业务服务只做本地验签,不共享密钥;/api/open/** 走独立安全链。
|
||||||
|
open-platform:
|
||||||
|
enabled: true
|
||||||
|
# base-api 的 JWKS 公钥地址
|
||||||
|
# 注意:不要改用 spring.security.oauth2.resourceserver.jwt.issuer-uri,
|
||||||
|
# Spring 会去做 OIDC 发现(base-api 没有该文档)导致启动失败。
|
||||||
|
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
|
||||||
|
# 必须与 base-api 令牌里的 iss 完全一致
|
||||||
|
issuer: https://base-api.websoft.top/api
|
||||||
|
# 必须与 base-api 令牌里的 aud 一致
|
||||||
|
audience: websoft-open-platform
|
||||||
|
# 对外接口前缀,版本号由 controller 的 @RequestMapping 决定
|
||||||
|
path-prefix: /api/open
|
||||||
|
# 返回给第三方前是否对手机号脱敏
|
||||||
|
mask-sensitive: true
|
||||||
|
|||||||
@@ -25,10 +25,9 @@
|
|||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
font-size: 22px;
|
height: 36px;
|
||||||
font-weight: 600;
|
width: auto;
|
||||||
color: #1d1d1f;
|
display: inline-block;
|
||||||
letter-spacing: -0.022em;
|
|
||||||
}
|
}
|
||||||
.page-title {
|
.page-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -162,7 +161,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">WebSoft</div>
|
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title + Lede -->
|
<!-- Title + Lede -->
|
||||||
|
|||||||
@@ -18,10 +18,9 @@
|
|||||||
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
||||||
.brand { text-align: center; margin-bottom: 28px; }
|
.brand { text-align: center; margin-bottom: 28px; }
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
font-size: 22px;
|
height: 36px;
|
||||||
font-weight: 600;
|
width: auto;
|
||||||
color: #1d1d1f;
|
display: inline-block;
|
||||||
letter-spacing: -0.022em;
|
|
||||||
}
|
}
|
||||||
.page-title {
|
.page-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -159,7 +158,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">WebSoft</div>
|
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="page-title">${title!'系统通知'}</h1>
|
<h1 class="page-title">${title!'系统通知'}</h1>
|
||||||
|
|||||||
@@ -18,10 +18,9 @@
|
|||||||
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
||||||
.brand { text-align: center; margin-bottom: 28px; }
|
.brand { text-align: center; margin-bottom: 28px; }
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
font-size: 22px;
|
height: 36px;
|
||||||
font-weight: 600;
|
width: auto;
|
||||||
color: #1d1d1f;
|
display: inline-block;
|
||||||
letter-spacing: -0.022em;
|
|
||||||
}
|
}
|
||||||
.page-title {
|
.page-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -207,7 +206,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">WebSoft</div>
|
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="page-title">密码重置</h1>
|
<h1 class="page-title">密码重置</h1>
|
||||||
@@ -217,7 +216,7 @@
|
|||||||
|
|
||||||
<!-- 资料大卡 -->
|
<!-- 资料大卡 -->
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-title">WebSoft 账户信息</div>
|
<div class="info-card-title">账户信息</div>
|
||||||
<div class="info-cols">
|
<div class="info-cols">
|
||||||
<div class="info-col">
|
<div class="info-col">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
|
|||||||
@@ -14,17 +14,10 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
.wrap { width: 100%; background: #ffffff; padding: 40px 0 36px; }
|
.wrap { width: 100%; background: #ffffff; padding: 40px 0 30px; }
|
||||||
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
||||||
|
|
||||||
.brand { text-align: center; margin-bottom: 28px; }
|
.brand { text-align: center; margin-bottom: 28px; }
|
||||||
.brand-logo {
|
.brand-logo { height: 36px; width: auto; display: inline-block; }
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1d1d1f;
|
|
||||||
letter-spacing: -0.022em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
@@ -42,40 +35,39 @@
|
|||||||
margin: 0 auto 30px;
|
margin: 0 auto 30px;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
.lede strong { font-weight: 600; }
|
.card {
|
||||||
|
|
||||||
/* 一张大资料卡,左"APPLE 账户"右"站点信息" */
|
|
||||||
.info-card {
|
|
||||||
background-color: #f5f5f7;
|
background-color: #f5f5f7;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 28px 32px 24px;
|
padding: 28px 32px;
|
||||||
margin: 0 0 22px;
|
margin: 0 0 22px;
|
||||||
}
|
}
|
||||||
.info-card-title {
|
.card-caption {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
color: #6e6e73;
|
color: #6e6e73;
|
||||||
margin-bottom: 14px;
|
font-weight: 400;
|
||||||
|
margin-bottom: 8px;
|
||||||
letter-spacing: -0.008em;
|
letter-spacing: -0.008em;
|
||||||
}
|
}
|
||||||
.info-cols {
|
.card-title {
|
||||||
display: table;
|
font-size: 22px;
|
||||||
width: 100%;
|
font-weight: 600;
|
||||||
border-spacing: 0;
|
letter-spacing: -0.022em;
|
||||||
|
color: #1d1d1f;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.info-col {
|
.card-sub {
|
||||||
display: table-cell;
|
font-size: 14px;
|
||||||
vertical-align: top;
|
color: #6e6e73;
|
||||||
width: 50%;
|
line-height: 1.5;
|
||||||
padding-right: 18px;
|
|
||||||
}
|
|
||||||
.info-col + .info-col {
|
|
||||||
border-left: 1px solid #d2d2d7;
|
|
||||||
padding-left: 22px;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
}
|
||||||
|
/* 登录信息字段列表 */
|
||||||
|
.field-list { margin-top: 16px; }
|
||||||
.field-row {
|
.field-row {
|
||||||
padding: 8px 0;
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid #d2d2d7;
|
||||||
}
|
}
|
||||||
|
.field-row:first-child { border-top: none; padding-top: 4px; }
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6e6e73;
|
color: #6e6e73;
|
||||||
@@ -88,122 +80,65 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.field-value a {
|
.field-value a { color: #06c; text-decoration: none; }
|
||||||
color: #06c;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.field-value a:hover { text-decoration: underline; }
|
.field-value a:hover { text-decoration: underline; }
|
||||||
.field-value.mono {
|
.field-value.mono {
|
||||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Menlo', 'Consolas', 'Courier New', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
/* 安全提示 */
|
||||||
/* 安全提示卡片 */
|
.tips { margin-top: 6px; font-size: 14px; color: #1d1d1f; line-height: 1.7; }
|
||||||
.tips-card {
|
.tips div { padding-left: 14px; position: relative; }
|
||||||
background-color: #f5f5f7;
|
.tips div:before {
|
||||||
border-radius: 18px;
|
content: "•"; position: absolute; left: 0; color: #6e6e73;
|
||||||
padding: 22px 32px;
|
|
||||||
margin: 0 0 22px;
|
|
||||||
}
|
}
|
||||||
.tips-caption {
|
/* 登录按钮 */
|
||||||
font-size: 13px;
|
.btn-wrap { text-align: center; margin: 30px 0 8px; }
|
||||||
color: #6e6e73;
|
.btn {
|
||||||
margin-bottom: 10px;
|
display: inline-block;
|
||||||
letter-spacing: -0.008em;
|
background-color: #1d1d1f;
|
||||||
}
|
color: #ffffff;
|
||||||
.tips-list {
|
font-size: 15px;
|
||||||
list-style: none;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
letter-spacing: -0.01em;
|
||||||
color: #1d1d1f;
|
padding: 13px 36px;
|
||||||
line-height: 1.7;
|
border-radius: 980px;
|
||||||
}
|
|
||||||
.tips-list li {
|
|
||||||
padding-left: 14px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.tips-list li:before {
|
|
||||||
content: "•";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
color: #6e6e73;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CTA 文字链接(仿 Apple 的"管理账户"链接样式) */
|
|
||||||
.cta {
|
|
||||||
text-align: center;
|
|
||||||
margin: 32px 0 6px;
|
|
||||||
}
|
|
||||||
.cta a {
|
|
||||||
font-size: 17px;
|
|
||||||
color: #06c;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
letter-spacing: -0.022em;
|
|
||||||
}
|
}
|
||||||
.cta a:hover { text-decoration: underline; }
|
.btn:hover { opacity: 0.9; }
|
||||||
.cta-sub {
|
/* 时间戳 */
|
||||||
|
.send-time {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #6e6e73;
|
color: #6e6e73;
|
||||||
margin-top: 6px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 帮助条 */
|
|
||||||
.help {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6e6e73;
|
|
||||||
margin: 26px auto 0;
|
|
||||||
max-width: 560px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.help a { color: #06c; text-decoration: none; }
|
|
||||||
.help a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
/* 底部 */
|
/* 底部 */
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 44px;
|
margin-top: 40px;
|
||||||
padding-top: 22px;
|
padding-top: 24px;
|
||||||
border-top: 1px solid #d2d2d7;
|
border-top: 1px solid #d2d2d7;
|
||||||
}
|
}
|
||||||
.footer-brand {
|
.footer-brand {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1d1d1f;
|
color: #1d1d1f;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
letter-spacing: -0.016em;
|
letter-spacing: -0.016em;
|
||||||
}
|
}
|
||||||
.footer-links {
|
.footer-links { font-size: 13px; color: #6e6e73; line-height: 1.8; }
|
||||||
font-size: 13px;
|
.footer-links a { color: #6e6e73; margin: 0 14px; text-decoration: none; }
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
.footer-links a {
|
|
||||||
color: #6e6e73;
|
|
||||||
margin: 0 14px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
|
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
|
||||||
.copyright {
|
.copyright { font-size: 12px; color: #6e6e73; margin-top: 16px; line-height: 1.4; }
|
||||||
font-size: 12px;
|
|
||||||
color: #6e6e73;
|
|
||||||
margin-top: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.copyright a { color: #6e6e73; }
|
.copyright a { color: #6e6e73; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.page-title { font-size: 30px; }
|
.page-title { font-size: 30px; }
|
||||||
.lede { font-size: 16px; }
|
.lede { font-size: 16px; }
|
||||||
|
.card { padding: 22px 22px; border-radius: 16px; }
|
||||||
.container { padding: 0 16px; }
|
.container { padding: 0 16px; }
|
||||||
.info-card, .tips-card { padding: 22px 22px; border-radius: 16px; }
|
|
||||||
.info-cols { display: block; }
|
|
||||||
.info-col { display: block; width: 100%; padding: 0; }
|
|
||||||
.info-col + .info-col {
|
|
||||||
border-left: none;
|
|
||||||
border-top: 1px solid #d2d2d7;
|
|
||||||
padding: 12px 0 0;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -212,73 +147,63 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">WebSoft</div>
|
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title + Lede -->
|
<!-- Title + Lede -->
|
||||||
<h1 class="page-title">开通成功</h1>
|
<h1 class="page-title">开通成功</h1>
|
||||||
<p class="lede">
|
<p class="lede">恭喜!您的企业官网已开通成功,可使用下方信息登录管理后台。</p>
|
||||||
您好!您的企业官网「<strong>${siteName!}</strong>」已开通成功。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 资料大卡:左站点 / 右账号 -->
|
<!-- 站点信息 + 登录信息主卡 -->
|
||||||
<div class="info-card">
|
<div class="card">
|
||||||
<div class="info-card-title">WebSoft 账户信息</div>
|
<div class="card-caption">站点信息</div>
|
||||||
<div class="info-cols">
|
<div class="card-title">企业官网「${siteName!}」</div>
|
||||||
<div class="info-col">
|
<div class="card-sub">请妥善保管登录信息,首次登录后建议立即修改密码。</div>
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-label">官网地址</div>
|
<div class="field-list">
|
||||||
<div class="field-value">
|
<div class="field-row">
|
||||||
<a href="${siteUrl!}">${siteUrl!}</a>
|
<div class="field-label">官网地址</div>
|
||||||
</div>
|
<div class="field-value"><a href="${siteUrl!}">${siteUrl!}</a></div>
|
||||||
</div>
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="field-label">后台地址</div>
|
|
||||||
<div class="field-value">
|
|
||||||
<a href="${adminUrl!}">${adminUrl!}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="info-col">
|
<div class="field-row">
|
||||||
<div class="field-row">
|
<div class="field-label">后台地址</div>
|
||||||
<div class="field-label">登录账号</div>
|
<div class="field-value"><a href="${adminUrl!}">${adminUrl!}</a></div>
|
||||||
<div class="field-value">${account!}</div>
|
</div>
|
||||||
</div>
|
<div class="field-row">
|
||||||
<div class="field-row">
|
<div class="field-label">登录账号</div>
|
||||||
<div class="field-label">登录密码</div>
|
<div class="field-value">${account!}</div>
|
||||||
<div class="field-value mono">${password!}</div>
|
</div>
|
||||||
</div>
|
<div class="field-row">
|
||||||
|
<div class="field-label">登录密码</div>
|
||||||
|
<div class="field-value mono">${password!}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 安全提示 -->
|
<!-- 安全提示 -->
|
||||||
<div class="tips-card">
|
<div class="card">
|
||||||
<div class="tips-caption">安全提示</div>
|
<div class="card-caption">安全提示</div>
|
||||||
<ul class="tips-list">
|
<div class="tips">
|
||||||
<li>请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。</li>
|
<div>请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。</div>
|
||||||
<li>建议首次登录后立即进入「个人中心 > 修改密码」重置密码。</li>
|
<div>建议首次登录后立即进入「个人中心 > 修改密码」重置密码。</div>
|
||||||
<li>如发现可疑登录或账号被盗,请立即联系客服并修改密码。</li>
|
<div>如发现可疑登录或账号被盗,请立即联系客服并修改密码。</div>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CTA -->
|
<!-- 登录按钮 -->
|
||||||
<div class="cta">
|
<div class="btn-wrap">
|
||||||
<a href="${adminUrl!}">点击登录后台 →</a>
|
<a href="${adminUrl!}" class="btn">登录管理后台</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="cta-sub">如按钮未生效,请将上面的后台地址复制到浏览器打开。</div>
|
|
||||||
|
|
||||||
<!-- 帮助 -->
|
<!-- 时间戳 -->
|
||||||
<p class="help">
|
<div class="send-time">发送时间:${sendTime!}</div>
|
||||||
如有疑问,请联系客服或访问 <a href="${helpUrl!'https://websoft.top/help'}">帮助中心</a>。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="footer-brand">WebSoft</div>
|
<div class="footer-brand">WebSoft</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="https://websoft.top">官方网站</a>
|
<a href="https://websoft.top">官方网站</a>
|
||||||
<a href="https://websoft.top/help">帮助中心</a>
|
<a href="${helpUrl!'https://websoft.top/help'}">帮助中心</a>
|
||||||
<a href="https://websoft.top/contact">联系我们</a>
|
<a href="https://websoft.top/contact">联系我们</a>
|
||||||
<a href="https://websoft.top/settings">账户设置</a>
|
<a href="https://websoft.top/settings">账户设置</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.gxwebsoft.common.system;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.common.mq.config.RabbitMQConfig;
|
||||||
|
import com.gxwebsoft.common.mq.message.SyncMessage;
|
||||||
|
import com.gxwebsoft.common.mq.producer.impl.RabbitMQSyncProducer;
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.amqp.rabbit.connection.CorrelationData;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.amqp.support.converter.MessageConverter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户密码字段的序列化契约测试。
|
||||||
|
*
|
||||||
|
* <p>背景:{@code User} 实体被十几个接口直接当响应体返回,其中
|
||||||
|
* {@code GET /api/system/user/getByUserId/{id}}、{@code /withoutAuth} 等还免登录,
|
||||||
|
* 原先响应里带着 BCrypt 密码哈希。修复方式是把 password / payPassword 设为 WRITE_ONLY,
|
||||||
|
* 这里的三个用例分别锁住:HTTP 不再输出、反序列化仍可写入、MQ 同步报文保持原样。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
class UserCredentialSerializationTest {
|
||||||
|
|
||||||
|
private static final String PWD_HASH = "$2a$10$abcdefghijklmnopqrstuvABCDEFGHIJKLMNOPQRSTUVWXYZ012345";
|
||||||
|
private static final String PAY_HASH = "$2a$10$zyxwvutsrqponmlkjihgfeDCBAHGFEDCBAZYXWVUTSRQPONMLKJIHG";
|
||||||
|
|
||||||
|
private User user() {
|
||||||
|
User user = new User();
|
||||||
|
user.setUserId(1);
|
||||||
|
user.setUsername("zhangsan");
|
||||||
|
user.setPhone("13800000009");
|
||||||
|
user.setPassword(PWD_HASH);
|
||||||
|
user.setPayPassword(PAY_HASH);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("序列化 User 时不输出任何密码字段")
|
||||||
|
void serializationHidesCredentials() throws Exception {
|
||||||
|
String json = new ObjectMapper().writeValueAsString(user());
|
||||||
|
|
||||||
|
assertFalse(json.contains("password"), "响应不应包含 password / payPassword 字段");
|
||||||
|
assertFalse(json.contains("payPassword"), "响应不应包含 payPassword 字段");
|
||||||
|
assertFalse(json.contains(PWD_HASH), "响应不应包含登录密码哈希");
|
||||||
|
assertFalse(json.contains(PAY_HASH), "响应不应包含支付密码哈希");
|
||||||
|
// 非敏感字段照常输出,避免误伤
|
||||||
|
assertTrue(json.contains("zhangsan"));
|
||||||
|
assertTrue(json.contains("13800000009"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("反序列化仍能写入密码,注册/改密/批量导入不受影响")
|
||||||
|
void deserializationStillAcceptsCredentials() throws Exception {
|
||||||
|
String body = "{\"username\":\"lisi\",\"password\":\"" + PWD_HASH
|
||||||
|
+ "\",\"payPassword\":\"" + PAY_HASH + "\"}";
|
||||||
|
|
||||||
|
User parsed = new ObjectMapper().readValue(body, User.class);
|
||||||
|
|
||||||
|
assertEquals("lisi", parsed.getUsername());
|
||||||
|
assertEquals(PWD_HASH, parsed.getPassword());
|
||||||
|
assertEquals(PAY_HASH, parsed.getPayPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("同步到 websopy 的 MQ 报文仍包含密码字段,行为与修复前一致")
|
||||||
|
void mqPayloadStillCarriesCredentials() {
|
||||||
|
RabbitTemplate rabbitTemplate = mock(RabbitTemplate.class);
|
||||||
|
MessageConverter messageConverter = mock(MessageConverter.class);
|
||||||
|
RabbitMQSyncProducer producer =
|
||||||
|
new RabbitMQSyncProducer(rabbitTemplate, messageConverter, new ObjectMapper());
|
||||||
|
|
||||||
|
producer.sendUserSyncMessage("websopy", "CREATE", user());
|
||||||
|
|
||||||
|
ArgumentCaptor<SyncMessage> captor = ArgumentCaptor.forClass(SyncMessage.class);
|
||||||
|
verify(rabbitTemplate).convertAndSend(
|
||||||
|
eq(RabbitMQConfig.SYNC_EXCHANGE), anyString(), captor.capture(),
|
||||||
|
any(CorrelationData.class));
|
||||||
|
|
||||||
|
Map<String, Object> data = captor.getValue().getData();
|
||||||
|
assertEquals(PWD_HASH, data.get("password"));
|
||||||
|
assertEquals(PAY_HASH, data.get("payPassword"));
|
||||||
|
assertEquals("zhangsan", data.get("username"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.gxwebsoft.openplatform;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
|
||||||
|
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.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口订单出参的字段裁剪与脱敏测试。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
class OpenOrderVOTest {
|
||||||
|
|
||||||
|
private Order order() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.setOrderId(1);
|
||||||
|
order.setOrderNo("1234567890");
|
||||||
|
order.setPhone("13800000009");
|
||||||
|
order.setRealName("张三");
|
||||||
|
order.setComments("内部备注");
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("默认对手机号脱敏")
|
||||||
|
void masksPhoneByDefault() {
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.from(List.of(order()), true);
|
||||||
|
|
||||||
|
assertEquals(1, list.size());
|
||||||
|
assertEquals("138****0009", list.get(0).getPhone());
|
||||||
|
assertEquals("张三", list.get(0).getRealName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("关闭脱敏后返回原号码")
|
||||||
|
void keepsPhoneWhenMaskingDisabled() {
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.from(List.of(order()), false);
|
||||||
|
|
||||||
|
assertEquals("13800000009", list.get(0).getPhone());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("手机号异常长度时原样返回,不抛异常")
|
||||||
|
void handlesShortPhone() {
|
||||||
|
Order order = order();
|
||||||
|
order.setPhone("123");
|
||||||
|
assertEquals("123", OpenOrderVO.from(List.of(order), true).get(0).getPhone());
|
||||||
|
|
||||||
|
order.setPhone(null);
|
||||||
|
assertNull(OpenOrderVO.from(List.of(order), true).get(0).getPhone());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("空列表返回空集合而不是 null")
|
||||||
|
void handlesEmptyList() {
|
||||||
|
assertTrue(OpenOrderVO.from(null, true).isEmpty());
|
||||||
|
assertTrue(OpenOrderVO.from(List.of(), true).isEmpty());
|
||||||
|
assertNotNull(OpenOrderVO.from(List.of(order()), true).get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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