Compare commits

..

90 Commits

Author SHA1 Message Date
gxwebsoft 397c3c4b08 chore(build): 更新平台版本至1.0.1
- 将pom.xml中的websoft-platform.version从1.0.0升级为1.0.1
- 反映平台层能力升级所需的版本变更
- 保持项目构建配置的最新状态
2026-09-22 16:24:08 +08:00
gxwebsoft a705539e77 build: Maven wrapper 升级到 3.9.16
与平台层迁移同一批改动。验证:clean test 通过,12 项测试全绿。
2026-09-22 10:58:04 +08:00
gxwebsoft c3e46f4bc8 refactor(platform): 移除旧版开放平台模块并迁移至独立仓库
- 删除api返回结果封装(ApiResult)、业务异常(BusinessException)、系统常量(Constants)等基础模块代码
- 移除开放平台上下文(OpenCaller)、租户上下文(OpenTenantContext)及相关拦截器等实现
- 删除开放平台安全配置、安全异常处理、JWT校验配置和相关测试用例
- 调整 MybatisPlusConfig 中对 OpenTenantContext 包路径的引用
- 将开放平台controller、参数、视图、配置、拦截器等迁移至新的独立仓库包名 com.gxwebsoft.platform.openapi
- 开放接口controller添加 @OpenApi 注解用于异常处理匹配
- 更新文档,补充开放平台模块移除和迁移说明,指导后续开发维护
- 保持包名兼容,确保遗留代码引用不变,无需做import改动
2026-09-22 08:50:29 +08:00
gxwebsoft 86e73b68b7 fix(user): 修复密码哈希通过免登录接口暴露问题
- 在 User 实体的 password 和 payPassword 字段添加@JsonProperty(WRITE_ONLY)注解
- 保证密码字段不再通过 HTTP 接口响应体序列化输出
- 保持反序列化能力,注册、改密及批量导入接口不受影响
- RabbitMQ 消息同步时显式补回密码字段,确保 MQ 报文不变
- 新增 UserCredentialSerializationTest,覆盖 HTTP 响应、反序列化和 MQ 同步报文场景
- 文档更新,说明已修复接口列表及仍需决策的免登录接口安全隐患
- 将 server-api 版本升级至 2.0
2026-09-22 08:40:21 +08:00
gxwebsoft 0923635575 feat(open-platform): 新增用户分页查询接口并更新订单权限及时间格式
- 添加 OpenUserController 支持分页查询本租户用户,权限为 sys:user:list
- 引入 OpenUserPageParam 作为用户查询白名单参数类,过滤敏感字段
- 定义 OpenUserVO,确保响应脱敏手机号、邮箱且不包含密码字段
- 修正订单接口权限 scope 由 order:read 改为 shop:shopOrder:list
- 订单及退款时间字段增加 JsonFormat 注解,统一时间格式为 yyyy-MM-dd HH:mm:ss
- 更新权限常量 OpenScopes,新增 SHOP_ORDER_LIST 和 SYS_USER_LIST
- 增加 OpenUserVO 单元测试,确保敏感字段不被泄露且脱敏逻辑正确
- 新增开放平台调用验证脚本 scripts/verify-open-api.sh,覆盖令牌获取及接口调用验证
- 修改接口文档与示例,明确权限 scope 和时间格式要求
- 修复 TenantController 权限校验问题,确保租户字段来源于令牌且不可伪造
- 禁用伪造 tenantId 请求头修改数据范围,保证租户数据隔离安全
2026-09-22 03:18:27 +08:00
gxwebsoft ea5021c3f0 feat(open-platform): 新增用户分页查询接口并更新订单权限及时间格式
- 添加 OpenUserController 支持分页查询本租户用户,权限为 sys:user:list
- 引入 OpenUserPageParam 作为用户查询白名单参数类,过滤敏感字段
- 定义 OpenUserVO,确保响应脱敏手机号、邮箱且不包含密码字段
- 修正订单接口权限 scope 由 order:read 改为 shop:shopOrder:list
- 订单及退款时间字段增加 JsonFormat 注解,统一时间格式为 yyyy-MM-dd HH:mm:ss
- 更新权限常量 OpenScopes,新增 SHOP_ORDER_LIST 和 SYS_USER_LIST
- 增加 OpenUserVO 单元测试,确保敏感字段不被泄露且脱敏逻辑正确
- 新增开放平台调用验证脚本 scripts/verify-open-api.sh,覆盖令牌获取及接口调用验证
- 修改接口文档与示例,明确权限 scope 和时间格式要求
- 修复 TenantController 权限校验问题,确保租户字段来源于令牌且不可伪造
- 禁用伪造 tenantId 请求头修改数据范围,保证租户数据隔离安全
2026-09-22 03:17:18 +08:00
gxwebsoft efccb786fe feat(open-platform): 新增开放平台独立链路及订单接口
- 新增开放平台配置块 application.yml,配置 JWKS、公钥地址、issuer、audience 及接口前缀
- 新增 OpenPlatformSecurityConfig,独立安全链路匹配 /api/open/** 请求
- JwtAuthenticationFilter 增加 shouldNotFilter 跳过 /api/open/**,避免 RS256 验签异常
- 新增 OpenTenantContext 与拦截器,令牌中 tenant_id 作为唯一租户来源,防止伪造请求头
- MybatisPlusConfig 优先使用 OpenTenantContext 里的租户 ID,确保数据隔离正确
- sys_order 表显式在 OrderMapper.xml 中使用 tenant_id 条件过滤,支持开放链路多租户隔离
- 新增开放平台订单分页查询接口 /api/open/v1/order/page,使用独立参数与返回值结构
- 出参 OpenOrderVO 支持手机号脱敏,保障数据安全,且只暴露必要字段
- 调整 SecurityConfig 加 @Order(2),让位给开放链路的 SecurityConfig @Order(1)
- TenantController.page 增加安全修复,未登录状态拒绝访问,防止匿名读取全部租户数据
- 新增开放平台专用异常处理及认证、权限拒绝响应,避免泄露内部异常信息
- 新增 pom 依赖 spring-boot-starter-oauth2-resource-server,用于 RS256 JWT 验签
- 完整开放平台对接文档 docs/OPEN_PLATFORM_INTEGRATION.md,涵盖设计理念与接口契约
- 增加 OpenOrderVO 单元测试,覆盖手机号脱敏、空列表处理等场景,提高可靠性
2026-09-22 03:08:31 +08:00
gxwebsoft 792d35c1c0 chore(config): 更新 Redis 密码配置
- 修改 application-paopao.yml 文件中的 Redis 连接密码
- 保持其他配置项不变
- 确保新的密码符合安全要求
2026-09-21 12:28:27 +08:00
gxwebsoft e7a12d8828 fix(system): 修复素材管理搜索中的列名错误
- 修正 FileRecordMapper.xml 中关键词搜索部分,将 b.name 和 b.comments 改为正确字段
- 搜索范围扩展至文件名、路径、注释及用户的用户名、昵称和真实姓名
- 添加关键词数字校验,避免非数字字符串误匹配 create_user_id
- 防止因跨 Mapper 复制导致的表别名错误及字段不存在的问题
2026-09-03 11:15:01 +08:00
gxwebsoft c88b41fd46 fix(WxLoginController): 修正 getOrderQRCodeUnlimited 接口 scene 参数逻辑
- 调整 scene 参数优先级为:显式 scene > 自定义页面使用原始 orderNo > 兼容原订单核销 scene=orderNo=xxx
- 当传入页面参数时,scene 直接使用原始 orderNo,满足小程序端 options.scene 读取需求
- page 参数无传入时默认设置为 package/admin/order-scan,保持兼容旧版订单核销页面
- 保证原有订单核销逻辑和接口调用行为向下兼容无影响
2026-09-01 12:06:45 +08:00
gxwebsoft f751d17771 feat(system): 优化微信小程序码获取接口,直接返回图片字节
- 修改getOrderQRCodeUnlimited接口,支持直接返回图片字节流
- 移除保存二维码图片到服务器文件的逻辑
- 在未登录时,通过响应码401返回错误提示
- 设置响应内容类型为image/png,禁用缓存,直接输出二维码图片数据
- 简化接口实现,提升性能与用户体验
2026-08-31 18:20:32 +08:00
gxwebsoft 539819d474 feat(system): 支持自定义页面与场景值获取微信小程序码
- 修改获取微信小程序码接口,新增page和scene参数支持自定义
- 优先使用传入的scene参数,兼容原订单核销逻辑
- 优先使用传入的page参数,兼容原订单核销页面
- 更新接口文档说明,明确支持数量极多的业务场景下小程序码获取
- 保持登录校验和访问令牌获取不变,确保接口安全性
2026-08-31 17:55:49 +08:00
gxwebsoft d93c4684ee chore(config): 添加生产环境配置文件
- 新增 application-paopao.yml 文件,包含数据源、Redis、日志、SocketIO、Knife4j、
  框架配置及生产环境证书配置
- 新增 application-vpc.yml 文件,包含数据源、Redis、日志、SocketIO、Knife4j、
  框架配置及生产环境证书配置
- 配置数据库连接、阿里云OSS存储相关信息及证书挂载路径
- 设置日志等级和文件名
- 配置 SocketIO 服务器地址和 Knife4j 增强功能及生产环境屏蔽选项
2026-08-20 19:00:50 +08:00
gxwebsoft 58972885ad fix(email): 统一注册成功邮件模板风格及调用逻辑
- 重写 register-success.html,视觉风格与邮箱验证码邮件对齐
- TenantServiceImpl 开通邮件改为发送 HTML 模板邮件,添加 sendTime 字段
- 修改 EmailTemplateUtil.sendRegisterSuccessEmail 方法签名及实现,传递正确模板变量
- 更新 EmailTestController 中的测试接口参数,支持新模板数据
- 修复 MainController 注册成功邮件调用用错参数导致空字段问题,使用兜底值避免编译错误
- 备注:register-success.html 为首次启用,旧逻辑未使用此模板仅发送纯文本邮件
- 测试方式:通过 POST /api/email-test/register-success?email=xxx 预览邮件渲染效果
2026-08-10 21:56:46 +08:00
gxwebsoft c585647dec fix(auth): 修复支付密码重置与前端权限显示问题
- 修正 resetPayPasswordBySms 短信码判定逻辑,分离短信码与万能码检查
- 增加日志和提示优化,消除 Redis 无码时的空指针异常
- 支持 resetSMSCode 接口传入自定义码和有效期参数,新增查询当前码接口
- 前端 reset-pay-password 页面改为自动发送验证码,改进发送时机与日志记录
- 调整门店中心菜单权限判定,改用当前登录 SysUser 的管理员标识替代店员 roleType
- 菜单渲染依据 isAdmin 权限过滤,优化非管理员的角标加载逻辑
- 明确前端权限隐藏,提示后端接口需独立权限验证
2026-08-10 20:02:19 +08:00
gxwebsoft 477b028e82 feat(payment): 实现订阅订单微信Native支付及二维码生成功能
- 新增 MyQrCodeUtil.generateBase64 生成二维码Base64字符串用于前端展示,无需写文件
- SubscriptionOrderPayResult新增支付二维码图片(qrImage)和订单状态(orderStatus)字段
- TenantSubscriptionOrderController新增创建订阅订单并生成微信Native支付二维码接口
- 新增查询订阅订单支付状态接口支持前端轮询支付结果
- TenantSubscriptionOrderController内部封装从订单构造价格结果方法,保证价格数据一致
- WxNativePayController支持订阅订单号支付,保留调用方传入订单号,避免随机生成
- 微信Native支付异步通知接口增加验签解密,解析交易结果后更新订阅订单支付状态与激活流程
- 异步通知中新增日志支持,完善异常捕获及幂等处理,保障回调安全和准确
- 依赖注入新增订阅订单及套餐服务,优化代码结构和职责分离
2026-08-10 09:25:39 +08:00
gxwebsoft 0fda607612 feat(tenant): 初始化新租户的默认MinIO上传配置
- 在TenantServiceImpl中新增initDefaultUploadSetting方法
- 判断租户是否已有upload配置,避免重复写入
- 创建默认MinIO配置内容并插入数据库
- 预热redis上传配置缓存,提升首次读取性能
- 自动建桶并应用匿名只读策略,失败不阻断流程
- 在租户菜单授权成功后调用初始化方法
- SettingMapper新增countByKeyAndTenant和insertSetting方法,绕过多租户插件执行数据库操作
2026-08-08 14:21:01 +08:00
gxwebsoft 70bd81c45d feat(main): 新增 websopy 内部服务调用密钥及默认域名初始化
- 在配置文件中添加内部服务调用密钥配置,支持环境变量覆盖
- ConfigProperties 添加 internalKey 属性用于读取密钥
- MainController 新增 initDefaultDomain 方法,调用 websopy 初始化租户默认域名
- 租户注册成功后调用默认域名初始化接口,失败时记录日志但不阻断流程
- TenantServiceImpl 内置超管账号时设置已安装标志,避免重复操作
2026-08-08 01:44:46 +08:00
gxwebsoft 262a9e0183 fix(system): 解决上传配置更新后的缓存同步问题
- 在SettingController中,上传配置变更后清除对应租户的上传缓存,避免接口继续命中旧配置
- 在SettingServiceImpl的更新方法中,上传配置变更成功后删除对应租户的上传缓存,确保配置生效
- 统一处理上传配置缓存清理,提高系统配置更新的正确性和一致性
2026-08-08 00:58:22 +08:00
gxwebsoft 4a827abe9b fix(ui): 修改账户信息卡片标题文案
- 将密码重置页面账户信息卡片的标题从“WebSoft 账户信息”改为“账户信息”
- 将注册成功页面账户信息卡片的标题从“WebSoft 账户信息”改为“账户信息”
2026-08-07 15:59:24 +08:00
gxwebsoft 4db7a11624 fix(ui): 修改账户信息卡片标题文案
- 将密码重置页面账户信息卡片的标题从“WebSoft 账户信息”改为“账户信息”
- 将注册成功页面账户信息卡片的标题从“WebSoft 账户信息”改为“账户信息”
2026-08-07 15:33:54 +08:00
gxwebsoft 280224e646 feat(email): 优化邮箱验证码邮件发送和更新通知模板样式
- 邮箱验证码邮件标题直接带验证码,方便通知预览和邮件列表查看
- 优先发送HTML格式邮件,失败时降级为文本邮件发送
- 更新通知邮件模板视觉样式,提升阅读体验和品牌感知
- 优化密码重置邮件模板布局和内容结构,增加安全提示和帮助链接
- 改进注册成功邮件模板样式,增强信息分区和用户指导
- 所有邮件模板统一字体和颜色风格,提高整体视觉一致性
- 为通知、密码重置和注册成功页面添加响应式样式支持手机端显示
2026-08-07 15:27:44 +08:00
gxwebsoft d8f8662456 fix(system): 修复超管账号注册后未标记初始化状态问题
- 在注册流程中标记超管账号为已安装状态(initialized)
- 新增调用用户更新接口同步安装状态
- 解决注册即开通功能中超管账号未正确初始化的错误
2026-08-07 11:21:19 +08:00
gxwebsoft e77353b478 feat(minio): 集成 MinIO 工具类并优化对象存储客户端构建
- 新增 MinioUtil 工具类,统一封装 MinIO SDK 的常用操作
- 规范化 endpoint 处理,避免签名校验时因路径错误失败
- 统一使用 MinioUtil.buildClient 创建 MinIO 客户端,避免自动探测 region 导致错误
- 实现自动创建 bucket 及设置匿名只读策略,兼容裸桶场景
- 进程内缓存已准备好的 bucket,减少重复请求
- 修正 AliOssController 和 FileRecordServiceImpl 的 MinIO 客户端初始化逻辑
2026-08-07 09:23:18 +08:00
gxwebsoft e487f021dc fix(storage): 规范化endpoint并指定MinIO客户端region
- 去除endpoint结尾斜杠,避免minio-java拼接双斜杠导致签名不匹配
- MinIO客户端构建时显式指定region为us-east-1
- 跳过MinIO SDK的自动getBucketLocation探测,避免反向代理环境下签名验证失败
- 修改AliOssController和FileRecordServiceImpl中相关代码逻辑
2026-08-07 04:00:39 +08:00
gxwebsoft 5ca3280ddc feat(storage): 支持 MinIO 存储并优化上传与删除逻辑
- 引入 MinIO 官方 Java SDK,替代 aliyun OSS SDK 处理与 MinIO 的交互
- 兼容 MinIO 的 AWS4-HMAC-SHA256 签名及 path-style 访问
- 区分上传和删除操作中 MinIO 与阿里云 OSS 的客户端实例创建及调用
- 统一文件路径处理,调整图片处理策略以兼容 MinIO 不支持的参数
- 增加对 MinIO 客户端异常的捕获与兼容处理
- 在 pom.xml 中添加对 okhttp 和 okio 依赖的版本锁定,解决版本冲突问题
2026-08-07 03:46:49 +08:00
gxwebsoft b9ec1fcdb9 fix(system): 修正站点域名配置及默认应用代码
- 修改 site 应用对应的域名后缀为 sitelnk.cn
- 调整默认应用代码由 mp 改为 site
- 更新域名映射中 site 项的配置为新的域名地址
- 保障邮件通知链接生成正确的站点域名
2026-08-07 03:17:28 +08:00
gxwebsoft a6a2e14abf fix(storage): 关闭 MinIO CNAME 支持解决证书不匹配问题
- 在 AliOssController 中关闭 MinIO 的 CNAME 支持,避免自定义域名被当作 CNAME
- 强制使用 path-style 访问方式,解决虚拟主机风格导致的证书不匹配
- 在 FileRecordServiceImpl 中同步应用同样的配置修改
- 修复了因 OSSV4Signer.getRegion() 返回 null 导致的 NPE 问题
2026-08-07 03:03:25 +08:00
gxwebsoft 18696f91a6 fix(oss): 修复MinIO SDK配置避免region空指针异常
- 调整MinIO客户端配置,使用 OSSClientBuilder 的链式调用构建
- 明确指定region为"us-east-1",防止自定义域名推导region失败导致NPE
- 保持SignatureVersion为V4,启用从endpoint提取配置信息以支持path-style访问
- 统一AliOssController和FileRecordServiceImpl中的MinIO配置逻辑
2026-08-07 02:54:21 +08:00
gxwebsoft 49e6cd2197 feat(system): 支持按应用代号发送开通成功邮件
- 在 Company 和 User 实体中新增 appCode 字段,接收前端传入的应用代号
- MainController 传递 appCode 到 Company 实体,用于后续处理
- TenantServiceImpl 根据 appCode 动态生成不同域名的网址和后台管理地址
- 优化企业开通成功邮件内容,增强安全提示和登录信息展示
- 邮件发送逻辑改为根据应用代号映射对应的站点和后台域名
2026-08-07 02:36:10 +08:00
gxwebsoft f7f7b30813 feat(auth): 添加邮箱验证码校验接口
- 在EmailCaptchaParam中新增验证码字段code,用于验证码校验
- 新增/verifyEmailCaptcha接口,支持邮箱验证码的校验逻辑
- 实现邮箱格式和验证码非空校验,提供错误反馈
- 验证通过后删除Redis中对应验证码,防止重复使用
- 支持开发环境验证码验证,方便调试验证流程
2026-08-07 01:48:12 +08:00
gxwebsoft b043270e52 feat(auth): 添加邮箱验证码校验接口
- 在EmailCaptchaParam中新增验证码字段code,用于验证码校验
- 新增/verifyEmailCaptcha接口,支持邮箱验证码的校验逻辑
- 实现邮箱格式和验证码非空校验,提供错误反馈
- 验证通过后删除Redis中对应验证码,防止重复使用
- 支持开发环境验证码验证,方便调试验证流程
2026-08-07 01:42:54 +08:00
gxwebsoft f720adc2a8 feat(system): 实现登录用户所属租户切换功能
- 增加判断当前登录用户切换到自身租户的直接放行逻辑
- 在租户切换成功时生成新的访问令牌并返回
- 异步保存登录记录,记录切换行为
- 解决注册即时场景下新超管租户切换验证不足的问题
2026-08-06 23:05:16 +08:00
gxwebsoft 2e51710e3a feat(system): 实现跨租户克隆授权功能
- 新增RoleMenuMapper和UserRoleMapper跨租户查询方法,支持忽略租户拦截器
- 实现grantCmsPermission方法,支持从参考租户克隆角色、菜单、角色菜单和用户角色到当前租户
- 角色克隆按role_code去重,已存在角色复用,新增角色批量保存
- 菜单克隆按层级复制,避免重复插入,保证父子菜单顺序
- 角色菜单克隆使用映射关系去重,批量保存新增绑定
- 用户角色克隆根据角色代码对齐用户名,避免重复绑定
- 返回克隆后的各资源统计数据,包括新增角色、菜单、角色菜单和用户角色数量
2026-08-06 21:21:10 +08:00
gxwebsoft 644188288a feat(api): 新增邮箱注册检查接口
- 新增 /checkEmailRegistered GET 接口,用于检查邮箱是否已注册
- 实现邮箱格式校验,保证输入的邮箱地址有效
- 查询邮箱注册的账户列表,统计账号数量并返回是否已注册
- 返回结果封装在 CheckEmailResult 类中,包含注册状态及账号数量
- 在 SecurityConfig 中添加 /api/checkEmailRegistered 接口白名单配置
2026-08-06 19:25:36 +08:00
gxwebsoft d532f6717f fix(security): 移除登录白名单中的 /api/grantCmsPermission 接口
- 从安全配置中剔除 /api/grantCmsPermission 接口的免认证访问
- 加强接口访问控制,防止未授权的权限授予操作
- 确保 CMS 权限管理接口需要严格认证验证
2026-08-06 19:12:11 +08:00
gxwebsoft 4612155457 fix(security): 添加/api/grantCmsPermission接口到安全忽略列表
- 在SecurityConfig中将/api/grantCmsPermission加入忽略验证的接口列表
- 确保该接口无需身份验证即可访问
- 维护现有的公共接口安全策略
2026-08-06 16:52:37 +08:00
gxwebsoft 907439044a fix(storage): 支持 MinIO 兼容 S3 协议的上传方式
- 在 AliOssController 和 FileRecordServiceImpl 中新增 uploadMethod 配置读取
- 针对 uploadMethod 为 minio 时,配置 ClientBuilderConfiguration 使用 V4 签名并自动启用 path-style 访问
- 修改 OSSClient 实例化逻辑,支持带配置参数构建以兼容 MinIO
- 图片和视频缩略图及预览图生成逻辑适配 MinIO,不使用阿里云特有的 x-oss-process 参数
- 保证 MinIO 返回的资源 URL 为原始路径,避免图片处理参数导致异常
2026-08-06 09:15:25 +08:00
gxwebsoft 39d3c4c894 feat(tenant): 授予当前租户cms建站权限
- 新增接口支持根据参考租户复制cms权限菜单到当前租户
- 支持请求参数指定参考租户ID,默认为配置项或企业模板
- 复制cms:*权限标识菜单及其祖先,保证菜单层级完整
- 实现菜单去重与接口幂等,避免重复插入菜单
- 将复制菜单绑定到当前租户超级管理员角色
- 添加相关服务层逻辑及事务保障
- 新增配置项websoft.cms.ref-tenant-id,指定默认参考租户ID
2026-08-06 02:00:35 +08:00
gxwebsoft c4177f167d fix(system): 修正支付密码重置时验证码读取与删除逻辑
- 统一使用 redisUtil 读取短信验证码,解决租户前缀不一致问题
- 修改验证码缓存key为 "code:" + 手机号,保持与 updatePayPassword 一致
- 验证码校验时同时支持开发环境验证码
- 重置成功后从 redisUtil 删除对应验证码缓存
- 移除原先使用 cacheClient 的验证码读取和删除操作
2026-08-05 11:15:27 +08:00
gxwebsoft 2581a77f09 feat(auth): 添加免登录切换租户功能
- 新增接口获取当前用户可切换的租户列表,支持手机号和邮箱聚合租户
- 实现免登录切换租户接口,需判断当前用户权限及目标租户状态
- 编写辅助方法查找可切换的用户账户
- 添加将用户信息转换为租户选项的工具方法
- 在切换成功后生成新的访问令牌并保存登录记录
2026-08-03 17:54:16 +08:00
gxwebsoft a8896e4523 feat(auth): 新增短信验证码重置支付密码功能
- 新增 ResetPayPasswordParam 参数类,包含手机号、验证码和支付密码字段
- 实现短信验证码校验及支付密码重置接口,校验手机号格式和密码格式
- 增加支付密码重置接口权限控制和登录用户一致性校验,防止手机号绕过
- 重置成功后销毁已使用验证码,确保安全性
- 增加短信发送接口的发送频率和每日发送次数限制,防止滥用
- 短信发送成功后更新缓存中的发送频率和每日计数记录
2026-08-03 15:24:49 +08:00
gxwebsoft 5f6df3dc26 fix(system): 取消应用过期时间的判断逻辑
- 注释掉了基于版本和过期时间判断应用是否过期的代码
- 取消了过期返回失败的处理逻辑
- 保留了公司实体对象的其他字段设置逻辑
2026-07-29 00:01:46 +08:00
gxwebsoft 3bacfe8b8f fix(auth): 注释非超级管理员登录限制逻辑
- 注释掉单租户登录时对非超级管理员账号的限制判断
- 避免非超级管理员登录时返回错误拦截
- 保持其他登录及账号状态判断逻辑不变
- 允许更多用户类型能正常登录系统
2026-07-27 11:46:28 +08:00
gxwebsoft 9fdc708bb8 fix(user): 修改查询条件支持超级管理员和管理员
- 将用户查询条件中超级管理员判断修改为超级管理员或管理员均可
- 修正了 is_super_admin 字段限制,新增对 is_admin 字段的支持
- 保证查询结果包含超级管理员和管理员用户数据
2026-07-27 11:08:57 +08:00
gxwebsoft a02521e64b fix(email): 优化邮箱验证码邮件标题
- 将邮件标题从“邮箱验证码 - WebSoft”修改为“邮箱验证码”
- 保留邮件内容不变,简化标题提升用户体验
- 确保验证码邮件发送功能正常运行
2026-07-24 20:55:15 +08:00
gxwebsoft 3ffd3255ee fix(auth): 修复邮箱登录SQL别名错误并支持邮箱找回密码
- UserMapper.selectAccountsByEmail 修正 FROM 别名 a 与 WHERE 中 u.is_super_admin 不匹配导致的 BadSqlGrammarException
- 去掉邮箱查询的管理员限定(邮箱已全局唯一,邮箱登录不再限管理员)
- 重置密码接口支持邮箱+邮箱验证码找回方式
2026-07-24 20:28:31 +08:00
gxwebsoft 14f49e43dd refactor(system): 优化通过手机号重置密码逻辑,改用UserMapper跨租户查询
- 引入UserMapper,替换原有UserService跨租户手机号查询方案
- 使用selectAccountsByPhone方法获取用户列表,更加明确忽略租户隔离
- 过滤冻结账号逻辑保持不变
- 更新密码时改为调用UserMapper的updateByUserId方法
- UserMapper.xml新增status和password字段的映射,提高数据完整性获取能力
2026-07-24 19:31:39 +08:00
gxwebsoft 73f0878fb2 feat(auth): 优化密码修改和重置逻辑
- 修改自己密码时新增多种输入校验,区分未设置密码用户免旧密码验证
- 重置密码接口支持两种模式:精确指定账号和通过手机号批量重置
- 忘记密码场景下手机号查询所有正常账号统一重置密码,排除冻结账号
- 精确定义用户和租户校验逻辑,确保数据匹配且安全
- 重置密码成功后,删除短信验证码缓存并记录登录日志
- 调整ResetPasswordParam参数注释,userId和tenantId支持非必填以适应场景
- User实体增加hasPassword字段,标记用户是否已设置密码
- UserServiceImpl中设置hasPassword字段以支持业务逻辑判断
2026-07-24 19:22:48 +08:00
gxwebsoft 34c6076068 feat(auth): 支持多租户超级管理员手机号登录及租户选择功能
- 修改登录接口,支持手机号关联多个超级管理员实现多租户场景
- 多租户登录时返回租户列表供前端选择,不返回 access_token
- 新增接口 loginBySelectTenant 选择租户后登录,生成并返回 access_token
- 新增 TenantOption 类,封装租户选项信息
- UserMapper 新增跨租户查询超级管理员方法,忽略租户隔离
- UserService 增加对应多租户查询方法并实现
- 调整 SecurityConfig,添加多租户选择登录接口白名单
- LoginResult 新增租户列表字段及构造方法,适配多租户登录返回结果
2026-07-24 18:49:08 +08:00
gxwebsoft cb2f856ec7 fix(system): 修复支付密码更新逻辑及防止空更新异常
- 支付密码使用加密后存储,避免明文入库
- 仅当支付密码非空时才更新密码字段,避免清空原有密码
- 增加更新字段检测,防止无字段更新时报错
- 确保返回当前用户信息时包含支付密码字段状态
2026-07-24 18:31:19 +08:00
gxwebsoft ac166e340d fix(system): 修正用户查询SQL条件和限制返回条数
- 调整了查询条件缩进,提升可读性
- 增加对超级管理员或管理员身份的过滤条件
- 添加了返回结果限制,确保只返回一条记录
2026-07-23 16:51:05 +08:00
gxwebsoft 61d8c6317e fix(security): 允许 sendSmsCaptchaByAdmin 接口免登录访问
- 在 SecurityConfig 的 permitAll 白名单中添加 `/api/sendSmsCaptchaByAdmin`
- 解决该接口因未登录被拦截导致返回 401 的问题
- 确保管理员手机号校验逻辑不受影响,接口可正常发送验证码
- 方便通过代理路径 `/api/_server/sendSmsCaptchaByAdmin` 访问接口
- 修复线上调用失败问题,保证服务正常可用
2026-07-23 16:39:58 +08:00
gxwebsoft 7217325756 删除条件: AND u.installed = 1 2026-07-23 08:46:08 +08:00
gxwebsoft c60cf13dd0 fix(user): 修复设为开发者时角色替换和同步问题
- 替换用户角色为 developer,确保用户仅拥有该单一角色
- 在设为开发者后移除用户现有角色并绑定 developer 角色
- 角色替换失败时记录警告日志但不影响标记过程
- 保持通过MQ异步同步更新后的用户数据到 websopy
- 增加相关日志以便追踪操作流程
2026-07-23 01:43:17 +08:00
gxwebsoft 6bb36b1554 feat(user): 添加设为开发者功能接口和业务逻辑
- 在UserController中新增设为开发者的接口,支持审核通过时标记用户
- 在UserService接口中新增markAsDeveloper方法声明
- 在UserServiceImpl中实现markAsDeveloper方法,将用户标记为开发者状态
- 标记成功后通过消息队列异步同步用户数据到websopy系统
- 添加接口权限校验,操作日志记录及接口文档说明
2026-07-22 14:09:34 +08:00
gxwebsoft dbe4f2962d refactor(system): 优化租户超级管理员字段命名
- 将Tenant实体中的超级管理员用户ID字段名从superAdminUserId修改为ownerId
- 更新字段的Schema描述,将“超级管理员用户ID”改为“超级管理员UID”
- 修改TenantMapper.xml中关联查询SQL对应的字段别名为ownerId以保持一致
- 保持原有逻辑和功能不变,提升字段命名语义清晰度
2026-07-22 00:56:59 +08:00
gxwebsoft eb9bfd167e refactor(system): 优化租户超级管理员字段命名
- 将Tenant实体中的超级管理员用户ID字段名从superAdminUserId修改为ownerId
- 更新字段的Schema描述,将“超级管理员用户ID”改为“超级管理员UID”
- 修改TenantMapper.xml中关联查询SQL对应的字段别名为ownerId以保持一致
- 保持原有逻辑和功能不变,提升字段命名语义清晰度
2026-07-21 14:29:41 +08:00
gxwebsoft 18db0f7d44 feat(system): 新增租户超级管理员用户ID字段
- Tenant实体类中新增superAdminUserId字段,标记为非数据库字段
- 在TenantMapper.xml的关联查询SQL中添加超级管理员用户ID字段查询
- 用于前端或业务逻辑识别租户对应的超级管理员用户ID,便于权限管理
2026-07-21 14:28:10 +08:00
gxwebsoft b2487572a6 feat(auth): 实现跨租户管理员手机号短信验证码发送功能
- 新增根据手机号查询跨租户管理员账号功能,支持超级管理员和普通管理员
- 新增后台管理员跨租户发送短信验证码接口,允许管理员身份验证后发送
- 将短信发送核心逻辑抽取到私有方法,支持传入租户ID读取对应配置
- 优化短信配置读取逻辑,兼容历史配置Key
- 移除MainController中硬编码的短信配置,改为通过配置和租户动态获取
- UserMapper及UserService相关接口和实现新增跨租户管理员查询方法
2026-07-20 23:30:35 +08:00
gxwebsoft 592345018e feat(system): 实现手机号换绑双重短信验证码验证
- 用户换绑手机号时新增两步短信验证码校验流程
- 第一步:向原手机号(或无原手机号时的新号)发送验证码验证账户归属
- 第二步:向新手机号发送验证码验证新号归属,若账户无旧手机号则跳过
- 新手机号绑定时增加唯一性校验,防止被其他账户占用
- 更新User实体,新增smsCodeNew属性用于存储新手机号验证码
- 优化Redis验证码缓存和删除逻辑,防止验证码滥用
2026-07-20 23:22:53 +08:00
gxwebsoft e20503269d feat(auth): 实现邮箱验证码发送与校验功能
- 新增邮箱格式校验方法 isValidEmail
- 添加发送邮箱验证码的邮件模板方法 sendCaptchaEmail
- 在用户信息更新时添加邮箱验证码校验逻辑,确保邮箱变更安全
- 新增 API 接口 /sendEmailCaptcha 用于发送邮箱验证码
- 添加 EmailCaptchaParam 参数类支持邮箱验证码请求数据
- 增加邮箱验证码的 Redis 缓存及过期时间常量定义
- 更新安全配置,允许 /api/sendEmailCaptcha 免认证访问
- User实体新增 emailCode 和 smsCode 字段用于验证码校验
2026-07-20 22:38:23 +08:00
gxwebsoft 7f53b39c56 feat(security): 新增通过邮箱登录接口白名单
- 在安全配置中添加 "/api/loginByEmail" 路径到登录白名单
- 支持用户通过邮箱进行登录请求
- 保证邮箱登录接口免认证访问权限
2026-07-18 11:32:03 +08:00
gxwebsoft 89571c81a4 fix(user): 修复默认角色创建时租户不存在校验问题
- 增加根据邮箱查询账号的方法,忽略租户隔离
- 引入 TenantService 以校验租户是否存在
- 在创建默认“用户”角色时添加租户存在性校验
- 防止因租户缺失导致外键约束失败异常
2026-07-18 11:18:00 +08:00
gxwebsoft aa287ba655 fix(system): 修复登录参数不能为空时返回信息不完整问题
- 修改登录接口中参数为空时的失败返回,增加了返回值的第二个参数null
- 确保调用fail方法时提供完整参数,避免可能的空指针异常
- 提升参数校验逻辑的稳定性和容错性
2026-07-18 11:01:02 +08:00
gxwebsoft 4220de534a Merge remote-tracking branch 'origin/master' 2026-07-18 11:00:15 +08:00
gxwebsoft 5e9dc385f5 feat(auth): 支持邮箱登录及跨租户账号查询
- 在LoginParam中添加邮箱字段支持
- 新增邮箱登录接口,支持跨租户通过邮箱登录功能
- 实现根据邮箱查询所有账号接口,方便多账号选择
- 添加UserService方法支持根据邮箱查询账号
- 在UserMapper.xml中新增根据邮箱查询所有账号SQL语句
- 登录接口增加用户状态和密码校验逻辑
- 设置登录成功后Token生成及Redis缓存同步
- 处理同一邮箱多个租户账号时的租户选择逻辑
2026-07-18 11:00:07 +08:00
b2894lxlx ba0e78d4c2 修复DictData的type字段无法保存的问题 2026-07-17 12:50:12 +08:00
gxwebsoft aa7f3f7a6e fix(api): 修复用户审核相关接口及查询逻辑
- 新增 GET /api/system/user/audit/{userId} 查询用户审核信息接口
- 修改前端 auditUser API 请求为 PUT,调整请求参数和字段映射
- UserMapper.xml 中增加 auditStatus 和 auditPending 查询条件支持
- UserParam.java 新增 auditStatus 字段及 auditPending 标记用于待处理审核用户查询
- 删除用户表新增审核字段的数据库脚本,避免重复添加
2026-07-12 00:57:39 +08:00
gxwebsoft ce73d14d1c feat(user): 添加查询用户审核信息接口
- 新增 `GET /api/system/user/audit/{userId}` 接口
- 实现根据用户ID查询用户审核状态和拒绝原因
- 接口仅允许拥有 'sys:auth:user' 权限的用户访问
- 当用户不存在时,返回相应失败提示
- 返回数据包含 userId、auditStatus 和 rejectReason 信息
2026-07-11 23:51:24 +08:00
gxwebsoft c0c140fcdf feat(user): 添加用户审核状态和店名字段支持
- 在数据库用户表中新增 audit_status 和 shop_name 字段
- 在用户实体类中添加 shopName 属性
- 登录参数类中新增 auditStatus 字段,支持前端传值控制审核流程
- 主控制器中增加对注册人姓名和店名的非空校验逻辑
- 注册用户时默认审核通过,支持 auditStatus=0 时进入审核流程
- 查询用户信息接口返回包含审核状态、驳回原因及地址完整性判断信息
- 用户服务实现中根据传入参数设置用户审核状态
2026-07-11 19:30:39 +08:00
gxwebsoft 54ee652584 feat(auth): 实现用户注册地址提交及审核状态管理
- 新增提交注册地址接口,支持录入及提交用户注册地址信息并标记待审核状态
- 新增获取当前用户审核状态接口,返回审核结果和驳回原因
- 用户实体新增审核状态和驳回原因字段,用于注册审核流程
- 管理端新增用户审核接口,支持通过或拒绝用户注册及填写驳回原因
- 下单流程中添加审核状态校验,未通过审核用户禁止下单
- 新增用户注册时审核状态字段,支持前端控制是否走审核流程
- 完善数据库表结构,新增审核状态及驳回原因字段,支持注册审核功能
2026-07-11 19:14:11 +08:00
gxwebsoft f8595dd2d5 fix(system): 修复查询条件添加已安装标记过滤
- 在UserMapper的查询中新增过滤条件,确保只查询已安装用户
- 优化查询逻辑,避免结果包含未安装用户
- 保持排序顺序不变,优先根据最后登录时间排序
- 增强系统数据准确性和筛选能力
2026-07-03 10:58:18 +08:00
gxwebsoft d49617a547 fix(system): 修复超级管理员标识设置和微信登录openid处理
- 在超级管理员用户创建时正确设置isSuperAdmin和isAdmin标识
- 修正超级管理员isAdmin属性设置顺序
- 微信登录接口中将openid和unionid设置到userParam,保证后续用户查找和注册使用
- 对已有用户补充openid和unionid信息,避免老用户缺少openid导致问题
- 更新用户信息后保存,确保数据一致性
2026-07-02 15:45:08 +08:00
gxwebsoft 4ba69b2ce2 fix(system): 修正管理员用户角色设置问题
- 给用户参数添加超级管理员标志
- 确保新创建租户的用户拥有超级管理员权限
- 改善用户权限初始化逻辑
2026-07-02 13:36:55 +08:00
gxwebsoft b119a92d94 fix(system): 修正管理员用户角色设置问题
- 给用户参数添加超级管理员标志
- 确保新创建租户的用户拥有超级管理员权限
- 改善用户权限初始化逻辑
2026-07-02 00:02:27 +08:00
gxwebsoft 81ac636fb1 fix(system): 修复UserMapper分页查询重复记录问题
- 解决分页查询时因sys_company多条记录导致的数据重复问题
- 将LEFT JOIN sys_company改为按tenant_id分组的子查询
- 修改后子查询仅取每个tenant_id的第一条公司记录
- 保证分页查询返回的数据不再重复
- 同步更新了编译后的目标类文件
2026-06-29 16:45:15 +08:00
gxwebsoft 0aa65d0195 fix(system): 修复UserMapper分页查询重复记录问题
- 解决分页查询时因sys_company多条记录导致的数据重复问题
- 将LEFT JOIN sys_company改为按tenant_id分组的子查询
- 修改后子查询仅取每个tenant_id的第一条公司记录
- 保证分页查询返回的数据不再重复
- 同步更新了编译后的目标类文件
2026-06-29 15:30:26 +08:00
b2894lxlx 09ef6558ae 去除字典重复校验 2026-06-28 21:14:07 +08:00
gxwebsoft bb34831208 Merge remote-tracking branch 'origin/master' 2026-06-28 13:07:35 +08:00
gxwebsoft b8c147c641 fix(AliOssController): 根据文件类型修正缩略图和预览图逻辑
- 获取上传文件的 contentType 以判断文件类型
- 图片类型时生成带图片处理参数的缩略图和预览图
- 视频类型时生成视频截帧的缩略图,预览图使用原始 URL
- 其他类型文件直接使用原始文件 URL 作为缩略图和预览图
- 避免非图片文件拼接图片处理参数导致显示异常
2026-06-28 13:07:27 +08:00
b2894lxlx fc98ee0509 密码登录新增手机号适配 2026-06-23 17:34:48 +08:00
b2894lxlx a220b505e6 密码登录新增手机号适配 2026-06-23 17:13:35 +08:00
b2894lxlx ab85f48a40 新增字典字段类型 2026-06-22 16:22:09 +08:00
gxwebsoft 13c48df4b3 fix(app_config): 修复小程序配置全局查询问题
- 调整 AppConfigMapper.xml,使用 bound_tenant_id 精确匹配绑定租户ID
- 修改 AppConfigService 去除 tenantId 为空直接返回的逻辑,支持全局查询
- 修改 WxLoginController,调用 getByCategory 时传入 null 以支持全局查询
- 新增实体字段 boundTenantId 用于区分绑定租户ID
- application-dev.yml 启用 SQL 日志和服务调试日志,便于问题排查
- 统一日志打印详细查询过程及结果,方便调试检查配置加载情况
2026-06-21 11:56:59 +08:00
gxwebsoft 6eb1c67516 fix(system): 调整小程序配置读取顺序
- 修改 getMpWxSetting 方法,优先读取 sys_setting(mp-weixin)
- 读取失败时回退到 db_websopy.app_config(category=wechat)
- 更新异常日志内容,明确读取失败顺序
- 适配业务需求调整配置优先级
- 影响所有调用 getMpWxSetting 的相关方法调用流程
2026-06-21 11:10:20 +08:00
gxwebsoft 03cefc9048 Merge remote-tracking branch 'origin/master' 2026-06-21 10:32:58 +08:00
gxwebsoft f5f9e3a19d fix(system): 调整小程序配置读取顺序
- 修改 getMpWxSetting 方法,优先读取 sys_setting(mp-weixin)
- 读取失败时回退到 db_websopy.app_config(category=wechat)
- 更新异常日志内容,明确读取失败顺序
- 适配业务需求调整配置优先级
- 影响所有调用 getMpWxSetting 的相关方法调用流程
2026-06-21 10:32:39 +08:00
b2894lxlx 7f341c2399 Merge remote-tracking branch 'origin/master' 2026-06-20 22:03:11 +08:00
b2894lxlx 2982818a0c 10198添加手机添加下级逻辑 2026-06-20 22:03:07 +08:00
91 changed files with 4920 additions and 1249 deletions
+1 -1
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
# 2026-06-21 工作日志
## WxLoginController 配置读取顺序调整
### 修改内容
修改了 `WxLoginController.java` 中的 `getMpWxSetting` 方法,调整小程序配置读取顺序:
**修改前:**
- 优先:`db_websopy.app_config`category=wechat
- 兜底:`sys_setting.mp-weixin`
**修改后:**
- 优先:`sys_setting.mp-weixin`
- 兜底:`db_websopy.app_config`category=wechat
### 修改原因
业务需求变更,需要优先从系统设置(sys_setting)读取小程序配置,数据库配置(app_config)作为兜底方案。
### 影响范围
影响所有调用 `getMpWxSetting` 方法的地方:
- `getOpenIdByCode` - 获取 openid
- `getAccessToken` - 获取 access_token
- `loginByOpenId` - openid 无感登录
- `getWxOpenId` / `getWxOpenIdOnly` - 获取微信 openId
### 文件位置
`/Users/gxwebsoft/JAVA/com.gxwebsoft.core/src/main/java/com/gxwebsoft/common/system/controller/WxLoginController.java`
---
## app_config 查询不到数据问题修复
### 问题现象
调用 `selectByCategory(tenantId, "wechat")` 查不到数据,但数据库中确实有记录。
### 根本原因
**tenant_id 不匹配!**
| 来源 | tenant_id |
|------|-----------|
| 数据库 app_config 表(wechat配置) | **5** |
| getTenantId() 返回的当前请求租户 | **16411** |
SQL 的 WHERE 条件 `ac.tenant_id = #{tenantId}` 用当前请求租户ID(16411)去匹配数据库中的记录(5),自然查不到。
### 修复方案
小程序配置通常是全局共享的,不应按当前请求租户过滤。修改了以下文件:
1. **AppConfigMapper.xml**
- 使用 MyBatis 动态 SQL `<if test="tenantId != null">`
- 当 tenantId 为 null 时,不加 `tenant_id` 过滤条件,查询所有租户下的配置
2. **AppConfigService.java**
- 移除 tenantId 为空时直接返回 null 的逻辑
- 支持全局查询模式
3. **WxLoginController.java**
- 调用 `getByCategory("wechat", null)` 传入 null,触发全局查询
4. **application-dev.yml**
- 启用 SQL 日志方便调试
### 修改文件清单
- `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.xml`
- `src/main/java/com/gxwebsoft/websopy/service/AppConfigService.java`
- `src/main/java/com/gxwebsoft/common/system/controller/WxLoginController.java`
- `src/main/resources/application-dev.yml`
+16
View File
@@ -0,0 +1,16 @@
# 2026-06-28 工作记录
## AliOssController 上传接口优化
**文件**: `src/main/java/com/gxwebsoft/common/system/controller/AliOssController.java`
**问题**: 上传文件时,无论文件类型如何,都无条件拼接 `?x-oss-process=image/...` 图片处理参数,导致非图片文件的 thumbnail/url 无效。
**修改内容**:
1.`FileServerUtil.getContentType(upload)` 调用移到设置 thumbnail/url 之前
2. 根据 contentType 判断文件类型,分别处理:
- **图片** (`image/*`): 照常拼接 OSS 图片处理参数(缩略图 100x100 + 预览图 750px宽)
- **视频** (`video/*`): 使用 OSS 视频截帧 `?x-oss-process=video/snapshot,t_0,f_jpg,w_100,h_100,m_fast` 生成缩略图
- **其他文件**: thumbnail 和 url 直接设为原始文件 URL,不加任何处理参数
**依赖**: 使用了已有的 `FileServerUtil.isImage(String contentType)` 方法判断图片类型。
+15
View File
@@ -0,0 +1,15 @@
# 2026-06-29
## fix: UserMapper.xml LEFT JOIN sys_company 导致分页查询记录重复
- 问题:`SELECT * FROM sys_user WHERE is_developer = 1` 只有5条记录,但 `/api/system/user/page?isDeveloper=true` 返回多条重复记录
- 原因:`LEFT JOIN sys_company g ON g.tenant_id = t.tenant_id` 中,一个 tenant_id 可能在 sys_company 中有多条公司记录,产生笛卡尔积
- 修复:将 `LEFT JOIN sys_company` 改为子查询,按 `tenant_id` 分组取第一条记录
```sql
LEFT JOIN (
SELECT tenant_id, MAX(company_name) as company_name, MAX(company_logo) as company_logo
FROM sys_company
GROUP BY tenant_id
) g ON g.tenant_id = t.tenant_id
```
- 同步修改了 `target/classes/` 下的编译版本
+11
View File
@@ -0,0 +1,11 @@
# 2026-07-11 工作日志
## 补充用户审核查询接口
- **问题**`GET /api/system/user/audit/{userId}` 返回404,接口不存在
- **原因**:只有 `PUT /api/system/user/audit`(执行审核操作),缺少查询用户审核信息的GET接口
- **修复**:在 `UserController.java` 中新增 `getAuditInfo` 方法,返回 userId、auditStatus、rejectReason
## 修复前端审核接口请求方法不匹配
- **问题**`Request method 'POST' not supported` 报错
- **原因**:前端 `auditUser` API 用 POST + 路径参数 `/audit/{id}`,后端是 PUT + body 参数 `/audit`
- **修复**:修改 `guilixu-admin/src/api/system/user/index.ts`,改为 PUT 请求、去掉路径参数、字段名映射(status→auditStatus, remark→rejectReason)、userId 放入 body
+28
View File
@@ -0,0 +1,28 @@
# 2026-07-21 工作记录
## 邀请成为开发者报错分析(UID:35739)
- 报错「该用户不是平台开发者,无法邀请为开发者角色」来源:`websopy-java` 项目(非 com.gxwebsoft.core)。
- 拦截点共 3 处,逻辑一致:邀请角色为 `developer` 时,跨库查 `gxwebsoft_core.sys_user.is_developer`,必须为 `1` 才放行:
- `com/gxwebsoft/app/service/impl/AppUserServiceImpl.java:168-172`API: POST /api/app/developer/.../invite
- `com/gxwebsoft/app/service/impl/AppInviteServiceImpl.java:257-261`
- `com/gxwebsoft/app/controller/AppMpInviteController.java:179-183`(小程序邀请)
- 「平台开发者」定义:
- `sys_user.type = 2`(见 AppUserController /check-access 判断)
- `sys_user.is_developer = 1`(邀请 developer 角色的硬性门槛,由 SysUserCrossDbMapper.selectIsDeveloperByUserId 查询)
- 关键发现:websopy-java 与 com.gxwebsoft.core 后端代码中**没有任何写入 is_developer=1 / type=2 的逻辑**,即「成为开发者」的赋权由 websopy-pc 前端开发者门户注册/认证流程或数据库操作完成(后端仅做读取校验)。
- 结论:UID:35739 的 `sys_user.is_developer` 为 0 或 NULL → 报错。需先让其完成「成为开发者」注册使 is_developer=1(并建议同步 type=2)。
- 潜在 Bug:邀请门槛用 is_developer,而 /check-access 用 type=2。若两字段不一致,会出现「能进开发者中心但无法被邀请为开发者」的矛盾。
## 统一开发者身份判定为 is_developer=1(已实施)
- 用户选定方案:把开发者身份判定统一为「sys_user.is_developer = 1」。
- 改动(websopy-java 项目):
- `AppUserController.checkAccess()`:原 `selectUserType()==2` → 改为 `selectIsDeveloperByUserId()==1`,同步更新 Javadoc。
- `AppProductServiceImpl` 转让应用所有权校验:原 `selectUserType()==2` → 改为 `selectIsDeveloperByUserId()==1`
- 三处邀请门槛(AppUserServiceImpl / AppInviteServiceImpl / AppMpInviteController)本就使用 is_developer=1,无需改。
- 结果:全站「平台开发者」判定统一为 is_developer=1 单一标准。
- 注意:此变更使 is_developer 成为唯一依据;存量仅 type=2、无 is_developer 的开发者将失去权限。需数据回填:`UPDATE gxwebsoft_core.sys_user SET is_developer=1 WHERE type=2 AND deleted=0;`(先备份)。UID:35739 仍需 is_developer=1 才能被邀请。
## Tenant 实体补充 superAdminUserId 字段
- 用户已在 `TenantMapper.xml``selectSql` 中 SELECT `u.user_id as superAdminUserId`LEFT JOIN gxwebsoft_core.sys_user u ON u.tenant_id=a.tenant_id AND u.is_super_admin=1 AND u.deleted=0)。
-`Tenant.java` 中补充 `private Integer superAdminUserId;` 并标注 `@TableField(exist = false)`,使 MyBatis 能映射该衍生字段(不是 sys_tenant 真实列,不参与增改)。
- `selectPageRel` / `selectListRel` 用 resultType=Tenant,可直接返回该字段。
+13
View File
@@ -0,0 +1,13 @@
# 2026-07-23 工作记录
## 短信验证码接口 sendSmsCaptchaByAdmin 发送失败排查
- 现象:线上 `https://site.websoft.top/api/_server/sendSmsCaptchaByAdmin` 调用手机 15678895934 失败,返回 `401 请先登录`
- 根因:`com/gxwebsoft/common/core/security/SecurityConfig.java``permitAll` 白名单里只有 `/api/sendSmsCaptcha``/api/sendEmailCaptcha`,没有 `/api/sendSmsCaptchaByAdmin`。该接口被 Spring Security 拦截,未登录请求直接 401,控制器逻辑未执行,验证码未发出。
- 已修复:在 SecurityConfig 的 permitAll antMatchers 中加入 `/api/sendSmsCaptchaByAdmin`(位于 `/api/sendSmsCaptcha` 之后)。
- 说明:接口内部已有 `getByPhoneAndAdmin` 管理员手机号校验,开放 permitAll 安全;线上代理前缀为 `/api/_server/**`,转发到后端 `/api/**`
- 验证方式:用 curl 直接打 `/api/_server/sendSmsCaptchaByAdmin` 复现 401;同前缀的 `/api/sendSmsCaptcha` 因在白名单可正常进入(需确认部署后 ByAdmin 不再 401)。
## 关键文件
- SecurityConfig.java`com/gxwebsoft/common/core/security/SecurityConfig.java`
- MainController.sendSmsCaptchaByAdmin`com/gxwebsoft/common/system/controller/MainController.java:656`
- 管理员校验 SQL`UserMapper.xml selectAdminByPhoneCrossTenant`sys_user.deleted=0 AND phone=? AND (is_super_admin=1 OR is_admin=1)
+23
View File
@@ -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` 预览真实渲染效果。
+7
View File
@@ -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`
+10
View File
@@ -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_usersys_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 列)。
+325
View File
@@ -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。
+50 -1
View File
@@ -5,7 +5,7 @@
<groupId>com.gxwebsoft</groupId>
<artifactId>server-api</artifactId>
<version>1.0</version>
<version>2.0</version>
<name>server-api</name>
<description>WebSoftApi project for Spring Boot</description>
@@ -24,9 +24,35 @@
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- 平台层版本:升级平台能力只改这一处 -->
<websoft-platform.version>1.0.1</websoft-platform.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- 强制 okhttp/okio 版本,避免 minio-java(要求 okhttp>=4.11.0) 与微信/支付 SDK 引入的 okhttp3 旧版冲突 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>3.6.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- 强制 okhttp 4.12.0minio-java 要求 okhttp>=4.11.0,放在 dependencies 首位确保胜出,
避免运行时 NoSuchMethodErrorokhttp3.RequestBody.create 等) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- spring-boot-devtools -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -179,6 +205,21 @@
<artifactId>spring-boot-starter-security</artifactId>
</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 - 升级到安全版本 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
@@ -297,6 +338,14 @@
<version>3.17.4</version>
</dependency>
<!-- MinIO 官方 Java SDK:用于对接 MinIO 兼容 S3 存储。
注意 aliyun OSS SDK 无 AWS4-HMAC-SHA256 签名器,无法对接 MinIO,故 minio 分支单独用此 SDK。 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- 阿里云 内容安全审核 -->
<dependency>
<groupId>com.aliyun</groupId>
+180
View File
@@ -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;
/**
* 内部服务调用密钥(调用 websopy 等内部接口时使用)
*/
private String internalKey;
/**
* 微信扫码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.TenantLineInnerInterceptor;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.common.system.entity.User;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
@@ -40,6 +41,12 @@ public class MybatisPlusConfig {
@Override
public Expression getTenantId() {
// 0 开放平台链路:租户只来自令牌,优先级最高
// 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离
Integer openTenantId = OpenTenantContext.getTenantId();
if (openTenantId != null) {
return new LongValue(openTenantId);
}
String tenantId;
// 从请求头拿ID
tenantId = request.getHeader("tenantId");
@@ -5,6 +5,10 @@ public class RedisConstants {
public static final String SMS_CODE_KEY = "sms";
// 验证码过期时间
public static final Long SMS_CODE_TTL = 5L;
// 邮箱验证码Key
public static final String EMAIL_CODE_KEY = "emailCode";
// 邮箱验证码过期时间(分钟)
public static final Long EMAIL_CODE_TTL = 5L;
// 微信凭证access-token
public static final String ACCESS_TOKEN_KEY = "access-token";
// 空值防止击穿数据库
@@ -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
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
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -20,6 +21,7 @@ import javax.annotation.Resource;
* @since 2020-03-23 18:04:52
*/
@Configuration
@Order(2)
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@@ -41,11 +43,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/login",
"/api/qr-login/**",
"/api/loginByUserId",
"/api/loginByEmail",
"/api/register",
"/api/superAdminRegister",
"/api/findAccountByPhone",
"/api/resetPassword",
"/api/checkPhoneRegistered",
"/api/checkEmailRegistered",
"/api/existence",
"/api/oss/upload",
"/druid/**",
@@ -53,8 +57,12 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/webjars/**",
"/hxz/v1/**",
"/api/sendSmsCaptcha",
"/api/sendSmsCaptchaByAdmin",
"/api/sendEmailCaptcha",
"/api/verifyEmailCaptcha",
"/api/loginBySms",
"/api/loginBySuperAdminSms",
"/api/loginBySelectTenant",
"/api/loginByDeveloperSms",
"/api/system/user/regByPhone",
"/api/parseToken/*",
@@ -290,4 +290,20 @@ public class CommonUtil {
return pattern.matcher(phoneNumber).matches();
}
/**
* 校验邮箱格式是否有效
*
* @param email 要验证的邮箱字符串
* @return 如果字符串是有效的邮箱地址,则返回true;否则返回false
*/
public static boolean isValidEmail(String email) {
if (email == null) {
return false;
}
// 邮箱格式正则:本地部分@域名.顶级域
String regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
Pattern pattern = Pattern.compile(regex);
return pattern.matcher(email).matches();
}
}
@@ -0,0 +1,151 @@
package com.gxwebsoft.common.core.utils;
import io.minio.BucketExistsArgs;
import io.minio.GetBucketPolicyArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.SetBucketPolicyArgs;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* MinIO 对象存储工具类
*
* <p>说明:aliyun-sdk-oss 只实现了阿里云自家的 OSS 签名(OSSV1/V2/V4Signer),
* 不含 AWS4-HMAC-SHA256,因此无法对接只接受 AWS4 的 MinIO。
* 所有 MinIO 操作统一走官方 minio-java SDK。
*
* @author WebSoft
* @since 2026-08-07
*/
public class MinioUtil {
/**
* MinIO 默认 region。
* 必须显式指定,否则 minio-java 会先发 {@code GET /{bucket}?location=} 做 region 自动探测,
* 该探测请求在 MinIO + 反向代理场景下容易触发 SignatureDoesNotMatch。
*/
public static final String DEFAULT_REGION = "us-east-1";
/**
* 已确保「存在 + 已配置匿名只读策略」的 bucket 缓存(endpoint|bucket)。
* 进程内缓存,避免每次上传都多发两次 HTTP 请求。
*/
private static final Set<String> READY_BUCKETS = ConcurrentHashMap.newKeySet();
private MinioUtil() {
}
/**
* 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
*/
public static String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
String result = endpoint.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
/**
* 构建 MinioClient(统一带上 region,跳过 getBucketLocation 探测)
*
* @param endpoint S3 API 地址,如 https://minio.websoft.top
* @param accessKeyId AK
* @param accessKeySecret SK
*/
public static MinioClient buildClient(String endpoint, String accessKeyId, String accessKeySecret) {
return MinioClient.builder()
.endpoint(normalizeEndpoint(endpoint))
.credentials(accessKeyId, accessKeySecret)
.region(DEFAULT_REGION)
.build();
}
/**
* 生成 bucket 的匿名只读策略。
* 只放开读取对象,<b>故意不给 s3:ListBucket</b>,避免匿名用户列出桶内全部文件清单。
*/
public static String buildPublicReadPolicy(String bucketName) {
return "{"
+ "\"Version\":\"2012-10-17\","
+ "\"Statement\":["
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:GetBucketLocation\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:GetObject\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}"
+ "]}";
}
/**
* 确保 bucket 可用:
* <ol>
* <li>bucket 不存在 → 自动创建,并套上匿名只读策略</li>
* <li>bucket 已存在但没有任何策略 → 补上匿名只读策略(兼容历史手动建的裸桶)</li>
* <li>bucket 已有自定义策略 → 不覆盖,尊重人工配置</li>
* </ol>
* 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。
*
* <p>本方法不会抛异常:即使当前 AK 没有建桶/改策略权限,也只打日志,
* 后续的上传动作照常执行(真正失败会由上传自身报错)。
*
* @return true 表示 bucket 已就绪
*/
public static boolean ensureBucketReady(MinioClient client, String endpoint, String bucketName) {
if (client == null || bucketName == null || bucketName.isEmpty()) {
return false;
}
String cacheKey = normalizeEndpoint(endpoint) + "|" + bucketName;
if (READY_BUCKETS.contains(cacheKey)) {
return true;
}
try {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
client.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName)
.config(buildPublicReadPolicy(bucketName))
.build());
System.out.println("[MinIO] 自动创建 bucket 并配置匿名只读策略: " + bucketName);
} else {
// minio-java 在无策略时返回空串而非抛错
String policy = client.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucketName).build());
if (policy == null || policy.trim().isEmpty()) {
client.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName)
.config(buildPublicReadPolicy(bucketName))
.build());
System.out.println("[MinIO] 已为存量 bucket 补充匿名只读策略: " + bucketName);
}
}
READY_BUCKETS.add(cacheKey);
return true;
} catch (Exception e) {
// 不阻断上传:权限不足或网络异常时仅告警
System.out.println("[MinIO] ensureBucketReady 失败(不影响上传), bucket=" + bucketName
+ ", reason=" + e.getMessage());
return false;
}
}
/**
* 手动失效缓存(例如后台修改了存储配置后调用)
*/
public static void evictBucketCache(String endpoint, String bucketName) {
READY_BUCKETS.remove(normalizeEndpoint(endpoint) + "|" + bucketName);
}
/**
* 清空全部缓存
*/
public static void clearBucketCache() {
READY_BUCKETS.clear();
}
}
@@ -7,8 +7,10 @@ import cn.hutool.extra.qrcode.QrConfig;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.util.Base64;
import java.util.HashMap;
import static com.gxwebsoft.common.core.constants.QRCodeConstants.*;
@@ -77,4 +79,17 @@ public class MyQrCodeUtil {
QrCodeUtil.generate(content, config, FileUtil.file(filePath));
return qrcodeUrl;
}
/**
* 生成二维码并返回 base64data: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.message.SyncMessage;
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
import com.gxwebsoft.common.system.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
@@ -108,6 +109,19 @@ public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate
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);
sendSyncMessage(message);
} catch (Exception e) {
@@ -7,6 +7,8 @@ import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.common.comm.SignVersion;
import com.aliyun.oss.common.auth.CredentialsProvider;
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
import com.aliyun.oss.common.utils.BinaryUtil;
@@ -22,6 +24,7 @@ import com.aliyuncs.profile.IClientProfile;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.utils.FileServerUtil;
import com.gxwebsoft.common.core.utils.MinioUtil;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
@@ -35,6 +38,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@@ -96,6 +101,10 @@ public class AliOssController extends BaseController {
String bucketName = settingInfo.getString("bucketName");
String accessKeyId = settingInfo.getString("accessKeyId");
String accessKeySecret = settingInfo.getString("accessKeySecret");
String uploadMethod = settingInfo.getString("uploadMethod");
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
endpoint = MinioUtil.normalizeEndpoint(endpoint);
// 判断是否登录
String authorization = getAuthorization();
@@ -112,9 +121,21 @@ public class AliOssController extends BaseController {
// 上传文件结果
FileRecord result;
boolean isMinio = "minio".equals(uploadMethod);
// MinIO 用官方 minio-java SDK(原生 AWS4-HMAC-SHA256 + path-style);
// aliyun OSS SDK 无 AWS4 签名器,对接不了 MinIO,因此 minio 分支独立使用 MinioClient。
OSS ossClient = null;
MinioClient minioClient = null;
if (isMinio) {
// 显式指定 region=us-east-1,跳过 minio-java 的 getBucketLocation 自动探测
// (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
// bucket 不存在则自动创建,并套上匿名只读策略(存量裸桶也会自动补策略)
MinioUtil.ensureBucketReady(minioClient, endpoint, bucketName);
} else {
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
}
try {
@@ -123,16 +144,18 @@ public class AliOssController extends BaseController {
String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length());
String originalName = file.getOriginalFilename();
// 创建PutObjectRequest对象
// 上传文件到对象存储
if (isMinio) {
// minio-javapath 此时无前导 "/",作为对象名(如 2026/08/07/xxx.png),默认 region=us-east-1
minioClient.uploadObject(UploadObjectArgs.builder()
.bucket(bucketName)
.object(path)
.filename(upload.getAbsolutePath())
.build());
} else {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload);
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
// ObjectMetadata metadata = new ObjectMetadata();
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// metadata.setObjectAcl(CannedAccessControlList.Private);
// putObjectRequest.setMetadata(metadata);
// 上传文件。
PutObjectResult ossResult = ossClient.putObject(putObjectRequest);
ossClient.putObject(putObjectRequest);
}
// 保存记录并返回
result = new FileRecord();
@@ -155,11 +178,39 @@ public class AliOssController extends BaseController {
result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName);
result.setLength(upload.length());
result.setPath(bucketDomain + path);
result.setThumbnail(bucketDomain + path + "?x-oss-process=image/resize,m_fixed,w_100,h_100/quality,Q_90");
result.setUrl(bucketDomain + path + "?x-oss-process=image/resize,w_750/quality,Q_90");
result.setDownloadUrl(bucketDomain + path);
// 获取文件类型
String contentType = FileServerUtil.getContentType(upload);
result.setContentType(contentType);
// 根据文件类型设置缩略图和预览图
// MinIO 不支持阿里云 x-oss-process 图片处理参数,统一返回原图 URL
isMinio = "minio".equals(uploadMethod);
if (FileServerUtil.isImage(contentType)) {
if (isMinio) {
result.setThumbnail(bucketDomain + path);
result.setUrl(bucketDomain + path);
} else {
// 图片:生成缩略图和压缩预览图
result.setThumbnail(bucketDomain + path + "?x-oss-process=image/resize,m_fixed,w_100,h_100/quality,Q_90");
result.setUrl(bucketDomain + path + "?x-oss-process=image/resize,w_750/quality,Q_90");
}
} else if (contentType != null && contentType.startsWith("video/")) {
if (isMinio) {
result.setThumbnail(bucketDomain + path);
result.setUrl(bucketDomain + path);
} else {
// 视频:使用OSS视频截帧生成缩略图(取首帧)
result.setThumbnail(bucketDomain + path + "?x-oss-process=video/snapshot,t_0,f_jpg,w_100,h_100,m_fast");
result.setUrl(bucketDomain + path);
}
} else {
// 非图片/视频:直接使用原始URL
result.setThumbnail(bucketDomain + path);
result.setUrl(bucketDomain + path);
}
result.setTenantId(Integer.valueOf(tenantId));
upload.delete();
fileRecordService.save(result);
@@ -186,10 +237,15 @@ public class AliOssController extends BaseController {
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} catch (Exception e) {
// 兼容 minio-java 抛出的异常(MinioException / IOException 等)
System.out.println("Caught an upload Exception: " + e.getMessage());
e.printStackTrace();
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
// MinioClient 内部复用 HttpClient 连接池,无需显式关闭
}
return fail("上传失败", null);
}
@@ -61,11 +61,11 @@ public class DictDataController extends BaseController {
.eq(DictData::getDictDataName, dictData.getDictDataName())) > 0) {
return fail("字典数据名称已存在");
}
if (dictDataService.count(new LambdaQueryWrapper<DictData>()
.eq(DictData::getDictId, dictData.getDictId())
.eq(DictData::getDictDataCode, dictData.getDictDataCode())) > 0) {
return fail("字典数据标识已存在");
}
// if (dictDataService.count(new LambdaQueryWrapper<DictData>()
// .eq(DictData::getDictId, dictData.getDictId())
// .eq(DictData::getDictDataCode, dictData.getDictDataCode())) > 0) {
// return fail("字典数据标识已存在");
// }
// 自动添加字典
final int count = dictService.count(new LambdaQueryWrapper<Dict>().eq(Dict::getDictCode, dictData.getDictCode()));
if (dictData.getDictCode() != null && count == 0) {
@@ -95,12 +95,12 @@ public class DictDataController extends BaseController {
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
return fail("字典数据名称已存在");
}
if (dictDataService.count(new LambdaQueryWrapper<DictData>()
.eq(DictData::getDictId, dictData.getDictId())
.eq(DictData::getDictDataCode, dictData.getDictDataCode())
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
return fail("字典数据标识已存在");
}
// if (dictDataService.count(new LambdaQueryWrapper<DictData>()
// .eq(DictData::getDictId, dictData.getDictId())
// .eq(DictData::getDictDataCode, dictData.getDictDataCode())
// .ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
// return fail("字典数据标识已存在");
// }
if (dictDataService.updateById(dictData)) {
return success("修改成功");
}
@@ -29,9 +29,11 @@ public class EmailTestController extends BaseController {
public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) {
try {
emailTemplateUtil.sendRegisterSuccessEmail(
"测试用户",
"13800138000",
"TestPassword123",
"广西河马科技有限公司",
"https://site-10654.sitelnk.cn",
"https://site.websoft.top",
email,
"F!huhtL3fqD8",
email,
getTenantId()
);
@@ -33,15 +33,21 @@ import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.mapper.CompanyMapper;
import com.gxwebsoft.common.system.param.LoginParam;
import com.gxwebsoft.common.system.param.SmsCaptchaParam;
import com.gxwebsoft.common.system.param.ResetPayPasswordParam;
import com.gxwebsoft.common.system.param.EmailCaptchaParam;
import com.gxwebsoft.common.core.constants.RedisConstants;
import com.gxwebsoft.common.system.param.FindAccountByPhoneParam;
import com.gxwebsoft.common.system.param.ResetPasswordParam;
import com.gxwebsoft.common.system.param.UpdatePasswordParam;
import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.result.CaptchaResult;
import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.common.system.result.TenantOption;
import com.gxwebsoft.common.system.result.AccountInfoResult;
import com.gxwebsoft.common.system.result.CheckEmailResult;
import com.gxwebsoft.common.system.result.CheckPhoneResult;
import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.common.system.mapper.UserMapper;
import com.gxwebsoft.common.system.util.EmailTemplateUtil;
import com.wf.captcha.SpecCaptcha;
import io.jsonwebtoken.Claims;
@@ -51,6 +57,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@@ -60,8 +67,11 @@ import java.net.URLEncoder;
import java.text.MessageFormat;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;
@@ -82,8 +92,12 @@ public class MainController extends BaseController {
@Resource
private ConfigProperties configProperties;
@Resource
private RestTemplate restTemplate;
@Resource
private UserService userService;
@Resource
private UserMapper userMapper;
@Resource
private RoleMenuService roleMenuService;
@Resource
private LoginRecordService loginRecordService;
@@ -149,6 +163,7 @@ public class MainController extends BaseController {
// return success(message, new LoginResult(access_token, user));
// }
// }
user = userService.getByUsernamePhone(username, tenantId);
}else {
// 判断图形验证码
if (!tenantId.equals(10159) && !tenantId.equals(10158)) {
@@ -160,7 +175,7 @@ public class MainController extends BaseController {
}
}
// 当前租户登录(登录账号|手机号码|邮箱登录)
user = userService.getByUsername(username, tenantId);
user = userService.getByUsernamePhone(username, tenantId);
}
if (user == null) {
@@ -236,6 +251,77 @@ public class MainController extends BaseController {
return success("登录成功", new LoginResult(access_token, user));
}
@Operation(summary = "邮箱登录(支持跨租户)")
@PostMapping("/loginByEmail")
public ApiResult<LoginResult> loginByEmail(@RequestBody LoginParam param, HttpServletRequest request) {
// 设置过期时间
Long tokenExpireTime = configProperties.getTokenExpireTime();
final String email = param.getEmail();
final String password = param.getPassword();
if (StrUtil.isBlank(email) || StrUtil.isBlank(password)) {
return fail("参数不能为空",null);
}
// 跨租户:按邮箱查询所有租户下的账号
List<User> users = userService.findAccountsByEmail(email);
if (users == null || users.isEmpty()) {
String message = "用户不存在";
loginRecordService.saveAsync(email, LoginRecord.TYPE_ERROR, message, null, request);
return fail(message, null);
}
User user;
if (users.size() == 1) {
user = users.get(0);
} else {
// 同一邮箱在多个租户下存在,需用户选择具体租户
if (param.getTenantId() == null) {
User first = users.get(0);
first.setHasAdminsByPhone(true);
return success("请选择登录用户", new LoginResult(null, first));
}
user = users.stream()
.filter(u -> u.getTenantId().equals(param.getTenantId()))
.findFirst()
.orElse(null);
if (user == null) {
return fail("用户不存在", null);
}
}
if (!user.getStatus().equals(0)) {
String message = "账号被冻结";
loginRecordService.saveAsync(email, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
return fail(message, null);
}
if (!userService.comparePassword(user.getPassword(), password)) {
String message = "密码错误";
loginRecordService.saveAsync(email, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
return fail(message, null);
}
// 登录成功
loginRecordService.saveAsync(email, LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
final JSONObject register = cacheClient.getSettingInfo("register", user.getTenantId());
if (register != null) {
final String ExpireTime = register.getString("tokenExpireTime");
if (ExpireTime != null) {
tokenExpireTime = Long.valueOf(ExpireTime);
}
}
// 签发token
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
tokenExpireTime, configProperties.getTokenKey());
// 同步redis
redisUtil.set("access_token:" + user.getUserId(), access_token, tokenExpireTime, TimeUnit.SECONDS);
return success("登录成功", new LoginResult(access_token, user));
}
@Operation(summary = "查询邮箱下全部账号(跨租户,用于多账号选择)")
@GetMapping("/listAdminsByEmailAll")
public ApiResult<?> listAdminsByEmailAll(LoginParam param) {
final List<User> accounts = userService.findAccountsByEmail(param.getEmail());
return success(accounts);
}
@Operation(summary = "检查用户是否存在")
@GetMapping("/existence")
public ApiResult<?> existence(ExistenceParam<User> param) {
@@ -260,11 +346,11 @@ public class MainController extends BaseController {
}
final Company company = companyMapper.getByTenantId(tenantId);
// 是否过期
if (company.getVersion() < 30) {
if (Instant.now().isAfter(company.getExpirationTime().toInstant())) {
return fail(MessageFormat.format("应用ID({0})已过期",company.getTenantId()),null);
}
}
// if (company.getVersion() < 30) {
// if (Instant.now().isAfter(company.getExpirationTime().toInstant())) {
// return fail(MessageFormat.format("应用ID({0})已过期",company.getTenantId()),null);
// }
// }
company.setBusinessEntity(null);
company.setPhone(null);
// 配置信息
@@ -318,14 +404,67 @@ public class MainController extends BaseController {
update.setAvatar(user.getAvatar());
update.setBgImage(user.getBgImage());
update.setSex(user.getSex());
update.setPhone(user.getPhone());
// 手机号变更:两步验证
// 第一步:短信验证码发往原手机号(验证当前账户归属),校验通过才允许换绑
// 第二步:向新手机号发送验证码(验证新号归属);账号原本无手机号时,第一步的码已发往新号,无需第二步
if (StrUtil.isNotBlank(user.getSmsCode())) {
String newPhone = user.getPhone();
if (StrUtil.isBlank(newPhone) || !CommonUtil.isValidPhoneNumber(newPhone)) {
return fail("手机号格式不正确", null);
}
User currentUser = userService.getById(getLoginUserId());
String currentPhone = currentUser != null ? currentUser.getPhone() : null;
// 第一步:校验原手机号(或账号原本无手机号时的新号)验证码
String verifyPhone = StrUtil.isNotBlank(currentPhone) ? currentPhone : newPhone;
String oldKey = "code:" + verifyPhone;
String cachedOld = redisUtil.get(oldKey);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (StrUtil.isBlank(cachedOld) || (!cachedOld.equals(user.getSmsCode()) && !user.getSmsCode().equals(devCode))) {
return fail("短信验证码不正确", null);
}
// 第二步:校验新手机号验证码(仅当账号原本已绑定手机号,才需要二次验证新号归属)
if (StrUtil.isNotBlank(currentPhone)) {
String newKey = "code:" + newPhone;
String cachedNew = redisUtil.get(newKey);
if (StrUtil.isBlank(cachedNew)
|| (!cachedNew.equals(user.getSmsCodeNew()) && !user.getSmsCodeNew().equals(devCode))) {
return fail("新手机号验证码不正确", null);
}
redisUtil.delete(newKey);
cacheClient.delete(newPhone);
}
// 新手机号唯一性校验:不可被其他账号占用
User existed = userService.getByPhone(newPhone);
if (existed != null && !existed.getUserId().equals(getLoginUserId())) {
return fail("该手机号已被其他账号绑定", null);
}
update.setPhone(newPhone);
redisUtil.delete(oldKey);
cacheClient.delete(verifyPhone);
}
// 邮箱变更:仅当提交邮箱验证码且校验通过时才更新,防止未经验证的邮箱被绑定
if (StrUtil.isNotBlank(user.getEmailCode())) {
String key = RedisConstants.EMAIL_CODE_KEY + ":" + user.getEmail();
String cached = redisUtil.get(key);
if (StrUtil.isBlank(cached) || !cached.equals(user.getEmailCode())) {
return fail("邮箱验证码不正确", null);
}
update.setEmail(user.getEmail());
redisUtil.delete(key);
}
// 未提交邮箱验证码时不更新邮箱(保持原值,禁止绕过校验)
update.setProvince(user.getProvince());
update.setCity(user.getCity());
update.setRegion(user.getRegion());
update.setAddress(user.getAddress());
update.setIntroduction(user.getIntroduction());
// 支付密码变更(个人中心设置/修改支付密码时透传明文):加密后落库,不允许明文入库
// 注意:仅当本次提交携带非空 payPassword 才更新,避免清空已有密码
if (StrUtil.isNotBlank(user.getPayPassword())) {
update.setPayPassword(userService.encodePassword(user.getPayPassword()));
}
// MyBatis-Plus: 如果没有任何可更新字段,会生成 `UPDATE ... WHERE ...`(没有 SET)导致 SQL 报错
// 这里检测一下“确实有字段需要更新”,否则直接返回当前用户信息。
if (ObjectUtil.isAllEmpty(
@@ -339,7 +478,8 @@ public class MainController extends BaseController {
update.getCity(),
update.getRegion(),
update.getAddress(),
update.getIntroduction()
update.getIntroduction(),
update.getPayPassword()
)) {
return success("没有需要更新的字段", userService.getByIdRel(update.getUserId()));
}
@@ -351,21 +491,80 @@ public class MainController extends BaseController {
return fail("保存失败", null);
}
@PreAuthorize("hasAuthority('sys:auth:user')")
@OperationLog
@Operation(summary = "提交注册地址(注册审核)")
@PostMapping("/auth/submitAddress")
public ApiResult<User> submitAddress(@RequestBody User param) {
Integer userId = getLoginUserId();
if (userId == null) {
return fail("用户未登录", null);
}
if (StrUtil.hasBlank(param.getRealName(), param.getShopName())) {
return fail("请填写注册人和店名", null);
}
if (StrUtil.hasBlank(param.getProvince(), param.getCity(), param.getRegion())) {
return fail("请选择所在地区", null);
}
User update = new User();
update.setUserId(userId);
update.setCountry("中国");
update.setRealName(param.getRealName());
update.setShopName(param.getShopName());
update.setProvince(param.getProvince());
update.setCity(param.getCity());
update.setRegion(param.getRegion());
update.setAddress(param.getAddress());
update.setAuditStatus(0); // 提交地址后标记为待审核
update.setRejectReason(null); // 清除之前的驳回原因
if (userService.updateById(update)) {
return success("提交成功", userService.getByIdRel(userId));
}
return fail("提交失败", null);
}
@Operation(summary = "查询当前用户审核状态")
@GetMapping("/auth/auditStatus")
public ApiResult<java.util.Map<String, Object>> getAuditStatus() {
Integer userId = getLoginUserId();
if (userId == null) {
return fail("未登录", null);
}
User user = userService.getByIdRel(userId);
java.util.Map<String, Object> result = new java.util.HashMap<>();
result.put("auditStatus", user.getAuditStatus());
result.put("rejectReason", user.getRejectReason());
result.put("hasAddress", StrUtil.isNotBlank(user.getRealName())
&& StrUtil.isNotBlank(user.getShopName())
&& StrUtil.isNotBlank(user.getProvince()));
return success(result);
}
@PreAuthorize("hasAuthority('sys:auth:password')")
@OperationLog
@Operation(summary = "修改自己密码")
@PutMapping("/auth/password")
public ApiResult<?> updatePassword(@RequestBody UpdatePasswordParam param) {
if (StrUtil.hasBlank(param.getOldPassword(), param.getPassword())) {
return fail("参数不能为空");
if (StrUtil.isBlank(param.getPassword())) {
return fail("新密码不能为空");
}
Integer userId = getLoginUserId();
if (userId == null) {
return fail("未登录");
}
if (!userService.comparePassword(userService.getById(userId).getPassword(), param.getOldPassword())) {
User dbUser = userService.getById(userId);
if (dbUser == null) {
return fail("用户不存在");
}
// 已设置过密码的必须验证旧密码;未设置过密码的(如验证码注册用户)直接设置新密码
if (StrUtil.isNotBlank(dbUser.getPassword())) {
if (StrUtil.isBlank(param.getOldPassword())) {
return fail("请输入当前密码");
}
if (!userService.comparePassword(dbUser.getPassword(), param.getOldPassword())) {
return fail("原密码输入不正确");
}
}
User user = new User();
user.setUserId(userId);
user.setPassword(userService.encodePassword(param.getPassword()));
@@ -402,6 +601,54 @@ public class MainController extends BaseController {
return fail("修改失败");
}
@PreAuthorize("hasAuthority('sys:auth:user')")
@OperationLog
@Operation(summary = "短信验证码重置支付密码(忘记支付密码场景)")
@PutMapping("/auth/pay-password")
public ApiResult<?> resetPayPasswordBySms(@RequestBody ResetPayPasswordParam param) {
if (StrUtil.hasBlank(param.getPhone(), param.getCode(), param.getPayPassword())) {
return fail("参数不能为空");
}
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
}
// 支付密码必须为 4 位数字
if (!param.getPayPassword().matches("^\\d{4}$")) {
return fail("支付密码必须为4位数字");
}
Integer userId = getLoginUserId();
if (userId == null) {
return fail("未登录");
}
User loginUser = getLoginUser();
if (loginUser == null) {
return fail("用户不存在");
}
// 防他人手机号绕过:入参手机号必须与当前登录用户绑定手机号一致
if (!param.getPhone().equals(loginUser.getPhone())) {
return fail("手机号与当前账号不一致");
}
// 验证码校验:sendSmsCaptcha 将验证码同时写入 redisUtil("code:" + 手机号,无租户前缀) 与
// cacheClient(带租户前缀)。由于发码接口为公开接口、重置接口为登录态接口,两者经 cacheClient
// 生成的 key 前缀不同(无租户 vs 有租户),故此处统一用 redisUtil 读取,与 updatePayPassword 保持一致
String verifyKey = "code:" + param.getPhone();
String cachedCode = redisUtil.get(verifyKey);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (StrUtil.isBlank(cachedCode) || (!param.getCode().equals(cachedCode) && !param.getCode().equals(devCode))) {
return fail("短信验证码不正确");
}
// 更新支付密码(加密后落库,不允许明文入库)
User update = new User();
update.setUserId(userId);
update.setPayPassword(userService.encodePassword(param.getPayPassword()));
if (userService.updateById(update)) {
// 核销已使用的验证码
redisUtil.delete(verifyKey);
return success("支付密码重置成功");
}
return fail("重置失败");
}
@PreAuthorize("hasAnyAuthority('sys:auth:user')")
@Operation(summary = "验证支付密码")
@PostMapping("/auth/checkPayPassword")
@@ -446,13 +693,6 @@ public class MainController extends BaseController {
if (param == null) {
return fail("参数不能为空");
}
// 默认配置(当租户未配置短信服务时使用)
String accessKeyId = "LTAI5t7jGTFTbpSLzzXY8HzP";
String accessKeySecret = "Z22EPJyUhQaIZfEEmZ4Hdbw6xZibCb";
String templateCode = "SMS_481670203";
String signName = "网宿信息";
String regionId = "cn-hangzhou";
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
}
@@ -485,13 +725,60 @@ public class MainController extends BaseController {
return fail("租户ID格式不正确");
}
}
return sendSmsCaptchaInternal(param, tenantId);
}
@Operation(summary = "发送短信验证码(后台管理员, 跨租户)")
@PostMapping("/sendSmsCaptchaByAdmin")
public ApiResult<?> sendSmsCaptchaByAdmin(@RequestBody SmsCaptchaParam param) {
if (param == null) {
return fail("参数不能为空");
}
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
}
// 跨租户校验:只要某个租户下该手机号是超级管理员或管理员即可发送验证码
final User admin = userService.getByPhoneAndAdmin(param.getPhone());
if (ObjectUtil.isEmpty(admin)) {
return fail("该手机号码未注册管理员账号!");
}
// 使用管理员所在租户的短信配置发送
return sendSmsCaptchaInternal(param, admin.getTenantId());
}
/**
* 短信发送核心逻辑
*
* @param param 参数
* @param configTenantId 用于读取租户短信配置的租户ID,为 null 时使用默认配置
*/
private ApiResult<?> sendSmsCaptchaInternal(SmsCaptchaParam param, Integer configTenantId) {
// 发送频率限制:60 秒重发间隔 + 每日上限 10 条
String intervalKey = "sms:interval:" + param.getPhone();
if (StrUtil.isNotBlank(redisUtil.get(intervalKey))) {
return fail("发送过于频繁,请 60 秒后再试");
}
String dailyKey = "sms:daily:" + cn.hutool.core.date.DateUtil.today() + ":" + param.getPhone();
String dailyCountStr = redisUtil.get(dailyKey);
int dailyCount = StrUtil.isBlank(dailyCountStr) ? 0 : Integer.parseInt(dailyCountStr);
if (dailyCount >= 10) {
return fail("今日短信发送次数已达上限");
}
// 默认配置(当租户未配置短信服务时使用)
String accessKeyId = "LTAI5t7jGTFTbpSLzzXY8HzP";
String accessKeySecret = "Z22EPJyUhQaIZfEEmZ4Hdbw6xZibCb";
String templateCode = "SMS_481670203";
String signName = "网宿信息";
String regionId = "cn-hangzhou";
// 读取租户的短信配置(SettingController写入的key格式为:sms:{tenantId}
if (tenantId != null) {
String settingJson = redisUtil.get("sms:" + tenantId);
if (configTenantId != null) {
String settingJson = redisUtil.get("sms:" + configTenantId);
// 兼容历史key
if (StrUtil.isBlank(settingJson)) {
settingJson = redisUtil.get("setting:sms:" + tenantId);
settingJson = redisUtil.get("setting:sms:" + configTenantId);
}
if (StrUtil.isNotBlank(settingJson)) {
JSONObject jsonObject = JSONObject.parseObject(settingJson);
@@ -539,6 +826,11 @@ public class MainController extends BaseController {
cacheClient.set(param.getPhone(), code, 5L, TimeUnit.MINUTES);
String key = "code:" + param.getPhone();
redisUtil.set(key, code, 5L, TimeUnit.MINUTES);
// 记录发送频率限制:60 秒间隔 + 每日计数
redisUtil.set("sms:interval:" + param.getPhone(), "1", 60L, TimeUnit.SECONDS);
String dailyNowStr = redisUtil.get("sms:daily:" + cn.hutool.core.date.DateUtil.today() + ":" + param.getPhone());
int dailyNow = StrUtil.isBlank(dailyNowStr) ? 0 : Integer.parseInt(dailyNowStr);
redisUtil.set("sms:daily:" + cn.hutool.core.date.DateUtil.today() + ":" + param.getPhone(), String.valueOf(dailyNow + 1), 1L, TimeUnit.DAYS);
return success("发送成功", result.get("Message"));
} else {
log.warn("短信发送失败 phone={}, result={}", DesensitizedUtil.mobilePhone(param.getPhone()), result);
@@ -563,6 +855,57 @@ public class MainController extends BaseController {
}
}
@Operation(summary = "发送邮箱验证码")
@PostMapping("/sendEmailCaptcha")
public ApiResult<?> sendEmailCaptcha(@RequestBody EmailCaptchaParam param) {
if (param == null || StrUtil.isBlank(param.getEmail())) {
return fail("邮箱不能为空");
}
if (!CommonUtil.isValidEmail(param.getEmail())) {
return fail("请输入有效的邮箱地址");
}
// 生成6位邮箱验证码
String code = Integer.toString(ThreadLocalRandom.current().nextInt(100000, 1000000));
// 存储到Redis,5分钟有效期(key 与校验时保持一致)
String key = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
redisUtil.set(key, code, RedisConstants.EMAIL_CODE_TTL, TimeUnit.MINUTES);
cacheClient.set(param.getEmail(), code, RedisConstants.EMAIL_CODE_TTL, TimeUnit.MINUTES);
Integer tenantId = getTenantId();
try {
emailTemplateUtil.sendCaptchaEmail(param.getEmail(), code, tenantId);
log.info("邮箱验证码发送成功 email={}", DesensitizedUtil.email(param.getEmail()));
return success("验证码已发送,请查收邮箱");
} catch (Exception e) {
log.error("邮箱验证码发送失败 email={}", param.getEmail(), e);
return fail("邮件发送失败,请稍后重试");
}
}
@Operation(summary = "校验邮箱验证码")
@PostMapping("/verifyEmailCaptcha")
public ApiResult<?> verifyEmailCaptcha(@RequestBody EmailCaptchaParam param) {
if (param == null || StrUtil.isBlank(param.getEmail())) {
return fail("邮箱不能为空");
}
if (!CommonUtil.isValidEmail(param.getEmail())) {
return fail("请输入有效的邮箱地址");
}
if (StrUtil.isBlank(param.getCode())) {
return fail("验证码不能为空");
}
// 与 sendEmailCaptcha 写入的 key 保持一致:emailCode:邮箱
String key = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
String cachedCode = redisUtil.get(key);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (cachedCode == null
|| (!param.getCode().equals(cachedCode) && !param.getCode().equals(devCode))) {
return fail("邮箱验证码不正确");
}
// 校验通过即删除,防止重复使用
redisUtil.delete(key);
return success("邮箱验证通过");
}
@OperationLog
@Operation(summary = "重置密码")
@PutMapping("/password")
@@ -682,6 +1025,8 @@ public class MainController extends BaseController {
final UserParam userParam = new UserParam();
userParam.setPhone(phone);
userParam.setTenantId(tenantId);
// 前端传 auditStatus=0 时走审核流程(新用户待审核)
userParam.setAuditStatus(param.getAuditStatus());
user = userService.addUser(userParam);
}
if (!user.getStatus().equals(0)) {
@@ -731,37 +1076,242 @@ public class MainController extends BaseController {
return fail(message, null);
}
User user = userService.getLastLoginSuperAdminByPhone(phone);
if (user == null) {
// 查询该手机号关联的所有超级管理员记录(跨租户)
List<User> superAdmins = userService.getSuperAdminsByPhone(phone);
if (superAdmins == null || superAdmins.isEmpty()) {
String message = "用户不存在";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, null, request);
return fail(message, null);
}
if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
String message = "非超级管理员账号不允许登录";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
return fail(message, null);
}
// 单租户:直接登录(现有逻辑不变,单租户用户无感知)
if (superAdmins.size() == 1) {
User user = superAdmins.get(0);
// if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
// String message = "非超级管理员账号不允许登录";
// loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
// return fail(message, null);
// }
if (!user.getStatus().equals(0)) {
String message = "账号被冻结";
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
return fail(message, null);
}
String access_token = generateToken(user);
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
return success("登录成功", new LoginResult(access_token, user));
}
// 多租户:返回租户列表,不生成 token,前端展示选择列表
List<TenantOption> tenants = superAdmins.stream()
.map(u -> new TenantOption(
u.getTenantId(),
u.getTenantName(),
u.getUserId(),
u.getUsername(),
u.getNickname(),
null,
u.getAvatar()
))
.collect(Collectors.toList());
return success("请选择要登录的租户", new LoginResult(tenants));
}
@Operation(summary = "选择租户后登录(多租户场景)")
@PostMapping("/loginBySelectTenant")
public ApiResult<LoginResult> loginBySelectTenant(@RequestBody LoginParam param, HttpServletRequest request) {
if (param == null) {
return fail("参数不能为空", null);
}
final String phone = param.getPhone();
if (!CommonUtil.isValidPhoneNumber(phone)) {
return fail("请输入有效的手机号码", null);
}
if (param.getTenantId() == null) {
return fail("请选择租户", null);
}
String code = param.getCode();
if (StrUtil.isBlank(code)) {
code = param.getSmsCode();
}
if (StrUtil.isBlank(code)) {
return fail("验证码不能为空", null);
}
String smsCode = redisUtil.get("code:" + phone);
String devSmsCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (!StrUtil.equals(code, smsCode) && !StrUtil.equals(code, devSmsCode)) {
String message = "验证码不正确";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, null, request);
return fail(message, null);
}
// 查询该手机号在指定租户下的超级管理员
User user = userService.getSuperAdminByPhoneAndTenantId(phone, param.getTenantId());
if (user == null) {
String message = "该租户下不存在此账号";
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, param.getTenantId(), request);
return fail(message, null);
}
// if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
// String message = "非超级管理员账号不允许登录";
// loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
// return fail(message, null);
// }
String access_token = generateToken(user);
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
return success("登录成功", new LoginResult(access_token, user));
}
@Operation(summary = "获取当前登录用户可切换的租户列表(免登录切换前置)")
@GetMapping("/auth/switchableTenants")
public ApiResult<?> switchableTenants() {
User current = getLoginUser();
if (current == null) {
return fail("未登录或登录已过期", null);
}
List<TenantOption> options = new ArrayList<>();
// 按手机号聚合(仅管理员账号)
if (StrUtil.isNotBlank(current.getPhone())) {
LoginParam phoneParam = new LoginParam();
phoneParam.setPhone(current.getPhone());
List<User> byPhone = userService.getAdminsByPhone(phoneParam);
if (byPhone != null) {
for (User u : byPhone) {
options.add(toTenantOption(u));
}
}
}
// 按邮箱聚合(管理员账号,按租户去重)
if (StrUtil.isNotBlank(current.getEmail())) {
List<User> byEmail = userService.findAccountsByEmail(current.getEmail());
if (byEmail != null) {
for (User u : byEmail) {
if (!Boolean.TRUE.equals(u.getIsAdmin())) {
continue;
}
final Integer tId = u.getTenantId();
if (tId != null && options.stream().noneMatch(o -> tId.equals(o.getTenantId()))) {
options.add(toTenantOption(u));
}
}
}
}
return success(options);
}
@Operation(summary = "免登录切换租户(需已登录,当前用户须为目标租户管理员)")
@PostMapping("/auth/switchTenant")
public ApiResult<LoginResult> switchTenant(@RequestBody LoginParam param, HttpServletRequest request) {
Integer targetTenantId = param.getTenantId();
if (targetTenantId == null) {
return fail("请选择租户", null);
}
User current = getLoginUser();
if (current == null) {
return fail("未登录或登录已过期", null);
}
// 切换到当前登录用户所属租户(如注册成功后刷新权限令牌)直接放行:
// 注册即时场景下可切换租户列表校验可能未能命中刚创建的新超管,自己切自己一定合法。
if (targetTenantId.equals(current.getTenantId())) {
String access_token = generateToken(current);
loginRecordService.saveAsync(current.getUsername(), LoginRecord.TYPE_LOGIN, null, current.getTenantId(), request);
return success("切换成功", new LoginResult(access_token, current));
}
// 校验目标租户在可切换范围内(与 switchableTenants 一致)
User target = findSwitchableUser(current, targetTenantId);
if (target == null) {
return fail("无权切换到该租户", null);
}
if (!target.getStatus().equals(0)) {
return fail("该租户账号已被冻结", null);
}
String access_token = generateToken(target);
loginRecordService.saveAsync(target.getUsername(), LoginRecord.TYPE_LOGIN, null, target.getTenantId(), request);
return success("切换成功", new LoginResult(access_token, target));
}
private User findSwitchableUser(User current, Integer targetTenantId) {
List<User> candidates = new ArrayList<>();
if (StrUtil.isNotBlank(current.getPhone())) {
LoginParam phoneParam = new LoginParam();
phoneParam.setPhone(current.getPhone());
List<User> byPhone = userService.getAdminsByPhone(phoneParam);
if (byPhone != null) {
candidates.addAll(byPhone);
}
}
if (StrUtil.isNotBlank(current.getEmail())) {
List<User> byEmail = userService.findAccountsByEmail(current.getEmail());
if (byEmail != null) {
for (User u : byEmail) {
if (Boolean.TRUE.equals(u.getIsAdmin())) {
candidates.add(u);
}
}
}
}
return candidates.stream()
.filter(u -> targetTenantId.equals(u.getTenantId()))
.findFirst()
.orElse(null);
}
private TenantOption toTenantOption(User u) {
return new TenantOption(
u.getTenantId(),
u.getTenantName(),
u.getUserId(),
u.getUsername(),
u.getNickname(),
null,
u.getAvatar()
);
}
/**
* 生成 JWT token 并存入 Redis
*/
private String generateToken(User user) {
Long tokenExpireTime = configProperties.getTokenExpireTime();
final JSONObject register = cacheClient.getSettingInfo("register", user.getTenantId());
if (register != null) {
final String ExpireTime = register.getString("tokenExpireTime");
if (ExpireTime != null) {
tokenExpireTime = Long.valueOf(ExpireTime);
final String expireTime = register.getString("tokenExpireTime");
if (expireTime != null) {
tokenExpireTime = Long.valueOf(expireTime);
}
}
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
tokenExpireTime, configProperties.getTokenKey());
redisUtil.set("access_token:" + user.getUserId(), access_token, tokenExpireTime, TimeUnit.SECONDS);
return success("登录成功", new LoginResult(access_token, user));
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 = "开发者短信验证码登录")
@@ -875,7 +1425,7 @@ public class MainController extends BaseController {
tokenExpireTime, configProperties.getTokenKey());
// 发送邮件通知
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));
}
@@ -922,6 +1472,7 @@ public class MainController extends BaseController {
final Company addCompany = tenantService.initialization(company);
final UserParam userParam = new UserParam();
userParam.setIsAdmin(true);
userParam.setIsSuperAdmin(true);
userParam.setPhone(phone);
userParam.setTemplateId(user.getTemplateId());
userParam.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
@@ -1081,6 +1632,7 @@ public class MainController extends BaseController {
company.setEmail(email);
company.setPhone(phone);
company.setPassword(password);
company.setAppCode(user.getAppCode());
company.setTid(tenant.getTenantId());
company.setShortName(tenantName);
company.setCategoryId(661);
@@ -1107,6 +1659,14 @@ public class MainController extends BaseController {
userParam1.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
final User adminByPhone = userService.getAdminByPhone(userParam1);
// 在 websopy 初始化默认域名(site-租户ID.sitelnk.cn)并标记为已生效
try {
initDefaultDomain(addCompany.getTenantId(), "site");
} catch (Exception e) {
log.error("注册后初始化默认域名失败: tenantId={}", addCompany.getTenantId(), e);
// 域名初始化失败不阻断注册流程
}
// 设置过期时间
Long tokenExpireTime = configProperties.getTokenExpireTime();
@@ -1166,14 +1726,20 @@ public class MainController extends BaseController {
/**
* 重置密码(找回密码功能)
* 支持两种验证方式:手机号+短信验证码 / 邮箱+邮箱验证码
* 支持两种模式:
* 1. 传 userId + tenantId:精确重置指定账号密码
* 2. 不传 userId:通过手机号/邮箱查找所有正常账号,统一重置密码(忘记密码场景)
*/
@Operation(summary = "重置密码")
@PostMapping("/resetPassword")
@Transactional(rollbackFor = Exception.class, isolation = Isolation.SERIALIZABLE)
public ApiResult<?> resetPassword(@RequestBody ResetPasswordParam param) {
// 验证手机
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
// 判断找回方式:邮箱 or 手机
boolean byEmail = StrUtil.isNotBlank(param.getEmail());
boolean byPhone = StrUtil.isNotBlank(param.getPhone());
if (!byEmail && !byPhone) {
return fail("请提供手机号或邮箱");
}
// 验证两次密码是否一致
@@ -1186,45 +1752,103 @@ public class MainController extends BaseController {
return fail("密码必须至少8位,且包含字母和数字");
}
// 验证短信验证码
String key = "code:" + param.getPhone();
String cachedCode = redisUtil.get(key);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (!param.getSmsCode().equals(cachedCode) && !param.getSmsCode().equals(devCode)) {
return fail("短信验证码不正确");
// ===== 验证码校验 =====
String verifyKey;
String inputCode;
if (byEmail) {
if (!CommonUtil.isValidEmail(param.getEmail())) {
return fail("请输入有效的邮箱地址");
}
if (StrUtil.isBlank(param.getEmailCode())) {
return fail("邮箱验证码不能为空");
}
verifyKey = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
inputCode = param.getEmailCode();
} else {
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
}
if (StrUtil.isBlank(param.getSmsCode())) {
return fail("短信验证码不能为空");
}
verifyKey = "code:" + param.getPhone();
inputCode = param.getSmsCode();
}
// 验证用户是否存在且手机号匹配
String cachedCode = redisUtil.get(verifyKey);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (!inputCode.equals(cachedCode) && !inputCode.equals(devCode)) {
return fail(byEmail ? "邮箱验证码不正确" : "短信验证码不正确");
}
// ===== 模式判断:userId 为空时按手机号/邮箱查所有用户统一重置 =====
if (StrUtil.isBlank(param.getUserId())) {
List<User> userList = byEmail
? userMapper.selectAccountsByEmail(param.getEmail())
: userMapper.selectAccountsByPhone(param.getPhone());
if (userList == null || userList.isEmpty()) {
return fail(byEmail ? "该邮箱未注册" : "该手机号未注册");
}
// 过滤掉被冻结的账号
List<User> activeUsers = userList.stream()
.filter(u -> u.getStatus() == null || u.getStatus() == 0)
.collect(Collectors.toList());
if (activeUsers.isEmpty()) {
return fail("账号已被冻结,请联系管理员");
}
// 统一重置所有正常账号密码
String encodedPassword = userService.encodePassword(param.getNewPassword());
String methodDesc = byEmail ? "通过邮箱" : "通过手机号";
for (User u : activeUsers) {
User update = new User();
update.setUserId(u.getUserId());
update.setPassword(encodedPassword);
userMapper.updateByUserId(update);
// 记录登录日志
LoginRecord record = new LoginRecord();
record.setUsername(u.getUsername());
record.setNickname(u.getNickname());
record.setLoginType(5);
record.setComments("密码重置成功(" + methodDesc + "");
record.setTenantId(u.getTenantId());
loginRecordService.save(record);
}
redisUtil.delete(verifyKey);
return success("密码重置成功");
}
// ===== 模式1:传了 userId,精确重置 =====
User user = userService.getByUserId(param.getUserId());
if (user == null) {
return fail("用户不存在");
}
if (byEmail) {
if (!param.getEmail().equals(user.getEmail())) {
return fail("邮箱与账号不匹配");
}
} else {
if (!param.getPhone().equals(user.getPhone())) {
return fail("手机号与账号不匹配");
}
}
if (!param.getTenantId().equals(user.getTenantId())) {
if (param.getTenantId() != null && !param.getTenantId().equals(user.getTenantId())) {
return fail("租户信息不匹配");
}
// 重置密码
boolean success = userService.resetUserPassword(param.getUserId(), param.getTenantId(), param.getNewPassword());
boolean success = userService.resetUserPassword(param.getUserId(), user.getTenantId(), param.getNewPassword());
if (success) {
// 密码重置成功后删除验证码
redisUtil.delete(key);
// 记录登录日志(密码重置)
redisUtil.delete(verifyKey);
LoginRecord record = new LoginRecord();
record.setUsername(user.getUsername());
record.setNickname(user.getNickname());
record.setLoginType(5); // 5表示密码重置(需要在LoginRecord中定义常量)
record.setLoginType(5);
record.setComments("密码重置成功");
record.setTenantId(user.getTenantId());
loginRecordService.save(record);
return success("密码重置成功");
} else {
return fail("密码重置失败,请稍后重试");
@@ -1235,6 +1859,20 @@ public class MainController extends BaseController {
* 检查手机号是否已注册(可选接口)
*/
@Operation(summary = "检查手机号是否已注册")
@GetMapping("/checkEmailRegistered")
public ApiResult<?> checkEmailRegistered(@RequestParam("email") String email) {
// 邮箱格式校验
if (!CommonUtil.isValidEmail(email)) {
return fail("请输入有效的邮箱地址");
}
// 统计该邮箱注册的账号数量(跨租户,复用 findAccountsByEmail
List<User> accounts = userService.findAccountsByEmail(email);
CheckEmailResult result = new CheckEmailResult();
result.setIsRegistered(accounts != null && !accounts.isEmpty());
result.setAccountCount(accounts != null ? accounts.size() : 0);
return success(result);
}
@GetMapping("/checkPhoneRegistered")
public ApiResult<?> checkPhoneRegistered(@RequestParam("phone") String phone) {
// 验证手机号
@@ -226,6 +226,7 @@ public class MenuController extends BaseController {
}
companyService.updateById(company);
loginUser.setInstalled(true);
loginUser.setIsSuperAdmin(true);
userService.updateById(loginUser);
return success("安装成功");
}
@@ -19,6 +19,7 @@ import com.gxwebsoft.common.system.param.OrderParam;
import com.gxwebsoft.common.system.service.MenuService;
import com.gxwebsoft.common.system.service.OrderGoodsService;
import com.gxwebsoft.common.system.service.OrderService;
import com.gxwebsoft.common.system.service.UserService;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -47,6 +48,18 @@ public class OrderController extends BaseController {
private MenuService menuService;
@Resource
private MenuMapper menuMapper;
@Resource
private UserService userService;
/** 检查用户审核状态:auditStatus==null(老用户)或1(已通过)返回true,否则false */
private boolean checkUserAuditStatus(User loginUser) {
if (loginUser == null) return false;
User user = userService.getByIdRel(loginUser.getUserId());
if (user == null) return false;
Integer auditStatus = user.getAuditStatus();
// auditStatus == null 表示老用户(功能上线前注册的),默认放行
return auditStatus == null || auditStatus == 1;
}
@Operation(summary = "分页查询订单")
@GetMapping("/page")
@@ -94,6 +107,9 @@ public class OrderController extends BaseController {
if(loginUser == null){
return fail("请先登录");
}
if (!checkUserAuditStatus(loginUser)) {
return fail("您的账号尚未通过审核,暂时无法下单");
}
if (ObjectUtil.isEmpty(order.getType())) {
return fail("订单类型不能为空");
}
@@ -184,6 +200,9 @@ public class OrderController extends BaseController {
// 记录当前登录用户id
User loginUser = getLoginUser();
if (loginUser != null) {
if (!checkUserAuditStatus(loginUser)) {
return fail("您的账号尚未通过审核,暂时无法下单");
}
// 封装订单数据
final Order order = new Order();
order.setType(cart.getType());
@@ -111,6 +111,10 @@ public class SettingController extends BaseController {
// 更新系统设置信息到缓存 key = ""
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
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")) {
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.update.LambdaUpdateWrapper;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.utils.CommonUtil;
@@ -17,6 +18,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -49,19 +52,26 @@ public class TenantController extends BaseController {
private RedisUtil redisUtil;
@Resource
private UserService userService;
// cms权限参考租户, 默认取平台租户
@Value("${websoft.cms.ref-tenant-id:10257}")
private Integer cmsRefTenantId;
@Operation(summary = "分页查询租户")
@GetMapping("/page")
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
if (param.getAll() == null || !param.getAll()) {
if (param.getUserId() == null) {
final User loginUser = getLoginUser();
if (loginUser != null && loginUser.getUserId() != null) {
if (param.getUserId() == null && loginUser.getUserId() != null) {
param.setUserId(loginUser.getUserId());
}
}
}
PageResult<Tenant> result = tenantService.pageRel(param);
// 如果传入 mask=false,设置不脱敏
if (param.getMask() != null && !param.getMask()) {
@@ -215,6 +225,40 @@ public class TenantController extends BaseController {
return success(menus);
}
@OperationLog
@Operation(summary = "授予当前租户cms建站权限")
@PostMapping("/grantCmsPermission")
public ApiResult<?> grantCmsPermission(@RequestBody(required = false) Map<String, Object> body) {
final Integer curTenant = getTenantId();
if (curTenant == null) {
return fail("获取当前租户失败, 请重新登录");
}
// 参考租户优先级: 请求参数 > 企业当前使用的租户模板 > 配置项websoft.cms.ref-tenant-id
Integer refTenant = null;
final Object refTenantId = body == null ? null : body.get("refTenantId");
if (refTenantId instanceof Number) {
refTenant = ((Number) refTenantId).intValue();
} else if (refTenantId != null && refTenantId.toString().matches("\\d+")) {
refTenant = Integer.valueOf(refTenantId.toString());
}
if (refTenant == null) {
final Company company = getCompany();
if (company != null && company.getPlanId() != null) {
refTenant = company.getPlanId();
}
}
if (refTenant == null) {
refTenant = cmsRefTenantId;
}
if (refTenant == null) {
return fail("未找到参考租户, 请配置websoft.cms.ref-tenant-id");
}
if (refTenant.equals(curTenant)) {
return fail("参考租户不能与当前租户相同");
}
return tenantService.grantCmsPermission(refTenant, curTenant);
}
@Operation(summary = "创建租户")
@PostMapping("/saveByPhone")
public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) {
@@ -4,17 +4,25 @@ import cn.hutool.core.util.IdUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
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.BaseController;
import lombok.extern.slf4j.Slf4j;
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.param.SubscriptionOrderParam;
import com.gxwebsoft.common.system.result.SubscriptionOrderCreateResult;
import com.gxwebsoft.common.system.result.SubscriptionOrderPayResult;
import com.gxwebsoft.common.system.result.SubscriptionPriceResult;
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.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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -29,6 +37,7 @@ import java.util.List;
/**
* 订阅订单接口
*/
@Slf4j
@Tag(name = "订阅订单")
@RestController
@RequestMapping("/api/system/subscription-order")
@@ -38,6 +47,10 @@ public class TenantSubscriptionOrderController extends BaseController {
private SettingService settingService;
@Resource
private WxNativePayController wxNativePayController;
@Resource
private TenantSubscriptionOrderService subscriptionOrderService;
@Resource
private TenantPackageService tenantPackageService;
@Operation(summary = "计算订阅订单价格")
@PostMapping("/calculate-price")
@@ -88,7 +101,6 @@ public class TenantSubscriptionOrderController extends BaseController {
JSONObject config = loadSubscriptionConfig();
final SubscriptionPriceResult price = buildPriceResult(param, config);
price.setPayPrice(new BigDecimal("0.01"));
if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) {
return fail("支付金额必须大于0", null);
}
@@ -114,6 +126,100 @@ public class TenantSubscriptionOrderController extends BaseController {
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以兼容历史数据)
*/
@@ -304,6 +304,49 @@ public class UserController extends BaseController {
return fail("修改失败");
}
@PreAuthorize("hasAuthority('sys:auth:user')")
@Operation(summary = "查询用户审核信息")
@GetMapping("/audit/{userId}")
public ApiResult<Map<String, Object>> getAuditInfo(@PathVariable("userId") Integer userId) {
User user = userService.getByIdRel(userId);
if (user == null) {
return fail("用户不存在",null);
}
Map<String, Object> result = new HashMap<>();
result.put("userId", user.getUserId());
result.put("auditStatus", user.getAuditStatus());
result.put("rejectReason", user.getRejectReason());
return success(result);
}
@PreAuthorize("hasAuthority('sys:user:update')")
@OperationLog
@Operation(summary = "审核用户注册(通过/拒绝)")
@PutMapping("/audit")
public ApiResult<?> auditUser(@RequestBody User user) {
if (user.getUserId() == null || user.getAuditStatus() == null
|| !Arrays.asList(0, 1, 2).contains(user.getAuditStatus())) {
return fail("参数不正确");
}
User update = new User();
update.setUserId(user.getUserId());
update.setAuditStatus(user.getAuditStatus());
// 拒绝时必须填写驳回原因
if (user.getAuditStatus() == 2) {
if (StrUtil.isBlank(user.getRejectReason())) {
return fail("驳回时请填写原因");
}
update.setRejectReason(user.getRejectReason());
} else {
// 通过或重新设为待审核时清除驳回原因
update.setRejectReason(null);
}
if (userService.updateById(update)) {
return success(user.getAuditStatus() == 1 ? "审核通过" : "已拒绝");
}
return fail("操作失败");
}
@PreAuthorize("hasAuthority('sys:user:update')")
@OperationLog
@Operation(summary = "修改推荐状态")
@@ -321,6 +364,20 @@ public class UserController extends BaseController {
return fail("修改失败");
}
@PreAuthorize("hasAuthority('sys:userRole:save')")
@OperationLog
@Operation(summary = "设为开发者(审核通过时标记,不新增角色)")
@PutMapping("/developer/{userId}")
public ApiResult<?> setDeveloper(@PathVariable("userId") Integer userId) {
if (userId == null) {
return fail("参数不正确");
}
if (userService.markAsDeveloper(userId)) {
return success("已设为开发者");
}
return fail("操作失败");
}
@PreAuthorize("hasAuthority('sys:user:update')")
@OperationLog
@Operation(summary = "批量修改用户状态")
@@ -63,9 +63,9 @@ public class UserRoleController extends BaseController {
@Operation(summary = "添加用户角色")
@PostMapping()
public ApiResult<?> save(@RequestBody UserRole userRole) {
// 记录当前登录用户id
// 记录当前登录用户id请求体未指定 userId 回退到当前登录用户
User loginUser = getLoginUser();
if (loginUser != null) {
if (userRole.getUserId() == null && loginUser != null) {
userRole.setUserId(loginUser.getUserId());
}
if (userRoleService.save(userRole)) {
@@ -35,7 +35,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -134,11 +136,23 @@ public class WxLoginController extends BaseController {
if (openid == null) {
return fail("获取openid失败", null);
}
// 关键 openid/unionid 设置到 userParam否则后续查库/注册都拿不到 openid
userParam.setOpenid(openid);
if (StrUtil.isNotBlank(unionid)) {
userParam.setUnionid(unionid);
}
// 2.通过openid查询用户是否已存在
User user = userService.getByOauthId(userParam);
// 3.存在则签发token并返回登录成功,不存在则注册新用户
if (user == null) {
user = addUser(userParam);
} else if (StrUtil.isBlank(user.getOpenid())) {
// 老用户比如手机号注册没有 openid补上
user.setOpenid(openid);
if (StrUtil.isNotBlank(unionid)) {
user.setUnionid(unionid);
}
userService.updateById(user);
}
// 4.签发token
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
@@ -199,6 +213,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
if (getTenantId() != null && getTenantId().equals(10198)) activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
@@ -255,7 +270,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
if (getTenantId() != null && getTenantId().equals(10198)) activateShopUserMemberByPhone(phone);
return success("注册并登录成功", new LoginResult(access_token, user));
} catch (BusinessException e) {
@@ -267,6 +282,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
}
@@ -328,6 +344,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
@@ -401,6 +418,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("注册并登录成功", new LoginResult(access_token, user));
@@ -413,6 +431,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user));
}
}
@@ -420,6 +439,26 @@ public class WxLoginController extends BaseController {
}
}
private void activateShopUserMemberByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return;
}
try {
String apiUrl = "https://paopao-api.websoft.top/api/shop/shop-user-member/activate-by-phone";
String normalizedPhone = phone.trim();
JSONObject body = new JSONObject();
body.put("phone", normalizedPhone);
String response = HttpRequest.post(apiUrl)
.header("Content-Type", "application/json")
.body(body.toJSONString())
.execute()
.body();
System.out.println("激活会员记录响应: " + response);
} catch (Exception e) {
System.err.println("激活会员记录失败,但不影响登录流程: " + e.getMessage());
}
}
@Operation(summary = "微信授权手机号码并更新")
@Transactional(rollbackFor = {Exception.class})
@PostMapping("/updatePhoneByMpWx")
@@ -437,23 +476,33 @@ public class WxLoginController extends BaseController {
}
/**
* 优先读取 db_websopy.app_configcategory=wechat不存在或异常时回退到 sys_setting (mp-weixin)
* 优先读取 sys_setting (mp-weixin)不存在或异常时回退到 db_websopy.app_configcategory=wechat
*
* 使用 bound_tenant_id 字段精确匹配当前请求租户绑定的小程序配置
*
* @param tenantId 租户ID null 时使用当前请求租户
* @return JSONObject 配置内容 appId / appSecret 等字段
*/
private JSONObject getMpWxSetting(Integer tenantId) {
Integer tid = tenantId != null ? tenantId : getTenantId();
System.out.println("[WxLoginController] getMpWxSetting 开始, 传入tenantId=" + tenantId + ", 实际使用tid=" + tid);
try {
JSONObject wechat = appConfigService.getByCategory("wechat", tid);
if (wechat != null && !wechat.isEmpty()) {
return wechat;
// 优先sys_setting.mp-weixin按当前租户查询
JSONObject setting = settingService.getBySettingKey("mp-weixin");
if (setting != null && !setting.isEmpty()) {
System.out.println("[WxLoginController] 从 sys_setting 读取到配置, appId=" + setting.getString("appId"));
return setting;
}
System.out.println("[WxLoginController] sys_setting 无配置,准备回退到 app_config, boundTenantId=" + tid);
} catch (Exception e) {
System.err.println("[WxLoginController] 读取 app_config 失败,回退 sys_setting: " + e.getMessage());
System.err.println("[WxLoginController] 读取 sys_setting 失败,回退 app_config: " + e.getMessage());
e.printStackTrace();
}
// 兜底 sys_setting.mp-weixin
return settingService.getBySettingKey("mp-weixin");
// 兜底 bound_tenant_id 精确查询 db_websopy.app_config
System.out.println("[WxLoginController] 调用 appConfigService.getByCategory(\"wechat\", " + tid + ")");
JSONObject result = appConfigService.getByCategory("wechat", tid);
System.out.println("[WxLoginController] app_config 返回结果: " + result);
return result;
}
/**
@@ -470,6 +519,8 @@ public class WxLoginController extends BaseController {
User addUser = new User();
// 注册用户
addUser.setStatus(0);
// 默认审核通过前端传 auditStatus=0 时走审核流程传其他值则按前端值
addUser.setAuditStatus(userParam.getAuditStatus() != null ? userParam.getAuditStatus() : 1);
addUser.setUsername(createUsername("wx_"));
addUser.setNickname("微信用户");
addUser.setPlatform(MP_WEIXIN);
@@ -916,33 +967,44 @@ public class WxLoginController extends BaseController {
return fail("获取失败", null);
}
@Operation(summary = "获取微信小程序码-订单核销码-数量极多的业务场景")
@Operation(summary = "获取微信小程序码-数量极多的业务场景(直接返回图片字节,支持自定义页面与场景值)")
@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();
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());
final HashMap<String, Object> map = new HashMap<>();
map.put("scene", "orderNo=".concat(orderNo));
map.put("page", "package/admin/order-scan");
// scene 优先级显式 scene > 自定义页面用原始 orderNo > 兼容原订单核销(orderNo=xxx)
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");
// 获取图片 Buffer
byte[] qrCode = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(map))
.execute().bodyBytes();
System.out.println("qrCode = " + qrCode);
// 保存的文件名称
final String fileName = CommonUtil.randomUUID8().concat(".png");
// 保存路径
String filePath = getUploadDir().concat("qrcode/") + fileName;
File file = FileUtil.writeBytes(qrCode, filePath);
if (file != null) {
return success(config.getFileServer().concat("/qrcode/").concat(fileName));
}
return fail("获取失败", null);
// 直接将小程序码图片字节写回响应不依赖 file.websoft.top 文件服务器
response.setContentType("image/png");
response.setHeader("Cache-Control", "no-cache");
response.getOutputStream().write(qrCode);
response.getOutputStream().flush();
}
@@ -12,15 +12,22 @@ import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.Order;
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.service.OrderService;
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.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.model.Amount;
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
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.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
@@ -62,6 +69,8 @@ public class WxNativePayController extends BaseController {
private CertificateService certificateService;
@Resource
private CertificateProperties certificateProperties;
@Resource
private TenantSubscriptionOrderService subscriptionOrderService;
@Operation(summary = "生成付款码")
@@ -92,7 +101,10 @@ public class WxNativePayController extends BaseController {
PrepayRequest request = new PrepayRequest();
// 计算金额
order.setMoney(new BigDecimal(order.getPayPrice().toString()));
// 若调用方已指定订单号如订阅订单则保留以关联回调查询否则生成随机号
if (order.getOrderNo() == null || order.getOrderNo().trim().isEmpty()) {
order.setOrderNo(CommonUtil.createOrderNo());
}
BigDecimal decimal = order.getMoney();
final BigDecimal multiply = decimal.multiply(new BigDecimal(100));
// BigDecimal 转换为 Integer
@@ -229,20 +241,67 @@ public class WxNativePayController extends BaseController {
@Schema(description = "异步通知")
@PostMapping("/notify/{tenantId}")
public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) {
System.out.println("异步通知*************** = ");
System.out.println("request header = " + header);
System.out.println("request body = " + body);
System.out.println("tenantId = " + tenantId);
log.info("微信Native支付异步通知, tenantId={}", tenantId);
try {
// 1) 获取支付配置用于验签/解密优先 Native 支付配置兜底会员支付配置再兜底静态测试商户
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请求)
// final String string = requestUtil.pushWxPayNotify(transaction, payment);
com.wechat.pay.java.core.notification.RequestParam requestParam = new com.wechat.pay.java.core.notification.RequestParam.Builder()
.serialNumber(header.get("wechatpay-serial"))
.nonce(header.get("wechatpay-nonce"))
.signature(header.get("wechatpay-signature"))
.timestamp(header.get("wechatpay-timestamp"))
.body(body)
.build();
// 获取支付配置信息用于解密
final SettingParam param = new SettingParam();
param.setSettingKey("payment");
param.setTenantId(tenantId);
final String uploadPath = config.getUploadPath(); // 服务器本地路径
NotificationConfig notificationConfig = new RSANotificationConfig.Builder()
.apiV3Key(apiV3Key)
.certificatesFromPath(apiclientCert)
.build();
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";
}
}
}
@@ -294,6 +294,10 @@ public class Company implements Serializable {
@TableField(exist = false)
private String password;
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site")
@TableField(exist = false)
private String appCode;
@Schema(description = "手机号(脱敏)")
@TableField(exist = false)
private String mobile;
@@ -34,6 +34,9 @@ public class DictData implements Serializable {
@Schema(description = "字典数据名称")
private String dictDataName;
@Schema(description = "字段类型")
private String type;
@Schema(description = "字段名称")
@TableField(exist = false)
private String text;
@@ -104,6 +104,10 @@ public class Tenant implements Serializable {
@TableField(exist = false)
private String phone;
@Schema(description = "超级管理员UID")
@TableField(exist = false)
private Integer ownerId;
@Schema(description = "管理地址")
@TableField(exist = false)
private String adminUrl;
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.annotation.*;
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 lombok.Data;
@@ -40,7 +41,14 @@ public class User implements UserDetails {
@Schema(description = "账号")
private String username;
/**
* 登录密码BCrypt 哈希
*
* <p>只写不读序列化时永远不输出避免任何接口把密码哈希返回给调用方
* 反序列化不受影响注册 / 改密 / 批量导入仍可正常写入</p>
*/
@Schema(description = "密码")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
@Schema(description = "昵称")
@@ -61,6 +69,22 @@ public class User implements UserDetails {
@Schema(description = "邮箱")
private String email;
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site")
@TableField(exist = false)
private String appCode;
@Schema(description = "邮箱验证码(非数据库字段,仅用于绑定/修改邮箱时校验)")
@TableField(exist = false)
private String emailCode;
@Schema(description = "短信验证码(非数据库字段,仅用于绑定/修改手机号时校验)")
@TableField(exist = false)
private String smsCode;
@Schema(description = "新手机号短信验证码(非数据库字段,仅用于换绑手机号时二次验证新号归属)")
@TableField(exist = false)
private String smsCodeNew;
@Schema(description = "资质")
private String aptitude;
@@ -70,7 +94,11 @@ public class User implements UserDetails {
@Schema(description = "特长")
private String speciality;
/**
* 支付密码BCrypt 哈希与登录密码同样只写不读
*/
@Schema(description = "支付密码")
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String payPassword;
@Schema(description = "职务")
@@ -114,6 +142,9 @@ public class User implements UserDetails {
@Schema(description = "街道地址")
private String address;
@Schema(description = "店名")
private String shopName;
@Schema(description = "行业类型(父级)")
private String industryParent;
@@ -222,6 +253,12 @@ public class User implements UserDetails {
@Schema(description = "专家角色")
private Integer expertType;
@Schema(description = "审核状态: 0待审核, 1已通过, 2已拒绝")
private Integer auditStatus;
@Schema(description = "审核驳回原因")
private String rejectReason;
@Schema(description = "状态, 0正常, 1冻结")
private Integer status;
@@ -339,6 +376,10 @@ public class User implements UserDetails {
@TableField(exist = false)
private Boolean hasAdminsByPhone;
@Schema(description = "是否已设置密码(未设置过密码的用户可免旧密码直接设置)")
@TableField(exist = false)
private Boolean hasPassword;
@Schema(description = "模板ID")
private Integer templateId;
@@ -36,4 +36,13 @@ public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
@InterceptorIgnore(tenantLine = "true")
List<Menu> listMenuByRoleIds(@Param("roleIds") List<Integer> roleIds, @Param("menuType") Integer menuType);
/**
* 跨租户查询角色菜单忽略租户拦截器
*
* @param tenantId 租户id
* @return List<RoleMenu>
*/
@InterceptorIgnore(tenantLine = "true")
List<RoleMenu> selectListAll(@Param("tenantId") Integer tenantId);
}
@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.common.system.entity.Setting;
import com.gxwebsoft.common.system.param.SettingParam;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -37,4 +39,20 @@ public interface SettingMapper extends BaseMapper<Setting> {
@InterceptorIgnore(tenantLine = "true")
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);
}
@@ -81,6 +81,15 @@ public interface UserMapper extends BaseMapper<User> {
@InterceptorIgnore(tenantLine = "true")
List<User> selectAccountsByPhone(@Param("phone") String phone);
/**
* 根据邮箱查询所有账号忽略租户隔离
*
* @param email 邮箱
* @return List<User>
*/
@InterceptorIgnore(tenantLine = "true")
List<User> selectAccountsByEmail(@Param("email") String email);
/**
* 根据手机号统计账号数量忽略租户隔离
*
@@ -108,4 +117,34 @@ public interface UserMapper extends BaseMapper<User> {
@InterceptorIgnore(tenantLine = "true")
User selectLastLoginDeveloperByPhone(@Param("phone") String phone);
/**
* 根据手机号查询管理员账号跨租户超级管理员或普通管理员均可忽略租户隔离
*
* @param phone 手机号
* @return User
*/
@InterceptorIgnore(tenantLine = "true")
User selectAdminByPhoneCrossTenant(@Param("phone") String phone);
/**
* 根据手机号查询所有超级管理员账号跨租户忽略租户隔离
* 用于多租户登录手机号关联多个租户时返回列表供用户选择
*
* @param phone 手机号
* @return List<User>
*/
@InterceptorIgnore(tenantLine = "true")
List<User> selectSuperAdminsByPhone(@Param("phone") String phone);
/**
* 根据手机号和租户ID查询超级管理员账号跨租户忽略租户隔离
* 用于多租户登录用户选择租户后查询该租户下的超级管理员
*
* @param phone 手机号
* @param tenantId 租户ID
* @return User
*/
@InterceptorIgnore(tenantLine = "true")
User selectSuperAdminByPhoneAndTenantId(@Param("phone") String phone, @Param("tenantId") Integer tenantId);
}
@@ -62,4 +62,13 @@ public interface UserRoleMapper extends BaseMapper<UserRole> {
*/
List<UserRole> selectListRel(@Param("param") UserRoleParam param);
/**
* 跨租户查询用户角色忽略租户拦截器
*
* @param tenantId 租户id
* @return List<UserRole>
*/
@InterceptorIgnore(tenantLine = "true")
List<UserRole> selectListAll(@Param("tenantId") Integer tenantId);
}
@@ -59,11 +59,17 @@
<if test="param.contentType != null">
AND a.content_type LIKE CONCAT('%', #{param.contentType}, '%')
</if>
<if test="param.keywords != null">
<if test="param.keywords != null and param.keywords != ''">
AND (
a.create_user_id = #{param.keywords}
OR b.name LIKE CONCAT('%', #{param.keywords}, '%')
OR b.comments LIKE CONCAT('%', #{param.keywords}, '%')
a.`name` LIKE CONCAT('%', #{param.keywords}, '%')
OR a.path LIKE CONCAT('%', #{param.keywords}, '%')
OR a.comments LIKE CONCAT('%', #{param.keywords}, '%')
OR b.username LIKE CONCAT('%', #{param.keywords}, '%')
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>
@@ -8,6 +8,12 @@
FROM sys_order a
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
<where>
<!-- 租户条件:sys_order 在 MybatisPlusConfig 的 ignoreTable 白名单里,
不受多租户插件管辖,必须在这里显式过滤。目前只有开放平台链路会设置该参数,
内部调用不传即保持原有行为。 -->
<if test="param.tenantId != null">
AND a.tenant_id = #{param.tenantId}
</if>
<if test="param.orderId != null">
AND a.order_id = #{param.orderId}
</if>
@@ -39,4 +39,9 @@
ORDER BY a.sort_number
</select>
<!-- 跨租户查询角色菜单 -->
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.RoleMenu">
SELECT * FROM sys_role_menu WHERE tenant_id = #{tenantId}
</select>
</mapper>
@@ -5,7 +5,7 @@
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*,b.company_name,b.company_logo as logo,b.admin_url,b.domain,b.free_domain,
u.phone,u.username
u.phone,u.username,u.user_id as ownerId
FROM sys_tenant a
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
LEFT JOIN gxwebsoft_core.sys_user u ON u.tenant_id = a.tenant_id AND u.is_super_admin = 1 AND u.deleted = 0
@@ -110,6 +110,12 @@
<if test="param.status != null">
AND a.`status` = #{param.status}
</if>
<if test="param.auditStatus != null">
AND a.`audit_status` = #{param.auditStatus}
</if>
<if test="param.auditPending != null and param.auditPending">
AND a.`audit_status` IN (0, 2)
</if>
<if test="param.createTimeStart != null">
AND a.create_time &gt;= #{param.createTimeStart}
</if>
@@ -365,6 +371,8 @@
a.tenant_id,
a.username,
a.avatar,
a.status,
a.password,
a.create_time,
t.tenant_name
FROM sys_user a
@@ -374,6 +382,24 @@
ORDER BY a.create_time DESC
</select>
<!-- 根据邮箱查询所有账号(忽略租户隔离,用于跨租户邮箱登录) -->
<select id="selectAccountsByEmail" resultType="com.gxwebsoft.common.system.entity.User">
SELECT a.user_id,
a.tenant_id,
a.username,
a.nickname,
a.avatar,
a.status,
a.password,
a.create_time,
t.tenant_name
FROM sys_user a
LEFT JOIN sys_tenant t ON a.tenant_id = t.tenant_id
WHERE a.deleted = 0
AND a.email = #{email}
ORDER BY a.create_time DESC
</select>
<!-- 根据手机号统计账号数量(忽略租户隔离) -->
<select id="countByPhone" resultType="java.lang.Integer">
SELECT COUNT(1)
@@ -430,4 +456,45 @@
LIMIT 1
</select>
<!-- 根据手机号查询管理员账号(跨租户:超级管理员或普通管理员均可,忽略租户隔离) -->
<select id="selectAdminByPhoneCrossTenant" resultType="com.gxwebsoft.common.system.entity.User">
SELECT u.*
FROM sys_user u
WHERE u.deleted = 0
AND u.phone = #{phone}
AND (u.is_super_admin = 1 OR u.is_admin = 1)
ORDER BY u.update_time DESC,
u.user_id DESC
LIMIT 1
</select>
<!-- 根据手机号查询所有超级管理员账号(跨租户,忽略租户隔离,用于多租户登录选择) -->
<select id="selectSuperAdminsByPhone" resultType="com.gxwebsoft.common.system.entity.User">
SELECT u.*,
t.tenant_name
FROM sys_user u
LEFT JOIN sys_tenant t ON u.tenant_id = t.tenant_id
WHERE u.deleted = 0
AND u.status = 0
AND u.phone = #{phone}
AND (u.is_super_admin = 1 OR u.is_admin = 1)
ORDER BY u.update_time DESC,
u.create_time DESC,
u.user_id DESC
</select>
<!-- 根据手机号和租户ID查询超级管理员账号(跨租户,忽略租户隔离,用于选择租户后登录) -->
<select id="selectSuperAdminByPhoneAndTenantId" resultType="com.gxwebsoft.common.system.entity.User">
SELECT u.*,
t.tenant_name
FROM sys_user u
LEFT JOIN sys_tenant t ON u.tenant_id = t.tenant_id
WHERE u.deleted = 0
AND u.status = 0
AND u.phone = #{phone}
AND u.is_super_admin = 1
AND u.tenant_id = #{tenantId}
LIMIT 1
</select>
</mapper>
@@ -64,4 +64,9 @@
<include refid="selectSql"></include>
</select>
<!-- 跨租户查询用户角色 -->
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.UserRole">
SELECT * FROM sys_user_role WHERE tenant_id = #{tenantId}
</select>
</mapper>
@@ -0,0 +1,33 @@
package com.gxwebsoft.common.system.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 发送邮箱验证码参数
*
* @author WebSoft
* @since 2026-07-20
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "发送邮箱验证码参数")
public class EmailCaptchaParam implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "邮箱")
private String email;
@Schema(description = "邮箱验证码(校验时使用)")
private String code;
@Schema(description = "租户ID")
private String tenantId;
@Schema(description = "场景")
private String scene;
}
@@ -28,6 +28,9 @@ public class LoginParam implements Serializable {
@Schema(description = "手机号码")
private String phone;
@Schema(description = "邮箱")
private String email;
@Schema(description = "短信验证码")
private String code;
@@ -55,4 +58,7 @@ public class LoginParam implements Serializable {
@Schema(description = "租户id")
private Integer tenantId;
@Schema(description = "审核状态: 0待审核, 1已通过, 2已拒绝(前端传值决定注册是否走审核流程)")
private Integer auditStatus;
}
@@ -21,20 +21,22 @@ import java.io.Serializable;
public class ResetPasswordParam implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "手机号不能为空")
@Schema(description = "手机号码", required = true)
@Schema(description = "手机号码(手机找回时必填)")
private String phone;
@NotBlank(message = "短信验证码不能为空")
@Schema(description = "短信验证码", required = true)
@Schema(description = "短信验证码(手机找回时必填)")
private String smsCode;
@NotBlank(message = "用户ID不能为空")
@Schema(description = "用户ID", required = true)
@Schema(description = "邮箱(邮箱找回时必填)")
private String email;
@Schema(description = "邮箱验证码(邮箱找回时必填)")
private String emailCode;
@Schema(description = "用户ID(未登录忘记密码场景可不传,后端按手机号查用户)")
private String userId;
@NotNull(message = "租户ID不能为空")
@Schema(description = "租户ID", required = true)
@Schema(description = "租户ID(未登录忘记密码场景可不传)")
private Integer tenantId;
@NotBlank(message = "新密码不能为空")
@@ -47,7 +49,6 @@ public class ResetPasswordParam implements Serializable {
@Schema(description = "确认密码", required = true)
private String confirmPassword;
@NotNull(message = "模板ID不能为空")
@Schema(description = "短信模板ID", required = true)
@Schema(description = "短信模板ID(可选)")
private Integer templateId;
}
@@ -0,0 +1,29 @@
package com.gxwebsoft.common.system.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 短信验证码重置支付密码参数
*
* @author WebSoft
* @since 2026-08-03
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "短信验证码重置支付密码参数")
public class ResetPayPasswordParam implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "手机号码")
private String phone;
@Schema(description = "短信验证码")
private String code;
@Schema(description = "新的支付密码(4位数字,明文透传,后端加密落库)")
private String payPassword;
}
@@ -110,6 +110,14 @@ public class UserParam extends BaseParam {
@QueryField(type = QueryType.EQ)
private Integer status;
@Schema(description = "审核状态: 0待审核, 1已通过, 2已拒绝(前端传值决定注册是否走审核流程)")
@QueryField(type = QueryType.EQ)
private Integer auditStatus;
@Schema(description = "是否查询待处理审核用户(待审核0/被驳回2)")
@TableField(exist = false)
private Boolean auditPending;
@Schema(description = "是否删除, 0否, 1是")
@TableLogic
private Integer deleted;
@@ -0,0 +1,28 @@
package com.gxwebsoft.common.system.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 检查邮箱返回结果
*
* @author WebSoft
* @since 2026-08-06
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "检查邮箱返回结果")
public class CheckEmailResult implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "是否已注册")
private Boolean isRegistered;
@Schema(description = "账号数量")
private Integer accountCount;
}
@@ -2,12 +2,11 @@ package com.gxwebsoft.common.system.result;
import com.gxwebsoft.common.system.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
/**
* 登录返回结果
@@ -17,7 +16,6 @@ import java.io.Serializable;
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "登录返回结果")
public class LoginResult implements Serializable {
private static final long serialVersionUID = 1L;
@@ -28,4 +26,16 @@ public class LoginResult implements Serializable {
@Schema(description = "用户信息")
private User user;
@Schema(description = "租户列表(多租户场景下返回,此时不返回access_token")
private List<TenantOption> tenants;
public LoginResult(String access_token, User user) {
this.access_token = access_token;
this.user = user;
}
public LoginResult(List<TenantOption> tenants) {
this.tenants = tenants;
}
}
@@ -16,6 +16,12 @@ public class SubscriptionOrderPayResult {
@Schema(description = "支付二维码链接")
private String codeUrl;
@Schema(description = "支付二维码图片(base64),前端可直接展示")
private String qrImage;
@Schema(description = "订单状态 0待支付 1已支付 2已激活 3已取消")
private Integer orderStatus;
@Schema(description = "价格信息")
private SubscriptionPriceResult price;
}
@@ -0,0 +1,52 @@
package com.gxwebsoft.common.system.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 租户选项多租户登录时返回供前端展示选择列表
*
* @author WebSoft
*/
@Data
@Schema(description = "租户选项")
public class TenantOption implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "租户名称")
private String tenantName;
@Schema(description = "用户ID")
private Integer userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "昵称")
private String nickname;
@Schema(description = "角色名称")
private String roleName;
@Schema(description = "头像")
private String avatar;
public TenantOption() {
}
public TenantOption(Integer tenantId, String tenantName, Integer userId,
String username, String nickname, String roleName, String avatar) {
this.tenantId = tenantId;
this.tenantName = tenantName;
this.userId = userId;
this.username = username;
this.nickname = nickname;
this.roleName = roleName;
this.avatar = avatar;
}
}
@@ -1,6 +1,7 @@
package com.gxwebsoft.common.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Company;
import com.gxwebsoft.common.system.entity.Tenant;
@@ -45,4 +46,14 @@ public interface TenantService extends IService<Tenant> {
boolean destructionAll(Integer tenantId);
Tenant getByCodeRel(String code);
/**
* 授予当前租户cms建站权限
* 从参考租户复制cms:*菜单到当前租户, 并绑定到当前租户的超级管理员角色
*
* @param refTenant 参考租户id(已配置好cms菜单的租户)
* @param curTenant 当前租户id
* @return ApiResult<?>
*/
ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant);
}
@@ -58,6 +58,8 @@ public interface UserService extends IService<User>, UserDetailsService {
*/
User getByUsername(String username, Integer tenantId);
User getByUsernamePhone(String username, Integer tenantId);
/**
* 添加用户
*
@@ -74,6 +76,14 @@ public interface UserService extends IService<User>, UserDetailsService {
*/
boolean updateUser(User user);
/**
* 将用户标记为开发者审核通过时调用直接更新用户标记位不新增角色
*
* @param userId 用户id
* @return boolean
*/
boolean markAsDeveloper(Integer userId);
/**
* 比较用户密码
*
@@ -142,6 +152,33 @@ public interface UserService extends IService<User>, UserDetailsService {
*/
User getLastLoginDeveloperByPhone(String phone);
/**
* 根据手机号查询管理员账号跨租户超级管理员或普通管理员均可忽略租户隔离
*
* @param phone 手机号
* @return 用户信息
*/
User getByPhoneAndAdmin(String phone);
/**
* 根据手机号查询所有超级管理员账号跨租户忽略租户隔离
* 用于多租户登录手机号关联多个租户时返回列表供用户选择
*
* @param phone 手机号
* @return List<User>
*/
List<User> getSuperAdminsByPhone(String phone);
/**
* 根据手机号和租户ID查询超级管理员账号跨租户忽略租户隔离
* 用于多租户登录用户选择租户后查询该租户下的超级管理员
*
* @param phone 手机号
* @param tenantId 租户ID
* @return User
*/
User getSuperAdminByPhoneAndTenantId(String phone, Integer tenantId);
List<User> pageAll(UserParam param);
User getByUserId(String userId);
@@ -154,6 +191,14 @@ public interface UserService extends IService<User>, UserDetailsService {
*/
List<User> findAccountsByPhone(String phone);
/**
* 根据邮箱查询所有账号忽略租户隔离用于跨租户登录
*
* @param email 邮箱
* @return List<User>
*/
List<User> findAccountsByEmail(String email);
/**
* 根据手机号统计账号数量
*
@@ -6,11 +6,14 @@ import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.common.comm.SignVersion;
import com.aliyun.oss.common.auth.CredentialsProvider;
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.MinioUtil;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Company;
@@ -20,6 +23,8 @@ import com.gxwebsoft.common.system.param.FileRecordParam;
import com.gxwebsoft.common.system.service.CompanyService;
import com.gxwebsoft.common.system.service.FileRecordService;
import com.gxwebsoft.common.system.service.SettingService;
import io.minio.MinioClient;
import io.minio.RemoveObjectArgs;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -93,13 +98,32 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
String bucketName = uploadConfig.getString("bucketName");
String accessKeyId = uploadConfig.getString("accessKeyId");
String accessKeySecret = uploadConfig.getString("accessKeySecret");
String uploadMethod = uploadConfig.getString("uploadMethod");
// 规范化 endpoint去掉结尾斜杠避免 minio-java 拼接出双斜杠导致签名不匹配
endpoint = MinioUtil.normalizeEndpoint(endpoint);
boolean isMinio = "minio".equals(uploadMethod);
OSS ossClient = null;
MinioClient minioClient = null;
if (isMinio) {
// MinIO 用官方 minio-java SDK原生 AWS4 + path-stylealiyun OSS SDK 无法对接 MinIO
// 显式指定 region=us-east-1跳过 getBucketLocation 自动探测该探测 GET 会触发 SignatureDoesNotMatch
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
} else {
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
}
for (FileRecord fileRecord : fileRecords) {
fileRecord.setPath(StrUtil.replace(fileRecord.getPath(), bucketDomain.concat("/"), ""));
try {
// 删除远程文件
if (isMinio) {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(fileRecord.getPath())
.build());
} else {
ossClient.deleteObject(bucketName, fileRecord.getPath());
}
// 释放空间大小
if (fileRecord.getCompanyId() > 0) {
Company company = companyService.getById(fileRecord.getCompanyId());
@@ -182,7 +182,15 @@ public class SettingServiceImpl extends ServiceImpl<SettingMapper, Setting> impl
@Override
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;
}
}
@@ -2,21 +2,43 @@ package com.gxwebsoft.common.system.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.utils.CommonUtil;
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.system.entity.*;
import com.gxwebsoft.common.system.mapper.MenuMapper;
import com.gxwebsoft.common.system.mapper.RoleMapper;
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.TenantMapper;
import com.gxwebsoft.common.system.param.MenuParam;
import com.gxwebsoft.common.system.param.RoleParam;
import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.common.system.param.TenantParam;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.alibaba.fastjson.JSONObject;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 租户Service实现
@@ -24,14 +46,24 @@ import java.util.List;
* @author 科技小王子
* @since 2023-07-17 17:49:53
*/
@Service
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
@Service
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
@Resource
private CompanyService companyService;
@Resource
private MenuService menuService;
@Resource
private MenuMapper menuMapper;
@Resource
private RoleMapper roleMapper;
@Resource
private RoleMenuMapper roleMenuMapper;
@Resource
private UserRoleMapper userRoleMapper;
@Resource
private RoleMenuService roleMenuService;
@Resource
private RoleService roleService;
@Resource
private UserRoleService userRoleService;
@@ -45,6 +77,8 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
private UserService userService;
@Resource
private RedisUtil redisUtil;
@Resource
private SettingMapper settingMapper;
@Override
public PageResult<Tenant> pageRel(TenantParam param) {
@@ -108,15 +142,17 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
superAdmin.setUsername("superAdmin");
superAdmin.setNickname(company.getShortName());
superAdmin.setPhone(company.getPhone());
superAdmin.setIsSuperAdmin(true);
superAdmin.setIsAdmin(true);
superAdmin.setEmail(company.getEmail());
superAdmin.setTemplateId(company.getTemplateId());
superAdmin.setIsAdmin(true);
superAdmin.setRealName(company.getBusinessEntity());
superAdmin.setPassword(userService.encodePassword("$2a$10$iMsEmh.rPlzwy/SVe6KW3.62vlwqMJpibhCF9jYN.fMqxdqymzMzu"));
if (company.getPassword() != null) {
superAdmin.setPassword(userService.encodePassword(company.getPassword()));
}
superAdmin.setTenantId(company.getTid());
superAdmin.setInstalled(true);
if(company.getTemplateId() != null){
superAdmin.setTemplateId(company.getTemplateId());
}
@@ -586,21 +622,49 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
// 添加菜单ID到超级管理员所属角色ID
if (resultMenu) {
saveRedis(company);
// 初始化默认 MinIO 云存储配置oss-{tenantId} + 自动建桶/策略失败不阻断开通
try {
initDefaultUploadSetting(company.getTid());
} catch (Exception e) {
System.out.println("[Tenant] 初始化默认上传配置失败(不影响开通): " + e.getMessage());
}
}
}
}
// 发送邮件通知
String title = "恭喜!您的应用已创建成功";
String appUrl = "\r\n应用地址:" + DomainUtil.getSiteUrl(company.getTid().toString());
String appName = "\r\n应用名称:" + company.getShortName();
String adminUrl = "\r\n后台管理:" + DomainUtil.getAdminUrl(company.getTid().toString());
String account = "\r\n账号:admin";
String password = "\r\n密码:" + company.getPassword();
String content = title + appUrl + appName + adminUrl + account + password;
// 发送邮件通知
// 发送邮件通知按应用代号区分域名前端注册时传入 appCode未知默认 site
// mp -> https://mp-{tid}.shoplnk.cn 后台 https://mp.websoft.top
// shop -> https://shop-{tid}.shoplnk.cn 后台 https://shop.websoft.top
// site -> https://site-{tid}.sitelnk.cn 后台 https://site.websoft.top
final Map<String, String[]> appDomainMap = new HashMap<>(3);
appDomainMap.put("mp", new String[]{"mp", "shoplnk.cn", "mp.websoft.top"});
appDomainMap.put("shop", new String[]{"shop", "shoplnk.cn", "shop.websoft.top"});
appDomainMap.put("site", new String[]{"site", "sitelnk.cn", "site.websoft.top"});
final String appCode = StrUtil.isBlank(company.getAppCode()) ? "site" : company.getAppCode();
final String[] domainCfg = appDomainMap.getOrDefault(appCode, appDomainMap.get("site"));
final String sitePrefix = domainCfg[0];
final String siteDomain = domainCfg[1];
final String adminHost = domainCfg[2];
final String siteUrl = "https://" + sitePrefix + "-" + company.getTid() + "." + siteDomain;
final String adminUrl = "https://" + adminHost;
final String title = "您的企业官网已开通成功";
// 发送 HTML 邮件通知模板风格与验证码邮件统一
if (company.getEmail() != null) {
emailRecordService.sendEmail(title, content, 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;
}
@@ -616,6 +680,52 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
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
public boolean destructionAll(Integer tenantId){
if (baseMapper.destructionAll(tenantId)) {
@@ -631,4 +741,220 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
return param.getOne(baseMapper.selectListRel(param));
}
@Override
@Transactional(rollbackFor = {Exception.class})
public ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant) {
if (refTenant == null || curTenant == null) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户或当前租户为空");
}
if (refTenant.equals(curTenant)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户不能与当前租户相同");
}
final Map<String, Object> data = new HashMap<>();
data.put("refTenantId", refTenant);
data.put("tenantId", curTenant);
// ===== 1. 克隆角色 sys_role role_code 去重superAdmin/admin/user 已存在则复用=====
final RoleParam refRoleParam = new RoleParam();
refRoleParam.setTenantId(refTenant);
final List<Role> refRoles = roleMapper.selectListAll(refRoleParam);
if (CollectionUtils.isEmpty(refRoles)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的角色");
}
final RoleParam curRoleParam = new RoleParam();
curRoleParam.setTenantId(curTenant);
// role_code -> Role当前租户已存在的角色
final Map<String, Role> curRoleByCode = roleMapper.selectListAll(curRoleParam).stream()
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
// oldRoleId -> newRoleId 映射
final Map<Integer, Integer> roleIdMapping = new HashMap<>();
// 待新增角色 role_code 去重
final Map<String, Role> toCreateByCode = new LinkedHashMap<>();
for (Role r : refRoles) {
final Role exist = curRoleByCode.get(r.getRoleCode());
if (exist != null) {
roleIdMapping.put(r.getRoleId(), exist.getRoleId());
} else if (!toCreateByCode.containsKey(r.getRoleCode())) {
final Role copy = new Role();
copy.setRoleCode(r.getRoleCode());
copy.setRoleName(r.getRoleName());
copy.setComments(r.getComments());
copy.setSortNumber(r.getSortNumber());
copy.setTenantId(curTenant);
toCreateByCode.put(r.getRoleCode(), copy);
}
}
int roleCreated = 0;
if (!toCreateByCode.isEmpty()) {
roleService.saveBatch(new ArrayList<>(toCreateByCode.values()));
// 重新读取当前租户角色回填 oldRoleId -> newRoleId 映射
final Map<String, Role> refreshed = roleMapper.selectListAll(curRoleParam).stream()
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
for (Role r : refRoles) {
final Role nr = refreshed.get(r.getRoleCode());
if (nr != null) {
roleIdMapping.put(r.getRoleId(), nr.getRoleId());
curRoleByCode.putIfAbsent(r.getRoleCode(), nr);
}
}
roleCreated = toCreateByCode.size();
}
// 当前租户全部角色roleId -> Role含新克隆的供后续 role_code 对齐使用
final Map<Integer, Role> newRoleById = new HashMap<>();
curRoleByCode.values().forEach(r -> newRoleById.put(r.getRoleId(), r));
// ===== 2. 克隆菜单 sys_menu按层级复制父先于子标题/权限去重=====
final MenuParam refMenuParam = new MenuParam();
refMenuParam.setTenantId(refTenant);
refMenuParam.setDeleted(0);
final List<Menu> refMenus = menuMapper.getMenuByClone(refMenuParam);
if (CollectionUtils.isEmpty(refMenus)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的菜单");
}
final Map<Integer, Menu> refMenuMap = new HashMap<>();
for (Menu menu : refMenus) {
refMenuMap.put(menu.getMenuId(), menu);
}
final MenuParam curMenuParam = new MenuParam();
curMenuParam.setTenantId(curTenant);
curMenuParam.setDeleted(0);
final Map<String, Integer> curMenuKeys = new HashMap<>();
for (Menu menu : menuMapper.getMenuByClone(curMenuParam)) {
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
}
final List<Menu> sources = refMenus.stream()
.sorted(Comparator.comparingInt((Menu d) -> getMenuDepth(d, refMenuMap)))
.collect(Collectors.toList());
final Map<Integer, Integer> menuIdMapping = new HashMap<>();
int menuCount = 0;
for (Menu source : sources) {
final Integer existsMenuId = curMenuKeys.get(getMenuKey(source));
if (existsMenuId != null) {
menuIdMapping.put(source.getMenuId(), existsMenuId);
continue;
}
final Menu menu = new Menu();
menu.setParentId(source.getParentId() == null ? 0 : menuIdMapping.getOrDefault(source.getParentId(), 0));
menu.setTitle(source.getTitle());
menu.setPath(source.getPath());
menu.setComponent(source.getComponent());
menu.setModules(source.getModules());
menu.setModulesUrl(source.getModulesUrl());
menu.setMenuType(source.getMenuType());
menu.setSortNumber(source.getSortNumber());
menu.setAuthority(source.getAuthority());
menu.setIcon(source.getIcon());
menu.setHide(source.getHide());
menu.setMeta(source.getMeta());
menu.setTenantId(curTenant);
menuService.save(menu);
menuIdMapping.put(source.getMenuId(), menu.getMenuId());
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
menuCount++;
}
// ===== 3. 克隆角色菜单 sys_role_menuroleId/menuId 用映射转换去重=====
final Set<String> curRmKeys = roleMenuMapper.selectListAll(curTenant).stream()
.map(rm -> rm.getRoleId() + "_" + rm.getMenuId())
.collect(Collectors.toSet());
final List<RoleMenu> rmsToSave = new ArrayList<>();
for (RoleMenu rm : roleMenuMapper.selectListAll(refTenant)) {
final Integer newRoleId = roleIdMapping.get(rm.getRoleId());
final Integer newMenuId = menuIdMapping.get(rm.getMenuId());
if (newRoleId == null || newMenuId == null) {
continue;
}
final String key = newRoleId + "_" + newMenuId;
if (curRmKeys.contains(key)) {
continue;
}
final RoleMenu copy = new RoleMenu();
copy.setRoleId(newRoleId);
copy.setMenuId(newMenuId);
copy.setTenantId(curTenant);
rmsToSave.add(copy);
curRmKeys.add(key);
}
int roleMenuCreated = 0;
if (!rmsToSave.isEmpty()) {
roleMenuService.saveBatch(rmsToSave);
roleMenuCreated = rmsToSave.size();
}
// ===== 4. 克隆用户角色 sys_user_role role_code 对齐当前租户用户去重=====
final List<UserRole> curUserRoles = userRoleMapper.selectListAll(curTenant);
final Set<String> curUrKeys = curUserRoles.stream()
.map(ur -> ur.getUserId() + "_" + ur.getRoleId())
.collect(Collectors.toSet());
// 当前租户 userId -> role_code 集合
final Map<Integer, Set<String>> userRoleCodes = new HashMap<>();
for (UserRole ur : curUserRoles) {
final Role role = newRoleById.get(ur.getRoleId());
if (role == null) {
continue;
}
userRoleCodes.computeIfAbsent(ur.getUserId(), k -> new HashSet<>()).add(role.getRoleCode());
}
final List<UserRole> ursToSave = new ArrayList<>();
for (UserRole ur : userRoleMapper.selectListAll(refTenant)) {
final Integer newRoleId = roleIdMapping.get(ur.getRoleId());
if (newRoleId == null) {
continue;
}
final Role newRole = newRoleById.get(newRoleId);
if (newRole == null) {
continue;
}
final String roleCode = newRole.getRoleCode();
// 找当前租户中 role_code 相同的用户建立绑定
for (Map.Entry<Integer, Set<String>> e : userRoleCodes.entrySet()) {
if (!e.getValue().contains(roleCode)) {
continue;
}
final String key = e.getKey() + "_" + newRoleId;
if (curUrKeys.contains(key)) {
continue;
}
final UserRole copy = new UserRole();
copy.setUserId(e.getKey());
copy.setRoleId(newRoleId);
copy.setTenantId(curTenant);
ursToSave.add(copy);
curUrKeys.add(key);
}
}
int userRoleCreated = 0;
if (!ursToSave.isEmpty()) {
userRoleService.saveBatch(ursToSave);
userRoleCreated = ursToSave.size();
}
data.put("roleTotal", refRoles.size());
data.put("roleCreated", roleCreated);
data.put("menuTotal", menuIdMapping.size());
data.put("menuCreated", menuCount);
data.put("roleMenuCreated", roleMenuCreated);
data.put("userRoleCreated", userRoleCreated);
return new ApiResult<>(Constants.RESULT_OK_CODE, "授权成功", data);
}
// 菜单去重标识, 优先使用权限标识, 目录类菜单权限标识为空时按标题+路由匹配
private String getMenuKey(Menu menu) {
if (StrUtil.isNotBlank(menu.getAuthority())) {
return "authority:" + menu.getAuthority();
}
return "menu:" + StrUtil.nullToEmpty(menu.getTitle()) + "@" + StrUtil.nullToEmpty(menu.getPath());
}
// 菜单层级, 用于保证父菜单先于子菜单插入
private int getMenuDepth(Menu menu, Map<Integer, Menu> menuMap) {
int depth = 0;
Menu current = menu;
while (current != null && current.getParentId() != null && current.getParentId() != 0 && depth < 20) {
current = menuMap.get(current.getParentId());
depth++;
}
return depth;
}
}
@@ -13,6 +13,7 @@ import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.mapper.UserMapper;
import com.gxwebsoft.common.system.param.LoginParam;
import com.gxwebsoft.common.system.param.RoleParam;
import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
import com.gxwebsoft.common.system.service.*;
@@ -26,6 +27,7 @@ import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -54,6 +56,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
private OrganizationService organizationService;
@Resource
private UserRefereeService userRefereeService;
@Resource
private TenantService tenantService;
@Autowired(required = false)
private SyncMessageProducer syncMessageProducer;
@@ -85,6 +89,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
param.setUserId(userId);
User user = param.getOne(baseMapper.selectListRel(param));
if (user != null) {
user.setHasPassword(StrUtil.isNotBlank(user.getPassword()));
user.setPassword(null);
user.setRoles(userRoleService.listByUserId(user.getUserId()));
user.setAuthorities(roleMenuService.listMenuByUserId(user.getUserId(), null));
@@ -110,6 +115,28 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return user;
}
@Override
public User getByUsernamePhone(String username, Integer tenantId) {
if (StrUtil.isBlank(username)) {
return null;
}
User user = getOne(new LambdaQueryWrapper<User>()
.and(wrapper -> wrapper
.eq(User::getUsername, username)
.or()
.eq(User::getPhone, username))
.eq(tenantId != null, User::getTenantId, tenantId)
.eq(User::getDeleted, 0)
.orderByDesc(User::getUserId)
.last("limit 1")
);
if (user != null) {
user.setRoles(userRoleService.listByUserId(user.getUserId()));
user.setAuthorities(roleMenuService.listMenuByUserId(user.getUserId(), null));
}
return user;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return getByUsername(username);
@@ -189,6 +216,49 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean markAsDeveloper(Integer userId) {
if (userId == null) {
return false;
}
User user = new User();
user.setUserId(userId);
user.setIsDeveloper(true);
boolean result = updateById(user);
if (result) {
// 替换用户角色为 developer先按用户所在租户找到 developer 角色删除该用户现有角色后
// 只绑定 developer保证用户最终只有开发者单一角色不与原角色并存
try {
User dbUser = getById(userId);
Integer tenantId = dbUser != null ? dbUser.getTenantId() : null;
RoleParam roleParam = new RoleParam();
roleParam.setRoleCode("developer");
if (tenantId != null) {
roleParam.setTenantId(tenantId);
}
Role developerRole = roleService.getByRoleCode(roleParam);
if (developerRole != null && developerRole.getRoleId() != null) {
userRoleService.remove(new LambdaUpdateWrapper<UserRole>().eq(UserRole::getUserId, userId));
userRoleService.saveBatch(userId, Collections.singletonList(developerRole.getRoleId()));
log.info("用户设为开发者并替换角色为 developer: userId={}, roleId={}", userId, developerRole.getRoleId());
}
} catch (Exception e) {
// 角色替换失败不影响 is_developer 标记位落库
log.warn("替换用户角色为 developer 失败(不影响 is_developer 标记): userId={}", userId, e);
}
// 标记开发者后通过MQ异步同步用户数据到 websopy
if (syncMessageProducer != null) {
User updatedUser = getAllByUserId(String.valueOf(userId));
if (updatedUser != null) {
syncMessageProducer.sendUserSyncMessage("websopy", "UPDATE", updatedUser);
log.info("用户设为开发者后同步到websopy: userId={}", userId);
}
}
}
return result;
}
@Override
public boolean comparePassword(String dbPassword, String inputPassword) {
return bCryptPasswordEncoder.matches(inputPassword, dbPassword);
@@ -255,6 +325,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
User addUser = new User();
// 注册用户
addUser.setStatus(0);
// 默认审核通过前端传 auditStatus=0 时走审核流程
addUser.setAuditStatus(userParam.getAuditStatus() != null ? userParam.getAuditStatus() : 1);
if(userParam.getUsername() != null){
addUser.setUsername(userParam.getUsername());
}
@@ -322,6 +394,10 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
role = roleService.getOne(roleQw, false);
// If the default "user" role is missing (fresh DB / incomplete init), create it to avoid empty roles.
if (role == null && addUser.getTenantId() != null && "user".equals(roleCode)) {
// 校验租户是否存在否则插入 sys_role 会因外键约束失败tenant_id 引用 sys_tenant
if (tenantService.getById(addUser.getTenantId()) == null) {
throw new BusinessException("租户不存在(tenantId=" + addUser.getTenantId() + "),无法创建默认角色");
}
Role defaultRole = new Role();
defaultRole.setRoleName("注册用户");
defaultRole.setRoleCode("user");
@@ -380,6 +456,27 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return baseMapper.selectLastLoginDeveloperByPhone(phone);
}
@Override
public User getByPhoneAndAdmin(String phone) {
return baseMapper.selectAdminByPhoneCrossTenant(phone);
}
@Override
public List<User> getSuperAdminsByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return null;
}
return baseMapper.selectSuperAdminsByPhone(phone);
}
@Override
public User getSuperAdminByPhoneAndTenantId(String phone, Integer tenantId) {
if (StrUtil.isBlank(phone) || tenantId == null) {
return null;
}
return baseMapper.selectSuperAdminByPhoneAndTenantId(phone, tenantId);
}
@Override
public List<User> pageAll(UserParam param) {
return baseMapper.pageRelAll(param);
@@ -449,6 +546,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return baseMapper.selectAccountsByPhone(phone);
}
@Override
public List<User> findAccountsByEmail(String email) {
if (StrUtil.isBlank(email)) {
return null;
}
return baseMapper.selectAccountsByEmail(email);
}
@Override
public Integer countAccountsByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
@@ -30,22 +30,23 @@ public class EmailTemplateUtil {
* @param email 邮箱
* @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 {
String title = "恭喜!您的账号已注册成功";
Map<String, Object> data = new HashMap<>();
data.put("username", username);
data.put("phone", phone);
String title = "您的企业官网已开通成功";
Map<String, Object> data = new HashMap<>(8);
data.put("siteName", siteName);
data.put("siteUrl", siteUrl);
data.put("adminUrl", adminUrl);
data.put("account", account);
data.put("password", password);
if (email != null && !email.trim().isEmpty()) {
data.put("email", email);
}
data.put("sendTime", DateUtil.now());
emailRecordService.sendHtmlEmail(title, "register-success.html", data, new String[]{email});
} catch (Exception e) {
// 如果HTML邮件发送失败降级为文本邮件
String content = "恭喜!您的WebSoft账号已注册成功\r\n用户名:" + username + "\r\n手机号码:" + phone + "\r\n密码" + password;
emailRecordService.sendEmail("恭喜!您的WebSoft账号已注册成功", content, email, tenantId);
String content = "恭喜!您的企业官网「" + siteName + "」已开通成功\r\n官网地址" + siteUrl
+ "\r\n后台地址:" + adminUrl + "\r\n登录账号:" + account + "\r\n登录密码:" + password;
emailRecordService.sendEmail("您的企业官网已开通成功", content, email, tenantId);
}
}
@@ -196,4 +197,30 @@ public class EmailTemplateUtil {
sendNotificationEmailWithAction(title, content, email, tenantId, actionUrl, actionText);
}
/**
* 发送邮箱验证码邮件
*
* @param email 收件人邮箱
* @param code 验证码
* @param tenantId 租户ID
*/
public void sendCaptchaEmail(String email, String code, Integer tenantId) {
if (email == null || email.trim().isEmpty()) {
return;
}
// 标题直接带上验证码手机通知预览/邮件列表一眼可见
String title = "【WebSoft】邮箱验证码:" + code;
try {
Map<String, Object> data = new HashMap<>();
data.put("code", code);
data.put("sendTime", DateUtil.now());
emailRecordService.sendHtmlEmail(title, "captcha.html", data, new String[]{email});
} catch (Exception e) {
// 如果HTML邮件发送失败降级为文本邮件标题仍含验证码
String content = "【WebSoft】邮箱验证码\r\n\r\n您的验证码:" + code + "\r\n\r\n5 分钟内有效,请勿泄露给他人。如非本人操作,请忽略此邮件。";
emailRecordService.sendEmail(title, 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>只暴露第三方确实需要的字段避免把内部实体 adminUrlmenuParamdeleted 整体吐出去</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-86012024-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);
}
}
@@ -30,6 +30,10 @@ public class AppConfig {
@TableField("tenant_id")
private Integer tenantId;
/** 应用绑定的租户ID(用于精确匹配当前请求租户的配置) */
@TableField("bound_tenant_id")
private Integer boundTenantId;
@TableField("config_key")
private String configKey;
@@ -3,18 +3,18 @@
<mapper namespace="com.gxwebsoft.websopy.mapper.AppConfigMapper">
<!--
跨表查询 db_websopy.app_config,关联 app_product 校验产品有效性
查询 db_websopy.app_config
注意:
1. 表名带库名前缀 db_websopy.app_config(该表在 db_websopy 库中)
2. Mapper 方法已加 @InterceptorIgnore(tenantLine = "true")
1. 使用 bound_tenant_id 字段精确匹配当前请求租户的绑定配置
2. 表名带库名前缀 db_websopy.app_config(该表在 db_websopy 库中)
3. Mapper 方法已加 @InterceptorIgnore(tenantLine = "true")
TenantLineInnerInterceptor 不会自动追加 tenant_id 条件
3. 手动传入 tenantId 参数精确匹配 app_config 自身的租户
4. INNER JOIN app_product,确保只返回该租户下有效产品(app_product.product_id = app_config.app_id)的配置
-->
<select id="selectByCategory" resultType="com.gxwebsoft.websopy.entity.AppConfig">
SELECT ac.config_id AS configId,
ac.app_id AS appId,
ac.tenant_id AS tenantId,
ac.bound_tenant_id AS boundTenantId,
ac.config_key AS configKey,
ac.config_value AS configValue,
ac.config_type AS configType,
@@ -22,12 +22,10 @@
ac.is_secret AS isSecret,
ac.description
FROM db_websopy.app_config ac
INNER JOIN db_websopy.app_product ap
ON ap.product_id = ac.app_id
AND ap.tenant_id = #{tenantId}
WHERE ac.deleted = 0
AND ac.tenant_id = #{tenantId}
AND ac.bound_tenant_id = #{tenantId}
AND ac.config_type = #{configType}
ORDER BY ac.config_id DESC
</select>
</mapper>
@@ -48,11 +48,14 @@ public class AppConfigService {
log.warn("[AppConfigService] tenantId 为空,跳过 configType={}", configType);
return null;
}
String cacheKey = buildCacheKey(configType, tenantId);
// 1. 命中 Redis 直接返回
String cached = stringRedisTemplate.opsForValue().get(cacheKey);
if (cached != null && !cached.isEmpty()) {
log.info("[AppConfigService] 命中 Redis 缓存 configType={}, tenantId={}, cacheKey={}, 缓存内容={}",
configType, tenantId, cacheKey, cached);
try {
return JSONObject.parseObject(cached);
} catch (Exception e) {
@@ -65,7 +68,15 @@ public class AppConfigService {
// 2. 跨表查 db_websopy.app_config
List<AppConfig> list;
try {
log.info("[AppConfigService] 开始查询 app_config, configType={}, tenantId={}", configType, tenantId);
list = appConfigMapper.selectByCategory(tenantId, configType);
log.info("[AppConfigService] 查询结果: {} 条记录", list == null ? 0 : list.size());
if (list != null && !list.isEmpty()) {
for (AppConfig c : list) {
log.info("[AppConfigService] 配置项: configKey={}, configValue={}, configType={}, tenantId={}",
c.getConfigKey(), c.getConfigValue(), c.getConfigType(), c.getTenantId());
}
}
} catch (Exception e) {
log.error("[AppConfigService] 跨表查询失败 configType={}, tenantId={}, err={}",
configType, tenantId, e.getMessage(), e);
@@ -77,6 +88,8 @@ public class AppConfigService {
}
// 3. 组装为 JSON去掉 config_key 中的 "configType." 前缀
// 注意 config_id 降序后相同 configKey 的后出现的会覆盖先出现的
// 所以降序后最新插入/更新的配置会生效
JSONObject result = new JSONObject();
String prefix = configType + ".";
for (AppConfig c : list) {
@@ -87,7 +100,11 @@ public class AppConfigService {
if (key.startsWith(prefix)) {
key = key.substring(prefix.length());
}
result.put(key, c.getConfigValue());
// 使用 putIfAbsent 保留先出现的 config_id 大的/最新的
// 但如果需要后出现的覆盖改为 result.put(key, c.getConfigValue())
result.putIfAbsent(key, c.getConfigValue()); // 保留第一个最新
log.info("[AppConfigService] 组装JSON: key={}, value={} (tenantId={}, configId={})",
key, c.getConfigValue(), c.getTenantId(), c.getConfigId());
}
// 4. 写缓存 2 小时
+2
View File
@@ -22,6 +22,8 @@ logging:
level:
com.gxwebsoft: DEBUG
com.baomidou.mybatisplus: DEBUG
com.gxwebsoft.websopy.mapper: DEBUG
com.gxwebsoft.websopy.service: DEBUG
socketio:
host: localhost #IP地址
+53
View File
@@ -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
+3
View File
@@ -53,6 +53,9 @@ config:
# websopy 服务地址(用于同步用户数据)
websopyUrl: https://websopy-api.websoft.top
# 内部服务调用密钥(调用 websopy 等内部接口时使用,请务必与 websopy 端保持一致)
internal-key: ${INTERNAL_KEY:websopy-internal-2025}
# 生产环境证书配置
certificate:
# 生产环境使用挂载卷模式
+53
View File
@@ -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
+19
View File
@@ -119,6 +119,9 @@ config:
# websopy 服务地址(用于同步用户数据)
websopyUrl: https://websopy-api.websoft.top
# 内部服务调用密钥(调用 websopy 等内部接口时使用,请务必与 websopy 端保持一致)
internal-key: ${INTERNAL_KEY:websopy-internal-2025}
# 阿里云OSS云存储
endpoint: https://oss-cn-shenzhen.aliyuncs.com
accessKeyId: LTAI5tGXuJku8MK7TA6gQMZw
@@ -165,3 +168,19 @@ certificate:
alipay-cert-public-key-file: "alipayCertPublicKey.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
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSoft 邮箱验证码</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.47059;
color: #1d1d1f;
background-color: #ffffff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.wrap { width: 100%; background: #ffffff; padding: 40px 0 30px; }
.container {
max-width: 680px;
margin: 0 auto;
padding: 0 22px;
}
.brand {
text-align: center;
margin-bottom: 28px;
}
.brand-logo {
height: 36px;
width: auto;
display: inline-block;
}
.page-title {
text-align: center;
font-size: 40px;
font-weight: 600;
letter-spacing: -0.025em;
color: #1d1d1f;
margin: 8px 0 30px;
line-height: 1.1;
}
.lede {
text-align: center;
font-size: 18px;
line-height: 1.4;
color: #1d1d1f;
margin: 0 auto 26px;
max-width: 520px;
}
.card {
background-color: #f5f5f7;
border-radius: 18px;
padding: 28px 32px;
margin: 0 0 22px;
}
.card-caption {
font-size: 13px;
line-height: 1.3;
color: #6e6e73;
font-weight: 400;
margin-bottom: 8px;
letter-spacing: -0.008em;
}
.card-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.022em;
color: #1d1d1f;
margin-bottom: 6px;
}
.card-sub {
font-size: 14px;
color: #6e6e73;
line-height: 1.4;
}
/* 验证码数字卡片 */
.code-card {
text-align: center;
padding: 38px 32px;
}
.code-caption {
font-size: 12px;
color: #6e6e73;
text-transform: none;
letter-spacing: 0.04em;
margin-bottom: 14px;
}
.code-value {
display: inline-block;
font-family: 'SF Mono', 'Menlo', 'Consolas', 'Courier New', monospace;
font-size: 44px;
font-weight: 600;
color: #1d1d1f;
letter-spacing: 14px;
text-indent: 14px;
line-height: 1.05;
margin: 4px 0 14px;
}
.code-expire {
font-size: 13px;
color: #6e6e73;
margin-top: 8px;
}
/* info 灰条 */
.info-card {
font-size: 14px;
color: #1d1d1f;
line-height: 1.5;
}
.info-card strong { color: #1d1d1f; font-weight: 600; }
.info-card .label {
display: inline-block;
font-size: 12px;
color: #6e6e73;
margin-right: 8px;
min-width: 56px;
}
a { color: #06c; text-decoration: none; }
a:hover { text-decoration: underline; }
/* 底部 */
.footer {
text-align: center;
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #d2d2d7;
}
.footer-brand {
font-size: 14px;
font-weight: 600;
color: #1d1d1f;
margin-bottom: 12px;
letter-spacing: -0.016em;
}
.footer-links {
font-size: 13px;
color: #6e6e73;
line-height: 1.8;
}
.footer-links a {
color: #6e6e73;
margin: 0 14px;
text-decoration: none;
}
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
.copyright {
font-size: 12px;
color: #6e6e73;
margin-top: 16px;
line-height: 1.4;
}
.copyright a { color: #6e6e73; }
@media (max-width: 600px) {
.page-title { font-size: 30px; }
.code-value { font-size: 34px; letter-spacing: 10px; text-indent: 10px; }
.card { padding: 22px 22px; border-radius: 16px; }
.container { padding: 0 16px; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="container">
<!-- Brand -->
<div class="brand">
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
</div>
<!-- Title + Lede -->
<h1 class="page-title">邮箱验证码</h1>
<p class="lede">您正在绑定或修改邮箱,请使用下方验证码完成验证。</p>
<!-- 验证码主体卡片 -->
<div class="card code-card">
<div class="code-caption">您的验证码</div>
<div class="code-value">${code!}</div>
<div class="code-expire">⏱ 5 分钟内有效,请尽快使用</div>
</div>
<!-- 操作说明卡片 -->
<div class="card info-card">
<div class="card-title">验证码用于确认邮箱真实性</div>
<div class="card-sub" style="margin-top:8px;">
请将验证码输入到注册或修改邮箱页面以完成操作。验证码仅对当前请求有效,过期后请重新获取。
</div>
</div>
<!-- 安全提示卡片 -->
<div class="card info-card">
<div class="card-caption">安全提示</div>
<div style="margin-top:6px;">
• 验证码请勿转发或告诉他人,WebSoft 工作人员不会向您索取。<br>
• 如非本人操作,请忽略此邮件,您的账号仍然安全。<br>
• 多次未收到验证码可在客户端重新申请。
</div>
</div>
<!-- 时间戳 -->
<div style="text-align:center; font-size:12px; color:#6e6e73; margin-top:18px;">
发送时间:${sendTime!}
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-brand">WebSoft</div>
<div class="footer-links">
<a href="https://websoft.top">官方网站</a>
<a href="https://websoft.top/help">帮助中心</a>
<a href="https://websoft.top/contact">联系我们</a>
<a href="https://websoft.top/settings">账户设置</a>
</div>
<div class="copyright">
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
<a href="https://websoft.top">websoft.top</a>
</div>
</div>
</div>
</div>
</body>
</html>
+152 -201
View File
@@ -3,257 +3,208 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSoft通知</title>
<title>WebSoft - 系统通知</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.email-container {
max-width: 600px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.47059;
color: #1d1d1f;
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
padding: 40px 30px;
text-align: center;
color: white;
}
.logo {
font-size: 32px;
font-weight: bold;
margin-bottom: 10px;
letter-spacing: 2px;
}
.header-subtitle {
font-size: 16px;
opacity: 0.9;
margin-bottom: 20px;
}
.notification-icon {
width: 60px;
height: 60px;
background-color: #00d2ff;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
}
.content {
padding: 40px 30px;
}
.title {
font-size: 24px;
color: #333;
margin-bottom: 20px;
text-align: center;
}
.message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
line-height: 1.8;
}
.notification-content {
background-color: #f8f9fa;
border-radius: 8px;
padding: 25px;
margin: 30px 0;
border-left: 4px solid #4facfe;
}
.notification-content h3 {
color: #333;
margin-bottom: 15px;
font-size: 18px;
}
.notification-body {
color: #555;
line-height: 1.8;
}
.cta-section {
text-align: center;
margin: 40px 0;
}
.cta-button {
.wrap { width: 100%; background: #ffffff; padding: 40px 0 36px; }
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
.brand { text-align: center; margin-bottom: 28px; }
.brand-logo {
height: 36px;
width: auto;
display: inline-block;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
text-decoration: none;
padding: 15px 40px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
transition: transform 0.2s ease;
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.4);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.6);
}
.info-box {
background-color: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
color: #1565c0;
}
.info-box strong {
color: #0d47a1;
}
.footer {
background-color: #2c3e50;
color: #ecf0f1;
padding: 30px;
.page-title {
text-align: center;
font-size: 40px;
font-weight: 600;
letter-spacing: -0.025em;
color: #1d1d1f;
margin: 8px 0 18px;
line-height: 1.1;
}
.lede {
text-align: center;
font-size: 18px;
line-height: 1.45;
color: #1d1d1f;
margin: 0 auto 30px;
max-width: 560px;
}
.footer-content {
margin-bottom: 20px;
.info-card {
background-color: #f5f5f7;
border-radius: 18px;
padding: 28px 32px 24px;
margin: 0 0 22px;
}
.footer-links {
margin: 20px 0;
.info-card-title {
font-size: 13px;
color: #6e6e73;
margin-bottom: 14px;
letter-spacing: -0.008em;
}
.info-body {
font-size: 15px;
line-height: 1.7;
color: #1d1d1f;
word-break: break-word;
}
.info-body a { color: #06c; text-decoration: none; }
.info-body a:hover { text-decoration: underline; }
.footer-links a {
color: #3498db;
text-decoration: none;
margin: 0 15px;
.tips-card {
background-color: #f5f5f7;
border-radius: 18px;
padding: 22px 32px;
margin: 0 0 22px;
}
.tips-caption {
font-size: 13px;
color: #6e6e73;
margin-bottom: 10px;
letter-spacing: -0.008em;
}
.tips-list {
list-style: none;
font-size: 14px;
color: #1d1d1f;
line-height: 1.7;
}
.tips-list li {
padding-left: 14px;
position: relative;
}
.tips-list li:before {
content: "•";
position: absolute;
left: 0;
color: #6e6e73;
}
.footer-links a:hover {
text-decoration: underline;
.cta {
text-align: center;
margin: 32px 0 6px;
}
.copyright {
font-size: 12px;
color: #95a5a6;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #34495e;
.cta a {
font-size: 17px;
color: #06c;
text-decoration: none;
letter-spacing: -0.022em;
}
.cta a:hover { text-decoration: underline; }
.cta-sub {
text-align: center;
font-size: 13px;
color: #6e6e73;
margin-top: 6px;
}
.timestamp {
text-align: right;
text-align: center;
font-size: 12px;
color: #999;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
color: #6e6e73;
margin-top: 18px;
}
.footer {
text-align: center;
margin-top: 44px;
padding-top: 22px;
border-top: 1px solid #d2d2d7;
}
.footer-brand {
font-size: 14px;
font-weight: 600;
color: #1d1d1f;
margin-bottom: 10px;
letter-spacing: -0.016em;
}
.footer-links {
font-size: 13px;
line-height: 1.8;
}
.footer-links a {
color: #6e6e73;
margin: 0 14px;
text-decoration: none;
}
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
.copyright {
font-size: 12px;
color: #6e6e73;
margin-top: 14px;
line-height: 1.4;
}
.copyright a { color: #6e6e73; }
@media (max-width: 600px) {
.email-container {
margin: 0;
box-shadow: none;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 24px;
}
.title {
font-size: 20px;
}
.page-title { font-size: 30px; }
.lede { font-size: 16px; }
.container { padding: 0 16px; }
.info-card, .tips-card { padding: 22px 22px; border-radius: 16px; }
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="header">
<div class="logo">WebSoft</div>
<div class="header-subtitle">企业级数字化解决方案</div>
<div class="notification-icon">📢</div>
<div class="wrap">
<div class="container">
<div class="brand">
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
</div>
<!-- Content -->
<div class="content">
<h1 class="title">${title!'系统通知'}</h1>
<p class="message">
${greeting!'您好!'}我们有一条重要通知需要告知您,请查看以下详细信息。
</p>
<h1 class="page-title">${title!'系统通知'}</h1>
<p class="lede">${greeting!'您好!'}我们有一条重要通知需要告知您,请查看以下详细信息。</p>
<!-- Notification Content -->
<div class="notification-content">
<h3>📋 通知详情</h3>
<div class="notification-body">
${content!'这是一条系统通知消息。'}
</div>
<!-- 通知详情卡(单张大卡) -->
<div class="info-card">
<div class="info-card-title">通知详情</div>
<div class="info-body">${content!'这是一条系统通知消息。'}</div>
</div>
<!-- Info Box -->
<!-- 温馨提示 -->
<% if(has(infoMessage)) { %>
<div class="info-box">
<strong>温馨提示</strong> ${infoMessage!}
<div class="tips-card">
<div class="tips-caption">温馨提示</div>
<ul class="tips-list">
<li>${infoMessage!}</li>
</ul>
</div>
<% } %>
<!-- CTA Button -->
<!-- CTA -->
<% if(has(actionUrl)) { %>
<div class="cta-section">
<a href="${actionUrl!}" class="cta-button">${actionText!'查看详情'}</a>
<div class="cta">
<a href="${actionUrl!}">${actionText!'查看详情'}</a>
</div>
<div class="cta-sub">如按钮未生效,请将上面的链接复制到浏览器打开。</div>
<% } %>
<!-- Timestamp -->
<div class="timestamp">
发送时间:${sendTime!}
</div>
</div>
<!-- 时间戳 -->
<div class="timestamp">发送时间:${sendTime!}</div>
<!-- Footer -->
<div class="footer">
<div class="footer-content">
<strong>WebSoft Admin</strong><br>
专业的企业数字化转型服务商
</div>
<div class="footer-brand">WebSoft</div>
<div class="footer-links">
<a href="https://websoft.top">官方网站</a>
<a href="https://websoft.top/help">帮助中心</a>
<a href="https://websoft.top/contact">联系我们</a>
<a href="https://websoft.top/settings">账户设置</a>
</div>
<div class="copyright">
© 2025 WebSoft Inc.
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
<a href="https://websoft.top">websoft.top</a>
</div>
</div>
</div>
</div>
</body>
</html>
+222 -256
View File
@@ -3,294 +3,255 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSoft密码重置</title>
<title>WebSoft - 密码重置</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.email-container {
max-width: 600px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.47059;
color: #1d1d1f;
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.header {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
padding: 40px 30px;
text-align: center;
color: white;
}
.logo {
font-size: 32px;
font-weight: bold;
margin-bottom: 10px;
letter-spacing: 2px;
}
.header-subtitle {
font-size: 16px;
opacity: 0.9;
margin-bottom: 20px;
}
.warning-icon {
width: 60px;
height: 60px;
background-color: #ffa502;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
}
.content {
padding: 40px 30px;
}
.title {
font-size: 24px;
color: #333;
margin-bottom: 20px;
text-align: center;
}
.message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
text-align: center;
line-height: 1.8;
}
.reset-info {
background-color: #fff5f5;
border-radius: 8px;
padding: 25px;
margin: 30px 0;
border-left: 4px solid #ff6b6b;
}
.reset-info h3 {
color: #333;
margin-bottom: 15px;
font-size: 18px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #fee;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #555;
min-width: 80px;
}
.info-value {
color: #333;
font-family: 'Courier New', monospace;
background-color: #fff;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid #ddd;
}
.cta-section {
text-align: center;
margin: 40px 0;
}
.cta-button {
.wrap { width: 100%; background: #ffffff; padding: 40px 0 36px; }
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
.brand { text-align: center; margin-bottom: 28px; }
.brand-logo {
height: 36px;
width: auto;
display: inline-block;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
text-decoration: none;
padding: 15px 40px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
transition: transform 0.2s ease;
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.6);
}
.security-tips {
background-color: #f8f9fa;
border-radius: 8px;
padding: 25px;
margin: 30px 0;
}
.security-tips h3 {
color: #333;
margin-bottom: 15px;
font-size: 18px;
}
.security-tips ul {
list-style: none;
padding: 0;
}
.security-tips li {
padding: 8px 0;
color: #666;
position: relative;
padding-left: 25px;
}
.security-tips li:before {
content: "🔒";
position: absolute;
left: 0;
}
.footer {
background-color: #2c3e50;
color: #ecf0f1;
padding: 30px;
.page-title {
text-align: center;
font-size: 40px;
font-weight: 600;
letter-spacing: -0.025em;
color: #1d1d1f;
margin: 8px 0 18px;
line-height: 1.1;
}
.lede {
text-align: center;
font-size: 18px;
line-height: 1.45;
color: #1d1d1f;
margin: 0 auto 30px;
max-width: 560px;
}
.footer-content {
margin-bottom: 20px;
.info-card {
background-color: #f5f5f7;
border-radius: 18px;
padding: 28px 32px 24px;
margin: 0 0 22px;
}
.footer-links {
margin: 20px 0;
.info-card-title {
font-size: 13px;
color: #6e6e73;
margin-bottom: 14px;
letter-spacing: -0.008em;
}
.footer-links a {
color: #3498db;
text-decoration: none;
margin: 0 15px;
.info-cols {
display: table;
width: 100%;
border-spacing: 0;
}
.info-col {
display: table-cell;
vertical-align: top;
width: 50%;
padding-right: 18px;
}
.info-col + .info-col {
border-left: 1px solid #d2d2d7;
padding-left: 22px;
padding-right: 0;
}
.field-row { padding: 8px 0; }
.field-label {
font-size: 12px;
color: #6e6e73;
margin-bottom: 4px;
letter-spacing: -0.006em;
}
.field-value {
font-size: 15px;
color: #1d1d1f;
word-break: break-all;
line-height: 1.4;
}
.field-value.mono {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 14px;
}
.footer-links a:hover {
text-decoration: underline;
.tips-card {
background-color: #f5f5f7;
border-radius: 18px;
padding: 22px 32px;
margin: 0 0 22px;
}
.tips-caption {
font-size: 13px;
color: #6e6e73;
margin-bottom: 10px;
letter-spacing: -0.008em;
}
.tips-list {
list-style: none;
font-size: 14px;
color: #1d1d1f;
line-height: 1.7;
}
.tips-list li {
padding-left: 14px;
position: relative;
}
.tips-list li:before {
content: "•";
position: absolute;
left: 0;
color: #6e6e73;
}
.warning-card {
background-color: #fff5f5;
border-radius: 18px;
padding: 22px 32px;
margin: 0 0 22px;
font-size: 14px;
color: #1d1d1f;
line-height: 1.6;
}
.warning-card strong { color: #b3271d; font-weight: 600; }
.cta {
text-align: center;
margin: 32px 0 6px;
}
.cta a {
font-size: 17px;
color: #06c;
text-decoration: none;
letter-spacing: -0.022em;
}
.cta a:hover { text-decoration: underline; }
.cta-sub {
text-align: center;
font-size: 13px;
color: #6e6e73;
margin-top: 6px;
}
.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 {
text-align: center;
margin-top: 44px;
padding-top: 22px;
border-top: 1px solid #d2d2d7;
}
.footer-brand {
font-size: 14px;
font-weight: 600;
color: #1d1d1f;
margin-bottom: 10px;
letter-spacing: -0.016em;
}
.footer-links {
font-size: 13px;
line-height: 1.8;
}
.footer-links a {
color: #6e6e73;
margin: 0 14px;
text-decoration: none;
}
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
.copyright {
font-size: 12px;
color: #95a5a6;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #34495e;
}
.warning-notice {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
.warning-notice strong {
color: #533f03;
color: #6e6e73;
margin-top: 14px;
line-height: 1.4;
}
.copyright a { color: #6e6e73; }
@media (max-width: 600px) {
.email-container {
margin: 0;
box-shadow: none;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 24px;
}
.title {
font-size: 20px;
.page-title { font-size: 30px; }
.lede { font-size: 16px; }
.container { padding: 0 16px; }
.info-card, .tips-card, .warning-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>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="header">
<div class="logo">WebSoft</div>
<div class="header-subtitle">企业级数字化解决方案</div>
<div class="warning-icon">⚠️</div>
<div class="wrap">
<div class="container">
<div class="brand">
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
</div>
<!-- Content -->
<div class="content">
<h1 class="title">密码重置通知</h1>
<p class="message">
您好!我们收到了您的密码重置请求。为了保障您的账户安全,请查看以下信息。
<h1 class="page-title">密码重置</h1>
<p class="lede">
您好!我们收到了您的密码重置请求,请查看以下账户信息以便您确认是否为本人操作。
</p>
<!-- Reset Information -->
<div class="reset-info">
<h3>🔑 重置信息</h3>
<div class="info-item">
<span class="info-label">账号:</span>
<span class="info-value">${username!}</span>
<!-- 资料大卡 -->
<div class="info-card">
<div class="info-card-title">账户信息</div>
<div class="info-cols">
<div class="info-col">
<div class="field-row">
<div class="field-label">登录账号</div>
<div class="field-value">${username!}</div>
</div>
<div class="info-item">
<span class="info-label">手机号</span>
<span class="info-value">${phone!}</span>
<div class="field-row">
<div class="field-label">手机号</div>
<div class="field-value">${phone!}</div>
</div>
<div class="info-item">
<span class="info-label">重置时间:</span>
<span class="info-value">${resetTime!}</span>
</div>
<div class="info-col">
<div class="field-row">
<div class="field-label">重置时间</div>
<div class="field-value">${resetTime!}</div>
</div>
<% if(has(newPassword)) { %>
<div class="info-item">
<span class="info-label">新密码</span>
<span class="info-value">${newPassword!}</span>
<div class="field-row">
<div class="field-label">新密码</div>
<div class="field-value mono">${newPassword!}</div>
</div>
<% } %>
</div>
<!-- Warning Notice -->
<div class="warning-notice">
<strong>⚠️ 重要提醒:</strong> 如果这不是您本人的操作,请立即联系客服并修改密码!
</div>
</div>
<!-- CTA Button -->
<div class="cta-section">
<a href="https://websoft.top/login" class="cta-button">立即登录</a>
<!-- 警告卡片 -->
<div class="warning-card">
<strong>重要提醒:</strong>如果这不是您本人的操作,请立即<a href="${contactUrl!'https://websoft.top/contact'}" style="color:#b3271d;text-decoration:underline;">联系客服</a>并修改密码,您的账户可能已被窃取。
</div>
<!-- Security Tips -->
<div class="security-tips">
<h3>🛡️ 安全建议</h3>
<ul>
<!-- 安全建议 -->
<div class="tips-card">
<div class="tips-caption">安全建议</div>
<ul class="tips-list">
<li>建议您立即登录并修改为更安全的密码</li>
<li>密码应包含大小写字母、数字和特殊字符</li>
<li>不要在多个网站使用相同的密码</li>
@@ -298,25 +259,30 @@
<li>如发现异常登录,请及时联系客服</li>
</ul>
</div>
</div>
<!-- Footer -->
<div class="cta">
<a href="${loginUrl!'https://websoft.top/login'}">立即登录 →</a>
</div>
<div class="cta-sub">如按钮未生效,请将上面的登录地址复制到浏览器打开。</div>
<p class="help">
如有疑问,请联系客服或访问 <a href="${helpUrl!'https://websoft.top/help'}">帮助中心</a>
</p>
<div class="footer">
<div class="footer-content">
<strong>WebSoft Admin</strong><br>
专业的企业数字化转型服务商
</div>
<div class="footer-brand">WebSoft</div>
<div class="footer-links">
<a href="https://websoft.top">官方网站</a>
<a href="https://websoft.top/help">帮助中心</a>
<a href="https://websoft.top/contact">联系我们</a>
<a href="https://websoft.top/settings">账户设置</a>
</div>
<div class="copyright">
© 2025 WebSoft Inc.
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
<a href="https://websoft.top">websoft.top</a>
</div>
</div>
</div>
</div>
</body>
</html>
+155 -287
View File
@@ -3,348 +3,216 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSoft账号注册成功</title>
<title>WebSoft - 企业官网开通成功</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.email-container {
max-width: 600px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.47059;
color: #1d1d1f;
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
.wrap { width: 100%; background: #ffffff; padding: 40px 0 30px; }
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
.brand { text-align: center; margin-bottom: 28px; }
.brand-logo { height: 36px; width: auto; display: inline-block; }
.page-title {
text-align: center;
color: white;
font-size: 40px;
font-weight: 600;
letter-spacing: -0.025em;
color: #1d1d1f;
margin: 8px 0 18px;
line-height: 1.1;
}
.logo {
font-size: 32px;
font-weight: bold;
margin-bottom: 10px;
letter-spacing: 2px;
}
.header-subtitle {
font-size: 16px;
opacity: 0.9;
margin-bottom: 20px;
}
.success-icon {
width: 60px;
height: 60px;
background-color: #4CAF50;
border-radius: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
}
.content {
padding: 40px 30px;
}
.welcome-title {
font-size: 24px;
color: #333;
margin-bottom: 20px;
.lede {
text-align: center;
}
.welcome-message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
text-align: center;
line-height: 1.8;
}
.account-info {
background-color: #f8f9fa;
border-radius: 8px;
padding: 25px;
margin: 30px 0;
border-left: 4px solid #667eea;
}
.account-info h3 {
color: #333;
margin-bottom: 15px;
font-size: 18px;
line-height: 1.45;
color: #1d1d1f;
margin: 0 auto 30px;
max-width: 560px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #e9ecef;
.card {
background-color: #f5f5f7;
border-radius: 18px;
padding: 28px 32px;
margin: 0 0 22px;
}
.info-item:last-child {
border-bottom: none;
.card-caption {
font-size: 13px;
line-height: 1.3;
color: #6e6e73;
font-weight: 400;
margin-bottom: 8px;
letter-spacing: -0.008em;
}
.info-label {
.card-title {
font-size: 22px;
font-weight: 600;
color: #555;
min-width: 80px;
letter-spacing: -0.022em;
color: #1d1d1f;
margin-bottom: 6px;
}
.info-value {
color: #333;
font-family: 'Courier New', monospace;
background-color: #fff;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid #ddd;
.card-sub {
font-size: 14px;
color: #6e6e73;
line-height: 1.5;
}
.cta-section {
text-align: center;
margin: 40px 0;
/* 登录信息字段列表 */
.field-list { margin-top: 16px; }
.field-row {
padding: 12px 0;
border-top: 1px solid #d2d2d7;
}
.cta-button {
.field-row:first-child { border-top: none; padding-top: 4px; }
.field-label {
font-size: 12px;
color: #6e6e73;
margin-bottom: 4px;
letter-spacing: -0.006em;
}
.field-value {
font-size: 15px;
color: #1d1d1f;
word-break: break-all;
line-height: 1.4;
}
.field-value a { color: #06c; text-decoration: none; }
.field-value a:hover { text-decoration: underline; }
.field-value.mono {
font-family: 'SF Mono', 'Menlo', 'Consolas', 'Courier New', monospace;
font-size: 14px;
letter-spacing: 0.5px;
}
/* 安全提示 */
.tips { margin-top: 6px; font-size: 14px; color: #1d1d1f; line-height: 1.7; }
.tips div { padding-left: 14px; position: relative; }
.tips div:before {
content: "•"; position: absolute; left: 0; color: #6e6e73;
}
/* 登录按钮 */
.btn-wrap { text-align: center; margin: 30px 0 8px; }
.btn {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
background-color: #1d1d1f;
color: #ffffff;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
padding: 13px 36px;
border-radius: 980px;
text-decoration: none;
padding: 15px 40px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
transition: transform 0.2s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.features {
margin: 40px 0;
}
.features h3 {
.btn:hover { opacity: 0.9; }
/* 时间戳 */
.send-time {
text-align: center;
margin-bottom: 25px;
color: #333;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.feature-item {
text-align: center;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.feature-icon {
font-size: 24px;
margin-bottom: 10px;
}
.feature-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.feature-desc {
font-size: 12px;
color: #666;
color: #6e6e73;
margin-top: 18px;
}
/* 底部 */
.footer {
background-color: #2c3e50;
color: #ecf0f1;
padding: 30px;
text-align: center;
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #d2d2d7;
}
.footer-content {
margin-bottom: 20px;
}
.footer-links {
margin: 20px 0;
}
.footer-links a {
color: #3498db;
text-decoration: none;
margin: 0 15px;
.footer-brand {
font-size: 14px;
font-weight: 600;
color: #1d1d1f;
margin-bottom: 12px;
letter-spacing: -0.016em;
}
.footer-links a:hover {
text-decoration: underline;
}
.copyright {
font-size: 12px;
color: #95a5a6;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #34495e;
}
.security-notice {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
.security-notice strong {
color: #533f03;
}
.footer-links { font-size: 13px; color: #6e6e73; line-height: 1.8; }
.footer-links a { color: #6e6e73; margin: 0 14px; text-decoration: none; }
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
.copyright { font-size: 12px; color: #6e6e73; margin-top: 16px; line-height: 1.4; }
.copyright a { color: #6e6e73; }
@media (max-width: 600px) {
.email-container {
margin: 0;
box-shadow: none;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 24px;
}
.welcome-title {
font-size: 20px;
}
.feature-grid {
grid-template-columns: 1fr;
}
.page-title { font-size: 30px; }
.lede { font-size: 16px; }
.card { padding: 22px 22px; border-radius: 16px; }
.container { padding: 0 16px; }
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="header">
<div class="logo">WebSoft</div>
<div class="header-subtitle">企业级数字化解决方案</div>
<div class="success-icon"></div>
<div class="wrap">
<div class="container">
<!-- Brand -->
<div class="brand">
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
</div>
<!-- Content -->
<div class="content">
<h1 class="welcome-title">恭喜!账号注册成功</h1>
<p class="welcome-message">
欢迎加入WebSoft大家庭!您的账号已成功创建,现在可以开始体验我们的企业级服务了。
</p>
<!-- Title + Lede -->
<h1 class="page-title">开通成功</h1>
<p class="lede">恭喜!您的企业官网已开通成功,可使用下方信息登录管理后台。</p>
<!-- Account Information -->
<div class="account-info">
<h3>📋 您的账号信息</h3>
<div class="info-item">
<span class="info-label">用户名:</span>
<span class="info-value">${username!}</span>
<!-- 站点信息 + 登录信息主卡 -->
<div class="card">
<div class="card-caption">站点信息</div>
<div class="card-title">企业官网「${siteName!}」</div>
<div class="card-sub">请妥善保管登录信息,首次登录后建议立即修改密码。</div>
<div class="field-list">
<div class="field-row">
<div class="field-label">官网地址</div>
<div class="field-value"><a href="${siteUrl!}">${siteUrl!}</a></div>
</div>
<div class="info-item">
<span class="info-label">手机号:</span>
<span class="info-value">${phone!}</span>
<div class="field-row">
<div class="field-label">后台地址</div>
<div class="field-value"><a href="${adminUrl!}">${adminUrl!}</a></div>
</div>
<div class="info-item">
<span class="info-label">登录密码:</span>
<span class="info-value">${password!}</span>
<div class="field-row">
<div class="field-label">登录账号</div>
<div class="field-value">${account!}</div>
</div>
<div class="field-row">
<div class="field-label">登录密码</div>
<div class="field-value mono">${password!}</div>
</div>
<% if(has(email)) { %>
<div class="info-item">
<span class="info-label">邮箱:</span>
<span class="info-value">${email!}</span>
</div>
<% } %>
</div>
<!-- Security Notice -->
<div class="security-notice">
<strong>🔒 安全提醒:</strong> 请妥善保管您的登录信息,建议首次登录后立即修改密码。
<!-- 安全提示 -->
<div class="card">
<div class="card-caption">安全提</div>
<div class="tips">
<div>请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。</div>
<div>建议首次登录后立即进入「个人中心 &gt; 修改密码」重置密码。</div>
<div>如发现可疑登录或账号被盗,请立即联系客服并修改密码。</div>
</div>
</div>
<!-- CTA Button -->
<div class="cta-section">
<a href="https://websoft.top/login" class="cta-button">立即登录</a>
<!-- 登录按钮 -->
<div class="btn-wrap">
<a href="${adminUrl!}" class="btn">登录管理后台</a>
</div>
<!-- Features -->
<div class="features">
<h3>🚀 开始探索WebSoft的强大功能</h3>
<div class="feature-grid">
<div class="feature-item">
<div class="feature-icon">📊</div>
<div class="feature-title">数据分析</div>
<div class="feature-desc">智能数据洞察</div>
</div>
<div class="feature-item">
<div class="feature-icon">🔧</div>
<div class="feature-title">系统管理</div>
<div class="feature-desc">高效运营管理</div>
</div>
<div class="feature-item">
<div class="feature-icon">👥</div>
<div class="feature-title">团队协作</div>
<div class="feature-desc">无缝团队合作</div>
</div>
<div class="feature-item">
<div class="feature-icon">🛡️</div>
<div class="feature-title">安全保障</div>
<div class="feature-desc">企业级安全</div>
</div>
</div>
</div>
</div>
<!-- 时间戳 -->
<div class="send-time">发送时间:${sendTime!}</div>
<!-- Footer -->
<div class="footer">
<div class="footer-content">
<strong>WebSoft Admin</strong><br>
专业的企业数字化转型服务商
</div>
<div class="footer-brand">WebSoft</div>
<div class="footer-links">
<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/settings">账户设置</a>
</div>
<div class="copyright">
© 2025 WebSoft Inc.
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
<a href="https://websoft.top">websoft.top</a>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -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 实体没有 @JsonIgnoreUserMapper 又用 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());
}
}