Compare commits

..

181 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
gxwebsoft 307c209565 fix(appconfig): 修复跨表查询以确保产品有效性
- selectByCategory SQL 增加 INNER JOIN app_product,确保只返回该租户下有效产品配置
- 加入关联条件 ap.product_id = ac.app_id 和 ap.tenant_id = #{tenantId}
- 保证查询结果中 app_config 的 app_id 必须对应有效且属于当前租户的产品
- 维护原有租户过滤和配置类型过滤逻辑,增强数据准确性
- 该改动对 Service 和 Controller 层无影响,无需修改调用逻辑
2026-06-18 17:17:45 +08:00
gxwebsoft bae1f75533 Merge remote-tracking branch 'origin/master' 2026-06-18 16:32:00 +08:00
gxwebsoft 797a140f11 refactor(wxlogin): 改造微信配置读取优先从 app_config 获取
- 新增 AppConfig 实体及跨库 Mapper、XML 实现 db_websopy.app_config 查询
- 增加 AppConfigService 提供带缓存的配置读取接口,缓存两小时
- WxLoginController 注入 AppConfigService,新增私有方法 getMpWxSetting()
- getMpWxSetting() 优先读取 app_config(category=wechat),失败回退 sys_setting.mp-weixin
- 替换微信小程序配置获取调用点,统一通过 getMpWxSetting() 获取配置
- 避免跨库 TenantLineInterceptor 干扰,Mapper 方法加 @InterceptorIgnore 标注
- 修正 StringRedisTemplate 注入避免 Bean 名冲突
- 新增读取失败或无配置时的降级和异常处理逻辑
- 日志补充便于排查配置读取过程及缓存情况
2026-06-18 16:31:04 +08:00
b2894lxlx fb75156a03 支付方式新增子商户id、子应用字段 2026-06-10 12:39:58 +08:00
gxwebsoft 779f90e813 feat(user): 新增开发者筛选条件
- 在UserParam中添加isDeveloper字段及其注解说明
- 在UserMapper.xml中添加isDeveloper的查询条件判断
- 支持根据开发者身份进行用户筛选查询
2026-06-05 17:19:31 +08:00
gxwebsoft 2a880df691 feat(auth): 新增开发者短信验证码登录功能
- 增加根据手机号查询最近登录开发者账号接口,忽略租户隔离
- 实现开发者登录时手机号无注册时的错误提示
- 在登录流程中支持场景参数,区分开发者登录逻辑
- 新增开发者短信验证码登录API及相关校验逻辑
- 将开发者短信登录接口加入安全配置免验证路径
- 在User实体中添加isDeveloper字段标识开发者账号
- 记录登录操作日志,包含成功与失败原因
- 支持token有效期配置优先取缓存中的注册配置
2026-05-31 11:30:07 +08:00
gxwebsoft ec3bededa4 feat(auth): 新增超级管理员手机号短信验证码登录功能
- 在登录接口中增加对superAdminLogin场景的手机号校验
- 提供超级管理员短信验证码登录接口,支持验证码校验和超级管理员身份验证
- 超级管理员登录成功后生成访问token并缓存
- 在UserMapper中添加根据手机号查询最近登录超级管理员账号的方法
- 在UserService及其实现中增加对应的查询接口
- 更新SecurityConfig,放行超级管理员短信登录接口的权限验证
- 增强登录记录保存逻辑,包含错误和成功的登录记录保存
2026-05-31 10:52:33 +08:00
gxwebsoft e2c84b94b9 fix(UserBalanceLog): 优化 SQL 参数空值校验
- 在多个 SQL 查询条件中添加参数非空且非空字符串判断
- 防止空字符串导致的错误匹配或异常
- 统一调整语句格式,保持代码规范和可读性
- 优化关联查询时参数过滤逻辑,提升查询准确性
2026-05-27 08:22:05 +08:00
gxwebsoft f7e3cad931 feat(system): 支持分页查询租户时根据all参数控制范围
- 新增TenantParam中的all字段用于控制是否查询全部租户
- page接口根据all参数判断是否自动设置当前登录用户userId作为过滤条件
- 保持mask参数逻辑不变,支持脱敏控制
- 改进分页查询功能,增强查询灵活性和权限控制
2026-04-27 09:32:36 +08:00
gxwebsoft 6a48299e12 fix(system): 修复超级管理员用户名设置错误
- 将超级管理员用户名从随机UUID改为固定的"superAdmin"
- 确保超级管理员账户标识一致性
- 便于后续权限管理和系统维护
2026-04-27 09:25:36 +08:00
gxwebsoft ed9d500e5d fix(system): 修正超级管理员标识及关联查询逻辑
- 将 TenantMapper.xml 中用户表连接更新为使用 gxwebsoft_core.sys_user
- 修改关联查询条件,使用 is_super_admin 替代 is_admin 标识
- 调整 User 实体中 isSuperAdmin 字段,移除@TableField注解以确保正确映射
2026-04-27 09:07:16 +08:00
gxwebsoft 64e9674d0e refactor(database): 优化租户相关SQL查询逻辑
- 移除嵌套子查询,改用子查询中取最小user_id方式关联管理员用户
- 简化管理员用户相关字段的查询逻辑,提升SQL可读性
- 直接关联sys_user表替代以前复杂多层嵌套结构
- 保持查询结果字段一致,避免影响现有功能使用
2026-04-27 07:26:47 +08:00
gxwebsoft 6804a0a824 refactor(database): 优化租户相关SQL查询逻辑
- 移除嵌套子查询,改用子查询中取最小user_id方式关联管理员用户
- 简化管理员用户相关字段的查询逻辑,提升SQL可读性
- 直接关联sys_user表替代以前复杂多层嵌套结构
- 保持查询结果字段一致,避免影响现有功能使用
2026-04-27 07:20:52 +08:00
gxwebsoft a3c4b74d33 refactor(tenant): 优化租户管理员用户查询逻辑
- 修改TenantMapper中sys_user的关联查询方式
- 使用ROW_NUMBER窗口函数获取每个租户的第一个管理员用户
- 过滤出is_admin为1且未删除的用户,提高查询准确性
- 避免直接按照user_id关联带来的潜在数据错误
- 保持查询结果结构不变,确保兼容现有业务逻辑
2026-04-27 07:17:10 +08:00
gxwebsoft c3bd90f234 fix(tenant): 修复租户初始化和关联查询问题
- tenantService.initialization()返回新增Company对象并使用其tenantId赋值
- userParam及userParam1新增tenantId字段,使用新创建租户ID
- Tenant实体新增username字段,支持用户名信息存储
- TenantMapper查询语句增加c.username字段关联查询
- TenantServiceImpl中超级管理员username设为随机UUID字符串,避免固定用户名冲突
2026-04-27 07:04:54 +08:00
gxwebsoft 5579f7494e refactor(system): 移除手机号唯一限制,改由数据库唯一约束处理
- 取消注册管理员时手机号唯一校验,允许同一手机号创建多个租户
- 删除代码中重复注册手机号的业务检查逻辑
- 数据库查询手机号码管理员时,强制必须传入租户ID进行多租户支持
- 修改SQL注释,明确手机管理员查询需提供租户ID
- 保证手机号唯一性通过数据库唯一约束机制实现,提高数据一致性和扩展性
2026-04-27 06:52:47 +08:00
gxwebsoft e9532ae4d7 fix(system): 修复用户查询条件中过滤模板ID的问题
- 移除了UserMapper.xml中用户查询条件中的template_id过滤
- 确保管理员和超级管理员帐号查询不受模板ID限制
- 优化查询逻辑,避免因template_id导致漏查符合条件的用户
2026-04-27 06:44:43 +08:00
gxwebsoft 2d012dbd7f feat(tenant): 支持租户手机号脱敏开关功能
- Tenant实体新增phoneMasked字段,默认开启手机号脱敏
- getPhone方法根据phoneMasked决定是否返回脱敏手机号
- TenantController分页接口支持mask参数,控制手机号是否脱敏
- TenantMapper调整SQL,始终关联sys_user表获取手机号
- TenantParam新增mask字段,兼容传入脱敏控制参数
2026-04-27 06:23:09 +08:00
gxwebsoft 5637690424 feat(system): 添加参数以支持租户查询中获取手机号码
- 在 TenantParam 类中新增 getPhone 字段用于控制是否获取手机号码
- 修改 TenantMapper.xml,增加条件性连接 sys_user 表以获取电话号码
- 根据 getPhone 字段动态添加手机号码字段及关联表连接查询
- 实现租户查询时根据需要可选择返回手机号信息
2026-04-27 06:12:45 +08:00
gxwebsoft 6cb23a8eee fix(system): 修复超级管理员注册时手机号获取逻辑
- 自动使用当前登录用户的手机号替代传入手机号
- 确保登录用户为空时仍使用请求中的手机号
- 优化超级管理员注册接口的手机号获取方式
2026-04-27 06:10:06 +08:00
gxwebsoft e2520001c9 refactor(system): 删除TenantServiceImpl中多余的userId更新逻辑
- 删除了对company和tenant的userId更新代码
- 简化了superAdmin用户保存后的逻辑
- 优化了代码结构,提升可读性和维护性
2026-04-26 14:24:59 +08:00
gxwebsoft f894c53184 fix(system): 保存租户时增加当前登录用户ID
- 在保存租户信息时设置当前登录用户的ID
- 确保tenant对象包含userId字段以关联操作人
- 优化租户数据初始化逻辑,增加操作追踪
2026-04-26 14:19:16 +08:00
gxwebsoft 5f253695c4 fix(system): 修复创建超级管理员时更新company和tenant的userId
- 在保存超级管理员用户后,更新company的userId字段
- 查询对应tenant并同步更新其userId字段
- 确保company和tenant的userId保持一致性
- 防止因userId未更新导致的数据不一致问题
2026-04-26 14:17:44 +08:00
gxwebsoft 05c67811ed fix(system): 移除重复权限注解修饰符
- 删除TenantController中get方法上的多余@PreAuthorize注解
- 确保接口权限校验逻辑仅由其他配置统一管理
- 清理代码提高可读性和维护性
2026-04-26 03:34:16 +08:00
gxwebsoft 7d562db19c fix(auth): 优化验证码校验逻辑处理
- 提取开发者短信验证码到变量减少重复获取
- 针对租户10519添加硬编码万能验证码"170083"支持
- 保持验证码不正确时的错误提示和日志记录逻辑
- 增强代码可读性和维护性
2026-04-21 10:12:21 +08:00
gxwebsoft 5e66c4c65b fix(auth): 优化验证码校验逻辑处理
- 提取开发者短信验证码到变量减少重复获取
- 针对租户10519添加硬编码万能验证码"170083"支持
- 保持验证码不正确时的错误提示和日志记录逻辑
- 增强代码可读性和维护性
2026-04-21 10:12:16 +08:00
gxwebsoft 5b3363d1ae feat(wx): 实现小程序码生成自动重试机制
- 在生成小程序码时增加首次失败后清理缓存并重试的逻辑
- 新增强制刷新 access_token 的方法 getAccessTokenForcibly
- 优化了获取 access_token 失败的日志提示,不再在错误时清理缓存
- 移除生成小程序码接口对 token 错误时清理缓存的判断及操作
- 移除异常时清理缓存的代码,避免误删除有效缓存
- 调整二维码请求参数,移除注释的颜色配置代码
2026-04-11 09:02:20 +08:00
gxwebsoft a57eb804eb feat(wx): 实现小程序码生成自动重试机制
- 在生成小程序码时增加首次失败后清理缓存并重试的逻辑
- 新增强制刷新 access_token 的方法 getAccessTokenForcibly
- 优化了获取 access_token 失败的日志提示,不再在错误时清理缓存
- 移除生成小程序码接口对 token 错误时清理缓存的判断及操作
- 移除异常时清理缓存的代码,避免误删除有效缓存
- 调整二维码请求参数,移除注释的颜色配置代码
2026-04-11 08:56:11 +08:00
gxwebsoft 789b8ddeca fix(wx): 优化获取手机号失败时的token缓存清理逻辑
- 解析不同类型的错误码以确保正确识别token相关错误
- 增加isTokenRelatedError方法判断特定错误码是否属于token相关错误
- 在获取手机号失败时清理对应的access_token缓存,防止token失效问题
- 输出详细日志便于排查微信获取手机号失败的异常情况
- 修正小程序二维码生成中env_version注释说明提升可读性
- 新增高级开发工程师专家配置数据
2026-04-11 08:47:52 +08:00
gxwebsoft 7aaf25c1ac fix(qrLogin): 优化小程序码Token错误处理与缓存清理
- 新增access_token对应Redis键,保持与WxService一致
- 获取access_token失败时添加缓存清理操作
- 生成小程序码API返回token相关错误时清理缓存
- 异常捕获时也进行access_token缓存清理,防止token问题
- 增加判断token相关错误码的方法,明确常见微信API错误码
- 实现清理access_token缓存方法,包含异常日志处理
2026-04-11 07:44:34 +08:00
gxwebsoft 1d5b65bcc0 fix(qrLogin): 修正小程序二维码环境版本参数
- 将小程序二维码请求中的 env_version 参数从 develop 改为 release
- 更新 expert-history.json 文件中的 lastUpdated 时间戳
2026-04-10 13:41:17 +08:00
gxwebsoft f382df7976 fix(mq): 修改RabbitMQ同步交换机名称
- 将同步交换机名称从 sync.exchange 修改为 sync.topic.exchange
- 更新相关配置以反映新的交换机名称
- 维护消息队列配置的一致性和正确性
2026-04-09 17:11:03 +08:00
gxwebsoft 9353fb6b85 fix(mq): 修改RabbitMQ同步交换机名称
- 将同步交换机名称从 sync.exchange 修改为 sync.topic.exchange
- 更新相关配置以反映新的交换机名称
- 维护消息队列配置的一致性和正确性
2026-04-09 16:44:01 +08:00
gxwebsoft bf12ed397c fix(mq): 修改RabbitMQ同步交换机名称
- 将同步交换机名称从 sync.exchange 修改为 sync.topic.exchange
- 更新相关配置以反映新的交换机名称
- 维护消息队列配置的一致性和正确性
2026-04-09 16:42:58 +08:00
gxwebsoft 0343979b5d feat(mq): 重构用户同步为MQ消息驱动并移除HTTP同步服务
- 将用户同步消息交换机由 DirectExchange 改为 TopicExchange,实现多目标系统路由
- 去除核心系统中用户同步队列定义和消费者,转由各子系统独立消费
- RabbitMQSyncProducer 动态构建 routing key,支持按目标系统路由同步消息
- UserServiceImpl 用户新增和更新后发送同步消息到 websopy 的 MQ 交换机
- 废弃 UserSyncService 中的 HTTP 同步接口,改为通过 MQ 实现用户数据同步
- 删除 UserSyncConsumer,核心系统不再直接消费用户同步消息
- 增加日志输出,便于追踪用户同步消息发送情况
- 保留废弃代码兼容旧引用,方便后续平滑迁移和维护
2026-04-09 16:12:05 +08:00
gxwebsoft 185a3cf7f5 fix(qrLogin): 调整二维码颜色配置逻辑
- 将二维码自动颜色配置改为启用(auto_color设为true)
- 注释掉手动设置二维码颜色的代码段
- 保持其他二维码参数配置不变
- 优化二维码生成逻辑,避免颜色冲突问题
2026-04-07 23:42:11 +08:00
gxwebsoft dc8cbb43a8 fix(qrLogin): 调整二维码颜色配置逻辑
- 将二维码自动颜色配置改为启用(auto_color设为true)
- 注释掉手动设置二维码颜色的代码段
- 保持其他二维码参数配置不变
- 优化二维码生成逻辑,避免颜色冲突问题
2026-04-07 22:21:58 +08:00
gxwebsoft 5c081c48c7 fix(qrLogin): 调整二维码颜色配置逻辑
- 将二维码自动颜色配置改为启用(auto_color设为true)
- 注释掉手动设置二维码颜色的代码段
- 保持其他二维码参数配置不变
- 优化二维码生成逻辑,避免颜色冲突问题
2026-04-07 22:00:52 +08:00
gxwebsoft 9a778898d5 fix(qrLogin): 修正二维码场景值传递问题
- 将 scene 参数由 "token=" + token 改为直接传 token
- 确保 scene 为字符串且长度不超过 32 字符
- 便于小程序端通过 router.params.scene 获取 token
- 优化注释说明二维码参数限制和使用方式
2026-04-07 21:24:28 +08:00
gxwebsoft ed9c59dae6 fix(qrLogin): 修正二维码场景值传递问题
- 将 scene 参数由 "token=" + token 改为直接传 token
- 确保 scene 为字符串且长度不超过 32 字符
- 便于小程序端通过 router.params.scene 获取 token
- 优化注释说明二维码参数限制和使用方式
2026-04-07 21:14:44 +08:00
gxwebsoft 1c9c2dfd82 fix(qrLogin): 修正二维码场景值传递问题
- 将 scene 参数由 "token=" + token 改为直接传 token
- 确保 scene 为字符串且长度不超过 32 字符
- 便于小程序端通过 router.params.scene 获取 token
- 优化注释说明二维码参数限制和使用方式
2026-04-07 21:14:34 +08:00
gxwebsoft f4e7e48254 fix(api): 修复二维码登录接口图片字节读取错误
- 将获取响应体字节的方法由bytes()改为bodyBytes()
- 修正了二维码图片内容的读取问题
- 增加接口调用的稳定性和正确性
2026-04-07 21:08:18 +08:00
gxwebsoft d1ad38c69f refactor(qrLogin): 简化二维码生成逻辑,移除文件生成和请求
- 二维码内容改为自定义协议,前端生成base64二维码
- 不再生成微信小程序码图片文件,删除相关生成方法
- 不再生成公众号带参数二维码URL,前端使用扫码跳转URL
- 扫码跳转URL改为配置值,提供默认域名降级处理
- 删除了生成文件和请求微信API的多余代码和相关依赖
- 保持miniprogramQrCodeUrl和wechatQrCodeUrl为空,由前端自动处理
2026-04-07 21:04:41 +08:00
gxwebsoft 345afab954 refactor(qrLogin): 简化二维码生成逻辑,移除文件生成和请求
- 二维码内容改为自定义协议,前端生成base64二维码
- 不再生成微信小程序码图片文件,删除相关生成方法
- 不再生成公众号带参数二维码URL,前端使用扫码跳转URL
- 扫码跳转URL改为配置值,提供默认域名降级处理
- 删除了生成文件和请求微信API的多余代码和相关依赖
- 保持miniprogramQrCodeUrl和wechatQrCodeUrl为空,由前端自动处理
2026-04-07 17:33:52 +08:00
gxwebsoft fa09d9dc23 refactor(mq): 优化RabbitMQ消息转换器配置
- 移除自定义ObjectMapper实例,改用注入的@Primary ObjectMapper
- 删除对JavaTimeModule和序列化配置的显式设置
- 简化messageConverter方法实现,提高配置复用性
- 保持消息转换功能一致性和代码简洁性
2026-04-07 03:12:59 +08:00
gxwebsoft 3e7f34fa0d fix(rabbitmq): 修复Spring Boot启动时objectMapper bean冲突
- 取消了RabbitMQConfig中通过方法参数注入ObjectMapper,避免重复定义bean
- 在messageConverter方法内新建ObjectMapper实例,独立配置序列化选项
- 在application.yml及其dev、prod配置文件启用allow-bean-definition-overriding
- 保证Spring Boot 2.5.15环境中bean定义覆盖正常,解决启动失败问题
- 修复后确保MQ消息队列和扫码登录功能正常运行
2026-04-07 03:05:34 +08:00
gxwebsoft 04ec9659b0 feat(mq): 实现 websopy 端 RabbitMQ 消费者功能
- 新增 SyncMessage.java 定义 websopy 端消息实体,复用 server-api 格式
- 新增 RabbitMQConfig.java 配置交换机、队列及死信队列
- 新增 SyncMessageConsumer.java 实现 USER_SYNC 消息监听与处理
- 修改 pom.xml,添加 spring-boot-starter-amqp 依赖
- 修改 application.yml,添加 RabbitMQ 连接配置和开关
- 优化消息生产者 RabbitMQSyncProducer 代码,移除多余注解
- 设计 server-api -> RabbitMQ -> websopy-java -> AppUserCacheService 流程接口
2026-04-07 02:57:06 +08:00
gxwebsoft a8372cedad feat(mq): 实现 websopy 端 RabbitMQ 消费者功能
- 新增 SyncMessage.java 定义 websopy 端消息实体,复用 server-api 格式
- 新增 RabbitMQConfig.java 配置交换机、队列及死信队列
- 新增 SyncMessageConsumer.java 实现 USER_SYNC 消息监听与处理
- 修改 pom.xml,添加 spring-boot-starter-amqp 依赖
- 修改 application.yml,添加 RabbitMQ 连接配置和开关
- 优化消息生产者 RabbitMQSyncProducer 代码,移除多余注解
- 设计 server-api -> RabbitMQ -> websopy-java -> AppUserCacheService 流程接口
2026-04-07 02:55:13 +08:00
gxwebsoft ca3651165c feat(sync): 实现MQ消息队列用户同步功能
- 添加RabbitMQ相关配置,支持消息确认与回退机制
- 新增统一消息实体SyncMessage,支持多种同步事件类型
- 实现RabbitMQ消息生产者RabbitMQSyncProducer,包含回调处理和用户同步消息发送
- 实现用户同步消息消费者UserSyncConsumer,支持消息重试和死信队列处理
- UserSyncService新增Map参数方法,适配MQ消息同步调用
- QrLoginServiceImpl改用MQ消息异步同步用户数据,替代原直接调用方式
- 应用配置新增RabbitMQ连接配置及MQ开关开关控制
- 前端接口和组件调整,支持扫码登录绑定手机号及跳转逻辑完善
2026-04-07 02:44:02 +08:00
gxwebsoft 3549e687f6 feat(login): 实现扫码登录绑定手机号后的用户同步逻辑
- 后端QrLoginServiceImpl新增nextAction字段支持绑定手机号和跳转操作
- 状态检查接口支持绑定手机号和跳转状态,响应字段增加redirectUrl和successMessage
- 移除新用户注册时立即同步用户到websopy的逻辑,避免手机号未绑定时缓存无效
- 绑定手机号成功后重新加载数据库用户并同步到websopy,确保手机号完整数据同步
- WxOfficialController中注释和逻辑调整,明确绑定手机号前不进行同步操作
- 补充文档中扫码登录绑定手机号和用户同步相关流程及API接口说明
2026-04-07 02:03:59 +08:00
gxwebsoft 181801abdb fix(wx): 修复微信公众号扫码关注及登录流程中的数据处理问题
- 调整默认角色分配,从guest改为user以提升用户权限合理性
- 完善扫码关注事件的用户信息处理流程,确保unionid和subscribe状态正确管理
- 修复用户角色绑定逻辑,处理无角色或无效角色时重新分配默认角色
- 优化扫码登录状态更新日志打印,提升信息可读性
- 修正用户注册流程中角色获取失败时的默认角色设置
- 增加生成微信扫码登录二维码接口的稳定性和错误处理
- 统一日志输出格式,增加关键节点日志便于问题追踪
- 清理微信消息
2026-04-07 01:53:21 +08:00
gxwebsoft e73e0fb6b5 feat(auth): 完善扫码登录状态逻辑,支持绑定手机号和跳转
- 新增响应字段 nextAction、redirectUrl 和 successMessage,用于表示下一步操作
- 扫码登录状态为绑定手机号时,设置 nextAction 为 bind_phone,清空跳转地址
- 扫码已确认且有访问令牌时,设置跳转控制台页面,带成功提示信息
- 其他状态默认保持等待状态,确保扫码流程向前推进
- 更新扫码登录响应模型,优化前端流程控制体验
2026-04-07 01:43:51 +08:00
gxwebsoft 3c89cbce40 fix(sync): 修复用户同步到 websopy 的数据格式及完整性问题
- 修正同步数据中的 tenant 字段名称为 tenant_id 以匹配 websopy 数据库字段
- 增加 tenantId 值的日志输出便于调试和校验
- 在 wx 官方控制器同步用户时先从数据库重新加载用户数据,保证同步数据完整性
- 若重新加载失败则回退使用当前用户对象进行同步
- 优化用户角色保存后同步操作的可靠性和准确性
2026-04-07 01:38:11 +08:00
gxwebsoft 03c94624d0 fix(sync): 修复用户同步到 websopy 的数据格式及完整性问题
- 修正同步数据中的 tenant 字段名称为 tenant_id 以匹配 websopy 数据库字段
- 增加 tenantId 值的日志输出便于调试和校验
- 在 wx 官方控制器同步用户时先从数据库重新加载用户数据,保证同步数据完整性
- 若重新加载失败则回退使用当前用户对象进行同步
- 优化用户角色保存后同步操作的可靠性和准确性
2026-04-07 00:50:51 +08:00
gxwebsoft 9ec04fef3a fix(user-sync): 修复用户同步时tenantId为空问题
- 在用户同步数据中添加tenantId字段,tenantId为空时默认传0
- 更新同步日志,增加tenantId信息输出,便于调试跟踪
- 在扫码登录流程中添加多处详细调试日志,输出关键变量状态
- 添加System.out调试信息,帮助排查绑定用户及扫码登录异常情况
2026-04-07 00:35:54 +08:00
gxwebsoft 003a248d37 fix(user-sync): 修复用户同步时tenantId为空问题
- 在用户同步数据中添加tenantId字段,tenantId为空时默认传0
- 更新同步日志,增加tenantId信息输出,便于调试跟踪
- 在扫码登录流程中添加多处详细调试日志,输出关键变量状态
- 添加System.out调试信息,帮助排查绑定用户及扫码登录异常情况
2026-04-06 23:27:25 +08:00
gxwebsoft 4ff46dbefe refactor(auto): 优化扫码登录逻辑与状态管理
- 引入统一的过期时间解析和剩余秒数计算方法,提升代码复用性
- 增加扫码登录状态刷新时对用户手机号绑定状态的处理逻辑
- 补充扫码登录状态存储流程,新增持久化方法支持过期时间自动更新
- 完善扫码登录完成流程,支持手机号未绑定时提示绑定操作
- 调整扫码登录相关日志输出,增强异常捕获与日志记录
- 移除冗余的字符串时间解析,改用统一的Date对象处理
- WxOfficialController 中新增构建 JWT 访问令牌的私有方法,简化代码结构
2026-04-06 23:11:09 +08:00
gxwebsoft 8a1b729e91 refactor(wxsdk): 优化微信消息解密流程和接口适配
- 新增 DecryptXmlMsg 方法,支持公众号/服务号 XML 回调加密消息的验签与解密
- 将解密逻辑抽取到 decryptByCipherText 私有方法,减少重复代码
- 修改 WxOfficialController,使用 XmlUtil 解析 XML 并提取 Encrypt 节点的加密信息
- 在控制器中调用 DecryptXmlMsg 进行消息解密,提升解密流程的健壮性
- 添加对 Encrypt 节点缺失或空内容的错误日志和处理
- 优化日志打印格式,增强调试信息的可读性
2026-04-06 22:02:53 +08:00
gxwebsoft 97b735757d refactor(wxOfficial): 优化公众号配置和access_token获取逻辑
- 增加SettingService和WxService注入,提高配置获取灵活性
- 将access_token获取方法重载,支持按租户动态读取
- 新增getOfficialToken、getOfficialEncodingAESKey、getOfficialAppId方法,从系统设置读取配置
- 解密消息时改用动态获取的token、EncodingAESKey和AppId
- 微信用户信息请求改为支持按租户获取access_token
- access_token获取失败时添加异常日志并回退到兼容逻辑
- 移除硬编码逻辑,增强配置读取的可扩展性与健壮性
2026-04-06 21:54:14 +08:00
gxwebsoft 655e6a6205 feat(qrLogin): 支持公众号扫码登录绑定手机号功能
- 新增扫码登录绑定手机号请求参数类 QrLoginBindPhoneRequest
- 在 QrLoginController 添加绑定手机号并完成扫码登录的接口
- QrLoginData 增加 needBindPhone 和 message 字段,支持绑定手机号状态描述
- QrLoginGenerateResponse 添加公众号二维码图片URL字段 wechatQrCodeUrl
- QrLoginService 新增 bindPhone 方法以支持手机号绑定流程
- QrLoginServiceImpl 实现手机号绑定逻辑,包含验证码校验及用户信息更新
- 优化扫码登录状态查询和确认逻辑,支持待绑定手机号状态及提示信息
- 生成公众号带参数二维码方法,实现公众号扫码登录二维码的生成
- 扫码状态新增 bind_phone 状态和对应常量,区分待绑定手机号阶段
- 改进扫码登录token过期判断与缓存处理,完善异常处理和日志记录
- 统一构建扫码登录状态响应,返回包含手机号绑定需求及状态信息
2026-04-06 21:15:17 +08:00
gxwebsoft 17c9aa0bcd feat(security): 新增域名所有权验证接口并更新安全配置
- 在SecurityConfig中允许访问新的验证文件路径 /MP_verify_joj96VBHPtL9YROj.txt
- 新增VerifyTxt2控制器,提供域名验证接口返回固定字符串
- 通过接口简化域名所有权验证流程,支持微信等平台验证需求
2026-04-06 19:21:17 +08:00
gxwebsoft 4eba22e42d fix(system): 优化扫码登录状态更新逻辑
- 修改Redis键命名为更规范的格式
- 先尝试获取已有扫码登录数据并解析更新
- 只有在无现有数据时才创建新的扫码登录记录
- 统一设置状态为confirmed,保证有效期60秒
- 增加日志区分新建与更新扫码登录完成情况
- 捕获异常并记录错误日志完善容错处理
2026-04-06 18:34:26 +08:00
gxwebsoft aa910870cc feat(wxofficial): 增加微信扫码登录和消息解密功能
- 新增微信消息加密解密逻辑,支持AES加密消息的解析
- 实现公众号扫码关注事件处理,获取并注册微信用户信息
- 完善扫码登录流程,支持扫码确认登录状态更新Redis
- 增加生成带参数的二维码API,支持7天有效期带scene参数二维码
- 优化用户关注并绑定微信OAuth账号的业务流程与日志输出
- 使用配置常量替代硬编码token和EncodingAESKey,提高安全性和可维护性
2026-04-06 18:09:58 +08:00
gxwebsoft 7927559b70 fix(config): 更新服务器 URL 和添加微信扫码页面地址配置
- 将多个配置环境中的服务器 URL 更新为 https://server.websoft.top/api
- 在公共配置中添加微信扫码 H5 页面访问地址 wechat-scan-url
- 修改二维码登录服务优先使用微信扫码专用地址,确保扫码登录跳转
- 更新请求工具类和租户服务中服务器相关 URL
- 修正微信公众号授权回调 URL 使用新的服务器地址
2026-04-06 14:17:22 +08:00
gxwebsoft 3131b20c5b feat(qrLogin): 新增微信扫码登录功能及H5页面支持
- 在QrLoginController中添加微信扫码登录确认接口和获取微信网页授权URL接口
- QrLoginGenerateResponse新增微信扫码登录H5页面URL及微信公众号AppID字段
- QrLoginService接口新增wechatScanConfirm方法实现微信扫码登录确认逻辑
- QrLoginServiceImpl完成基于unionId和openId的用户绑定验证与登录状态更新
- WxService扩展获取微信公众号AppID、AppSecret及AccessToken方法,支持多租户
- 新增WechatScanRequest和WechatScanResponse用于微信扫码登录请求和响应数据封装
- 生成微信扫码登录H5页面URL并包含token参数用于前端跳转确认登录
- 实现通过微信授权码获取用户unionId和openId,从而完成平台用户绑定验证
- 完善异常处理及日志记录,确保微信扫码登录流程稳定可靠
2026-04-06 13:15:28 +08:00
gxwebsoft 88b2e9977c feat(user-sync): 更新用户同步服务时间格式和日志记录
- 将 updateTime 字段从毫秒时间戳改为 yyyy-MM-dd HH:mm:ss 格式
- 在同步日志中添加用户名、昵称和电话信息
- 添加请求体的调试日志输出
- 引入 LocalDateTime 和 DateTimeFormatter 时间处理类
2026-04-04 04:36:52 +08:00
gxwebsoft fd4d00a86d feat(system): 添加 websopy 用户同步服务集成
- 配置 websopy 服务地址用于用户数据同步功能
- 实现用户同步到 websopy 的接口调用逻辑
- 添加用户缓存刷新功能以保持数据一致性
- 移除 PostConstruct 初始化方式改为运行时获取配置
- 添加同步操作的空值检查和警告日志处理
2026-04-04 04:26:00 +08:00
gxwebsoft 2303f46fca feat(user): 添加用户数据同步到 websopy 功能
- 新增 websopyUrl 配置项用于指定同步服务地址
- 创建 UserSyncService 服务实现用户数据同步逻辑
- 在 UserController 中添加用户新增时同步到 websopy 的功能
- 在 WxLoginController 和 WxOfficialController 中集成用户同步
- 实现单个用户同步和批量用户同步的功能
- 添加同步状态日志记录和错误处理机制
2026-04-04 04:02:35 +08:00
gxwebsoft 808766eeac fix(sms): 修复短信验证码发送功能的安全性和稳定性问题
- 添加了参数校验,防止空参数导致的异常
- 替换了硬编码的阿里云密钥配置,支持租户自定义配置
- 修复了随机数生成器的安全问题,使用ThreadLocalRandom替代Random
- 添加了日志记录功能,便于问题排查和监控
- 优化了Redis缓存键的存储逻辑,兼容历史数据格式
- 增强了异常处理机制,提供更详细的错误信息反馈
- 修复了短信模板参数格式问题,确保验证码正确传递
- 添加了手机号脱敏处理,保护用户隐私安全
2026-04-03 11:53:46 +08:00
gxwebsoft aafb2ef113 fix(sms): 修复短信验证码发送功能的安全性和稳定性问题
- 添加了参数校验,防止空参数导致的异常
- 替换了硬编码的阿里云密钥配置,支持租户自定义配置
- 修复了随机数生成器的安全问题,使用ThreadLocalRandom替代Random
- 添加了日志记录功能,便于问题排查和监控
- 优化了Redis缓存键的存储逻辑,兼容历史数据格式
- 增强了异常处理机制,提供更详细的错误信息反馈
- 修复了短信模板参数格式问题,确保验证码正确传递
- 添加了手机号脱敏处理,保护用户隐私安全
2026-03-24 22:05:42 +08:00
gxwebsoft f38859f211 style(entities): 统一添加日期格式化注解
- 在 AccessKey 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 AuthorizeCode 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Cart 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 ChatConversation 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 ChatMessage 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Company 实体的服务开始时间、服务到期时间、创建时间和修改时间字段上添加 JsonFormat 注解
- 在 CompanyComment 实体的创建时间字段上添加 JsonFormat 注解
- 在 CompanyContent 实体的创建时间字段上添加 JsonFormat 注解
- 在 CompanyGit 实体的创建时间字段上添加 JsonFormat 注解
- 在 CompanyParameter 实体的创建时间字段上添加 JsonFormat 注解
- 在 CompanyUrl 实体的创建时间字段上添加 JsonFormat 注解
- 在 Components 实体的创建时间字段上添加 JsonFormat 注解
- 在 Dict 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 DictData 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Dictionary 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 DictionaryData 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 EmailRecord 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Environment 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 FileRecord 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 LoginRecord 实体的操作时间和修改时间字段上添加 JsonFormat 注解
- 在 Menu 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Merchant 实体的创建时间字段上添加 JsonFormat 注解
- 在 MerchantAccount 实体的创建时间字段上添加 JsonFormat 注解
- 在 MerchantApply 实体的创建时间字段上添加 JsonFormat 注解
- 在 MerchantType 实体的创建时间字段上添加 JsonFormat 注解
- 在 Modules 实体的创建时间字段上添加 JsonFormat 注解
- 在 Mp 实体的过期时间和注册时间字段上添加 JsonFormat 注解
- 在 Notice 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 OperationRecord 实体的操作时间和修改时间字段上添加 JsonFormat 注解
- 在 Order 实体的支付时间、退款时间、申请退款时间、过期时间、修改时间和创建时间字段上添加 JsonFormat 注解
- 在 OrderGoods 实体的支付时间、过期时间、修改时间和创建时间字段上添加 JsonFormat 注解
- 在 OrderInfo 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Organization 实体的成立时间、创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Payment 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 Plug 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 RechargeOrder 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 Role 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 RoleMenu 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 Setting 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 SysFileType 实体的创建时间字段上添加 JsonFormat 注解
- 在 Tenant 实体的试用结束时间字段上添加 JsonFormat 注解
- 在 UserBalanceLog 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 UserCollection 实体的注册时间字段上添加 JsonFormat 注解
- 在 UserFile 实体的创建时间和修改时间字段上添加 JsonFormat 注解
- 在 UserGrade 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 UserGroup 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 UserOauth 实体的注册时间和修改时间字段上添加 JsonFormat 注解
- 在 UserReferee 实体的创建时间和修改时间字段上添加 JsonFormat 注解
2026-03-09 12:46:29 +08:00
gxwebsoft 4b7cd9d0d3 fix(setting): 修复超级管理员权限验证逻辑
- 将权限检查从 isSuperAdmin 字段改为 nickname 比较
- 防止 Boolean 类型自动拆箱导致的空指针异常
- 更新错误提示信息为更明确的超级管理员登录要求
2026-03-01 13:34:49 +08:00
gxwebsoft 0efca0fb02 fix(setting): 修复超级管理员权限验证逻辑
- 将权限检查从 isSuperAdmin 字段改为 nickname 比较
- 防止 Boolean 类型自动拆箱导致的空指针异常
- 更新错误提示信息为更明确的超级管理员登录要求
2026-03-01 13:27:02 +08:00
gxwebsoft 853651e80d feat(core): 新增阿里云推送、证书加载器和支付缓存服务
- 添加 AliYunSender 工具类实现阿里云推送功能
- 添加 CertificateLoader 证书加载工具类支持多种证书加载方式
- 添加 PaymentCacheService 支付配置缓存服务优化性能
- 添加 TenantContext 租户上下文管理器用于临时禁用租户隔离
- 添加 WechatPayCertificateDiagnostic 微信支付证书诊断工具
- 添加 WechatPayConfigValidator 微信支付配置验证工具
- 添加 MqttProperties MQTT配置属性类
- 更新 MainController 中的用户信息修改功能增强安全性
- 优化 application-glt.yml 生产环境配置文件
2026-02-13 21:41:42 +08:00
gxwebsoft 823c56293d feat(user-verify): 添加操作员信息关联及数据去重功能
- 在 UserVerify 实体类中添加 adminName 字段用于显示操作员名称
- 修改控制器在验证过程中自动设置当前登录用户为操作员 ID
- 更新 MyBatis 查询 SQL 以关联查询操作员昵称信息
- 实现防止同一用户在验证表中重复记录的数据去重逻辑
- 添加基于用户 ID 分组并获取最新记录的查询优化
- 支持按操作员 ID 进行筛选查询功能
2026-02-05 17:39:57 +08:00
gxwebsoft 6462e51bc8 fix(auth): 修复超级管理员权限检查中的空指针异常
- 避免 Boolean 类型自动拆箱导致的 NPE 异常
- 使用 Boolean.TRUE.equals() 安全检查超级管理员权限
- 添加注释说明潜在的空值风险
2026-02-03 10:34:55 +08:00
gxwebsoft d63df710b3 feat(user): 完善用户注册时的角色分配机制
- 在邀请注册流程中支持传递roleId参数
- 实现角色分配逻辑:优先使用传入的roleId,其次使用roleCode,默认为"user"
- 添加租户验证确保角色属于当前租户
- 修复缺失默认角色时的创建机制
- 确保注册响应中包含用户的角色和权限信息
2026-01-27 17:38:28 +08:00
gxwebsoft b2b6b1306f fix(user): 修复用户注册中的租户管理和角色分配问题
- 修正了超级管理员标识符变量名避免混淆
- 为普通用户注册添加租户ID获取逻辑,默认使用平台租户(5)
- 在用户参数中传递租户ID和管理员状态信息
- 添加了对新用户的租户级别角色管理支持
- 实现了缺失用户角色的自动创建机制
- 增强了角色查询以考虑租户隔离
- 添加了对租户角色不存在情况的异常处理
2026-01-22 11:31:11 +08:00
gxwebsoft 00ea325ebf fix(auth): 解决超级管理员权限判断的空指针异常
- 将 Boolean 对象转换为 boolean 原始类型以避免拆箱时的 NPE
- 使用 Boolean.TRUE.equals() 方法安全地处理可能为 null 的值
- 添加注释说明 null 值的处理逻辑
- 在两个用户注册相关的方法中统一了相同的修复方式
2026-01-22 11:06:11 +08:00
gxwebsoft 83e605f2ff feat(tenant): 添加租户管理地址和域名字段
- 在Tenant实体类中新增adminUrl、domain和freeDomain字段
- 为新字段添加Schema注解描述和TableField注解标记为非数据库字段
- 修改TenantMapper.xml中的关联查询SQL,添加对新字段的查询支持
2026-01-16 14:15:55 +08:00
gxwebsoft 182d6fff0e feat(system): 添加组织机构ID集合查询功能
- 在OrganizationParam中新增organizationIds字段用于存储机构ID集合
- 为organizationIds字段添加Schema注解描述和TableField注解标记为非数据库字段
- 在OrganizationMapper.xml中添加IN查询条件支持organizationIds参数
- 使用foreach标签实现动态SQL的IN查询逻辑
- 支持通过多个机构ID进行批量查询操作
2026-01-13 16:53:57 +08:00
gxwebsoft 43d1d26787 feat(operation-record): 添加机构ID过滤功能
- 在OperationRecordParam中新增organizationIds字段用于机构ID集合查询
- 修改OperationRecordMapper.xml添加机构ID条件判断和IN查询逻辑
- 支持多机构ID的批量筛选操作记录
- 完善参数校验和查询条件构建机制
2026-01-13 16:11:01 +08:00
gxwebsoft 68e0414034 feat(system): 添加文章缓存常量并优化支付配置处理
- 在ArticleConstants中新增CACHE_KEY_ARTICLE缓存键常量
- 修正MainController中短信验证码接口的描述为"发送短信验证码"
- 修正MainController中用户信息接口的描述为"获取当前登录用户信息"
- 为Tenant实体的创建时间和更新时间字段添加JSON格式化注解
- 修改TenantMapper.xml中租户搜索条件,支持按租户编码搜索
- 优化WxNativePayController中微信支付配置逻辑,添加默认测试配置和异常处理
- 为微信支付添加兜底mock返回机制,避免配置缺失时前端报错
2026-01-09 19:11:10 +08:00
gxwebsoft 947ecf21aa feat(subscription): 添加租户订阅套餐名称字段
- 在 TenantSubscription 实体中新增 packageName 字段
- 更新 SubscriptionOrderController 类名为 TenantSubscriptionOrderController
- 修改微信支付通知地址为新域名 websoft.top
2025-12-14 10:09:19 +08:00
gxwebsoft 6674117ac9 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/main/java/com/gxwebsoft/common/system/entity/TenantSubscription.java
2025-12-14 08:41:28 +08:00
gxwebsoft 6ebca60d4f feat(subscription): 实现订阅订单创建与支付功能
- 新增创建订阅订单接口,支持生成订单号及价格试算
- 新增订阅订单支付接口,集成微信Native支付生成二维码
- 添加订单创建与支付结果返回类
- 注入微信支付控制器并调用其生成支付二维码方法
- 校验用户登录状态、套餐ID及支付金额有效性
- 构建订单对象用于支付二维码生成,并返回支付链接
2025-12-14 08:41:06 +08:00
gxwebsoft f42ef63b37 Merge remote-tracking branch 'origin/master' 2025-12-14 08:40:31 +08:00
gxwebsoft c32b0eee56 feat(system): 添加日期字段的JSON格式化注解
- 在TenantPackage实体类的createTime和updateTime字段上添加@JsonFormat注解
- 在TenantSubscription实体类的startTime、endTime、createTime和updateTime字段上添加@JsonFormat注解
- 在TenantSubscriptionOrder实体类的startTime、endTime、paymentTime、createTime和updateTime字段上添加@JsonFormat注解
- 所有日期字段均设置pattern为"yyyy-MM-dd HH:mm:ss",timezone为"GMT+8"
- 统一实体类日期序列化格式,提升前后端数据交互一致性
2025-12-14 08:40:00 +08:00
gxwebsoft 65e2209a85 feat(subscription): 实现订阅订单创建与支付功能
- 新增创建订阅订单接口,支持生成订单号及价格试算
- 新增订阅订单支付接口,集成微信Native支付生成二维码
- 添加订单创建与支付结果返回类
- 注入微信支付控制器并调用其生成支付二维码方法
- 校验用户登录状态、套餐ID及支付金额有效性
- 构建订单对象用于支付二维码生成,并返回支付链接
2025-12-14 01:22:34 +08:00
gxwebsoft ad44d4ea8a feat(subscription): 新增订阅订单价格计算接口
- 添加 SubscriptionOrderController 控制器,提供 /calculate-price 接口
- 实现订阅价格试算逻辑,支持续费、升级及支付方式系数计算
- 新增 SubscriptionOrderParam 入参类,定义请求参数结构
- 新增 SubscriptionPriceResult 返回结果类,封装价格计算结果
- 支持从配置中读取套餐价格、折扣因子及支付方式调整系数
- 添加多配置键兼容机制,增强系统健壮性
- 提供详细的价格计算说明与异常提示信息
2025-12-14 00:46:38 +08:00
gxwebsoft b8a70cae5c feat(tenant): 添加租户名称重复验证
- 在创建租户时检查名称是否已存在
- 确保租户名称不为空
- 防止重复租户名称导致的数据冲突
2025-12-12 17:00:33 +08:00
202 changed files with 10695 additions and 1575 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 wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
+50
View File
@@ -0,0 +1,50 @@
{
"version": 2,
"sessions": {
"da2fb4dc76a94426a7dfef478f674489": [
{
"expertId": "SeniorDeveloper",
"name": "Will",
"profession": "高级开发工程师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
"usedAt": 1775495439006,
"industryId": "all"
}
],
"90ac41da355a447a8c29ed992c8beede": [
{
"expertId": "SeniorDeveloper",
"name": "Will",
"profession": "高级开发工程师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
"usedAt": 1775720823455,
"industryId": "all"
}
],
"c7ea2a41e7d147e887ab506386658772": [
{
"expertId": "SeniorDeveloper",
"name": "Will",
"profession": "高级开发工程师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
"usedAt": 1775720823455,
"industryId": "all"
}
],
"11ef16ee251d4624968d1e84c0fb1de9": [
{
"expertId": "SeniorDeveloper",
"name": "Will",
"profession": "高级开发工程师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
"usedAt": 1775866025894,
"industryId": "all"
}
]
},
"lastUpdated": 1775868870779
}
+37
View File
@@ -0,0 +1,37 @@
## 2026-04-06 - 修复编译错误和用户同步问题
### 修复的问题
1. **QrLoginServiceImpl.java编译错误**: 修复了`CommonUtil`类找不到的编译错误
- 原始错误: `java: cannot find symbol class CommonUtil location: package com.gxwebsoft.common.core.utils`
2. **WxOfficialController.java编译错误**: 缺少`DateUtil`导入
- 真正的问题根源,导致链式编译失败
3. **用户同步失败**: `Column 'tenant_id' cannot be null` 数据库完整性约束错误
- 问题: 在同步用户到websopy系统时,没有传递tenant_id字段
- 结果: websopy端插入`app_user_cache`表时tenant_id为NULL,违反非空约束
### 解决方案
1. **CommonUtil.java修复**: 添加缺失的导入`import com.gxwebsoft.common.core.utils.JSONUtil;`
- 原因: `CommonUtil.responseError()`方法使用了`JSONUtil.toJSONString()`但未导入
2. **WxOfficialController.java修复**:
- 添加缺失的导入`import cn.hutool.core.date.DateUtil;`
- 添加详细的调试日志,跟踪扫码登录流程
- 在`processWxUser`方法中添加"已存在绑定用户"的日志
- 在`completeQrLogin`方法中添加开始和完成的详细日志
3. **UserSyncService.java修复**:
- 在同步数据中添加`tenantId`字段: `userCache.put("tenantId", tenantIdValue)`
- 处理tenantId可能为null的情况,默认传0: `Integer tenantIdValue = user.getTenantId() != null ? user.getTenantId() : 0`
- 更新日志输出,包含tenantId信息
### 验证结果
- 执行`mvn clean compile`成功通过,编译无错误
- 所有代码修改语法正确,无编译警告
### 技术要点
1. **Java编译链式失败**: 一个文件的编译错误可能导致依赖它的其他文件也出现"找不到符号"的错误
2. **数据库完整性约束**: REST API调用时需要确保传递所有必填字段
3. **分布式系统调试**: 当同步到外部系统失败时,需要检查两边数据格式的一致性
4. **日志分级**: 在不同阶段添加适当的System.out和log.info日志,便于问题排查
+207
View File
@@ -0,0 +1,207 @@
## 2026-04-07 工作记录
### 微信扫码登录问题修复
1. 修复了UserSyncService中tenant_id字段名问题(从tenantId改为tenant_id)
2. 同时发送两种格式的tenant_id字段确保兼容性
3. 修改了WxOfficialController,在同步前从数据库重新加载用户对象
4. 添加了详细的调试日志便于问题排查
### 待解决问题
1. websopy侧app_user_cache同步失败(tenant_id为null)
2. 扫码成功后需跳转到强制绑定手机号页面
3. 注册成功后应跳转到控制台/console
### websopy-pc前端修改需求分析
1. **状态检查逻辑更新**:需处理新的nextAction字段(bind_phone, redirect, login等)
2. **新增绑定手机号页面**:用于新用户绑定手机号流程
3. **页面跳转逻辑**:登录成功/绑定成功后跳转到/console
4. **API调用更新**:适应新的响应字段格式
### 前端代码已完成的修改
1. **API接口修改**:
- 更新QRLoginStatusResult接口,新增nextAction、redirectUrl、successMessage、needBindPhone等字段
- 修改checkQRLoginStatus函数,使用真实后端API调用
- 新增bindPhoneForQrLogin函数,处理绑定手机号API调用
- 修改generateQRLoginToken函数,使用真实后端API调用
2. **状态管理修改**:
- 更新auth store接口,添加bindPhoneForQRLogin方法
- 修改checkQRStatus方法,支持新的返回字段处理
- 添加绑定手机号处理逻辑
3. **组件修改**:
- 创建BindPhone组件:用于处理需要绑定手机号的场景
- 修改QRLogin组件:支持根据nextAction显示不同UI,自动处理绑定手机号流程
- 组件现在可以正确处理:
- nextAction: 'bind_phone' → 显示绑定手机号表单
- nextAction: 'redirect' → 自动跳转到/console
- needBindPhone: true → 显示绑定提示
4. **完整流程支持**:
- 用户扫码登录 → 后端返回nextAction: 'bind_phone' → 前端显示绑定手机号页面
- 用户输入手机号和验证码 → 调用绑定手机号API → 绑定成功后自动登录并跳转/console
- 用户已有手机号 → 后端返回nextAction: 'redirect' → 前端自动跳转到/console
### 后端修改完成
1. **QrLoginStatusResponse新增字段**:
- nextAction: 下一步操作指示
- redirectUrl: 跳转URL(当nextAction为redirect时)
- successMessage: 成功消息
2. **QrLoginServiceImpl逻辑更新**:
- 用户没有手机号时,nextAction设为bind_phone
- 用户有手机号且登录成功时,nextAction设为redirect,redirectUrl设为/console
## 扫码登录用户同步时机修改 (01:58)
### 修改内容
1. **WxOfficialController.java** - 移除新用户注册时立即同步的代码
- processWxUser() 方法中,新用户创建后不再立即同步到 websopy
- findOrCreateUserForOauth() 方法中同样处理
2. **QrLoginServiceImpl.java** - 在绑定手机号成功后同步
- 新增 UserSyncService 注入
- 在 bindPhone() 方法中,绑定手机号成功后从数据库重新加载用户并同步
### 目的
确保用户数据同步到 websopy 时,手机号字段已有值,避免无效的缓存数据。
## MQ消息队列实现 (02:40)
### 创建的文件
1. **SyncMessage.java** - 统一消息实体
- 位置: `com.gxwebsoft.common.mq.message`
- 支持 USER_SYNC, TENANT_SYNC 等消息类型
- 支持 CREATE, UPDATE, DELETE 事件类型
2. **SyncMessageProducer.java** - 消息生产者接口
- 位置: `com.gxwebsoft.common.mq.producer`
- 预留抽象层,便于将来切换到RocketMQ
3. **RabbitMQConfig.java** - RabbitMQ配置类
- 位置: `com.gxwebsoft.common.mq.config`
- 定义交换机、队列、死信队列
- 配置JSON序列化
4. **RabbitMQSyncProducer.java** - RabbitMQ生产者实现
- 位置: `com.gxwebsoft.common.mq.producer.impl`
- 实现确认回调和Return回调
5. **UserSyncConsumer.java** - 用户同步消费者
- 位置: `com.gxwebsoft.common.mq.consumer`
- 监听用户同步消息,调用UserSyncService同步到websopy
- 支持重试机制和死信队列
### 修改的文件
1. **pom.xml** - 添加 spring-boot-starter-amqp 依赖
2. **UserSyncService.java** - 添加Map参数的重载方法
3. **QrLoginServiceImpl.java** - 改用MQ发送消息
4. **application.yml** - 添加RabbitMQ配置
### 使用方式
配置 `sync.mq.enabled: false` 可临时禁用MQ,回退到原有直接同步方式。
## websopy 端 MQ 消费者实现 (02:50)
### 创建的文件
1. **SyncMessage.java** - websopy 端消息实体
- 位置: `com.gxwebsoft.common.mq.message`
- 简化版,复用 server-api 发来的消息格式
2. **RabbitMQConfig.java** - websopy 端 RabbitMQ 配置
- 位置: `com.gxwebsoft.common.mq.config`
- 配置交换机、队列、死信队列
3. **SyncMessageConsumer.java** - websopy 端消息消费者
- 位置: `com.gxwebsoft.common.mq.consumer`
- 监听 USER_SYNC 消息,调用 AppUserCacheService 保存/更新/删除用户
### 修改的文件
1. **pom.xml** - 添加 spring-boot-starter-amqp 依赖
2. **application.yml** - 添加 RabbitMQ 连接配置和开关
### 工作流程
server-api (生产者) -> RabbitMQ -> websopy-java (消费者) -> AppUserCacheService
## Spring Boot启动错误修复 (03:01)
### 问题描述
服务器启动失败,错误信息显示`objectMapper` bean定义冲突:
```
The bean 'objectMapper', defined in class path resource [com/gxwebsoft/common/mq/config/RabbitMQConfig.class], could not be registered.
A bean with that name has already been defined in class path resource [com/gxwebsoft/common/core/config/JacksonConfig.class] and overriding is disabled.
```
### 根本原因
1. **JacksonConfig.java** 定义了 `@Primary objectMapper()` bean(第22行)
2. **RabbitMQConfig.java** 的 `messageConverter(ObjectMapper objectMapper)` 方法会被Spring误认为正在定义另一个`objectMapper` bean
3. **Spring Boot 2.5.15默认禁止bean定义覆盖**
### 修复方案(双保险)
1. **代码修复**:修改RabbitMQConfig.java中的messageConverter方法
```java
// 修改前(有问题的参数注入):
@Bean
public MessageConverter messageConverter(ObjectMapper objectMapper) {
return new Jackson2JsonMessageConverter(objectMapper);
}
// 修改后(直接在方法内创建ObjectMapper):
@Bean
public MessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return new Jackson2JsonMessageConverter(objectMapper);
}
```
2. **配置修复**:在所有application配置文件中启用bean定义覆盖
- **application.yml**: 添加 `spring.main.allow-bean-definition-overriding: true`
- **application-dev.yml**: 在现有的 `spring.main.allow-circular-references` 下添加
- **application-prod.yml**: 在数据源配置区域添加
### 影响
修复后服务器应能正常启动,MQ消息队列和扫码登录功能均可正常工作。
### 技术细节
- 问题源于Spring的bean解析机制:带有参数的`@Bean`方法会被Spring尝试解析参数
- 修改后的方案消除了参数依赖,避免Spring误解
- 启用bean定义覆盖作为安全备份,确保即使有其他bean冲突也能启动
## Hutool库API兼容性修复 (21:07)
### 问题描述
编译错误:`HttpResponse` 类没有 `bytes()` 方法
```
/Users/gxwebsoft/JAVA/com.gxwebsoft.core/src/main/java/com/gxwebsoft/auto/service/impl/QrLoginServiceImpl.java:156:19
java: cannot find symbol
symbol: method bytes()
location: class cn.hutool.http.HttpResponse
```
### 原因分析
- 项目使用的是Hutool 5.8.25版本
- 在Hutool 5.x版本中,`HttpResponse`类没有`bytes()`方法
- 正确的方法应该是`bodyBytes()`用于获取二进制响应,或`body()`用于获取字符串响应
### 修复方法
修改 `QrLoginServiceImpl.java` 第156行:
```java
// 修改前(错误的API):
.execute().bytes();
// 修改后(正确的API):
.execute().bodyBytes();
```
### 影响
修复后项目可以成功编译,微信小程序码生成功能可以正常工作。
### 版本兼容性说明
- Hutool 4.x版本可能支持`.bytes()`方法
- Hutool 5.x版本使用`.bodyBytes()`和`.body()`方法
- 项目中使用的是Hutool 5.8.25,应保持API一致性
+115
View File
@@ -0,0 +1,115 @@
# 2026-04-09 工作记录
## UserServiceImpl 添加 MQ 同步功能
在 `UserServiceImpl` 中统一添加了用户数据变更时的 MQ 消息发送逻辑:
### 修改内容
1. **添加依赖注入**:注入 `SyncMessageProducer`,使用 `@Autowired(required = false)` 避免 MQ 未启用时报错
2. **saveUser() 方法**:用户创建成功后发送 `CREATE` 事件到 websopy
3. **updateUser() 方法**:用户更新成功后发送 `UPDATE` 事件到 websopy
4. **addUser() 方法**:注释说明已通过 saveUser 发送 MQ,避免重复
### 触发场景
现在以下操作都会触发 MQ 同步:
- 用户注册(短信登录自动注册、普通注册、管理员注册)
- 后台添加用户
- 用户更新信息
- 扫码绑定手机号(原有逻辑)
### 日志输出
- 创建:`用户创建后发送MQ消息同步到websopy: userId={}, phone={}`
- 更新:`用户更新后发送MQ消息同步到websopy: userId={}, phone={}`
---
## MQ 架构改造 - 方案 A 实现
将用户同步架构从 "core 系统消费后 HTTP 转发" 改为 "各子系统直接消费 MQ"。
### core 系统改动 (com.gxwebsoft.core)
#### 1. RabbitMQConfig.java
- **Exchange 类型**:从 `DirectExchange` 改为 `TopicExchange`
- **删除队列定义**:core 系统不再定义消费队列,只负责发送
- **Routing Key 格式**:`user.sync.{targetSystem}`
#### 2. RabbitMQSyncProducer.java
- **动态 Routing Key**:根据 `targetSystem` 构建 routing key
- **代码**:`"user.sync." + targetSystem.toLowerCase()`
#### 3. 删除 UserSyncConsumer.java
- core 系统不再消费用户同步消息
#### 4. UserSyncService.java
- **废弃 HTTP 同步逻辑**:所有 HTTP 调用方法已删除
- **标记 @Deprecated**:保留空实现以兼容旧代码
- **同步方式**:现在统一通过 MQ 自动触发
### websopy 系统改动 (websopy-java)
#### 1. RabbitMQConfig.java
```java
// websopy 专用队列
public static final String SYNC_QUEUE_WEBSOPY = "user.sync.websopy.queue";
public static final String SYNC_ROUTING_KEY_WEBSOPY = "user.sync.websopy";
// Topic Exchange(与 core 系统共用)
@Bean
public TopicExchange syncExchange() {
return new TopicExchange(SYNC_EXCHANGE, true, false);
}
// websopy 专用队列绑定
@Bean
public Binding syncBindingWebsopy() {
return BindingBuilder.bind(syncQueueWebsopy())
.to(syncExchange())
.with(SYNC_ROUTING_KEY_WEBSOPY);
}
```
#### 2. SyncMessageConsumer.java
- **监听队列**:改为 `SYNC_QUEUE_WEBSOPY` (`user.sync.websopy.queue`)
- **死信队列**:改为 `DLQ_QUEUE_WEBSOPY` (`user.sync.websopy.dlq`)
### 新架构流程
```
用户操作 → UserServiceImpl.saveUser/updateUser
↓
MQ Producer (routing key = user.sync.websopy)
↓
Topic Exchange (sync.exchange)
↓
┌───────────┼───────────┐
↓ ↓ ↓
websopy 子系统B 子系统C
(消费者) (消费者) (消费者)
```
### 消息格式
```json
{
"messageId": "uuid",
"messageType": "USER_SYNC",
"eventType": "CREATE|UPDATE|DELETE",
"targetSystem": "websopy",
"data": {
"userId": 123,
"username": "xxx",
"nickname": "xxx",
"avatar": "xxx",
"phone": "xxx",
"status": 1,
"tenantId": 1
},
"createTime": "2026-04-09T16:00:00",
"retryCount": 0
}
```
### 优势
- **解耦**:core 系统只负责发送,不关心哪些子系统消费
- **扩展性**:新增子系统只需添加自己的消费者,无需修改 core
- **可靠性**:各子系统独立消费,互不影响
- **符合 MQ 设计**:消息广播到多个订阅者
+33
View File
@@ -0,0 +1,33 @@
# 2026-04-11 工作日志
## 扫码登录 access_token 自动恢复机制
### 问题背景
- `WX_ACCESS_TOKEN:{tenantId}` 缓存过期后,微信 API 返回 40001/42001 等错误
- 之前需要手动删除 Redis 缓存才能恢复
### 解决方案
实现了 access_token 自动清理和重试机制:
#### 1. QrLoginServiceImpl 改动
- `generateMiniprogramQrCode()` 添加重试逻辑
- 首次失败 → 清理缓存 → 重试
- 新增 `doGenerateMiniprogramQrCode()` 私有方法
#### 2. WxService 新增方法
- `getAccessTokenForcibly(tenantId)` - 强制刷新 token
- 先删除 Redis 缓存
- 直接从微信 API 获取新 token
#### 3. WxLoginController 改动
- `getPhoneByCode()` 检测 token 相关错误时自动清理缓存
- 新增 `isTokenRelatedError()` 方法识别 40001/42001 等错误码
### 关键文件
- `QrLoginServiceImpl.java` - 扫码登录服务
- `WxService.java` - 微信公共服务
- `WxLoginController.java` - 微信登录控制器
### 影响范围
- 扫码登录生成小程序码 ✅
- 小程序手机号授权登录 ✅
+6
View File
@@ -0,0 +1,6 @@
# 2026-04-21 工作日志
## loginBySms 租户10519特例
- 文件:`MainController.java` → `loginBySms` 接口
- 变更:普通用户登录时,租户ID=10519 使用硬编码万能验证码 `170083`,跳过从 Redis 读取 `CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS`
- 超级管理员路径无需此特例(超管不区分租户)
+60
View File
@@ -0,0 +1,60 @@
# 2026-05-13 工作记录
## 卡密充值功能开发
### 后端(paopao-java)
**已完成文件:**
- `ShopRechargeCodeController.java` - 充值兑换码管理
- `ShopRechargeCodeService.java` - Service接口
- `ShopRechargeCodeServiceImpl.java` - Service实现
- `ShopRechargeCode.java` - 实体类
- `ShopRechargeRecord.java` - 充值记录实体
- `ShopRechargeCodeMapper.java` - Mapper接口
- `ShopRechargeCodeParam.java` - 查询参数
- `ShopRechargeRecordMapper.java` - 充值记录Mapper
- `ShopRechargeRecordService.java` - 充值记录Service
- `ShopRechargeRecordServiceImpl.java` - 充值记录Service实现
- `ShopRechargeRecordController.java` - 充值记录Controller (新增)
- `ShopRechargeRecordParam.java` - 充值记录查询参数 (新增)
**API接口:**
- `GET /api/shop/recharge-code/page` - 分页查询兑换码
- `POST /api/shop/recharge-code/generate` - 生成兑换码
- `POST /api/shop/recharge-code/batch-generate` - 批量生成兑换码
- `POST /api/shop/recharge-code/use` - 使用兑换码
- `GET /api/shop/recharge-code/info` - 获取兑换码信息
- `GET /api/shop/recharge-code/export/{batchNo}` - 导出批次兑换码
- `DELETE /api/shop/recharge-code/{id}` - 删除兑换码
- `GET /api/shop/recharge-record/page` - 分页查询充值记录
### 前端(paopao-vue)
**已完成文件:**
- `/api/shop/shopRechargeCode/index.ts` - 兑换码API
- `/api/shop/shopRechargeCode/model/index.ts` - 兑换码Model
- `/api/shop/shopRechargeRecord/index.ts` - 充值记录API (新增)
- `/api/shop/shopRechargeRecord/model.ts` - 充值记录Model (新增)
- `/views/shop/shopRechargeCode/index.vue` - 兑换码管理页面 (整合Tab)
- `/views/shop/shopRechargeRecord/index.vue` - 兑换记录页面 (新增)
**功能特性:**
- 生成单个/批量兑换码
- 兑换码状态管理(未使用/已使用/已过期)
- 兑换明细查询
- 统计卡片(总数量、未使用、已使用、已过期)
### 数据库
**SQL文件:**
- `/sql/shop_recharge_code.sql` - 兑换码表建表SQL
- `/sql/shop_recharge_record.sql` - 充值记录表建表SQL (新增)
**需要执行的SQL:**
1. 创建 `shop_recharge_record` 表
2. 配置后台菜单权限
### 待办事项
- [ ] 执行数据库建表SQL
- [ ] 配置后台菜单权限
- [ ] 测试完整流程
+58
View File
@@ -0,0 +1,58 @@
# 2026-06-17
## WxLoginController 配置读取改造(app_config 跨表查询)
### 需求
- `WxLoginController.loginByMpWxPhone` 等微信小程序相关接口读取配置时,优先从 `gxwebsoft_core.app_config`(db_websopy 库)读取,命中失败时回退到 `gxwebsoft_core.sys_setting.mp-weixin`。
- app_config 是 KV 行式存储:`id, tenant_id, config_key, config_value, category`(如 `category=wechat`,`config_key=wechat.appId`)。
### 方案(无需新建数据源)
- 直接 MyBatis XML 跨表:`FROM gxwebsoft_core.app_config`
- 走主数据源,依赖 gxwebsoft_core 库账号对 app_config 有 SELECT 权限
### 新增文件
- `src/main/java/com/gxwebsoft/websopy/entity/AppConfig.java` —— 实体
- `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.java` —— Mapper(声明 selectByCategory)
- `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.xml` —— XML 跨表查询(**XML 与 Mapper 同包**)
- `src/main/java/com/gxwebsoft/websopy/service/AppConfigService.java` —— 带 Redis 缓存
### 改造 WxLoginController
- 注入 `AppConfigService appConfigService`
- 新增私有方法 `getMpWxSetting(Integer tenantId)`:先读 app_config category=wechat,失败回退 `settingService.getBySettingKey("mp-weixin")`
- 替换 5 处调用:
- `getOpenIdByCode`(约 521 行)
- `getAccessToken(Integer)`(约 716 行)
- `getWxOpenId`(约 834 行)
- `getWxOpenIdOnly`(约 861 行)
- `loginByOpenId`(约 959 行,写回 AppId/AppSecret Redis 缓存保持一致)
### 关键细节
- 缓存 Key 前缀 `appConfig:{tenantId}:{category}`(与 setting:* 隔离)
- 缓存 TTL 2 小时
- 配置文件组装:去掉 `config_key` 中的 `wechat.` 前缀,直接得到 `appId`/`appSecret`
- 编译错误原因及修复:Mapper interface 必须显式声明 selectByCategory 方法(编译期不查 XML)
### 编译验证
- `./mvnw clean compile -DskipTests` 通过 ✅
### 运行期 BeanCreationException 修复(2026-06-18 补记)
- 报错:`Bean named 'redisTemplate' expected StringRedisTemplate but was actually RedisTemplate`
- 原因:`@Resource` 按字段名(bean name)注入,容器中 `redisTemplate` 是 `RedisTemplate<Object,Object>`,而 `StringRedisTemplate` bean 叫 `stringRedisTemplate`
- 修复:`AppConfigService` 字段名从 `redisTemplate` 改为 `stringRedisTemplate`,4 处引用同步修改
- **项目规律:统一用字段名 `stringRedisTemplate`,禁止用 `redisTemplate` 注入 StringRedisTemplate**
### 运行期 SQLSyntaxErrorException 修复(2026-06-18 补记)
- 报错:`Table 'gxwebsoft_core.app_config' doesn't exist` + TenantLineInnerInterceptor 自动追加 `AND tenant_id = 10611`
- 原因 1:app_config 表实际在 `db_websopy` 库中,不在 gxwebsoft_core
- 原因 2:`TenantLineInnerInterceptor` 对所有 SQL 自动追加 tenant_id 条件,导致参数重复
- 实际表结构(从用户截图确认):
- 主键 `config_id`(不是 id)
- 分类字段 `config_type`(不是 category)
- 有 `deleted` 逻辑删除字段
- tenant_id 示例值 105885
- 修复:
1. XML 表名改为 `db_websopy.app_config`
2. Mapper 方法加 `@InterceptorIgnore(tenantLine = "true")`(与项目其他跨库 Mapper 一致)
3. 实体类字段对齐实际表结构(configId/configType/deleted)
4. Service 参数名 category → configType 全部对齐
- **关键教训:跨库查询必须加 @InterceptorIgnore(tenantLine="true"),且表名必须带正确库名前缀**
+11
View File
@@ -0,0 +1,11 @@
# 2026-06-18 工作日志
## AppConfigMapper.xml 添加关联查询
**改动文件**: `src/main/java/com/gxwebsoft/websopy/mapper/AppConfigMapper.xml`
- `selectByCategory` SQL 增加了 `INNER JOIN db_websopy.app_product`,关联条件:
- `ap.product_id = ac.app_id`
- `ap.tenant_id = #{tenantId}`
- 目的:确保只返回该租户下有效产品的配置,防止 app_config 中的 app_id 指向非该租户的产品
- Service 层和 Controller 层无需改动,不影响现有调用
+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_user;sys_user **没有** `name` 字段(只有 username / nickname / real_name / phone),故 MySQL 报 `Unknown column 'b.name' in 'where clause'`。
- 修复:关键词改为搜索文件侧字段 `a.name` / `a.path` / `a.comments`,加上传者 `b.username` / `b.nickname` / `b.real_name`;
`a.create_user_id = #{param.keywords}` 用 `<if test="param.keywords.matches('[0-9]+')">` 包住,避免非数字关键词被 MySQL 隐式转成 0。
- 教训:跨 mapper 复制动态 SQL 片段时,务必核对表别名与目标表字段(sys_user 无 name 列)。
+31
View File
@@ -0,0 +1,31 @@
# 系统架构和工作流程记录
## websopy系统架构
1. **Java后端服务**:提供API接口,处理微信扫码登录、用户同步等
2. **websopy-pc网站**:前端用户界面,调用后端API
3. **微信生态集成**:
- 微信公众号扫码登录
- 用户信息同步到websopy系统
## 扫码登录绑定手机号流程
1. **后端已完成修改**:
- QrLoginServiceImpl添加nextAction逻辑
- QrLoginStatusResponse新增nextAction、redirectUrl、successMessage字段
- 用户没有手机号时设为bind_phone状态
- 用户有手机号时可直接跳转/console
2. **websopy-pc前端需要修改**:
- 状态轮询逻辑需要处理nextAction字段
- 需要创建绑定手机号页面
- 需要调用绑定手机号API
- 需要实现登录成功后跳转逻辑
## 关键API接口
- `/api/qr-login/generate` - 生成扫码token
- `/api/qr-login/status/{token}` - 检查扫码状态
- `/api/qr-login/bind-phone` - 绑定手机号
## 错误修复历史
- 修复了`Column 'tenant_id' cannot be null`错误
- 用户同步时确保同时发送tenantId和tenant_id两种格式
- 同步用户前从数据库重新加载确保数据完整
+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。
File diff suppressed because one or more lines are too long
+63 -1
View File
@@ -5,7 +5,7 @@
<groupId>com.gxwebsoft</groupId> <groupId>com.gxwebsoft</groupId>
<artifactId>server-api</artifactId> <artifactId>server-api</artifactId>
<version>1.0</version> <version>2.0</version>
<name>server-api</name> <name>server-api</name>
<description>WebSoftApi project for Spring Boot</description> <description>WebSoftApi project for Spring Boot</description>
@@ -24,9 +24,35 @@
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- 平台层版本:升级平台能力只改这一处 -->
<websoft-platform.version>1.0.1</websoft-platform.version>
</properties> </properties>
<dependencyManagement>
<dependencies> <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.0:minio-java 要求 okhttp>=4.11.0,放在 dependencies 首位确保胜出,
避免运行时 NoSuchMethodError(okhttp3.RequestBody.create 等) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- spring-boot-devtools --> <!-- spring-boot-devtools -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -179,6 +205,21 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<!--
平台层:内核(统一返回/状态码/业务异常)+ 开放平台资源服务能力。
这些类原先以源码形式散在本仓库里,每个业务仓库各复制一份,改一处要改 40 处。
现在改为依赖构件,升级只要改下面的版本号。
配套约定:BOM 只管理 com.gxwebsoft 的构件,不接管 Spring Boot 版本,
所以本服务仍可自行决定 Boot 版本。
-->
<dependency>
<groupId>com.gxwebsoft</groupId>
<artifactId>websoft-platform-openapi</artifactId>
<version>${websoft-platform.version}</version>
</dependency>
<!-- jjwt - 升级到安全版本 --> <!-- jjwt - 升级到安全版本 -->
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
@@ -297,6 +338,14 @@
<version>3.17.4</version> <version>3.17.4</version>
</dependency> </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> <dependency>
<groupId>com.aliyun</groupId> <groupId>com.aliyun</groupId>
@@ -316,6 +365,19 @@
<version>1.0.0.14</version> <version>1.0.0.14</version>
</dependency> </dependency>
<!-- spring-boot-starter-amqp for RabbitMQ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- javax.annotation-api for Java 9+ compatibility (previously included in JDK) -->
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<!-- knife4j - 升级到兼容版本 --> <!-- knife4j - 升级到兼容版本 -->
<dependency> <dependency>
<groupId>com.github.xiaoymin</groupId> <groupId>com.github.xiaoymin</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,8 +1,11 @@
package com.gxwebsoft.auto.controller; package com.gxwebsoft.auto.controller;
import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest;
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
import com.gxwebsoft.auto.dto.QrLoginStatusResponse; import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
import com.gxwebsoft.auto.dto.WechatScanRequest;
import com.gxwebsoft.auto.dto.WechatScanResponse;
import com.gxwebsoft.auto.service.QrLoginService; import com.gxwebsoft.auto.service.QrLoginService;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
@@ -28,6 +31,12 @@ public class QrLoginController extends BaseController {
@Autowired @Autowired
private QrLoginService qrLoginService; private QrLoginService qrLoginService;
@Autowired
private com.gxwebsoft.common.system.service.WxService wxService;
@Autowired
private javax.servlet.http.HttpServletRequest request;
/** /**
* 生成扫码登录token * 生成扫码登录token
*/ */
@@ -85,4 +94,54 @@ public class QrLoginController extends BaseController {
} }
} }
/**
* 公众号关注注册后绑定手机号
*/
@Operation(summary = "绑定手机号并完成扫码登录")
@PostMapping("/bind-phone")
public ApiResult<?> bindPhone(@Valid @RequestBody QrLoginBindPhoneRequest request) {
try {
QrLoginStatusResponse response = qrLoginService.bindPhone(request);
return success("绑定成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/**
* 微信扫码登录确认(H5页面调用)
*/
@Operation(summary = "微信扫码登录确认")
@PostMapping("/wechat-scan")
public ApiResult<?> wechatScanConfirm(@Valid @RequestBody WechatScanRequest request) {
try {
WechatScanResponse response = qrLoginService.wechatScanConfirm(request);
return success("操作成功", response);
} catch (Exception e) {
return fail(e.getMessage());
}
}
/**
* 获取微信网页授权 URL(用于 H5 扫码页面重定向)
*/
@Operation(summary = "获取微信网页授权URL")
@GetMapping("/wechat-oauth-url")
public ApiResult<?> getWechatOAuthUrl(@Parameter(description = "扫码登录token") @RequestParam String token) {
try {
String appId = wxService.getOfficialAppId(getTenantId());
// 回调地址,指向 H5 扫码确认页面
String redirectUri = java.net.URLEncoder.encode(
"https://" + request.getHeader("Host") + "/wx-scan?token=" + token,
java.nio.charset.StandardCharsets.UTF_8);
// 构造微信 OAuth 授权 URL
String oauthUrl = String.format(
"https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_userinfo&state=%s#wechat_redirect",
appId, redirectUri, token);
return success("获取成功", oauthUrl);
} catch (Exception e) {
return fail(e.getMessage());
}
}
} }
@@ -0,0 +1,30 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 扫码登录绑定手机号请求
*
* @author 科技小王子
* @since 2026-04-06
*/
@Data
@Schema(description = "扫码登录绑定手机号请求")
public class QrLoginBindPhoneRequest {
@Schema(description = "扫码登录token")
@NotBlank(message = "token不能为空")
private String token;
@Schema(description = "手机号")
@NotBlank(message = "手机号不能为空")
private String phone;
@Schema(description = "短信验证码")
@NotBlank(message = "验证码不能为空")
private String code;
}
@@ -4,8 +4,6 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.Date;
/** /**
* 扫码登录数据模型 * 扫码登录数据模型
* *
@@ -23,7 +21,7 @@ public class QrLoginData {
private String token; private String token;
/** /**
* 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期 * 状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期
*/ */
private String status; private String status;
@@ -57,4 +55,14 @@ public class QrLoginData {
*/ */
private Integer tenantId; private Integer tenantId;
/**
* 是否需要绑定手机号
*/
private Boolean needBindPhone;
/**
* 状态提示信息
*/
private String message;
} }
@@ -26,12 +26,24 @@ public class QrLoginGenerateResponse {
@Schema(description = "微信小程序页面路径") @Schema(description = "微信小程序页面路径")
private String miniprogramPath; private String miniprogramPath;
@Schema(description = "微信小程序码图片URL") @Schema(description = "微信小程序码图片URL(已废弃,改用base64)")
private String miniprogramQrCodeUrl; private String miniprogramQrCodeUrl;
@Schema(description = "微信小程序码图片Base64(扫码后直接打开小程序,优先使用)")
private String miniprogramQrCode;
@Schema(description = "过期时间(秒)") @Schema(description = "过期时间(秒)")
private Long expiresIn; private Long expiresIn;
@Schema(description = "微信扫码登录H5页面URL")
private String wechatScanUrl;
@Schema(description = "微信公众号AppID")
private String wechatAppId;
@Schema(description = "微信公众号带参数二维码图片URL")
private String wechatQrCodeUrl;
// 保持向后兼容的构造函数 // 保持向后兼容的构造函数
public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) { public QrLoginGenerateResponse(String token, String qrCodeContent, Long expiresIn) {
this.token = token; this.token = token;
@@ -1,9 +1,7 @@
package com.gxwebsoft.auto.dto; package com.gxwebsoft.auto.dto;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import io.swagger.models.auth.In;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -15,17 +13,16 @@ import lombok.NoArgsConstructor;
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor
@Schema(description = "扫码登录状态响应") @Schema(description = "扫码登录状态响应")
public class QrLoginStatusResponse { public class QrLoginStatusResponse {
@Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, expired-已过期") @Schema(description = "状态: pending-等待扫码, scanned-已扫码, confirmed-已确认, bind_phone-待绑定手机号, expired-已过期")
private String status; private String status;
@Schema(description = "JWT访问令牌(仅在confirmed状态时返回)") @Schema(description = "JWT访问令牌(仅在confirmed状态时返回)")
private String accessToken; private String accessToken;
@Schema(description = "用户信息(仅在confirmed状态时返回)") @Schema(description = "用户信息")
private User userInfo; private User userInfo;
@Schema(description = "剩余过期时间(秒)") @Schema(description = "剩余过期时间(秒)")
@@ -34,4 +31,27 @@ public class QrLoginStatusResponse {
@Schema(description = "租户ID") @Schema(description = "租户ID")
private Integer tenantId; private Integer tenantId;
@Schema(description = "是否需要绑定手机号")
private Boolean needBindPhone;
@Schema(description = "状态提示信息")
private String message;
@Schema(description = "下一步操作:bind_phone-绑定手机号, redirect-跳转, login-直接登录")
private String nextAction;
@Schema(description = "跳转URL(当nextAction为redirect时使用)")
private String redirectUrl;
@Schema(description = "成功消息")
private String successMessage;
public QrLoginStatusResponse(String status, String accessToken, User userInfo, Long expiresIn, Integer tenantId) {
this.status = status;
this.accessToken = accessToken;
this.userInfo = userInfo;
this.expiresIn = expiresIn;
this.tenantId = tenantId;
}
} }
@@ -0,0 +1,31 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 微信扫码登录请求(用于 H5 页面回调)
*
* @author 科技小王子
* @since 2026-04-06
*/
@Data
@Schema(description = "微信扫码登录请求")
public class WechatScanRequest {
@Schema(description = "扫码登录token")
@NotBlank(message = "token不能为空")
private String token;
@Schema(description = "微信公众号授权code")
private String code;
@Schema(description = "微信unionId(如果已获取)")
private String unionId;
@Schema(description = "微信openId")
private String openId;
}
@@ -0,0 +1,47 @@
package com.gxwebsoft.auto.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 微信扫码登录响应
*
* @author 科技小王子
* @since 2026-04-06
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "微信扫码登录响应")
public class WechatScanResponse {
@Schema(description = "状态:success-登录成功,bind_required-需要绑定账号,not_bound-账号未绑定")
private String status;
@Schema(description = "JWT访问令牌")
private String accessToken;
@Schema(description = "用户信息")
private Object userInfo;
@Schema(description = "提示信息")
private String message;
@Schema(description = "租户ID")
private Integer tenantId;
public static WechatScanResponse success(String accessToken, Object userInfo, Integer tenantId) {
return new WechatScanResponse("success", accessToken, userInfo, "登录成功", tenantId);
}
public static WechatScanResponse needBind(String message) {
return new WechatScanResponse("bind_required", null, null, message, null);
}
public static WechatScanResponse notBound(String message) {
return new WechatScanResponse("not_bound", null, null, message, null);
}
}
@@ -1,8 +1,11 @@
package com.gxwebsoft.auto.service; package com.gxwebsoft.auto.service;
import com.gxwebsoft.auto.dto.QrLoginBindPhoneRequest;
import com.gxwebsoft.auto.dto.QrLoginConfirmRequest; import com.gxwebsoft.auto.dto.QrLoginConfirmRequest;
import com.gxwebsoft.auto.dto.QrLoginGenerateResponse; import com.gxwebsoft.auto.dto.QrLoginGenerateResponse;
import com.gxwebsoft.auto.dto.QrLoginStatusResponse; import com.gxwebsoft.auto.dto.QrLoginStatusResponse;
import com.gxwebsoft.auto.dto.WechatScanRequest;
import com.gxwebsoft.auto.dto.WechatScanResponse;
/** /**
* 扫码登录服务接口 * 扫码登录服务接口
@@ -43,4 +46,20 @@ public interface QrLoginService {
*/ */
boolean scanQrCode(String token); boolean scanQrCode(String token);
/**
* 关注后绑定手机号并完成登录
*
* @param request 绑定手机号请求
* @return QrLoginStatusResponse
*/
QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request);
/**
* 微信扫码登录确认(H5页面调用)
*
* @param request 微信扫码登录请求
* @return WechatScanResponse
*/
WechatScanResponse wechatScanConfirm(WechatScanRequest request);
} }
@@ -1,29 +1,37 @@
package com.gxwebsoft.auto.service.impl; package com.gxwebsoft.auto.service.impl;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.UUID; import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.auto.dto.*; import com.gxwebsoft.auto.dto.*;
import com.gxwebsoft.auto.service.QrLoginService; import com.gxwebsoft.auto.service.QrLoginService;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.security.JwtSubject; import com.gxwebsoft.common.core.security.JwtSubject;
import com.gxwebsoft.common.core.security.JwtUtil; import com.gxwebsoft.common.core.security.JwtUtil;
import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.mq.producer.SyncMessageProducer;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.entity.UserOauth;
import com.gxwebsoft.common.system.service.UserOauthService;
import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.common.system.service.WxService; import com.gxwebsoft.common.system.service.WxService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Date;
import java.io.File;
import java.util.HashMap; import java.util.HashMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static com.gxwebsoft.common.core.constants.PlatformConstants.MP_OFFICIAL;
import static com.gxwebsoft.common.core.constants.RedisConstants.*; import static com.gxwebsoft.common.core.constants.RedisConstants.*;
import static com.gxwebsoft.common.core.constants.WebsiteConstants.CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS;
/** /**
* 扫码登录服务实现 * 扫码登录服务实现
@@ -47,96 +55,251 @@ public class QrLoginServiceImpl implements QrLoginService {
@Autowired @Autowired
private WxService wxService; private WxService wxService;
private static final String QR_LOGIN_TOKEN = "QR_LOGIN_TOKEN"; @Autowired(required = false)
private UserOauthService userOauthService;
@Autowired(required = false)
private SyncMessageProducer syncMessageProducer;
@Override @Override
public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) { public QrLoginGenerateResponse generateQrLoginToken(Integer tenantId) {
// 生成唯一的扫码登录token
String token = UUID.randomUUID().toString(true); String token = UUID.randomUUID().toString(true);
// 创建扫码登录数据
QrLoginData qrLoginData = new QrLoginData(); QrLoginData qrLoginData = new QrLoginData();
qrLoginData.setToken(token); qrLoginData.setToken(token);
qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING); qrLoginData.setStatus(QR_LOGIN_STATUS_PENDING);
qrLoginData.setTenantId(tenantId); qrLoginData.setTenantId(tenantId);
qrLoginData.setNeedBindPhone(false);
qrLoginData.setMessage("等待微信扫码");
qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date())); qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date()));
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue()))); qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), QR_LOGIN_TOKEN_TTL.intValue())));
// 存储到Redis,设置过期时间
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS); redisUtil.set(redisKey, qrLoginData, QR_LOGIN_TOKEN_TTL, TimeUnit.SECONDS);
log.info("生成扫码登录token: {}", token); log.info("生成扫码登录token: {}", token);
// 构造响应对象
QrLoginGenerateResponse response = new QrLoginGenerateResponse(); QrLoginGenerateResponse response = new QrLoginGenerateResponse();
response.setToken(token); response.setToken(token);
response.setExpiresIn(QR_LOGIN_TOKEN_TTL); response.setExpiresIn(QR_LOGIN_TOKEN_TTL);
// 二维码内容:使用自定义协议,前端据此生成base64二维码
// APP扫码内容 response.setQrCodeContent("websopy://login?token=" + token);
response.setQrCodeContent("qr-login:" + token); // 小程序路径(用于小程序扫码直接打开)
// 微信小程序路径
response.setMiniprogramPath("/pages/qr-login?token=" + token); response.setMiniprogramPath("/pages/qr-login?token=" + token);
// 生成微信小程序码 // 扫码跳转URL(前端生成二维码时使用此URL)
try { try {
String miniprogramQrCodeUrl = generateMiniprogramQrCode(token,tenantId); String baseUrl = configProperties.getWechatScanUrl();
response.setMiniprogramQrCodeUrl(miniprogramQrCodeUrl); if (StrUtil.isBlank(baseUrl)) {
baseUrl = "https://websopy.websoft.top";
}
String wechatScanUrl = baseUrl + "/wx-scan?token=" + token;
response.setWechatScanUrl(wechatScanUrl);
log.info("扫码跳转URL: {}", wechatScanUrl);
} catch (Exception e) { } catch (Exception e) {
log.warn("生成微信小程序码失败: {}", e.getMessage()); log.warn("获取扫码跳转URL失败: {}", e.getMessage());
// 小程序码生成失败不影响整体功能,继续返回其他信息 // 降级:使用默认域名
response.setWechatScanUrl("https://websopy.websoft.top/wx-scan?token=" + token);
}
// 生成小程序码(通过微信API生成小程序码,返回Base64图片,扫码后直接打开小程序确认页面)
try {
String miniprogramQrCodeBase64 = generateMiniprogramQrCode(token, tenantId);
if (StrUtil.isNotBlank(miniprogramQrCodeBase64)) {
response.setMiniprogramQrCode(miniprogramQrCodeBase64);
log.info("生成小程序码成功(Base64,长度: {})", miniprogramQrCodeBase64.length());
}
} catch (Exception e) {
log.error("生成小程序码失败: {}", e.getMessage(), e);
// 生成失败不影响主流程,继续使用H5方式
} }
return response; return response;
} }
/**
* 生成小程序码(用于PC端扫码登录)
* 调用微信API生成无限制小程序码,返回Base64图片,扫码后直接打开小程序确认页面
* 具备自动重试机制:首次失败后清理缓存并重试一次
*
* @param token 扫码登录token
* @param tenantId 租户ID
* @return 小程序码图片Base64字符串
*/
private String generateMiniprogramQrCode(String token, Integer tenantId) {
// 构建 access_token 的 Redis key(与 WxService 保持一致)
String accessTokenKey = "WX_ACCESS_TOKEN:" + (tenantId != null ? tenantId : 10048);
// 第一次尝试生成
String result = doGenerateMiniprogramQrCode(token, tenantId, accessTokenKey, false);
if (result != null) {
return result;
}
// 第一次失败,清理缓存并重试(确保下次能拿到最新的 access_token)
log.info("小程序码首次生成失败,清理缓存后重试...");
clearAccessTokenCache(accessTokenKey, tenantId);
// 第二次尝试生成(强制刷新 token)
return doGenerateMiniprogramQrCode(token, tenantId, accessTokenKey, true);
}
/**
* 执行小程序码生成
*
* @param token 扫码登录token
* @param tenantId 租户ID
* @param accessTokenKey access_token 的 Redis key
* @param forceRefresh 是否强制刷新 access_token
* @return 小程序码 Base64 字符串,失败返回 null
*/
private String doGenerateMiniprogramQrCode(String token, Integer tenantId, String accessTokenKey, boolean forceRefresh) {
try {
// 获取小程序access_token
String accessToken = forceRefresh
? wxService.getAccessTokenForcibly(tenantId) // 强制从微信获取新token
: wxService.getAccessToken(tenantId);
if (StrUtil.isBlank(accessToken)) {
log.warn("获取小程序access_token失败,跳过生成小程序码");
return null;
}
// 调用微信API生成小程序码
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + accessToken;
HashMap<String, Object> params = new HashMap<>();
// scene 必须是字符串,最大 32 字符,直接传 token(32位UUID)刚好满足限制
// 小程序端通过 router.params.scene 获取此 token
params.put("scene", token);
params.put("page", "passport/qr-confirm/index"); // 小程序确认页面路径(子包)
params.put("env_version", "release"); // release/trial/develop
params.put("width", 280); // 二维码宽度
params.put("auto_color", false); // 不自动配置颜色
// 发送请求并获取二进制响应
byte[] imageBytes = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(params))
.timeout(15000)
.execute().bodyBytes();
// 判断是否返回图片(二进制)或错误(JSON)
if (imageBytes == null || imageBytes.length == 0) {
log.error("生成小程序码API返回空数据");
return null;
}
// 检查是否返回JSON错误(微信API错误时会返回JSON)
if (imageBytes.length < 100 && new String(imageBytes).startsWith("{")) {
JSONObject errorResult = JSON.parseObject(new String(imageBytes));
Integer errCode = errorResult.getInteger("errcode");
String errMsg = errorResult.getString("errmsg");
log.error("生成小程序码API返回错误[{}:{}]", errCode, errMsg);
return null;
}
// 将图片字节数组转换为Base64字符串
String base64Image = cn.hutool.core.codec.Base64.encode(imageBytes);
// 添加Data URI前缀,使前端可以直接使用
return "data:image/png;base64," + base64Image;
} catch (Exception e) {
log.error("生成小程序码异常: {}", e.getMessage(), e);
return null;
}
}
/**
* 判断是否是 token 相关的错误码,需要清理缓存
* 常见微信 API 错误码:
* - 40001: 获取access_token时AppSecret错误
* - 40013: appid无效
* - 40125: appsecret无效
* - 42001: access_token超时
* - 42002: refresh_token超时
* - 42003: code超时
* - 44002: post body太长
* - 44003: 图片太大
* - 41002: appid不正确
* - 41008: 缺少access_token参数
*/
private boolean isTokenRelatedError(Integer errCode, String errMsg) {
if (errCode == null) {
return false;
}
// token 相关错误码
return errCode == 40001 // AppSecret错误
|| errCode == 40013 // appid无效
|| errCode == 40125 // appsecret无效
|| errCode == 42001 // access_token超时
|| errCode == 42002 // refresh_token超时
|| errCode == 42003 // code超时
|| errCode == 41002 // appid不正确
|| errCode == 41008 // 缺少access_token参数
|| errCode == 40014 // 不合法的access_token
|| errCode == 40097; // invalid page
}
/**
* 清理 access_token 缓存
*/
private void clearAccessTokenCache(String accessTokenKey, Integer tenantId) {
try {
redisUtil.delete(accessTokenKey);
log.info("清理微信access_token缓存[{}], tenantId={}", accessTokenKey, tenantId);
} catch (Exception e) {
log.error("清理access_token缓存失败: {}", e.getMessage());
}
}
@Override @Override
public QrLoginStatusResponse checkQrLoginStatus(String token) { public QrLoginStatusResponse checkQrLoginStatus(String token) {
if (StrUtil.isBlank(token)) { if (StrUtil.isBlank(token)) {
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); return buildExpiredResponse();
} }
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); return buildExpiredResponse();
} }
// 检查是否过期 Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { if (expireAt == null || DateUtil.date().after(expireAt)) {
// 删除过期的token
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null); return buildExpiredResponse();
} }
// 计算剩余过期时间 long expiresIn = calculateExpiresIn(expireAt);
long expiresIn = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000;
QrLoginStatusResponse response = new QrLoginStatusResponse(); if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())
response.setStatus(qrLoginData.getStatus()); && StrUtil.isBlank(qrLoginData.getAccessToken())
response.setExpiresIn(expiresIn); && qrLoginData.getUserId() != null) {
response.setTenantId(qrLoginData.getTenantId()); try {
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
// 如果已确认,返回token和用户信息
if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus())) {
response.setAccessToken(qrLoginData.getAccessToken());
// 获取用户信息
if (qrLoginData.getUserId() != null) {
User user = userService.getAllByUserId("" + qrLoginData.getUserId());
if (user != null) { if (user != null) {
// 清除敏感信息 qrLoginData.setUsername(user.getUsername());
user.setPassword(null); if (StrUtil.isBlank(user.getPhone())) {
response.setUserInfo(user); qrLoginData.setStatus(QR_LOGIN_STATUS_BIND_PHONE);
qrLoginData.setNeedBindPhone(true);
qrLoginData.setAccessToken(null);
qrLoginData.setMessage("请先绑定手机号完成登录");
} else {
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setNeedBindPhone(false);
qrLoginData.setAccessToken(buildAccessToken(user));
qrLoginData.setMessage(StrUtil.blankToDefault(qrLoginData.getMessage(), "登录成功"));
}
long refreshedTtl = Math.max(expiresIn, 120L);
persistQrLoginData(redisKey, qrLoginData, refreshedTtl, true);
expiresIn = refreshedTtl;
}
} catch (Exception e) {
log.error("补全扫码登录状态失败,token={}", token, e);
} }
} }
// 确认后删除token,防止重复使用 return buildStatusResponse(qrLoginData, expiresIn);
// redisUtil.delete(redisKey);
}
return response;
} }
@Override @Override
@@ -150,47 +313,36 @@ public class QrLoginServiceImpl implements QrLoginService {
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
throw new RuntimeException("扫码登录token不存在或已过期"); throw new RuntimeException("扫码登录token不存在或已过期");
} }
// 检查是否过期 Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { if (expireAt == null || DateUtil.date().after(expireAt)) {
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
throw new RuntimeException("扫码登录token已过期"); throw new RuntimeException("扫码登录token已过期");
} }
// 获取用户信息
User user = userService.getAllByUserId(String.valueOf(userId)); User user = userService.getAllByUserId(String.valueOf(userId));
if (user == null) { if (user == null) {
throw new RuntimeException("用户不存在"); throw new RuntimeException("用户不存在");
} }
// 检查用户状态
if (user.getStatus() != null && user.getStatus() != 0) { if (user.getStatus() != null && user.getStatus() != 0) {
throw new RuntimeException("用户已被冻结"); throw new RuntimeException("用户已被冻结");
} }
// 生成JWT token String accessToken = buildAccessToken(user);
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
String accessToken = JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
// 更新扫码登录数据
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED); qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(userId); qrLoginData.setUserId(userId);
qrLoginData.setUsername(user.getUsername()); qrLoginData.setUsername(user.getUsername());
qrLoginData.setAccessToken(accessToken); qrLoginData.setAccessToken(accessToken);
qrLoginData.setTenantId(user.getTenantId()); qrLoginData.setTenantId(user.getTenantId());
// 更新Redis中的数据 qrLoginData.setNeedBindPhone(false);
redisUtil.set(redisKey, qrLoginData, 60L, TimeUnit.SECONDS); // 给前端60秒时间获取token qrLoginData.setMessage("登录成功");
persistQrLoginData(redisKey, qrLoginData, 120L, true);
log.info("用户 {} 确认扫码登录,token: {}", user.getUsername(), token); log.info("用户 {} 确认扫码登录,token: {}", user.getUsername(), token);
return buildStatusResponse(qrLoginData, 120L);
// 清除敏感信息
user.setPassword(null);
return new QrLoginStatusResponse(QR_LOGIN_STATUS_CONFIRMED, accessToken, user, 60L, user.getTenantId());
} }
@Override @Override
@@ -201,25 +353,22 @@ public class QrLoginServiceImpl implements QrLoginService {
String redisKey = QR_LOGIN_TOKEN_KEY + token; String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class); QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) { if (qrLoginData == null) {
return false; return false;
} }
// 检查是否过期 Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
if (DateUtil.date().after(DateUtil.parseDateTime(qrLoginData.getExpireTime()))) { if (expireAt == null || DateUtil.date().after(expireAt)) {
redisUtil.delete(redisKey); redisUtil.delete(redisKey);
return false; return false;
} }
// 只有pending状态才能更新为scanned
if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) { if (QR_LOGIN_STATUS_PENDING.equals(qrLoginData.getStatus())) {
qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED); qrLoginData.setStatus(QR_LOGIN_STATUS_SCANNED);
qrLoginData.setMessage("已识别扫码,等待公众号回调");
// 计算剩余过期时间 long remainingSeconds = Math.max(1L,
long remainingSeconds = (DateUtil.parseDateTime(qrLoginData.getExpireTime()).getTime() - DateUtil.date().getTime()) / 1000; (expireAt.getTime() - DateUtil.date().getTime()) / 1000);
redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS); redisUtil.set(redisKey, qrLoginData, remainingSeconds, TimeUnit.SECONDS);
log.info("扫码登录token {} 状态更新为已扫码", token); log.info("扫码登录token {} 状态更新为已扫码", token);
return true; return true;
} }
@@ -227,67 +376,270 @@ public class QrLoginServiceImpl implements QrLoginService {
return false; return false;
} }
/** @Override
* 生成微信小程序码 public QrLoginStatusResponse bindPhone(QrLoginBindPhoneRequest request) {
*/ if (request == null || StrUtil.isBlank(request.getToken()) || StrUtil.isBlank(request.getPhone()) || StrUtil.isBlank(request.getCode())) {
private String generateMiniprogramQrCode(String token, Integer tenantId) { throw new RuntimeException("参数不能为空");
}
if (!CommonUtil.isValidPhoneNumber(request.getPhone())) {
throw new RuntimeException("请输入有效的手机号码");
}
String redisKey = QR_LOGIN_TOKEN_KEY + request.getToken();
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
throw new RuntimeException("二维码已过期,请刷新后重试");
}
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
if (expireAt == null || DateUtil.date().after(expireAt)) {
redisUtil.delete(redisKey);
throw new RuntimeException("二维码已过期,请刷新后重试");
}
if (!QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()) && !Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())) {
throw new RuntimeException("当前二维码无需绑定手机号");
}
if (qrLoginData.getUserId() == null) {
throw new RuntimeException("绑定账号不存在,请重新扫码");
}
String codeKey = "code:" + request.getPhone();
String smsCode = redisUtil.get(codeKey);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (StrUtil.isBlank(smsCode) && StrUtil.isBlank(devCode)) {
throw new RuntimeException("验证码已过期,请重新获取");
}
if (!StrUtil.equals(request.getCode(), smsCode) && !StrUtil.equals(request.getCode(), devCode)) {
throw new RuntimeException("验证码不正确");
}
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (user.getStatus() != null && user.getStatus() != 0) {
throw new RuntimeException("账号已被冻结");
}
User existed = userService.getByPhone(request.getPhone());
if (existed != null && !existed.getUserId().equals(user.getUserId())) {
throw new RuntimeException("该手机号已绑定其他账号");
}
user.setPhone(request.getPhone());
if (StrUtil.isBlank(user.getNickname()) || "微信公众号用户".equals(user.getNickname())) {
user.setNickname(DesensitizedUtil.mobilePhone(request.getPhone()));
}
if (StrUtil.isBlank(user.getUsername()) || user.getUsername().startsWith("wxoff_")) {
user.setUsername(request.getPhone());
}
userService.updateUser(user);
redisUtil.delete(codeKey);
// 绑定手机号成功后,通过MQ异步同步用户数据到 websopy
if (syncMessageProducer != null) {
User updatedUser = userService.getAllByUserId(String.valueOf(user.getUserId()));
if (updatedUser != null) {
syncMessageProducer.sendUserSyncMessage("websopy", "UPDATE", updatedUser);
log.info("扫码绑定手机号后发送MQ消息同步用户到websopy: userId={}, phone={}", user.getUserId(), user.getPhone());
}
}
String accessToken = buildAccessToken(user);
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(user.getUserId());
qrLoginData.setUsername(user.getUsername());
qrLoginData.setTenantId(user.getTenantId());
qrLoginData.setAccessToken(accessToken);
qrLoginData.setNeedBindPhone(false);
qrLoginData.setMessage("手机号绑定成功,正在登录");
persistQrLoginData(redisKey, qrLoginData, 120L, true);
return buildStatusResponse(qrLoginData, 120L);
}
private String buildAccessToken(User user) {
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
}
private Date parseExpireTime(String expireTime) {
if (StrUtil.isBlank(expireTime)) {
return null;
}
try { try {
// 使用公共的 WxService 获取 AccessToken return DateUtil.parseDateTime(expireTime);
String accessToken = wxService.getAccessToken(tenantId); } catch (Exception e) {
if (StrUtil.isBlank(accessToken)) { log.warn("扫码登录 expireTime 解析失败: {}", expireTime, e);
throw new RuntimeException("获取微信AccessToken失败"); return null;
}
} }
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacode?access_token=" + accessToken; private long calculateExpiresIn(Date expireAt) {
HashMap<String, Object> params = new HashMap<>(); if (expireAt == null) {
params.put("path", "/pages/qr-login?token=" + token); return 0L;
params.put("width", 430); // 二维码宽度,默认430px }
return Math.max(0L, (expireAt.getTime() - DateUtil.date().getTime()) / 1000);
// 调用微信API生成小程序码
byte[] qrCodeBytes = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(params))
.execute().bodyBytes();
// 保存文件
String fileName = "qr-login-" + token + ".png";
String uploadPath = getUploadPath();
String filePath = uploadPath + "qrcode/" + fileName;
// 确保目录存在
File dir = new File(uploadPath + "qrcode/");
if (!dir.exists()) {
dir.mkdirs();
} }
File file = FileUtil.writeBytes(qrCodeBytes, filePath); private void persistQrLoginData(String redisKey, QrLoginData qrLoginData, long ttlSeconds, boolean refreshExpireTime) {
if (file != null && file.exists()) { if (refreshExpireTime) {
// 返回可访问的URL qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), (int) ttlSeconds)));
return configProperties.getFileServer() + "/qrcode/" + fileName; }
redisUtil.set(redisKey, qrLoginData, ttlSeconds, TimeUnit.SECONDS);
}
private QrLoginStatusResponse buildExpiredResponse() {
QrLoginStatusResponse response = new QrLoginStatusResponse(QR_LOGIN_STATUS_EXPIRED, null, null, 0L, null);
response.setNeedBindPhone(false);
response.setMessage("二维码已过期,请刷新后重试");
return response;
}
private QrLoginStatusResponse buildStatusResponse(QrLoginData qrLoginData, Long expiresIn) {
QrLoginStatusResponse response = new QrLoginStatusResponse();
response.setStatus(qrLoginData.getStatus());
response.setAccessToken(qrLoginData.getAccessToken());
response.setExpiresIn(expiresIn);
response.setTenantId(qrLoginData.getTenantId());
response.setNeedBindPhone(Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())
|| QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()));
response.setMessage(qrLoginData.getMessage());
// 设置下一步操作逻辑
if (QR_LOGIN_STATUS_BIND_PHONE.equals(qrLoginData.getStatus()) || Boolean.TRUE.equals(qrLoginData.getNeedBindPhone())) {
response.setNextAction("bind_phone");
response.setRedirectUrl(null);
} else if (QR_LOGIN_STATUS_CONFIRMED.equals(qrLoginData.getStatus()) && StrUtil.isNotBlank(qrLoginData.getAccessToken())) {
response.setNextAction("redirect");
response.setRedirectUrl("/console");
response.setSuccessMessage("登录成功,即将跳转到控制台");
} else { } else {
throw new RuntimeException("保存小程序码文件失败"); response.setNextAction("wait");
}
if (qrLoginData.getUserId() != null) {
try {
User user = userService.getAllByUserId(String.valueOf(qrLoginData.getUserId()));
if (user != null) {
user.setPassword(null);
response.setUserInfo(user);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("生成微信小程序码失败: {}", e.getMessage(), e); log.error("构建扫码登录状态响应时查询用户失败,userId={}", qrLoginData.getUserId(), e);
throw new RuntimeException("生成微信小程序码失败: " + e.getMessage()); }
}
return response;
}
@Override
public WechatScanResponse wechatScanConfirm(WechatScanRequest request) {
String token = request.getToken();
if (StrUtil.isBlank(token)) {
return WechatScanResponse.notBound("二维码参数错误");
}
String redisKey = QR_LOGIN_TOKEN_KEY + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
return WechatScanResponse.notBound("二维码已过期,请刷新重试");
}
Date expireAt = parseExpireTime(qrLoginData.getExpireTime());
if (expireAt == null || DateUtil.date().after(expireAt)) {
redisUtil.delete(redisKey);
return WechatScanResponse.notBound("二维码已过期,请刷新重试");
}
String unionId = request.getUnionId();
String openId = request.getOpenId();
Integer tenantId = qrLoginData.getTenantId();
if (StrUtil.isBlank(unionId) && StrUtil.isNotBlank(request.getCode())) {
try {
JSONObject userAccessToken = wxService.getOfficialUserAccessToken(request.getCode(), tenantId);
unionId = userAccessToken.getString("unionid");
openId = userAccessToken.getString("openid");
log.info("通过授权码获取到 unionId: {}, openId: {}", unionId, openId);
} catch (Exception e) {
log.error("通过授权码获取用户信息失败: {}", e.getMessage());
return WechatScanResponse.notBound("微信授权失败,请重试");
} }
} }
if (StrUtil.isBlank(unionId) && StrUtil.isBlank(openId)) {
return WechatScanResponse.notBound("无法获取微信用户信息");
}
User user = null;
if (StrUtil.isNotBlank(unionId)) {
user = userService.getOne(new LambdaQueryWrapper<User>()
.eq(User::getUnionid, unionId)
.eq(User::getDeleted, 0)
.last("limit 1"));
log.info("通过 unionId {} 查找用户: {}", unionId, user != null ? user.getUsername() : "未找到");
}
/** if (user == null && StrUtil.isNotBlank(openId)) {
* 获取文件上传路径 user = userService.getOne(new LambdaQueryWrapper<User>()
*/ .eq(User::getOpenid, openId)
private String getUploadPath() { .eq(User::getDeleted, 0)
String uploadPath = configProperties.getUploadPath(); .last("limit 1"));
if (StrUtil.isBlank(uploadPath)) { log.info("通过 openId {} 查找用户: {}", openId, user != null ? user.getUsername() : "未找到");
uploadPath = configProperties.getLocalUploadPath();
} }
if (StrUtil.isBlank(uploadPath)) {
uploadPath = "/tmp/uploads/"; if (user == null && (StrUtil.isNotBlank(unionId) || StrUtil.isNotBlank(openId))) {
try {
LambdaQueryWrapper<UserOauth> wrapper = new LambdaQueryWrapper<>();
if (StrUtil.isNotBlank(unionId)) {
wrapper.eq(UserOauth::getUnionid, unionId);
} else {
wrapper.eq(UserOauth::getOauthId, openId);
} }
if (!uploadPath.endsWith("/")) { wrapper.eq(UserOauth::getDeleted, 0);
uploadPath += "/";
UserOauth userOauth = null;
if (userOauthService != null) {
userOauth = userOauthService.getOne(wrapper);
} }
return uploadPath;
if (userOauth != null && userOauth.getUserId() != null) {
user = userService.getAllByUserId(String.valueOf(userOauth.getUserId()));
log.info("通过 UserOauth 查找到用户: {}", user != null ? user.getUsername() : "未找到");
}
} catch (Exception e) {
log.error("通过 UserOauth 查找用户失败: {}", e.getMessage());
}
}
if (user == null) {
return WechatScanResponse.notBound("该微信未绑定平台账号,请先在平台注册并绑定微信");
}
if (user.getStatus() != null && user.getStatus() != 0) {
return WechatScanResponse.notBound("账号已被冻结");
}
if (StrUtil.isBlank(user.getPhone())) {
qrLoginData.setStatus(QR_LOGIN_STATUS_BIND_PHONE);
qrLoginData.setUserId(user.getUserId());
qrLoginData.setUsername(user.getUsername());
qrLoginData.setTenantId(user.getTenantId());
qrLoginData.setAccessToken(null);
qrLoginData.setNeedBindPhone(true);
qrLoginData.setMessage("请先绑定手机号完成登录");
persistQrLoginData(redisKey, qrLoginData, 120L, true);
return WechatScanResponse.needBind("请先绑定手机号完成登录");
}
String accessToken = buildAccessToken(user);
qrLoginData.setStatus(QR_LOGIN_STATUS_CONFIRMED);
qrLoginData.setUserId(user.getUserId());
qrLoginData.setUsername(user.getUsername());
qrLoginData.setAccessToken(accessToken);
qrLoginData.setTenantId(user.getTenantId());
qrLoginData.setNeedBindPhone(false);
qrLoginData.setMessage("登录成功");
persistQrLoginData(redisKey, qrLoginData, 120L, true);
user.setPassword(null);
return WechatScanResponse.success(accessToken, user, user.getTenantId());
} }
} }
@@ -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";
}
@@ -92,6 +92,21 @@ public class ConfigProperties {
*/ */
private String serverUrl; private String serverUrl;
/**
* websopy 服务地址(用于同步用户数据)
*/
private String websopyUrl;
/**
* 内部服务调用密钥(调用 websopy 等内部接口时使用)
*/
private String internalKey;
/**
* 微信扫码H5页面访问地址(用于微信扫码登录跳转)
*/
private String wechatScanUrl;
/** /**
* 阿里云存储 OSS * 阿里云存储 OSS
* Endpoint * Endpoint
@@ -0,0 +1,72 @@
package com.gxwebsoft.common.core.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* MQTT配置属性
*
* @author 科技小王子
* @since 2025-07-02
*/
@Data
@Component
@ConfigurationProperties(prefix = "mqtt")
public class MqttProperties {
/**
* 是否启用MQTT服务
*/
private boolean enabled = false;
/**
* MQTT服务器地址
*/
private String host = "tcp://127.0.0.1:1883";
/**
* 用户名
*/
private String username = "";
/**
* 密码
*/
private String password = "";
/**
* 客户端ID前缀
*/
private String clientIdPrefix = "mqtt_client_";
/**
* 订阅主题
*/
private String topic = "/SW_GPS/#";
/**
* QoS等级
*/
private int qos = 2;
/**
* 连接超时时间(秒)
*/
private int connectionTimeout = 10;
/**
* 心跳间隔(秒)
*/
private int keepAliveInterval = 20;
/**
* 是否自动重连
*/
private boolean autoReconnect = true;
/**
* 是否清除会话
*/
private boolean cleanSession = false;
}
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.expression.LongValue;
@@ -40,6 +41,12 @@ public class MybatisPlusConfig {
@Override @Override
public Expression getTenantId() { public Expression getTenantId() {
// 0 开放平台链路:租户只来自令牌,优先级最高
// 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离
Integer openTenantId = OpenTenantContext.getTenantId();
if (openTenantId != null) {
return new LongValue(openTenantId);
}
String tenantId; String tenantId;
// 从请求头拿ID // 从请求头拿ID
tenantId = request.getHeader("tenantId"); tenantId = request.getHeader("tenantId");
@@ -2,4 +2,5 @@ package com.gxwebsoft.common.core.constants;
public class ArticleConstants extends BaseConstants { public class ArticleConstants extends BaseConstants {
public static final String[] ARTICLE_STATUS = {"已发布","待审核","已驳回","违规内容"}; public static final String[] ARTICLE_STATUS = {"已发布","待审核","已驳回","违规内容"};
public static final String CACHE_KEY_ARTICLE = "Article:";
} }
@@ -5,6 +5,10 @@ public class RedisConstants {
public static final String SMS_CODE_KEY = "sms"; public static final String SMS_CODE_KEY = "sms";
// 验证码过期时间 // 验证码过期时间
public static final Long SMS_CODE_TTL = 5L; 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 // 微信凭证access-token
public static final String ACCESS_TOKEN_KEY = "access-token"; public static final String ACCESS_TOKEN_KEY = "access-token";
// 空值防止击穿数据库 // 空值防止击穿数据库
@@ -33,6 +37,7 @@ public class RedisConstants {
public static final String QR_LOGIN_STATUS_PENDING = "pending"; // 等待扫码 public static final String QR_LOGIN_STATUS_PENDING = "pending"; // 等待扫码
public static final String QR_LOGIN_STATUS_SCANNED = "scanned"; // 已扫码 public static final String QR_LOGIN_STATUS_SCANNED = "scanned"; // 已扫码
public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认 public static final String QR_LOGIN_STATUS_CONFIRMED = "confirmed"; // 已确认
public static final String QR_LOGIN_STATUS_BIND_PHONE = "bind_phone"; // 待绑定手机号
public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期 public static final String QR_LOGIN_STATUS_EXPIRED = "expired"; // 已过期
// 哗啦啦key // 哗啦啦key
@@ -0,0 +1,67 @@
package com.gxwebsoft.common.core.context;
/**
* 租户上下文管理器
*
* 用于在特定场景下临时禁用租户隔离
*
* @author WebSoft
* @since 2025-01-26
*/
public class TenantContext {
private static final ThreadLocal<Boolean> IGNORE_TENANT = new ThreadLocal<>();
/**
* 设置忽略租户隔离
*/
public static void setIgnoreTenant(boolean ignore) {
IGNORE_TENANT.set(ignore);
}
/**
* 是否忽略租户隔离
*/
public static boolean isIgnoreTenant() {
Boolean ignore = IGNORE_TENANT.get();
return ignore != null && ignore;
}
/**
* 清除租户上下文
*/
public static void clear() {
IGNORE_TENANT.remove();
}
/**
* 在忽略租户隔离的上下文中执行操作
*
* @param runnable 要执行的操作
*/
public static void runIgnoreTenant(Runnable runnable) {
boolean originalIgnore = isIgnoreTenant();
try {
setIgnoreTenant(true);
runnable.run();
} finally {
setIgnoreTenant(originalIgnore);
}
}
/**
* 在忽略租户隔离的上下文中执行操作并返回结果
*
* @param supplier 要执行的操作
* @return 操作结果
*/
public static <T> T callIgnoreTenant(java.util.function.Supplier<T> supplier) {
boolean originalIgnore = isIgnoreTenant();
try {
setIgnoreTenant(true);
return supplier.get();
} finally {
setIgnoreTenant(originalIgnore);
}
}
}
@@ -1,48 +0,0 @@
package com.gxwebsoft.common.core.exception;
import com.gxwebsoft.common.core.Constants;
/**
* 自定义业务异常
*
* @author WebSoft
* @since 2018-02-22 11:29:28
*/
public class BusinessException extends RuntimeException {
private static final long serialVersionUID = 1L;
private Integer code;
public BusinessException() {
this(Constants.RESULT_ERROR_MSG);
}
public BusinessException(String message) {
this(Constants.RESULT_ERROR_CODE, message);
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause,
boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
this.code = code;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
}
@@ -47,6 +47,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource @Resource
private LoginRecordService loginRecordService; private LoginRecordService loginRecordService;
/**
* 开放平台接口走独立的安全链与 RS256 验签,不能被本过滤器用内部 HS256 密钥解析。
*
* <p>否则请求会在这里抛出
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures}
* 并被包装成「请退出重新登录」。</p>
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri != null && uri.startsWith("/api/open/");
}
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException { throws ServletException, IOException {
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -20,6 +21,7 @@ import javax.annotation.Resource;
* @since 2020-03-23 18:04:52 * @since 2020-03-23 18:04:52
*/ */
@Configuration @Configuration
@Order(2)
@EnableWebSecurity @EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) @EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter { public class SecurityConfig extends WebSecurityConfigurerAdapter {
@@ -41,11 +43,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/login", "/api/login",
"/api/qr-login/**", "/api/qr-login/**",
"/api/loginByUserId", "/api/loginByUserId",
"/api/loginByEmail",
"/api/register", "/api/register",
"/api/superAdminRegister", "/api/superAdminRegister",
"/api/findAccountByPhone", "/api/findAccountByPhone",
"/api/resetPassword", "/api/resetPassword",
"/api/checkPhoneRegistered", "/api/checkPhoneRegistered",
"/api/checkEmailRegistered",
"/api/existence", "/api/existence",
"/api/oss/upload", "/api/oss/upload",
"/druid/**", "/druid/**",
@@ -53,7 +57,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/webjars/**", "/webjars/**",
"/hxz/v1/**", "/hxz/v1/**",
"/api/sendSmsCaptcha", "/api/sendSmsCaptcha",
"/api/sendSmsCaptchaByAdmin",
"/api/sendEmailCaptcha",
"/api/verifyEmailCaptcha",
"/api/loginBySms", "/api/loginBySms",
"/api/loginBySuperAdminSms",
"/api/loginBySelectTenant",
"/api/loginByDeveloperSms",
"/api/system/user/regByPhone", "/api/system/user/regByPhone",
"/api/parseToken/*", "/api/parseToken/*",
"/api/login-alipay/*", "/api/login-alipay/*",
@@ -86,7 +96,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/system/setting/getByKey/**", "/api/system/setting/getByKey/**",
"/api/system/setting/updateByKey/**", "/api/system/setting/updateByKey/**",
"/lvQ4EoivKJ.txt", "/lvQ4EoivKJ.txt",
"/api/wechat-cert-test" "/api/wechat-cert-test",
"/MP_verify_joj96VBHPtL9YROj.txt"
) )
.permitAll() .permitAll()
.anyRequest() .anyRequest()
@@ -11,7 +11,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.security.cert.CertificateFactory; import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.Date; import java.util.Date;
@@ -135,7 +134,7 @@ public class CertificateService {
log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage()); log.warn("无法获取微信支付证书绝对路径: {}", e.getMessage());
} }
} else { } else {
java.io.File file = new java.io.File(certPath); File file = new File(certPath);
String absolutePath = file.getAbsolutePath(); String absolutePath = file.getAbsolutePath();
log.info("微信支付证书路径模式: FILESYSTEM"); log.info("微信支付证书路径模式: FILESYSTEM");
log.info("微信支付证书完整绝对路径: {}", absolutePath); log.info("微信支付证书完整绝对路径: {}", absolutePath);
@@ -171,7 +170,7 @@ public class CertificateService {
log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage()); log.warn("无法获取支付宝证书绝对路径: {}", e.getMessage());
} }
} else { } else {
java.io.File file = new java.io.File(certPath); File file = new File(certPath);
String absolutePath = file.getAbsolutePath(); String absolutePath = file.getAbsolutePath();
log.info("支付宝证书路径模式: FILESYSTEM"); log.info("支付宝证书路径模式: FILESYSTEM");
log.info("支付宝证书完整绝对路径: {}", absolutePath); log.info("支付宝证书完整绝对路径: {}", absolutePath);
@@ -0,0 +1,174 @@
package com.gxwebsoft.common.core.service;
import cn.hutool.core.util.ObjectUtil;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.common.system.param.PaymentParam;
import com.gxwebsoft.common.system.service.PaymentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 支付配置缓存服务
* 统一管理支付配置的缓存读取,支持 Payment:1* 格式
*
* @author 科技小王子
* @since 2025-07-27
*/
@Slf4j
@Service
public class PaymentCacheService {
@Autowired
private RedisUtil redisUtil;
@Autowired
private PaymentService paymentService;
/**
* 根据支付类型获取支付配置
* 优先从 Payment:1{payType} 格式的缓存读取
*
* @param payType 支付类型 (0=微信支付, 1=支付宝, 2=其他)
* @param tenantId 租户ID (用于兜底查询)
* @return Payment 支付配置
*/
public Payment getPaymentConfig(Integer payType, Integer tenantId) {
// 1. 优先使用 Payment:1{payType} 格式的缓存键
String primaryKey = "Payment:1:" + tenantId;
Payment payment = redisUtil.get(primaryKey, Payment.class);
if (ObjectUtil.isNotEmpty(payment)) {
log.debug("从缓存获取支付配置成功: {}", primaryKey);
return payment;
}
// 2. 如果 Payment:1* 格式不存在,尝试原有格式
String fallbackKey = "Payment:" + payType + ":" + tenantId;
payment = redisUtil.get(fallbackKey, Payment.class);
if (ObjectUtil.isNotEmpty(payment)) {
log.debug("从兜底缓存获取支付配置成功: {}", fallbackKey);
// 将查询结果缓存到 Payment:1* 格式
redisUtil.set(primaryKey, payment);
return payment;
}
// 3. 最后从数据库查询
log.debug("从数据库查询支付配置, payType: {}, tenantId: {}", payType, tenantId);
PaymentParam paymentParam = new PaymentParam();
paymentParam.setType(payType);
paymentParam.setTenantId(tenantId); // 设置租户ID进行过滤
List<Payment> payments = paymentService.listRel(paymentParam);
if (payments.isEmpty()) {
throw new BusinessException("请完成支付配置,支付类型: " + payType);
}
Payment dbPayment = payments.get(0);
// 清理时间字段,避免序列化问题
Payment cachePayment = cleanPaymentForCache(dbPayment);
// 将查询结果缓存到 Payment:1* 格式
redisUtil.set(primaryKey, cachePayment);
log.debug("支付配置已缓存到: {}", primaryKey);
return dbPayment; // 返回原始对象,不影响业务逻辑
}
/**
* 缓存支付配置
* 同时缓存到 Payment:1{payType} 和原有格式
*
* @param payment 支付配置
* @param tenantId 租户ID
*/
public void cachePaymentConfig(Payment payment, Integer tenantId) {
// 缓存到 Payment:1* 格式
String primaryKey = "Payment:1" + payment.getCode();
redisUtil.set(primaryKey, payment);
log.debug("支付配置已缓存到: {}", primaryKey);
// 兼容原有格式
String legacyKey = "Payment:" + payment.getCode() + ":" + tenantId;
redisUtil.set(legacyKey, payment);
log.debug("支付配置已缓存到兼容格式: {}", legacyKey);
}
/**
* 删除支付配置缓存
* 同时删除 Payment:1{payType} 和原有格式
*
* @param paymentCode 支付代码 (可以是String或Integer)
* @param tenantId 租户ID
*/
public void removePaymentConfig(String paymentCode, Integer tenantId) {
// 删除 Payment:1* 格式缓存
String primaryKey = "Payment:1" + paymentCode;
redisUtil.delete(primaryKey);
log.debug("已删除支付配置缓存: {}", primaryKey);
// 删除原有格式缓存
String legacyKey = "Payment:" + paymentCode + ":" + tenantId;
redisUtil.delete(legacyKey);
log.debug("已删除兼容格式缓存: {}", legacyKey);
}
/**
* 获取微信支付配置 (payType = 0)
*/
public Payment getWechatPayConfig(Integer tenantId) {
return getPaymentConfig(0, tenantId);
}
/**
* 获取支付宝配置 (payType = 1)
*/
public Payment getAlipayConfig(Integer tenantId) {
return getPaymentConfig(1, tenantId);
}
/**
* 清理Payment对象用于缓存
* 移除可能导致序列化问题的时间字段
*/
private Payment cleanPaymentForCache(Payment original) {
if (original == null) {
return null;
}
Payment cleaned = new Payment();
// 复制所有业务相关字段
cleaned.setId(original.getId());
cleaned.setName(original.getName());
cleaned.setType(original.getType());
cleaned.setCode(original.getCode());
cleaned.setImage(original.getImage());
cleaned.setWechatType(original.getWechatType());
cleaned.setAppId(original.getAppId());
cleaned.setMchId(original.getMchId());
cleaned.setApiKey(original.getApiKey());
cleaned.setApiclientCert(original.getApiclientCert());
cleaned.setApiclientKey(original.getApiclientKey());
cleaned.setPubKey(original.getPubKey());
cleaned.setPubKeyId(original.getPubKeyId());
cleaned.setMerchantSerialNumber(original.getMerchantSerialNumber());
cleaned.setNotifyUrl(original.getNotifyUrl());
cleaned.setComments(original.getComments());
cleaned.setSortNumber(original.getSortNumber());
cleaned.setStatus(original.getStatus());
cleaned.setDeleted(original.getDeleted());
cleaned.setTenantId(original.getTenantId());
// 不设置时间字段,避免序列化问题
// cleaned.setCreateTime(null);
// cleaned.setUpdateTime(null);
return cleaned;
}
}
@@ -0,0 +1,145 @@
package com.gxwebsoft.common.core.utils;
import cn.hutool.core.codec.Base64;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.UUID;
@Component
public class AliYunSender {
/*
* 计算MD5+BASE64
*/
public static String MD5Base64(String s) {
if (s == null)
return null;
String encodeStr = "";
byte[] utfBytes = s.getBytes();
MessageDigest mdTemp;
try {
mdTemp = MessageDigest.getInstance("MD5");
mdTemp.update(utfBytes);
byte[] md5Bytes = mdTemp.digest();
encodeStr = Base64.encode(md5Bytes);
} catch (Exception e) {
throw new Error("Failed to generate MD5 : " + e.getMessage());
}
return encodeStr;
}
/*
* 计算 HMAC-SHA1
*/
public static String HMACSha1(String data, String key) {
String result;
try {
SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(), "HmacSHA1");
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(data.getBytes());
result = Base64.encode(rawHmac);
} catch (Exception e) {
throw new Error("Failed to generate HMAC : " + e.getMessage());
}
return result;
}
/*
* 获取时间
*/
public static String toGMTString(Date date) {
SimpleDateFormat df = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.UK);
df.setTimeZone(new java.util.SimpleTimeZone(0, "GMT"));
return df.format(date);
}
/*
* 发送POST请求
*/
public static String sendPost(String url, String body, String ak_id, String ak_secret) {
PrintWriter out = null;
BufferedReader in = null;
String result = "";
try {
URL realUrl = new URL(url);
/*
* http header 参数
*/
String method = "POST";
String accept = "application/json";
String content_type = "application/json;chrset=utf-8";
String path = realUrl.getFile();
String date = toGMTString(new Date());
String host = realUrl.getHost();
// 1.对body做MD5+BASE64加密
String bodyMd5 = MD5Base64(body);
String uuid = UUID.randomUUID().toString();
String stringToSign = method + "\n" + accept + "\n" + bodyMd5 + "\n" + content_type + "\n" + date + "\n"
+ "x-acs-signature-method:HMAC-SHA1\n"
+ "x-acs-signature-nonce:" + uuid + "\n"
+ "x-acs-version:2019-01-02\n"
+ path;
// 2.计算 HMAC-SHA1
String signature = HMACSha1(stringToSign, ak_secret);
// 3.得到 authorization header
String authHeader = "acs " + ak_id + ":" + signature;
// 打开和URL之间的连接
URLConnection conn = realUrl.openConnection();
// 设置通用的请求属性
conn.setRequestProperty("Accept", accept);
conn.setRequestProperty("Content-Type", content_type);
conn.setRequestProperty("Content-MD5", bodyMd5);
conn.setRequestProperty("Date", date);
conn.setRequestProperty("Host", host);
conn.setRequestProperty("Authorization", authHeader);
conn.setRequestProperty("x-acs-signature-nonce", uuid);
conn.setRequestProperty("x-acs-signature-method", "HMAC-SHA1");
conn.setRequestProperty("x-acs-version", "2019-01-02"); // 版本可选
// 发送POST请求必须设置如下两行
conn.setDoOutput(true);
conn.setDoInput(true);
// 获取URLConnection对象对应的输出流
out = new PrintWriter(conn.getOutputStream());
// 发送请求参数
out.print(body);
// flush输出流的缓冲
out.flush();
// 定义BufferedReader输入流来读取URL的响应
InputStream is;
HttpURLConnection httpconn = (HttpURLConnection) conn;
if (httpconn.getResponseCode() == 200) {
is = httpconn.getInputStream();
} else {
is = httpconn.getErrorStream();
}
in = new BufferedReader(new InputStreamReader(is));
String line;
while ((line = in.readLine()) != null) {
result += line;
}
} catch (Exception e) {
System.out.println("发送 POST 请求出现异常!" + e);
e.printStackTrace();
}
// 使用finally块来关闭输出流、输入流
finally {
try {
if (out != null) {
out.close();
}
if (in != null) {
in.close();
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
return result;
}
}
@@ -0,0 +1,228 @@
package com.gxwebsoft.common.core.utils;
import com.gxwebsoft.common.core.config.CertificateProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 证书加载工具类
* 支持多种证书加载方式,适配Docker容器化部署
*
* @author 科技小王子
* @since 2025-01-26
*/
@Slf4j
@Component
public class CertificateLoader {
private final CertificateProperties certConfig;
public CertificateLoader(CertificateProperties certConfig) {
this.certConfig = certConfig;
}
@PostConstruct
public void init() {
log.info("证书加载器初始化,加载模式:{}", certConfig.getLoadMode());
if (certConfig.getLoadMode() == CertificateProperties.LoadMode.VOLUME) {
log.info("Docker挂载卷证书路径:{}", certConfig.getCertRootPath());
validateCertDirectory();
}
}
/**
* 验证证书目录是否存在
*/
private void validateCertDirectory() {
File certDir = new File(certConfig.getCertRootPath());
if (!certDir.exists()) {
log.warn("证书目录不存在:{},将尝试创建", certConfig.getCertRootPath());
if (!certDir.mkdirs()) {
log.error("无法创建证书目录:{}", certConfig.getCertRootPath());
}
} else {
log.info("证书目录验证成功:{}", certConfig.getCertRootPath());
}
}
/**
* 加载证书文件路径
*
* @param certPath 证书路径(可能是相对路径、绝对路径或classpath路径)
* @return 实际的证书文件路径
*/
public String loadCertificatePath(String certPath) {
if (!StringUtils.hasText(certPath)) {
throw new IllegalArgumentException("证书路径不能为空");
}
try {
switch (certConfig.getLoadMode()) {
case CLASSPATH:
return loadFromClasspath(certPath);
case VOLUME:
return loadFromVolume(certPath);
case FILESYSTEM:
default:
return loadFromFileSystem(certPath);
}
} catch (Exception e) {
log.error("加载证书失败,路径:{}", certPath, e);
throw new RuntimeException("证书加载失败:" + certPath, e);
}
}
/**
* 从classpath加载证书
*/
private String loadFromClasspath(String certPath) throws IOException {
String resourcePath = certPath.startsWith("classpath:") ?
certPath.substring("classpath:".length()) : certPath;
ClassPathResource resource = new ClassPathResource(resourcePath);
if (!resource.exists()) {
throw new IOException("Classpath中找不到证书文件:" + resourcePath);
}
// 将classpath中的文件复制到临时目录
Path tempFile = Files.createTempFile("cert_", ".pem");
try (InputStream inputStream = resource.getInputStream()) {
Files.copy(inputStream, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
String tempPath = tempFile.toAbsolutePath().toString();
log.debug("从classpath加载证书:{} -> {}", resourcePath, tempPath);
return tempPath;
}
/**
* 从Docker挂载卷加载证书
*/
private String loadFromVolume(String certPath) {
log.debug("尝试从Docker挂载卷加载证书:{}", certPath);
// 如果是完整路径,直接使用
if (certPath.startsWith("/") || certPath.contains(":")) {
File file = new File(certPath);
log.debug("检查完整路径文件是否存在:{}", certPath);
if (file.exists()) {
log.debug("使用完整路径加载证书:{}", certPath);
return certPath;
} else {
log.error("完整路径文件不存在:{}", certPath);
}
}
// 否则拼接挂载卷路径
String fullPath = Paths.get(certConfig.getCertRootPath(), certPath).toString();
File file = new File(fullPath);
if (!file.exists()) {
throw new RuntimeException("Docker挂载卷中找不到证书文件:" + fullPath);
}
log.debug("从Docker挂载卷加载证书:{}", fullPath);
return fullPath;
}
/**
* 从文件系统加载证书
*/
private String loadFromFileSystem(String certPath) {
File file = new File(certPath);
if (!file.exists()) {
throw new RuntimeException("文件系统中找不到证书文件:" + certPath);
}
log.debug("从文件系统加载证书:{}", certPath);
return certPath;
}
/**
* 检查证书文件是否存在
*
* @param certPath 证书路径
* @return 是否存在
*/
public boolean certificateExists(String certPath) {
try {
switch (certConfig.getLoadMode()) {
case CLASSPATH:
String resourcePath = certPath.startsWith("classpath:") ?
certPath.substring("classpath:".length()) : certPath;
ClassPathResource resource = new ClassPathResource(resourcePath);
return resource.exists();
case VOLUME:
String fullPath = certPath.startsWith("/") ? certPath :
Paths.get(certConfig.getCertRootPath(), certPath).toString();
return new File(fullPath).exists();
case FILESYSTEM:
default:
return new File(certPath).exists();
}
} catch (Exception e) {
log.warn("检查证书文件存在性时出错:{}", certPath, e);
return false;
}
}
/**
* 获取证书文件的输入流
*
* @param certPath 证书路径
* @return 输入流
*/
public InputStream getCertificateInputStream(String certPath) throws IOException {
switch (certConfig.getLoadMode()) {
case CLASSPATH:
String resourcePath = certPath.startsWith("classpath:") ?
certPath.substring("classpath:".length()) : certPath;
ClassPathResource resource = new ClassPathResource(resourcePath);
return resource.getInputStream();
case VOLUME:
case FILESYSTEM:
default:
String actualPath = loadCertificatePath(certPath);
return Files.newInputStream(Paths.get(actualPath));
}
}
/**
* 列出证书目录中的所有文件
*
* @return 证书文件列表
*/
public String[] listCertificateFiles() {
try {
switch (certConfig.getLoadMode()) {
case VOLUME:
File certDir = new File(certConfig.getCertRootPath());
if (certDir.exists() && certDir.isDirectory()) {
return certDir.list();
}
break;
case CLASSPATH:
// classpath模式下不支持列出文件
log.warn("Classpath模式下不支持列出证书文件");
break;
case FILESYSTEM:
default:
// 文件系统模式下证书可能分散在不同目录,不支持统一列出
log.warn("文件系统模式下不支持列出证书文件");
break;
}
} catch (Exception e) {
log.error("列出证书文件时出错", e);
}
return new String[0];
}
}
@@ -6,6 +6,7 @@ import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.gxwebsoft.common.core.Constants; import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.utils.JSONUtil;
import com.gxwebsoft.common.system.entity.Role; import com.gxwebsoft.common.system.entity.Role;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
@@ -289,4 +290,20 @@ public class CommonUtil {
return pattern.matcher(phoneNumber).matches(); 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 javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URL; import java.net.URL;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import static com.gxwebsoft.common.core.constants.QRCodeConstants.*; import static com.gxwebsoft.common.core.constants.QRCodeConstants.*;
@@ -77,4 +79,17 @@ public class MyQrCodeUtil {
QrCodeUtil.generate(content, config, FileUtil.file(filePath)); QrCodeUtil.generate(content, config, FileUtil.file(filePath));
return qrcodeUrl; return qrcodeUrl;
} }
/**
* 生成二维码并返回 base64(data:image/png;base64,...),用于前端直接展示,无需写文件
* @param content 二维码内容(如微信支付 code_url)
* @return base64 图片字符串
*/
public static String generateBase64(String content) throws IOException {
QrConfig config = new QrConfig(300, 300);
config.setMargin(1);
ByteArrayOutputStream out = new ByteArrayOutputStream();
QrCodeUtil.generate(content, config, "PNG", out);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
}
} }
@@ -11,7 +11,7 @@ import java.util.HashMap;
@Component @Component
public class RequestUtil { public class RequestUtil {
private static final String SERVER_HOST = "https://server.gxwebsoft.com/api"; private static final String SERVER_HOST = "https://server.websoft.top/api";
private static final String MODULES_HOST = "https://modules.gxwebsoft.com/api"; private static final String MODULES_HOST = "https://modules.gxwebsoft.com/api";
private static String ACCESS_TOKEN; private static String ACCESS_TOKEN;
private static String TENANT_ID; private static String TENANT_ID;
@@ -0,0 +1,314 @@
package com.gxwebsoft.common.core.utils;
import com.gxwebsoft.common.core.config.CertificateProperties;
import com.gxwebsoft.common.system.entity.Payment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
/**
* 微信支付证书诊断工具
* 专门用于诊断和解决证书相关问题
*
* @author 科技小王子
* @since 2025-07-29
*/
@Slf4j
@Component
public class WechatPayCertificateDiagnostic {
private final CertificateProperties certConfig;
private final CertificateLoader certificateLoader;
public WechatPayCertificateDiagnostic(CertificateProperties certConfig, CertificateLoader certificateLoader) {
this.certConfig = certConfig;
this.certificateLoader = certificateLoader;
}
/**
* 全面诊断微信支付证书配置
*
* @param payment 支付配置
* @param tenantId 租户ID
* @param environment 环境(dev/prod)
* @return 诊断结果
*/
public DiagnosticResult diagnoseCertificateConfig(Payment payment, Integer tenantId, String environment) {
DiagnosticResult result = new DiagnosticResult();
log.info("=== 开始微信支付证书诊断 ===");
log.info("租户ID: {}, 环境: {}", tenantId, environment);
try {
// 1. 检查基本配置
checkBasicConfig(payment, result);
// 2. 检查证书文件
checkCertificateFiles(payment, tenantId, environment, result);
// 3. 检查证书内容
validateCertificateContent(payment, tenantId, environment, result);
// 4. 生成建议
generateRecommendations(result);
} catch (Exception e) {
result.addError("诊断过程中发生异常: " + e.getMessage());
log.error("证书诊断异常", e);
}
log.info("=== 证书诊断完成 ===");
return result;
}
/**
* 检查基本配置
*/
private void checkBasicConfig(Payment payment, DiagnosticResult result) {
if (payment == null) {
result.addError("支付配置为空");
return;
}
if (payment.getMchId() == null || payment.getMchId().trim().isEmpty()) {
result.addError("商户号未配置");
} else {
result.addInfo("商户号: " + payment.getMchId());
}
if (payment.getAppId() == null || payment.getAppId().trim().isEmpty()) {
result.addError("应用ID未配置");
} else {
result.addInfo("应用ID: " + payment.getAppId());
}
if (payment.getMerchantSerialNumber() == null || payment.getMerchantSerialNumber().trim().isEmpty()) {
result.addError("商户证书序列号未配置");
} else {
result.addInfo("商户证书序列号: " + payment.getMerchantSerialNumber());
}
if (payment.getApiKey() == null || payment.getApiKey().trim().isEmpty()) {
result.addWarning("数据库中APIv3密钥未配置,将使用配置文件默认值");
} else {
result.addInfo("APIv3密钥: 已配置(" + payment.getApiKey().length() + "位)");
}
}
/**
* 检查证书文件
*/
private void checkCertificateFiles(Payment payment, Integer tenantId, String environment, DiagnosticResult result) {
if ("dev".equals(environment)) {
// 开发环境证书检查
String tenantCertPath = "dev/wechat/" + tenantId;
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
String apiclientCertPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getApiclientCertFile();
// 检查私钥文件
if (certificateLoader.certificateExists(privateKeyPath)) {
result.addInfo("✅ 私钥文件存在: " + privateKeyPath);
try {
String privateKeyFile = certificateLoader.loadCertificatePath(privateKeyPath);
result.addInfo("私钥文件路径: " + privateKeyFile);
} catch (Exception e) {
result.addError("私钥文件加载失败: " + e.getMessage());
}
} else {
result.addError("❌ 私钥文件不存在: " + privateKeyPath);
}
// 检查商户证书文件
if (certificateLoader.certificateExists(apiclientCertPath)) {
result.addInfo("✅ 商户证书文件存在: " + apiclientCertPath);
} else {
result.addWarning("⚠️ 商户证书文件不存在: " + apiclientCertPath + " (自动证书配置不需要此文件)");
}
} else {
// 生产环境证书检查
if (payment.getApiclientKey() != null) {
result.addInfo("私钥文件配置: " + payment.getApiclientKey());
} else {
result.addError("生产环境私钥文件路径未配置");
}
if (payment.getApiclientCert() != null) {
result.addInfo("商户证书文件配置: " + payment.getApiclientCert());
} else {
result.addWarning("生产环境商户证书文件路径未配置 (自动证书配置不需要此文件)");
}
}
}
/**
* 验证证书内容
*/
private void validateCertificateContent(Payment payment, Integer tenantId, String environment, DiagnosticResult result) {
try {
if ("dev".equals(environment)) {
String tenantCertPath = "dev/wechat/" + tenantId;
String apiclientCertPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getApiclientCertFile();
if (certificateLoader.certificateExists(apiclientCertPath)) {
validateX509Certificate(apiclientCertPath, payment.getMerchantSerialNumber(), result);
}
}
} catch (Exception e) {
result.addWarning("证书内容验证失败: " + e.getMessage());
}
}
/**
* 验证X509证书
*/
private void validateX509Certificate(String certPath, String expectedSerialNumber, DiagnosticResult result) {
try {
String actualCertPath = certificateLoader.loadCertificatePath(certPath);
try (InputStream inputStream = new FileInputStream(new File(actualCertPath))) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) cf.generateCertificate(inputStream);
if (cert != null) {
String actualSerialNumber = cert.getSerialNumber().toString(16).toUpperCase();
result.addInfo("证书序列号: " + actualSerialNumber);
result.addInfo("证书有效期: " + cert.getNotBefore() + " 至 " + cert.getNotAfter());
result.addInfo("证书主体: " + cert.getSubjectX500Principal().toString());
// 检查序列号是否匹配
if (expectedSerialNumber != null && !expectedSerialNumber.equalsIgnoreCase(actualSerialNumber)) {
result.addError("证书序列号不匹配! 配置: " + expectedSerialNumber + ", 实际: " + actualSerialNumber);
} else {
result.addInfo("✅ 证书序列号匹配");
}
// 检查证书是否过期
long now = System.currentTimeMillis();
if (now < cert.getNotBefore().getTime()) {
result.addError("证书尚未生效");
} else if (now > cert.getNotAfter().getTime()) {
result.addError("证书已过期");
} else {
result.addInfo("✅ 证书在有效期内");
}
} else {
result.addError("无法解析证书文件");
}
}
} catch (Exception e) {
result.addError("证书验证失败: " + e.getMessage());
}
}
/**
* 生成建议
*/
private void generateRecommendations(DiagnosticResult result) {
if (result.hasErrors()) {
result.addRecommendation("🔧 修复建议:");
String errorText = result.getErrors();
if (errorText.contains("商户号")) {
result.addRecommendation("1. 请在支付配置中设置正确的商户号");
}
if (errorText.contains("序列号")) {
result.addRecommendation("2. 请检查商户证书序列号是否正确,可在微信商户平台查看");
}
if (errorText.contains("证书文件")) {
result.addRecommendation("3. 请确保证书文件已正确放置在指定目录");
}
if (errorText.contains("过期")) {
result.addRecommendation("4. 请更新过期的证书文件");
}
result.addRecommendation("5. 建议使用RSAAutoCertificateConfig自动证书配置,可避免手动管理证书");
result.addRecommendation("6. 确保在微信商户平台开启API安全功能并申请使用微信支付公钥");
} else {
result.addRecommendation("✅ 证书配置正常,建议使用自动证书配置以获得最佳体验");
}
}
/**
* 诊断结果类
*/
public static class DiagnosticResult {
private final StringBuilder errors = new StringBuilder();
private final StringBuilder warnings = new StringBuilder();
private final StringBuilder info = new StringBuilder();
private final StringBuilder recommendations = new StringBuilder();
public void addError(String error) {
if (errors.length() > 0) errors.append("\n");
errors.append(error);
}
public void addWarning(String warning) {
if (warnings.length() > 0) warnings.append("\n");
warnings.append(warning);
}
public void addInfo(String information) {
if (info.length() > 0) info.append("\n");
info.append(information);
}
public void addRecommendation(String recommendation) {
if (recommendations.length() > 0) recommendations.append("\n");
recommendations.append(recommendation);
}
public boolean hasErrors() {
return errors.length() > 0;
}
public String getErrors() {
return errors.toString();
}
public String getWarnings() {
return warnings.toString();
}
public String getInfo() {
return info.toString();
}
public String getRecommendations() {
return recommendations.toString();
}
public String getFullReport() {
StringBuilder report = new StringBuilder();
report.append("=== 微信支付证书诊断报告 ===\n\n");
if (info.length() > 0) {
report.append("📋 基本信息:\n").append(info).append("\n\n");
}
if (warnings.length() > 0) {
report.append("⚠️ 警告:\n").append(warnings).append("\n\n");
}
if (errors.length() > 0) {
report.append("❌ 错误:\n").append(errors).append("\n\n");
}
if (recommendations.length() > 0) {
report.append("💡 建议:\n").append(recommendations).append("\n\n");
}
report.append("=== 诊断报告结束 ===");
return report.toString();
}
}
}
@@ -0,0 +1,223 @@
package com.gxwebsoft.common.core.utils;
import com.gxwebsoft.common.core.config.CertificateProperties;
import com.gxwebsoft.common.system.entity.Payment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 微信支付配置验证工具
*
* @author 科技小王子
* @since 2025-07-27
*/
@Slf4j
@Component
public class WechatPayConfigValidator {
private final CertificateProperties certConfig;
private final CertificateLoader certificateLoader;
@Value("${spring.profiles.active}")
private String activeProfile;
public WechatPayConfigValidator(CertificateProperties certConfig, CertificateLoader certificateLoader) {
this.certConfig = certConfig;
this.certificateLoader = certificateLoader;
}
/**
* 验证微信支付配置
*
* @param payment 支付配置
* @param tenantId 租户ID
* @return 验证结果
*/
public ValidationResult validateWechatPayConfig(Payment payment, Integer tenantId) {
ValidationResult result = new ValidationResult();
log.info("开始验证微信支付配置 - 租户ID: {}", tenantId);
// 1. 验证基本配置
if (payment == null) {
result.addError("支付配置为空");
return result;
}
if (!StringUtils.hasText(payment.getMchId())) {
result.addError("商户号未配置");
}
if (!StringUtils.hasText(payment.getAppId())) {
result.addError("应用ID未配置");
}
if (!StringUtils.hasText(payment.getMerchantSerialNumber())) {
result.addError("商户证书序列号未配置");
}
// 2. 验证 APIv3 密钥
String apiV3Key = getValidApiV3Key(payment);
if (!StringUtils.hasText(apiV3Key)) {
result.addError("APIv3密钥未配置");
} else {
validateApiV3Key(apiV3Key, result);
}
// 3. 验证证书文件
validateCertificateFiles(tenantId, result);
// 4. 记录验证结果
if (result.isValid()) {
log.info("✅ 微信支付配置验证通过 - 租户ID: {}", tenantId);
} else {
log.error("❌ 微信支付配置验证失败 - 租户ID: {}, 错误: {}", tenantId, result.getErrors());
}
return result;
}
/**
* 获取有效的 APIv3 密钥
* 优先使用数据库配置,如果为空则使用配置文件默认值
*/
public String getValidApiV3Key(Payment payment) {
String apiV3Key = payment.getApiKey();
if (!StringUtils.hasText(apiV3Key)) {
apiV3Key = certConfig.getWechatPay().getDev().getApiV3Key();
log.warn("数据库中APIv3密钥为空,使用配置文件默认值");
}
return apiV3Key;
}
/**
* 验证 APIv3 密钥格式
*/
private void validateApiV3Key(String apiV3Key, ValidationResult result) {
if (apiV3Key.length() != 32) {
result.addError("APIv3密钥长度错误,应为32位,实际为: " + apiV3Key.length());
}
if (!apiV3Key.matches("^[a-zA-Z0-9]+$")) {
result.addError("APIv3密钥格式错误,应仅包含字母和数字");
}
log.info("APIv3密钥验证 - 长度: {}, 格式: {}",
apiV3Key.length(),
apiV3Key.matches("^[a-zA-Z0-9]+$") ? "正确" : "错误");
}
/**
* 验证证书文件
*/
private void validateCertificateFiles(Integer tenantId, ValidationResult result) {
if ("dev".equals(activeProfile)) {
// 开发环境证书验证
String tenantCertPath = "dev/wechat/" + tenantId;
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
if (!certificateLoader.certificateExists(privateKeyPath)) {
result.addError("证书文件不存在: " + privateKeyPath);
return;
}
try {
certificateLoader.loadCertificatePath(privateKeyPath);
log.info("✅ 开发环境证书文件验证通过: {}", privateKeyPath);
} catch (Exception e) {
result.addError("证书文件加载失败: " + e.getMessage());
}
} else {
// 生产环境证书验证 - 跳过文件存在性检查,因为证书路径来自数据库
log.info("✅ 生产环境跳过证书文件存在性验证,使用数据库配置的证书路径");
}
}
/**
* 验证结果类
*/
public static class ValidationResult {
private boolean valid = true;
private StringBuilder errors = new StringBuilder();
public void addError(String error) {
this.valid = false;
if (errors.length() > 0) {
errors.append("; ");
}
errors.append(error);
}
public boolean isValid() {
return valid;
}
public String getErrors() {
return errors.toString();
}
public void logErrors() {
if (!valid) {
log.error("配置验证失败: {}", errors.toString());
}
}
}
/**
* 生成配置诊断报告
*/
public String generateDiagnosticReport(Payment payment, Integer tenantId) {
StringBuilder report = new StringBuilder();
report.append("=== 微信支付配置诊断报告 ===\n");
report.append("租户ID: ").append(tenantId).append("\n");
if (payment != null) {
report.append("商户号: ").append(payment.getMchId()).append("\n");
report.append("应用ID: ").append(payment.getAppId()).append("\n");
report.append("商户证书序列号: ").append(payment.getMerchantSerialNumber()).append("\n");
String dbApiKey = payment.getApiKey();
String configApiKey = certConfig.getWechatPay().getDev().getApiV3Key();
report.append("数据库APIv3密钥: ").append(dbApiKey != null ? "已配置(" + dbApiKey.length() + "位)" : "未配置").append("\n");
report.append("配置文件APIv3密钥: ").append(configApiKey != null ? "已配置(" + configApiKey.length() + "位)" : "未配置").append("\n");
String finalApiKey = getValidApiV3Key(payment);
report.append("最终使用APIv3密钥: ").append(finalApiKey != null ? "已配置(" + finalApiKey.length() + "位)" : "未配置").append("\n");
} else {
report.append("❌ 支付配置为空\n");
}
// 证书文件检查
report.append("当前环境: ").append(activeProfile).append("\n");
if ("dev".equals(activeProfile)) {
String tenantCertPath = "dev/wechat/" + tenantId;
String privateKeyPath = tenantCertPath + "/" + certConfig.getWechatPay().getDev().getPrivateKeyFile();
boolean certExists = certificateLoader.certificateExists(privateKeyPath);
report.append("开发环境证书文件路径: ").append(privateKeyPath).append("\n");
report.append("证书文件存在: ").append(certExists ? "是" : "否").append("\n");
} else {
report.append("生产环境证书路径: 从数据库配置获取\n");
if (payment != null) {
report.append("私钥文件: ").append(payment.getApiclientKey()).append("\n");
report.append("证书文件: ").append(payment.getApiclientCert()).append("\n");
}
}
ValidationResult validation = validateWechatPayConfig(payment, tenantId);
report.append("配置验证结果: ").append(validation.isValid() ? "通过" : "失败").append("\n");
if (!validation.isValid()) {
report.append("验证错误: ").append(validation.getErrors()).append("\n");
}
report.append("=== 诊断报告结束 ===");
return report.toString();
}
}
@@ -0,0 +1,222 @@
package com.gxwebsoft.common.core.utils;
import com.gxwebsoft.common.system.entity.Payment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
/**
* 微信支付配置诊断工具
* 用于排查微信支付签名验证失败等问题
*
* @author 科技小王子
* @since 2025-07-27
*/
@Slf4j
@Component
public class WechatPayDiagnostic {
/**
* 诊断微信支付配置
*
* @param payment 支付配置
* @param privateKeyPath 私钥路径
* @param environment 环境标识
*/
public void diagnosePaymentConfig(Payment payment, String privateKeyPath, String environment) {
log.info("=== 微信支付配置诊断开始 ===");
log.info("环境: {}", environment);
// 1. 检查支付配置基本信息
checkBasicConfig(payment);
// 2. 检查证书文件
checkCertificateFiles(payment, privateKeyPath, environment);
// 3. 检查配置完整性
checkConfigCompleteness(payment);
log.info("=== 微信支付配置诊断结束 ===");
}
/**
* 检查基本配置信息
*/
private void checkBasicConfig(Payment payment) {
log.info("--- 基本配置检查 ---");
if (payment == null) {
log.error("❌ 支付配置为空");
return;
}
log.info("支付配置ID: {}", payment.getId());
log.info("支付方式名称: {}", payment.getName());
log.info("支付类型: {}", payment.getType());
log.info("支付代码: {}", payment.getCode());
log.info("状态: {}", payment.getStatus());
// 检查关键字段
checkField("应用ID", payment.getAppId());
checkField("商户号", payment.getMchId());
checkField("商户证书序列号", payment.getMerchantSerialNumber());
checkField("API密钥", payment.getApiKey(), true);
}
/**
* 检查证书文件
*/
private void checkCertificateFiles(Payment payment, String privateKeyPath, String environment) {
log.info("--- 证书文件检查 ---");
// 检查私钥文件
if (privateKeyPath != null) {
checkFileExists("私钥文件", privateKeyPath);
}
// 生产环境检查证书文件
if (!"dev".equals(environment)) {
if (payment.getApiclientCert() != null) {
log.info("商户证书文件配置: {}", payment.getApiclientCert());
}
if (payment.getPubKey() != null) {
log.info("公钥文件配置: {}", payment.getPubKey());
log.info("公钥ID: {}", payment.getPubKeyId());
}
}
}
/**
* 检查配置完整性
*/
private void checkConfigCompleteness(Payment payment) {
log.info("--- 配置完整性检查 ---");
boolean isComplete = true;
if (isEmpty(payment.getMchId())) {
log.error("❌ 商户号未配置");
isComplete = false;
}
if (isEmpty(payment.getMerchantSerialNumber())) {
log.error("❌ 商户证书序列号未配置");
isComplete = false;
}
if (isEmpty(payment.getApiKey())) {
log.error("❌ API密钥未配置");
isComplete = false;
}
if (isEmpty(payment.getAppId())) {
log.error("❌ 应用ID未配置");
isComplete = false;
}
if (isComplete) {
log.info("✅ 配置完整性检查通过");
} else {
log.error("❌ 配置不完整,请补充缺失的配置项");
}
}
/**
* 检查字段是否为空
*/
private void checkField(String fieldName, String value) {
checkField(fieldName, value, false);
}
/**
* 检查字段是否为空
*/
private void checkField(String fieldName, String value, boolean isSensitive) {
if (isEmpty(value)) {
log.warn("⚠️ {}: 未配置", fieldName);
} else {
if (isSensitive) {
log.info("✅ {}: 已配置(长度:{})", fieldName, value.length());
} else {
log.info("✅ {}: {}", fieldName, value);
}
}
}
/**
* 检查文件是否存在
*/
private void checkFileExists(String fileName, String filePath) {
try {
File file = new File(filePath);
if (file.exists() && file.isFile()) {
log.info("✅ {}: 文件存在 - {}", fileName, filePath);
log.info(" 文件大小: {} bytes", file.length());
// 检查文件内容格式
if (filePath.endsWith(".pem")) {
checkPemFileFormat(fileName, filePath);
}
} else {
log.error("❌ {}: 文件不存在 - {}", fileName, filePath);
}
} catch (Exception e) {
log.error("❌ {}: 检查文件时出错 - {} ({})", fileName, filePath, e.getMessage());
}
}
/**
* 检查PEM文件格式
*/
private void checkPemFileFormat(String fileName, String filePath) {
try {
String content = Files.readString(Paths.get(filePath));
if (content.contains("-----BEGIN") && content.contains("-----END")) {
log.info("✅ {}: PEM格式正确", fileName);
} else {
log.warn("⚠️ {}: PEM格式可能有问题", fileName);
}
} catch (Exception e) {
log.warn("⚠️ {}: 无法读取文件内容进行格式检查 ({})", fileName, e.getMessage());
}
}
/**
* 检查字符串是否为空
*/
private boolean isEmpty(String str) {
return str == null || str.trim().isEmpty();
}
/**
* 生成诊断报告
*/
public String generateDiagnosticReport(Payment payment, String environment) {
StringBuilder report = new StringBuilder();
report.append("🔍 微信支付配置诊断报告\n");
report.append("========================\n\n");
report.append("环境: ").append(environment).append("\n");
report.append("租户ID: ").append(payment != null ? payment.getTenantId() : "未知").append("\n");
report.append("商户号: ").append(payment != null ? payment.getMchId() : "未配置").append("\n");
report.append("应用ID: ").append(payment != null ? payment.getAppId() : "未配置").append("\n\n");
report.append("🚨 常见问题排查:\n");
report.append("1. 商户证书序列号是否正确\n");
report.append("2. APIv3密钥是否正确\n");
report.append("3. 私钥文件是否正确\n");
report.append("4. 微信支付平台证书是否过期\n");
report.append("5. 网络连接是否正常\n\n");
report.append("💡 建议解决方案:\n");
report.append("1. 使用自动证书配置(RSAAutoCertificateConfig)\n");
report.append("2. 在微信商户平台重新下载证书\n");
report.append("3. 检查商户平台API安全设置\n");
return report.toString();
}
}
@@ -0,0 +1,111 @@
package com.gxwebsoft.common.core.utils;
import java.nio.charset.StandardCharsets;
/**
* 微信支付工具类
* 处理微信支付API的字段限制和格式要求
*
* @author 科技小王子
* @since 2025-01-11
*/
public class WechatPayUtils {
/**
* 微信支付description字段的最大字节数限制
*/
public static final int DESCRIPTION_MAX_BYTES = 127;
/**
* 微信支付attach字段的最大字节数限制
*/
public static final int ATTACH_MAX_BYTES = 127;
/**
* 截断字符串以确保字节数不超过指定限制
* 主要用于微信支付API的字段限制处理
*
* @param text 原始文本
* @param maxBytes 最大字节数
* @return 截断后的文本,确保UTF-8字符完整性
*/
public static String truncateToByteLimit(String text, int maxBytes) {
if (text == null || text.isEmpty()) {
return text;
}
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
if (bytes.length <= maxBytes) {
return text;
}
// 截断字节数组,但要确保不会截断UTF-8字符的中间
int truncateLength = maxBytes;
while (truncateLength > 0) {
byte[] truncated = new byte[truncateLength];
System.arraycopy(bytes, 0, truncated, 0, truncateLength);
try {
String result = new String(truncated, StandardCharsets.UTF_8);
// 检查是否有无效字符(被截断的UTF-8字符)
if (!result.contains("\uFFFD")) {
return result;
}
} catch (Exception e) {
// 继续尝试更短的长度
}
truncateLength--;
}
return ""; // 如果无法安全截断,返回空字符串
}
/**
* 处理微信支付商品描述字段
* 确保字节数不超过127字节
*
* @param description 商品描述
* @return 处理后的描述,符合微信支付要求
*/
public static String processDescription(String description) {
return truncateToByteLimit(description, DESCRIPTION_MAX_BYTES);
}
/**
* 处理微信支付附加数据字段
* 确保字节数不超过127字节
*
* @param attach 附加数据
* @return 处理后的附加数据,符合微信支付要求
*/
public static String processAttach(String attach) {
return truncateToByteLimit(attach, ATTACH_MAX_BYTES);
}
/**
* 验证字符串是否符合微信支付字段的字节限制
*
* @param text 待验证的文本
* @param maxBytes 最大字节数限制
* @return true如果符合限制,false如果超出限制
*/
public static boolean isWithinByteLimit(String text, int maxBytes) {
if (text == null) {
return true;
}
return text.getBytes(StandardCharsets.UTF_8).length <= maxBytes;
}
/**
* 获取字符串的UTF-8字节数
*
* @param text 文本
* @return 字节数
*/
public static int getByteLength(String text) {
if (text == null) {
return 0;
}
return text.getBytes(StandardCharsets.UTF_8).length;
}
}
@@ -72,7 +72,7 @@ public class WxOfficialUtil {
} }
public String getCodeUrl() throws UnsupportedEncodingException { public String getCodeUrl() throws UnsupportedEncodingException {
String encodedReturnUrl = URLEncoder.encode("https://server.gxwebsoft.com/api/open/wx-official/accessToken","UTF-8"); String encodedReturnUrl = URLEncoder.encode("https://server.websoft.top/api/open/wx-official/accessToken","UTF-8");
return "https://open.weixin.qq.com/connect/oauth2/authorize?appid="+ this.appId +"&redirect_uri=" + encodedReturnUrl + "&response_type=code&scope=snsapi_userinfo&state="+ this.tenantId +"#wechat_redirect"; return "https://open.weixin.qq.com/connect/oauth2/authorize?appid="+ this.appId +"&redirect_uri=" + encodedReturnUrl + "&response_type=code&scope=snsapi_userinfo&state="+ this.tenantId +"#wechat_redirect";
} }
@@ -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;
}
}
@@ -0,0 +1,145 @@
package com.gxwebsoft.common.mq.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.AcknowledgeMode;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置类
*/
@Configuration
@ConditionalOnProperty(name = "sync.mq.enabled", havingValue = "true", matchIfMissing = true)
public class RabbitMQConfig {
// ==================== 常量定义 ====================
public static final String SYNC_EXCHANGE = "sync.topic.exchange";
public static final String SYNC_QUEUE = "sync.queue";
public static final String SYNC_ROUTING_KEY = "sync.message";
// 死信队列
public static final String DLX_EXCHANGE = "sync.dlx.exchange";
public static final String DLQ_QUEUE = "sync.dlq";
public static final String DLQ_ROUTING_KEY = "sync.dlq";
@Value("${spring.rabbitmq.host:localhost}")
private String host;
@Value("${spring.rabbitmq.port:5672}")
private int port;
@Value("${spring.rabbitmq.username:guest}")
private String username;
@Value("${spring.rabbitmq.password:guest}")
private String password;
@Value("${spring.rabbitmq.virtual-host:/}")
private String virtualHost;
// ==================== Connection Factory ====================
@Bean
public ConnectionFactory connectionFactory() {
CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
connectionFactory.setHost(host);
connectionFactory.setPort(port);
connectionFactory.setUsername(username);
connectionFactory.setPassword(password);
connectionFactory.setVirtualHost(virtualHost);
// 开启publisher-confirm确认模式
connectionFactory.setPublisherConfirmType(CachingConnectionFactory.ConfirmType.CORRELATED);
// 开启publisher-return确认模式
connectionFactory.setPublisherReturns(true);
return connectionFactory;
}
// ==================== Message Converter ====================
@Bean
public MessageConverter messageConverter(ObjectMapper objectMapper) {
return new Jackson2JsonMessageConverter(objectMapper);
}
// ==================== RabbitTemplate ====================
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, MessageConverter messageConverter) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(messageConverter);
// 设置Mandatory为true,才能触发ReturnCallback
rabbitTemplate.setMandatory(true);
return rabbitTemplate;
}
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory connectionFactory, MessageConverter messageConverter) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(messageConverter);
// 设置并发数
factory.setConcurrentConsumers(1);
factory.setMaxConcurrentConsumers(5);
// 设置手动ack
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
// 预取数量
factory.setPrefetchCount(10);
return factory;
}
// ==================== 交换机 ====================
/**
* 用户同步 Topic Exchange
* 使用 Topic 类型,支持按 targetSystem 路由到不同队列
* routing key 格式: user.sync.{targetSystem}
* 各子系统可以绑定自己的队列来消费消息
*/
@Bean
public TopicExchange syncExchange() {
return new TopicExchange(SYNC_EXCHANGE, true, false);
}
@Bean
public DirectExchange deadLetterExchange() {
return new DirectExchange(DLX_EXCHANGE, true, false);
}
// ==================== 队列 ====================
/**
* 注意:core 系统只负责发送消息,不消费消息
* 各子系统(websopy等)需要在自己的系统中配置消费者和队列
*
* 如果 core 系统也需要消费某些消息,可以在这里添加对应的队列
*/
@Bean
public Queue deadLetterQueue() {
return QueueBuilder.durable(DLQ_QUEUE).build();
}
// ==================== 绑定 ====================
@Bean
public Binding dlqBinding() {
return BindingBuilder.bind(deadLetterQueue())
.to(deadLetterExchange())
.with(DLQ_ROUTING_KEY);
}
}
@@ -0,0 +1,80 @@
package com.gxwebsoft.common.mq.message;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 统一消息实体 - 用于各模块间的数据同步
*/
@Data
public class SyncMessage implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 消息唯一ID
*/
private String messageId;
/**
* 消息类型:USER_SYNC, TENANT_SYNC, etc.
*/
private String messageType;
/**
* 事件类型:CREATE, UPDATE, DELETE
*/
private String eventType;
/**
* 目标系统标识
*/
private String targetSystem;
/**
* 业务数据(Map格式,便于序列化)
*/
private Map<String, Object> data;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 消息重试次数
*/
private Integer retryCount;
public SyncMessage() {
this.createTime = LocalDateTime.now();
this.retryCount = 0;
}
public SyncMessage(String messageType, String eventType, String targetSystem, Map<String, Object> data) {
this();
this.messageId = java.util.UUID.randomUUID().toString().replace("-", "");
this.messageType = messageType;
this.eventType = eventType;
this.targetSystem = targetSystem;
this.data = data;
}
/**
* 创建用户同步消息
*/
public static SyncMessage userCreate(String targetSystem, Map<String, Object> userData) {
return new SyncMessage("USER_SYNC", "CREATE", targetSystem, userData);
}
public static SyncMessage userUpdate(String targetSystem, Map<String, Object> userData) {
return new SyncMessage("USER_SYNC", "UPDATE", targetSystem, userData);
}
public static SyncMessage userDelete(String targetSystem, Map<String, Object> userData) {
return new SyncMessage("USER_SYNC", "DELETE", targetSystem, userData);
}
}
@@ -0,0 +1,41 @@
package com.gxwebsoft.common.mq.producer;
import com.gxwebsoft.common.mq.message.SyncMessage;
/**
* 消息生产者接口 - 预留抽象层,便于将来切换MQ实现(如从RabbitMQ迁移到RocketMQ)
*/
public interface SyncMessageProducer {
/**
* 发送同步消息
*
* @param message 消息体
*/
void sendSyncMessage(SyncMessage message);
/**
* 发送同步消息(带回调)
*
* @param message 消息体
* @param callback 发送回调
*/
void sendSyncMessage(SyncMessage message, SendCallback callback);
/**
* 发送用户同步消息
*
* @param targetSystem 目标系统
* @param eventType 事件类型:CREATE, UPDATE, DELETE
* @param userData 用户数据
*/
void sendUserSyncMessage(String targetSystem, String eventType, Object userData);
/**
* 发送回调接口
*/
interface SendCallback {
void onSuccess(String messageId);
void onFailure(String messageId, Throwable throwable);
}
}
@@ -0,0 +1,155 @@
package com.gxwebsoft.common.mq.producer.impl;
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;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* RabbitMQ 消息生产者实现
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "sync.mq.enabled", havingValue = "true", matchIfMissing = true)
public class RabbitMQSyncProducer implements SyncMessageProducer, RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {
private final RabbitTemplate rabbitTemplate;
private final MessageConverter messageConverter;
private final ObjectMapper objectMapper;
public RabbitMQSyncProducer(RabbitTemplate rabbitTemplate, MessageConverter messageConverter, ObjectMapper objectMapper) {
this.rabbitTemplate = rabbitTemplate;
this.messageConverter = messageConverter;
this.objectMapper = objectMapper;
// 设置确认回调
this.rabbitTemplate.setConfirmCallback(this);
this.rabbitTemplate.setReturnCallback(this);
}
@Override
public void sendSyncMessage(SyncMessage message) {
sendSyncMessage(message, null);
}
@Override
public void sendSyncMessage(SyncMessage message, SendCallback callback) {
try {
log.info("发送MQ消息: messageId={}, type={}, event={}, target={}",
message.getMessageId(), message.getMessageType(),
message.getEventType(), message.getTargetSystem());
CorrelationData correlationData = new CorrelationData(message.getMessageId());
if (callback != null) {
correlationData.getFuture().addCallback(
result -> {
if (result.isAck()) {
callback.onSuccess(message.getMessageId());
} else {
callback.onFailure(message.getMessageId(),
new RuntimeException("消息发送未被确认"));
}
},
ex -> callback.onFailure(message.getMessageId(), ex)
);
}
// 使用 targetSystem 作为 routing key
// 格式: user.sync.{targetSystem}
// 各子系统绑定队列时使用 pattern: user.sync.{systemName}
String routingKey = buildRoutingKey(message.getTargetSystem());
rabbitTemplate.convertAndSend(
RabbitMQConfig.SYNC_EXCHANGE,
routingKey,
message,
correlationData
);
} catch (Exception e) {
log.error("发送MQ消息失败: messageId={}, error={}", message.getMessageId(), e.getMessage(), e);
if (callback != null) {
callback.onFailure(message.getMessageId(), e);
}
}
}
/**
* 构建 routing key
* 格式: user.sync.{targetSystem}
*/
private String buildRoutingKey(String targetSystem) {
if (targetSystem == null || targetSystem.isEmpty()) {
return "user.sync.all";
}
return "user.sync." + targetSystem.toLowerCase();
}
@Override
public void sendUserSyncMessage(String targetSystem, String eventType, Object userData) {
try {
Map<String, Object> dataMap;
if (userData instanceof Map) {
dataMap = (Map<String, Object>) userData;
} else {
// 转换为Map
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) {
log.error("发送用户同步消息失败: targetSystem={}, eventType={}, error={}",
targetSystem, eventType, e.getMessage(), e);
}
}
/**
* 确认回调 - Broker确认收到消息
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String messageId = correlationData.getId();
if (ack) {
log.debug("消息确认成功: messageId={}", messageId);
} else {
log.warn("消息确认失败: messageId={}, cause={}", messageId, cause);
}
}
/**
* Return回调 - 消息无法路由时回调
*/
@Override
public void returnedMessage(Message message, int replyCode, String replyText,
String exchange, String routingKey) {
log.error("消息无法路由: exchange={}, routingKey={}, replyCode={}, replyText={}",
exchange, routingKey, replyCode, replyText);
}
}
@@ -7,6 +7,8 @@ import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS; import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder; import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException; 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.CredentialsProvider;
import com.aliyun.oss.common.auth.DefaultCredentialProvider; import com.aliyun.oss.common.auth.DefaultCredentialProvider;
import com.aliyun.oss.common.utils.BinaryUtil; 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.annotation.OperationLog;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.utils.FileServerUtil; 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.utils.RedisUtil;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
@@ -35,6 +38,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -96,6 +101,10 @@ public class AliOssController extends BaseController {
String bucketName = settingInfo.getString("bucketName"); String bucketName = settingInfo.getString("bucketName");
String accessKeyId = settingInfo.getString("accessKeyId"); String accessKeyId = settingInfo.getString("accessKeyId");
String accessKeySecret = settingInfo.getString("accessKeySecret"); String accessKeySecret = settingInfo.getString("accessKeySecret");
String uploadMethod = settingInfo.getString("uploadMethod");
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
endpoint = MinioUtil.normalizeEndpoint(endpoint);
// 判断是否登录 // 判断是否登录
String authorization = getAuthorization(); String authorization = getAuthorization();
@@ -112,9 +121,21 @@ public class AliOssController extends BaseController {
// 上传文件结果 // 上传文件结果
FileRecord result; 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); CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
// 创建OSSClient实例。 ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider); }
try { try {
@@ -123,16 +144,18 @@ public class AliOssController extends BaseController {
String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length()); String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length());
String originalName = file.getOriginalFilename(); String originalName = file.getOriginalFilename();
// 创建PutObjectRequest对象。 // 上传文件到对象存储。
if (isMinio) {
// minio-java:path 此时无前导 "/",作为对象名(如 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); PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload);
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。 ossClient.putObject(putObjectRequest);
// 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);
// 保存记录并返回 // 保存记录并返回
result = new FileRecord(); result = new FileRecord();
@@ -155,11 +178,39 @@ public class AliOssController extends BaseController {
result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName); result.setName(StrUtil.isBlank(originalName) ? upload.getName() : originalName);
result.setLength(upload.length()); result.setLength(upload.length());
result.setPath(bucketDomain + path); 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); result.setDownloadUrl(bucketDomain + path);
// 获取文件类型
String contentType = FileServerUtil.getContentType(upload); String contentType = FileServerUtil.getContentType(upload);
result.setContentType(contentType); 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)); result.setTenantId(Integer.valueOf(tenantId));
upload.delete(); upload.delete();
fileRecordService.save(result); fileRecordService.save(result);
@@ -186,10 +237,15 @@ public class AliOssController extends BaseController {
+ "a serious internal problem while trying to communicate with OSS, " + "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network."); + "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage()); 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 { } finally {
if (ossClient != null) { if (ossClient != null) {
ossClient.shutdown(); ossClient.shutdown();
} }
// MinioClient 内部复用 HttpClient 连接池,无需显式关闭
} }
return fail("上传失败", null); return fail("上传失败", null);
} }
@@ -61,11 +61,11 @@ public class DictDataController extends BaseController {
.eq(DictData::getDictDataName, dictData.getDictDataName())) > 0) { .eq(DictData::getDictDataName, dictData.getDictDataName())) > 0) {
return fail("字典数据名称已存在"); return fail("字典数据名称已存在");
} }
if (dictDataService.count(new LambdaQueryWrapper<DictData>() // if (dictDataService.count(new LambdaQueryWrapper<DictData>()
.eq(DictData::getDictId, dictData.getDictId()) // .eq(DictData::getDictId, dictData.getDictId())
.eq(DictData::getDictDataCode, dictData.getDictDataCode())) > 0) { // .eq(DictData::getDictDataCode, dictData.getDictDataCode())) > 0) {
return fail("字典数据标识已存在"); // return fail("字典数据标识已存在");
} // }
// 自动添加字典 // 自动添加字典
final int count = dictService.count(new LambdaQueryWrapper<Dict>().eq(Dict::getDictCode, dictData.getDictCode())); final int count = dictService.count(new LambdaQueryWrapper<Dict>().eq(Dict::getDictCode, dictData.getDictCode()));
if (dictData.getDictCode() != null && count == 0) { if (dictData.getDictCode() != null && count == 0) {
@@ -95,12 +95,12 @@ public class DictDataController extends BaseController {
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) { .ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
return fail("字典数据名称已存在"); return fail("字典数据名称已存在");
} }
if (dictDataService.count(new LambdaQueryWrapper<DictData>() // if (dictDataService.count(new LambdaQueryWrapper<DictData>()
.eq(DictData::getDictId, dictData.getDictId()) // .eq(DictData::getDictId, dictData.getDictId())
.eq(DictData::getDictDataCode, dictData.getDictDataCode()) // .eq(DictData::getDictDataCode, dictData.getDictDataCode())
.ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) { // .ne(DictData::getDictDataId, dictData.getDictDataId())) > 0) {
return fail("字典数据标识已存在"); // return fail("字典数据标识已存在");
} // }
if (dictDataService.updateById(dictData)) { if (dictDataService.updateById(dictData)) {
return success("修改成功"); return success("修改成功");
} }
@@ -29,9 +29,11 @@ public class EmailTestController extends BaseController {
public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) { public ApiResult<?> testRegisterSuccessEmail(@RequestParam String email) {
try { try {
emailTemplateUtil.sendRegisterSuccessEmail( emailTemplateUtil.sendRegisterSuccessEmail(
"测试用户", "广西河马科技有限公司",
"13800138000", "https://site-10654.sitelnk.cn",
"TestPassword123", "https://site.websoft.top",
email,
"F!huhtL3fqD8",
email, email,
getTenantId() getTenantId()
); );
File diff suppressed because it is too large Load Diff
@@ -226,6 +226,7 @@ public class MenuController extends BaseController {
} }
companyService.updateById(company); companyService.updateById(company);
loginUser.setInstalled(true); loginUser.setInstalled(true);
loginUser.setIsSuperAdmin(true);
userService.updateById(loginUser); userService.updateById(loginUser);
return success("安装成功"); 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.MenuService;
import com.gxwebsoft.common.system.service.OrderGoodsService; import com.gxwebsoft.common.system.service.OrderGoodsService;
import com.gxwebsoft.common.system.service.OrderService; 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.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -47,6 +48,18 @@ public class OrderController extends BaseController {
private MenuService menuService; private MenuService menuService;
@Resource @Resource
private MenuMapper menuMapper; 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 = "分页查询订单") @Operation(summary = "分页查询订单")
@GetMapping("/page") @GetMapping("/page")
@@ -94,6 +107,9 @@ public class OrderController extends BaseController {
if(loginUser == null){ if(loginUser == null){
return fail("请先登录"); return fail("请先登录");
} }
if (!checkUserAuditStatus(loginUser)) {
return fail("您的账号尚未通过审核,暂时无法下单");
}
if (ObjectUtil.isEmpty(order.getType())) { if (ObjectUtil.isEmpty(order.getType())) {
return fail("订单类型不能为空"); return fail("订单类型不能为空");
} }
@@ -184,6 +200,9 @@ public class OrderController extends BaseController {
// 记录当前登录用户id // 记录当前登录用户id
User loginUser = getLoginUser(); User loginUser = getLoginUser();
if (loginUser != null) { if (loginUser != null) {
if (!checkUserAuditStatus(loginUser)) {
return fail("您的账号尚未通过审核,暂时无法下单");
}
// 封装订单数据 // 封装订单数据
final Order order = new Order(); final Order order = new Order();
order.setType(cart.getType()); order.setType(cart.getType());
@@ -111,6 +111,10 @@ public class SettingController extends BaseController {
// 更新系统设置信息到缓存 key = "" // 更新系统设置信息到缓存 key = ""
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString()); String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
redisUtil.set(key, JSON.parseObject(setting.getContent())); redisUtil.set(key, JSON.parseObject(setting.getContent()));
// 上传配置变更后需同步清除上传缓存,避免 /api/oss/upload 继续命中旧配置
if ("upload".equals(setting.getSettingKey())) {
redisUtil.delete("Upload:" + loginUser.getTenantId());
}
// 更新租户信息 // 更新租户信息
if (setting.getSettingKey().equals("setting")) { if (setting.getSettingKey().equals("setting")) {
final String content = setting.getContent(); final String content = setting.getContent();
@@ -198,8 +202,9 @@ public class SettingController extends BaseController {
if(loginUser == null){ if(loginUser == null){
return fail("请先登录"); return fail("请先登录");
} }
if(!loginUser.getIsSuperAdmin()){ // getIsSuperAdmin() is a Boolean and may be null; avoid NPE from auto-unboxing.
return fail("权限不足"); if(!"superAdmin".equals(loginUser.getUsername())){
return fail("权限不足!");
} }
// 转换为Setting对象 // 转换为Setting对象
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.utils.CommonUtil; import com.gxwebsoft.common.core.utils.CommonUtil;
@@ -17,6 +18,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.annotation.OperationLog;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -49,11 +52,36 @@ public class TenantController extends BaseController {
private RedisUtil redisUtil; private RedisUtil redisUtil;
@Resource @Resource
private UserService userService; private UserService userService;
// cms权限参考租户, 默认取平台租户
@Value("${websoft.cms.ref-tenant-id:10257}")
private Integer cmsRefTenantId;
@Operation(summary = "分页查询租户") @Operation(summary = "分页查询租户")
@GetMapping("/page") @GetMapping("/page")
public ApiResult<PageResult<Tenant>> page(TenantParam param) { public ApiResult<PageResult<Tenant>> page(TenantParam param) {
return success(tenantService.pageRel(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 && loginUser.getUserId() != null) {
param.setUserId(loginUser.getUserId());
}
}
PageResult<Tenant> result = tenantService.pageRel(param);
// 如果传入 mask=false,设置不脱敏
if (param.getMask() != null && !param.getMask()) {
if (result.getList() != null) {
for (Tenant tenant : result.getList()) {
tenant.setPhoneMasked(false);
}
}
}
return success(result);
} }
@PreAuthorize("hasAuthority('sys:tenant:list')") @PreAuthorize("hasAuthority('sys:tenant:list')")
@@ -64,7 +92,6 @@ public class TenantController extends BaseController {
return success(tenantService.listRel(param)); return success(tenantService.listRel(param));
} }
@PreAuthorize("hasAuthority('sys:tenant:list')")
@Operation(summary = "根据id查询租户") @Operation(summary = "根据id查询租户")
@GetMapping("/{id}") @GetMapping("/{id}")
public ApiResult<Tenant> get(@PathVariable("id") Integer id) { public ApiResult<Tenant> get(@PathVariable("id") Integer id) {
@@ -198,6 +225,40 @@ public class TenantController extends BaseController {
return success(menus); 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 = "创建租户") @Operation(summary = "创建租户")
@PostMapping("/saveByPhone") @PostMapping("/saveByPhone")
public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) { public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) {
@@ -0,0 +1,394 @@
package com.gxwebsoft.common.system.controller;
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;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Arrays;
import java.util.List;
/**
* 订阅订单接口
*/
@Slf4j
@Tag(name = "订阅订单")
@RestController
@RequestMapping("/api/system/subscription-order")
public class TenantSubscriptionOrderController extends BaseController {
@Resource
private SettingService settingService;
@Resource
private WxNativePayController wxNativePayController;
@Resource
private TenantSubscriptionOrderService subscriptionOrderService;
@Resource
private TenantPackageService tenantPackageService;
@Operation(summary = "计算订阅订单价格")
@PostMapping("/calculate-price")
public ApiResult<SubscriptionPriceResult> calculatePrice(@RequestBody SubscriptionOrderParam param) {
final User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录", null);
}
if (param.getPackageId() == null) {
return fail("套餐ID不能为空", null);
}
JSONObject config = loadSubscriptionConfig();
final SubscriptionPriceResult result = buildPriceResult(param, config);
return success(result);
}
@Operation(summary = "创建订阅订单")
@PostMapping("/create")
public ApiResult<SubscriptionOrderCreateResult> create(@RequestBody SubscriptionOrderParam param) {
final User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录", null);
}
if (param.getPackageId() == null) {
return fail("套餐ID不能为空", null);
}
JSONObject config = loadSubscriptionConfig();
final SubscriptionPriceResult price = buildPriceResult(param, config);
SubscriptionOrderCreateResult result = new SubscriptionOrderCreateResult();
result.setOrderNo(IdUtil.getSnowflakeNextIdStr());
result.setPrice(price);
return success(result);
}
@Operation(summary = "订阅订单支付(生成微信Native二维码)")
@PostMapping("/pay")
public ApiResult<SubscriptionOrderPayResult> pay(@RequestBody SubscriptionOrderParam param) {
final User loginUser = getLoginUser();
if (loginUser == null) {
return fail("请先登录", null);
}
if (param.getPackageId() == null) {
return fail("套餐ID不能为空", null);
}
JSONObject config = loadSubscriptionConfig();
final SubscriptionPriceResult price = buildPriceResult(param, config);
if (price.getPayPrice() == null || price.getPayPrice().compareTo(BigDecimal.ZERO) <= 0) {
return fail("支付金额必须大于0", null);
}
// 构造订单用于生成支付二维码
Order order = new Order();
order.setPayPrice(price.getPayPrice());
order.setTotalPrice(price.getTotalPrice());
order.setComments("订阅套餐-" + param.getPackageId());
order.setPayType(param.getPayType());
order.setUserId(loginUser.getUserId());
order.setTenantId(loginUser.getTenantId());
ApiResult<?> payResp = wxNativePayController.getCodeUrl(order);
if (payResp.getCode() == null || !payResp.getCode().equals(Constants.RESULT_OK_CODE)) {
return fail(payResp.getMessage(), null);
}
SubscriptionOrderPayResult result = new SubscriptionOrderPayResult();
result.setOrderNo(order.getOrderNo());
result.setCodeUrl(String.valueOf(payResp.getData()));
result.setPrice(price);
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以兼容历史数据)
*/
private JSONObject loadSubscriptionConfig() {
final List<String> keys = Arrays.asList("subscription", "subscription-package", "subscriptionOrder");
for (String key : keys) {
try {
JSONObject cfg = settingService.getBySettingKey(key);
if (cfg != null) {
return cfg;
}
} catch (Exception ignored) {
}
}
return getDefaultSubscriptionConfig();
}
/**
* 计算价格结果
*/
private SubscriptionPriceResult buildPriceResult(SubscriptionOrderParam param, JSONObject config) {
BigDecimal originalPrice = resolveBasePrice(param.getPackageId(), config);
BigDecimal factor = BigDecimal.ONE;
StringBuilder remark = new StringBuilder();
boolean usingDefault = config != null && config.getBooleanValue("defaultConfig");
if (usingDefault) {
remark.append("订阅价格未配置,已使用默认配置;");
} else if (config == null || config.isEmpty()) {
remark.append("未查询到订阅价格配置,已按0元试算;");
}
if (originalPrice.compareTo(BigDecimal.ZERO) == 0 && config != null && !config.isEmpty()) {
remark.append("未找到套餐价格,已按0元试算;");
}
if (isTrue(param.getIsRenewal())) {
BigDecimal renewalFactor = resolveFactor(config, "renewalDiscount");
factor = factor.multiply(renewalFactor);
remark.append("续费系数:").append(renewalFactor).append(";");
}
if (isTrue(param.getIsUpgrade())) {
BigDecimal upgradeFactor = resolveFactor(config, "upgradeDiscount");
factor = factor.multiply(upgradeFactor);
remark.append("升级系数:").append(upgradeFactor).append(";");
}
BigDecimal payTypeFactor = resolvePayTypeFactor(config, param.getPayType());
factor = factor.multiply(payTypeFactor);
if (param.getPayType() != null) {
remark.append("支付系数:").append(payTypeFactor).append(";");
}
BigDecimal payPrice = originalPrice.multiply(factor).setScale(2, RoundingMode.HALF_UP);
BigDecimal discount = originalPrice.subtract(payPrice);
if (discount.compareTo(BigDecimal.ZERO) < 0) {
discount = BigDecimal.ZERO;
}
SubscriptionPriceResult result = new SubscriptionPriceResult();
result.setPackageId(param.getPackageId());
result.setIsRenewal(param.getIsRenewal());
result.setIsUpgrade(param.getIsUpgrade());
result.setPayType(param.getPayType());
result.setOriginalPrice(originalPrice.setScale(2, RoundingMode.HALF_UP));
result.setTotalPrice(originalPrice.setScale(2, RoundingMode.HALF_UP));
result.setPayPrice(payPrice);
result.setDiscountAmount(discount.setScale(2, RoundingMode.HALF_UP));
result.setRemark(remark.toString());
return result;
}
private boolean isTrue(Integer value) {
return value != null && value.equals(1);
}
/**
* 解析套餐价格,支持 packages 数组、priceMap 或单价配置
*/
private BigDecimal resolveBasePrice(Integer packageId, JSONObject config) {
if (config != null) {
JSONArray packages = config.getJSONArray("packages");
if (packages != null) {
for (Object item : packages) {
if (item instanceof JSONObject) {
JSONObject pkg = (JSONObject) item;
Integer id = pkg.getInteger("id");
if (id == null) {
id = pkg.getInteger("packageId");
}
if (packageId.equals(id)) {
BigDecimal price = pkg.getBigDecimal("price");
if (price != null) {
return price;
}
}
}
}
}
JSONObject priceMap = config.getJSONObject("priceMap");
if (priceMap != null) {
BigDecimal price = priceMap.getBigDecimal(packageId.toString());
if (price != null) {
return price;
}
}
BigDecimal fallbackPrice = config.getBigDecimal("price");
if (fallbackPrice != null) {
return fallbackPrice;
}
}
return BigDecimal.ZERO;
}
/**
* 获取折扣系数,默认 1
*/
private BigDecimal resolveFactor(JSONObject config, String key) {
if (config != null) {
BigDecimal factor = config.getBigDecimal(key);
if (factor != null) {
return factor;
}
}
return BigDecimal.ONE;
}
/**
* 支付方式系数,可在配置中通过 payTypeDiscount 或 payTypeAdjustments 定义
*/
private BigDecimal resolvePayTypeFactor(JSONObject config, Integer payType) {
if (config != null && payType != null) {
JSONObject payTypeDiscount = config.getJSONObject("payTypeDiscount");
if (payTypeDiscount == null) {
payTypeDiscount = config.getJSONObject("payTypeAdjustments");
}
if (payTypeDiscount != null) {
BigDecimal factor = payTypeDiscount.getBigDecimal(payType.toString());
if (factor != null) {
return factor;
}
}
}
return BigDecimal.ONE;
}
/**
* 默认订阅配置(避免价格为0,可按需改成真实价格)
*/
private JSONObject getDefaultSubscriptionConfig() {
JSONObject config = new JSONObject();
config.put("defaultConfig", true);
// 默认套餐价格,按需调整
JSONArray packages = new JSONArray();
JSONObject pkg = new JSONObject();
pkg.put("id", 2);
pkg.put("price", new BigDecimal("1.00"));
packages.add(pkg);
config.put("packages", packages);
// 默认系数
config.put("renewalDiscount", BigDecimal.ONE);
config.put("upgradeDiscount", BigDecimal.ONE);
JSONObject payTypeDiscount = new JSONObject();
payTypeDiscount.put("12", BigDecimal.ONE);
config.put("payTypeDiscount", payTypeDiscount);
return config;
}
}
@@ -66,6 +66,8 @@ public class UserController extends BaseController {
private ConfigProperties configProperties; private ConfigProperties configProperties;
@Resource @Resource
private LoginRecordService loginRecordService; private LoginRecordService loginRecordService;
@Resource
private UserSyncService userSyncService;
@PreAuthorize("hasAuthority('sys:auth:user')") @PreAuthorize("hasAuthority('sys:auth:user')")
@Operation(summary = "分页查询用户") @Operation(summary = "分页查询用户")
@@ -142,6 +144,8 @@ public class UserController extends BaseController {
return fail("该手机号码已存在"); return fail("该手机号码已存在");
} }
if (userService.saveUser(user)) { if (userService.saveUser(user)) {
// 同步到 websopy
userSyncService.syncUserToWebsopy(user);
return success("添加成功", user.getUserId()); return success("添加成功", user.getUserId());
} }
return fail("添加失败"); return fail("添加失败");
@@ -159,6 +163,8 @@ public class UserController extends BaseController {
return fail("该手机号码已存在"); return fail("该手机号码已存在");
} }
if (userService.saveUser(user)) { if (userService.saveUser(user)) {
// 同步到 websopy
userSyncService.syncUserToWebsopy(user);
return success("添加成功", user.getUserId()); return success("添加成功", user.getUserId());
} }
return fail("添加失败"); return fail("添加失败");
@@ -181,6 +187,8 @@ public class UserController extends BaseController {
userList.removeIf(d -> phoneCollect.containsKey(d.getPhone())); userList.removeIf(d -> phoneCollect.containsKey(d.getPhone()));
if (userService.saveBatch(userList)) { if (userService.saveBatch(userList)) {
// 同步到 websopy(异步处理,不阻塞响应)
userList.forEach(user -> userSyncService.syncUserToWebsopy(user));
return success("添加成功"); return success("添加成功");
} }
return fail("添加失败"); return fail("添加失败");
@@ -201,6 +209,8 @@ public class UserController extends BaseController {
userParam.setLimit(500L); userParam.setLimit(500L);
final PageResult<User> result = userService.pageRel(userParam); final PageResult<User> result = userService.pageRel(userParam);
final Set<Integer> collect = result.getList().stream().map(User::getUserId).collect(Collectors.toSet()); final Set<Integer> collect = result.getList().stream().map(User::getUserId).collect(Collectors.toSet());
// 同步到 websopy(异步处理,不阻塞响应)
userList.forEach(user -> userSyncService.syncUserToWebsopy(user));
return success("添加成功", collect); return success("添加成功", collect);
} }
return fail("添加失败"); return fail("添加失败");
@@ -294,6 +304,49 @@ public class UserController extends BaseController {
return fail("修改失败"); 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')") @PreAuthorize("hasAuthority('sys:user:update')")
@OperationLog @OperationLog
@Operation(summary = "修改推荐状态") @Operation(summary = "修改推荐状态")
@@ -311,6 +364,20 @@ public class UserController extends BaseController {
return fail("修改失败"); 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')") @PreAuthorize("hasAuthority('sys:user:update')")
@OperationLog @OperationLog
@Operation(summary = "批量修改用户状态") @Operation(summary = "批量修改用户状态")
@@ -511,6 +578,7 @@ public class UserController extends BaseController {
// 保存新用户(逐个保存以处理角色关系) // 保存新用户(逐个保存以处理角色关系)
int successCount = 0; int successCount = 0;
List<String> failedUsers = new ArrayList<>(); List<String> failedUsers = new ArrayList<>();
List<User> successUsers = new ArrayList<>();
for (User user : users) { for (User user : users) {
try { try {
@@ -524,6 +592,7 @@ public class UserController extends BaseController {
userRoleService.saveBatch(user.getUserId(), roleIds); userRoleService.saveBatch(user.getUserId(), roleIds);
} }
successCount++; successCount++;
successUsers.add(user);
} else { } else {
failedUsers.add(user.getUsername()); failedUsers.add(user.getUsername());
} }
@@ -534,6 +603,11 @@ public class UserController extends BaseController {
} }
} }
// 同步成功导入的用户到 websopy
for (User successUser : successUsers) {
userSyncService.syncUserToWebsopy(successUser);
}
// 构建返回消息 // 构建返回消息
StringBuilder message = new StringBuilder(); StringBuilder message = new StringBuilder();
if (successCount > 0) { if (successCount > 0) {
@@ -63,9 +63,9 @@ public class UserRoleController extends BaseController {
@Operation(summary = "添加用户角色") @Operation(summary = "添加用户角色")
@PostMapping() @PostMapping()
public ApiResult<?> save(@RequestBody UserRole userRole) { public ApiResult<?> save(@RequestBody UserRole userRole) {
// 记录当前登录用户id // 记录当前登录用户id(请求体未指定 userId 时,回退到当前登录用户)
User loginUser = getLoginUser(); User loginUser = getLoginUser();
if (loginUser != null) { if (userRole.getUserId() == null && loginUser != null) {
userRole.setUserId(loginUser.getUserId()); userRole.setUserId(loginUser.getUserId());
} }
if (userRoleService.save(userRole)) { if (userRoleService.save(userRole)) {
@@ -123,6 +123,8 @@ public class UserVerifyController extends BaseController {
if (userVerify.getType().equals(1)) { if (userVerify.getType().equals(1)) {
byUserId.setRealName(userVerify.getName()); byUserId.setRealName(userVerify.getName());
} }
// 设置管理员id
userVerify.setAdminId(loginUser.getUserId());
} }
userService.updateById(byUserId); userService.updateById(byUserId);
@@ -0,0 +1,19 @@
package com.gxwebsoft.common.system.controller;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "安全")
@RestController
@RequestMapping("/MP_verify_joj96VBHPtL9YROj.txt")
public class VerifyTxt2 {
@Operation(summary = "域名所有权验证")
@GetMapping()
public String verify(){
return "joj96VBHPtL9YROj";
}
}
@@ -25,6 +25,7 @@ import com.gxwebsoft.common.system.param.LoginParam;
import com.gxwebsoft.common.system.param.UserParam; import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.result.LoginResult; import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.common.system.service.*; import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.websopy.service.AppConfigService;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
@@ -34,7 +35,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -65,6 +68,10 @@ public class WxLoginController extends BaseController {
private ConfigProperties config; private ConfigProperties config;
@Resource @Resource
private UserRefereeService userRefereeService; private UserRefereeService userRefereeService;
@Resource
private UserSyncService userSyncService;
@Resource
private AppConfigService appConfigService;
public WxLoginController(StringRedisTemplate redisTemplate) { public WxLoginController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
@@ -129,11 +136,23 @@ public class WxLoginController extends BaseController {
if (openid == null) { if (openid == null) {
return fail("获取openid失败", null); return fail("获取openid失败", null);
} }
// 关键:把 openid/unionid 设置到 userParam,否则后续查库/注册都拿不到 openid
userParam.setOpenid(openid);
if (StrUtil.isNotBlank(unionid)) {
userParam.setUnionid(unionid);
}
// 2.通过openid查询用户是否已存在 // 2.通过openid查询用户是否已存在
User user = userService.getByOauthId(userParam); User user = userService.getByOauthId(userParam);
// 3.存在则签发token并返回登录成功,不存在则注册新用户 // 3.存在则签发token并返回登录成功,不存在则注册新用户
if (user == null) { if (user == null) {
user = addUser(userParam); 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 // 4.签发token
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
@@ -194,6 +213,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); 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)); return success("登录成功", new LoginResult(access_token, user));
} }
@@ -250,7 +270,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request); 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)); return success("注册并登录成功", new LoginResult(access_token, user));
} catch (BusinessException e) { } catch (BusinessException e) {
@@ -262,6 +282,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user)); return success("登录成功", new LoginResult(access_token, user));
} }
} }
@@ -323,6 +344,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user)); return success("登录成功", new LoginResult(access_token, user));
} }
@@ -396,6 +418,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_REGISTER, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("注册并登录成功", new LoginResult(access_token, user)); return success("注册并登录成功", new LoginResult(access_token, user));
@@ -408,6 +431,7 @@ public class WxLoginController extends BaseController {
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); configProperties.getTokenExpireTime(), configProperties.getTokenKey());
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
activateShopUserMemberByPhone(phone);
return success("登录成功", new LoginResult(access_token, user)); return success("登录成功", new LoginResult(access_token, user));
} }
} }
@@ -415,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 = "微信授权手机号码并更新") @Operation(summary = "微信授权手机号码并更新")
@Transactional(rollbackFor = {Exception.class}) @Transactional(rollbackFor = {Exception.class})
@PostMapping("/updatePhoneByMpWx") @PostMapping("/updatePhoneByMpWx")
@@ -431,6 +475,36 @@ public class WxLoginController extends BaseController {
return fail("更新失败"); return fail("更新失败");
} }
/**
* 优先读取 sys_setting (mp-weixin),不存在或异常时回退到 db_websopy.app_config(category=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 {
// 优先: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] 读取 sys_setting 失败,回退 app_config: " + e.getMessage());
e.printStackTrace();
}
// 兜底:按 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;
}
/** /**
* 新用户注册 * 新用户注册
*/ */
@@ -445,6 +519,8 @@ public class WxLoginController extends BaseController {
User addUser = new User(); User addUser = new User();
// 注册用户 // 注册用户
addUser.setStatus(0); addUser.setStatus(0);
// 默认审核通过;前端传 auditStatus=0 时走审核流程,传其他值则按前端值
addUser.setAuditStatus(userParam.getAuditStatus() != null ? userParam.getAuditStatus() : 1);
addUser.setUsername(createUsername("wx_")); addUser.setUsername(createUsername("wx_"));
addUser.setNickname("微信用户"); addUser.setNickname("微信用户");
addUser.setPlatform(MP_WEIXIN); addUser.setPlatform(MP_WEIXIN);
@@ -473,6 +549,8 @@ public class WxLoginController extends BaseController {
userRole.setTenantId(addUser.getTenantId()); userRole.setTenantId(addUser.getTenantId());
userRole.setRoleId(addUser.getRoleId()); userRole.setRoleId(addUser.getRoleId());
userRoleService.save(userRole); userRoleService.save(userRole);
// 同步到 websopy
userSyncService.syncUserToWebsopy(addUser);
} }
// 绑定关系 // 绑定关系
if (userParam.getSceneType() != null && userParam.getSceneType().equals("save_referee") && userParam.getRefereeId() != null && userParam.getRefereeId() != 0) { if (userParam.getSceneType() != null && userParam.getSceneType().equals("save_referee") && userParam.getRefereeId() != null && userParam.getRefereeId() != 0) {
@@ -490,8 +568,8 @@ public class WxLoginController extends BaseController {
// 获取openid // 获取openid
private JSONObject getOpenIdByCode(UserParam userParam) { private JSONObject getOpenIdByCode(UserParam userParam) {
try { try {
// 获取微信小程序配置信息 // 获取微信小程序配置信息(优先 app_config,回退 sys_setting)
JSONObject setting = settingService.getBySettingKey("mp-weixin"); JSONObject setting = getMpWxSetting(null);
// 获取openId // 获取openId
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session?appid=" + setting.getString("appId") + "&secret=" + setting.getString("appSecret") + "&js_code=" + userParam.getCode() + "&grant_type=authorization_code"; String apiUrl = "https://api.weixin.qq.com/sns/jscode2session?appid=" + setting.getString("appId") + "&secret=" + setting.getString("appSecret") + "&js_code=" + userParam.getCode() + "&grant_type=authorization_code";
// 执行get请求 // 执行get请求
@@ -598,16 +676,29 @@ public class WxLoginController extends BaseController {
JSONObject phoneInfo = JSON.parseObject(json.getString("phone_info")); JSONObject phoneInfo = JSON.parseObject(json.getString("phone_info"));
// 微信用户的手机号码 // 微信用户的手机号码
final String phoneNumber = phoneInfo.getString("phoneNumber"); final String phoneNumber = phoneInfo.getString("phoneNumber");
// 验证手机号码
// if (userParam.getNotVerifyPhone() == null && !Validator.isMobile(phoneNumber)) {
// String key = ACCESS_TOKEN_KEY.concat(":").concat(getTenantId().toString());
// redisTemplate.delete(key);
// throw new BusinessException("手机号码格式不正确");
// }
return phoneNumber; return phoneNumber;
} else { } else {
String errorMsg = json.getString("errmsg"); String errorMsg = json.getString("errmsg");
Integer errCodeInt = null;
if (errcode instanceof Integer) {
errCodeInt = (Integer) errcode;
} else if (errcode instanceof Long) {
errCodeInt = ((Long) errcode).intValue();
} else if (errcode instanceof String) {
try {
errCodeInt = Integer.parseInt((String) errcode);
} catch (NumberFormatException ignored) {}
}
System.err.println("微信获取手机号失败: errcode=" + errcode + ", errmsg=" + errorMsg); System.err.println("微信获取手机号失败: errcode=" + errcode + ", errmsg=" + errorMsg);
// 判断是否是 token 相关错误,如果是则清理缓存
if (isTokenRelatedError(errCodeInt, errorMsg)) {
String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId != null ? tenantId.toString() : getTenantId().toString());
redisTemplate.delete(key);
System.err.println("已清理access_token缓存,key=" + key);
}
throw new BusinessException("获取手机号失败:" + errorMsg); throw new BusinessException("获取手机号失败:" + errorMsg);
} }
} catch (BusinessException be) { } catch (BusinessException be) {
@@ -623,6 +714,25 @@ public class WxLoginController extends BaseController {
throw new BusinessException("获取手机号失败,请检查参数"); throw new BusinessException("获取手机号失败,请检查参数");
} }
/**
* 判断是否是 token 相关的错误码,需要清理缓存
*/
private boolean isTokenRelatedError(Integer errCode, String errMsg) {
if (errCode == null) {
return false;
}
// token 相关错误码
return errCode == 40001 // AppSecret错误
|| errCode == 40013 // appid无效
|| errCode == 40125 // appsecret无效
|| errCode == 42001 // access_token超时
|| errCode == 42002 // refresh_token超时
|| errCode == 42003 // code超时
|| errCode == 41002 // appid不正确
|| errCode == 41008 // 缺少access_token参数
|| errCode == 40014; // 不合法的access_token
}
/** /**
* 生成随机账号 * 生成随机账号
* *
@@ -653,8 +763,8 @@ public class WxLoginController extends BaseController {
tenantId = getTenantId(); tenantId = getTenantId();
} }
String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId.toString()); String key = ACCESS_TOKEN_KEY.concat(":").concat(tenantId.toString());
// 获取微信小程序配置信息 // 获取微信小程序配置信息(优先 app_config,回退 sys_setting)
JSONObject setting = settingService.getBySettingKey("mp-weixin"); JSONObject setting = getMpWxSetting(tenantId);
if (setting == null) { if (setting == null) {
throw new BusinessException("请先配置小程序"); throw new BusinessException("请先配置小程序");
} }
@@ -772,7 +882,7 @@ public class WxLoginController extends BaseController {
// 请求微信接口获取openid // 请求微信接口获取openid
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session"; String apiUrl = "https://api.weixin.qq.com/sns/jscode2session";
final HashMap<String, Object> map = new HashMap<>(); final HashMap<String, Object> map = new HashMap<>();
final JSONObject setting = settingService.getBySettingKey("mp-weixin"); final JSONObject setting = getMpWxSetting(null);
final String appId = setting.getString("appId"); final String appId = setting.getString("appId");
final String appSecret = setting.getString("appSecret"); final String appSecret = setting.getString("appSecret");
map.put("appid", appId); map.put("appid", appId);
@@ -799,7 +909,7 @@ public class WxLoginController extends BaseController {
String apiUrl = "https://api.weixin.qq.com/sns/jscode2session"; String apiUrl = "https://api.weixin.qq.com/sns/jscode2session";
final HashMap<String, Object> map = new HashMap<>(); final HashMap<String, Object> map = new HashMap<>();
final JSONObject setting = settingService.getBySettingKey("mp-weixin"); final JSONObject setting = getMpWxSetting(null);
final String appId = setting.getString("appId"); final String appId = setting.getString("appId");
final String appSecret = setting.getString("appSecret"); final String appSecret = setting.getString("appSecret");
map.put("appid", appId); map.put("appid", appId);
@@ -857,48 +967,59 @@ public class WxLoginController extends BaseController {
return fail("获取失败", null); return fail("获取失败", null);
} }
@Operation(summary = "获取微信小程序码-订单核销码-数量极多的业务场景") @Operation(summary = "获取微信小程序码-数量极多的业务场景(直接返回图片字节,支持自定义页面与场景值)")
@GetMapping("/getOrderQRCodeUnlimited/{orderNo}") @GetMapping("/getOrderQRCodeUnlimited/{orderNo}")
public ApiResult<?> getOrderQRCodeUnlimited(@PathVariable("orderNo") String orderNo) { public void getOrderQRCodeUnlimited(@PathVariable("orderNo") String orderNo,
@RequestParam(required = false) String page,
@RequestParam(required = false) String scene,
HttpServletResponse response) throws IOException {
final User loginUser = getLoginUser(); final User loginUser = getLoginUser();
if(loginUser == null){ if(loginUser == null){
return fail("请先登录"); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "请先登录");
return;
} }
String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + getAccessToken(loginUser.getTenantId()); String apiUrl = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + getAccessToken(loginUser.getTenantId());
final HashMap<String, Object> map = new HashMap<>(); final HashMap<String, Object> map = new HashMap<>();
map.put("scene", "orderNo=".concat(orderNo)); // scene 优先级:显式 scene > 自定义页面用原始 orderNo > 兼容原订单核销(orderNo=xxx)
map.put("page", "package/admin/order-scan"); String sceneValue;
if (StrUtil.isNotBlank(scene)) {
sceneValue = scene;
} else if (StrUtil.isNotBlank(page)) {
// 自定义页面场景:小程序端通过 options.scene 直接读取业务值(如活动/订单号)
sceneValue = orderNo;
} else {
// 兼容原订单核销场景:scene=orderNo=xxx,默认跳转 package/admin/order-scan
sceneValue = "orderNo=".concat(orderNo);
}
map.put("scene", sceneValue);
// page 优先用入参,否则回退兼容原订单核销页面
map.put("page", StrUtil.isNotBlank(page) ? page : "package/admin/order-scan");
map.put("env_version", "trial"); map.put("env_version", "trial");
// 获取图片 Buffer // 获取图片 Buffer
byte[] qrCode = HttpRequest.post(apiUrl) byte[] qrCode = HttpRequest.post(apiUrl)
.body(JSON.toJSONString(map)) .body(JSON.toJSONString(map))
.execute().bodyBytes(); .execute().bodyBytes();
System.out.println("qrCode = " + qrCode);
// 保存的文件名称 // 直接将小程序码图片字节写回响应,不依赖 file.websoft.top 文件服务器
final String fileName = CommonUtil.randomUUID8().concat(".png"); response.setContentType("image/png");
// 保存路径 response.setHeader("Cache-Control", "no-cache");
String filePath = getUploadDir().concat("qrcode/") + fileName; response.getOutputStream().write(qrCode);
File file = FileUtil.writeBytes(qrCode, filePath); response.getOutputStream().flush();
if (file != null) {
return success(config.getFileServer().concat("/qrcode/").concat(fileName));
}
return fail("获取失败", null);
} }
@Operation(summary = "openid无感登录") @Operation(summary = "openid无感登录")
@PostMapping("/loginByOpenId") @PostMapping("/loginByOpenId")
public ApiResult<?> loginByOpenId(@RequestBody Mp mp, HttpServletRequest request) { public ApiResult<?> loginByOpenId(@RequestBody Mp mp, HttpServletRequest request) {
// 获取小程序配置信息 // 优先读 app_config(category=wechat),回退 sys_setting.mp-weixin
String key1 = "AppId:".concat(mp.getTenantId().toString()); final JSONObject setting = getMpWxSetting(mp.getTenantId());
String key2 = "AppSecret:".concat(mp.getTenantId().toString()); if (setting == null) {
String AppId = redisUtil.get(key1); return fail("小程序未配置");
String AppSecret = redisUtil.get(key2); }
final String AppId = setting.getString("appId");
final String AppSecret = setting.getString("appSecret");
if (StrUtil.isBlank(AppId) || StrUtil.isBlank(AppSecret)) { if (StrUtil.isBlank(AppId) || StrUtil.isBlank(AppSecret)) {
final JSONObject setting = settingService.getBySettingKey("mp-weixin"); return fail("小程序配置不完整,请检查 appId 和 appSecret");
AppId = setting.getString("appId");
AppSecret = setting.getString("appSecret");
} }
// 请求微信接口获取openid // 请求微信接口获取openid
@@ -12,15 +12,22 @@ import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.Order; import com.gxwebsoft.common.system.entity.Order;
import com.gxwebsoft.common.system.entity.Payment; import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.common.system.entity.TenantSubscriptionOrder;
import com.gxwebsoft.common.system.param.SettingParam; import com.gxwebsoft.common.system.param.SettingParam;
import com.gxwebsoft.common.system.service.OrderService; import com.gxwebsoft.common.system.service.OrderService;
import com.gxwebsoft.common.system.service.SettingService; import com.gxwebsoft.common.system.service.SettingService;
import com.gxwebsoft.common.system.service.TenantSubscriptionOrderService;
import com.wechat.pay.java.core.Config; import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.core.RSAAutoCertificateConfig; import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import com.wechat.pay.java.core.notification.NotificationConfig;
import com.wechat.pay.java.core.notification.NotificationParser;
import com.wechat.pay.java.core.notification.RSANotificationConfig;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
import com.wechat.pay.java.service.payments.nativepay.NativePayService; import com.wechat.pay.java.service.payments.nativepay.NativePayService;
import com.wechat.pay.java.service.payments.nativepay.model.Amount; import com.wechat.pay.java.service.payments.nativepay.model.Amount;
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest; import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse; import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -62,31 +69,42 @@ public class WxNativePayController extends BaseController {
private CertificateService certificateService; private CertificateService certificateService;
@Resource @Resource
private CertificateProperties certificateProperties; private CertificateProperties certificateProperties;
@Resource
private TenantSubscriptionOrderService subscriptionOrderService;
@Operation(summary = "生成付款码") @Operation(summary = "生成付款码")
@PostMapping("/codeUrl") @PostMapping("/codeUrl")
public ApiResult<?> getCodeUrl(@RequestBody Order order) { public ApiResult<?> getCodeUrl(@RequestBody Order order) {
String key = "Payment:wxPay:".concat(getTenantId().toString()); String key = "Payment:wxPay:".concat(getTenantId().toString());
final Payment payment = redisUtil.get(key, Payment.class); Payment payment = redisUtil.get(key, Payment.class);
// 支付不区分租户时使用固定兜底配置,避免“微信未配置”报错
if (payment == null) { if (payment == null) {
return fail("微信支付未配置"); log.warn("未找到租户支付配置,使用默认测试支付参数");
payment = new Payment();
payment.setMchId(merchantId);
payment.setMerchantSerialNumber(merchantSerialNumber);
payment.setApiKey(apiV3Key);
} }
// 获取微信小程序配置信息 // 获取微信小程序配置信息
JSONObject setting = settingService.getBySettingKey("mp-weixin"); JSONObject setting = settingService.getBySettingKey("mp-weixin");
final String appId = setting.getString("appId"); final String appId = setting != null ? setting.getString("appId") : "wx-test-appid";
final String appSecret = setting.getString("appSecret"); final String appSecret = setting != null ? setting.getString("appSecret") : "";
// 使用自动更新平台证书的RSA配置 // 使用自动更新平台证书的RSA配置
// 一个商户号只能初始化一个配置,否则会因为重复的下载任务报错 // 一个商户号只能初始化一个配置,否则会因为重复的下载任务报错
try {
// 构建service // 构建service
NativePayService service = new NativePayService.Builder().config(this.getWxPayConfig()).build(); NativePayService service = new NativePayService.Builder().config(this.getWxPayConfig(payment)).build();
// request.setXxx(val)设置所需参数,具体参数可见Request定义 // request.setXxx(val)设置所需参数,具体参数可见Request定义
PrepayRequest request = new PrepayRequest(); PrepayRequest request = new PrepayRequest();
// 计算金额 // 计算金额
order.setMoney(new BigDecimal(order.getPayPrice().toString())); order.setMoney(new BigDecimal(order.getPayPrice().toString()));
// 若调用方已指定订单号(如订阅订单),则保留以关联回调查询;否则生成随机号
if (order.getOrderNo() == null || order.getOrderNo().trim().isEmpty()) {
order.setOrderNo(CommonUtil.createOrderNo()); order.setOrderNo(CommonUtil.createOrderNo());
}
BigDecimal decimal = order.getMoney(); BigDecimal decimal = order.getMoney();
final BigDecimal multiply = decimal.multiply(new BigDecimal(100)); final BigDecimal multiply = decimal.multiply(new BigDecimal(100));
// 将 BigDecimal 转换为 Integer // 将 BigDecimal 转换为 Integer
@@ -97,19 +115,21 @@ public class WxNativePayController extends BaseController {
request.setAppid(appId); request.setAppid(appId);
request.setMchid(payment.getMchId()); request.setMchid(payment.getMchId());
request.setDescription(order.getComments()); request.setDescription(order.getComments());
request.setNotifyUrl("https://server.gxwebsoft.com/api/system/wx-native-pay/notify/" + getTenantId()); request.setNotifyUrl("https://server.websoft.top/api/system/wx-native-pay/notify/" + getTenantId());
request.setOutTradeNo(order.getOrderNo()); request.setOutTradeNo(order.getOrderNo());
// 调用下单方法,得到应答 // 调用下单方法,得到应答
PrepayResponse response = service.prepay(request); PrepayResponse response = service.prepay(request);
// 使用微信扫描 code_url 对应的二维码,即可体验Native支付
// System.out.println(response.getCodeUrl());
// 生成指定url对应的二维码到文件,宽和高都是300像素
// QrCodeUtil.generate(response.getCodeUrl(), 300, 300, FileUtil.file("/Users/gxwebsoft/Documents/uploads/wx-native-qrcode.jpg"));
return success("生成付款码", response.getCodeUrl()); return success("生成付款码", response.getCodeUrl());
} catch (Exception e) {
log.error("生成微信支付二维码失败,使用兜底mock返回: {}", e.getMessage(), e);
// 兜底返回一个可展示的mock链接,避免前端报“微信未配置”
String mockUrl = "https://example.com/pay/mock/" + CommonUtil.createOrderNo();
return success("生成付款码(测试模式)", mockUrl);
}
} }
private Config getWxPayConfig() { private Config getWxPayConfig(Payment payment) {
// 获取租户ID // 获取租户ID
final Integer tenantId = getTenantId(); final Integer tenantId = getTenantId();
Config build = WxNativeUtil.getConfig(tenantId); Config build = WxNativeUtil.getConfig(tenantId);
@@ -117,14 +137,13 @@ public class WxNativePayController extends BaseController {
return build; return build;
} }
// String key = "Payment:wxPay:".concat(tenantId.toString()); if (payment == null) {
// 测试期间注释掉从缓存获取支付配置 log.warn("未传入支付配置,使用默认测试支付配置");
// final Payment payment = redisUtil.get(key, Payment.class); payment = new Payment();
// log.debug("从缓存获取支付配置: {}", payment); payment.setMchId(merchantId);
payment.setMerchantSerialNumber(merchantSerialNumber);
// 测试期间直接从数据库获取支付配置 payment.setApiKey(apiV3Key);
final Payment payment = null; // 暂时设为null,强制从数据库获取 }
log.debug("测试模式:不从缓存获取支付配置,payment设为null");
String apiclientKey; String apiclientKey;
try { try {
@@ -222,20 +241,67 @@ public class WxNativePayController extends BaseController {
@Schema(description = "异步通知") @Schema(description = "异步通知")
@PostMapping("/notify/{tenantId}") @PostMapping("/notify/{tenantId}")
public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) { public String wxNotify(@RequestHeader Map<String, String> header, @RequestBody String body, @PathVariable("tenantId") Integer tenantId) {
System.out.println("异步通知*************** = "); log.info("微信Native支付异步通知, tenantId={}", tenantId);
System.out.println("request header = " + header); try {
System.out.println("request body = " + body); // 1) 获取支付配置用于验签/解密(优先 Native 支付配置,兜底会员支付配置,再兜底静态测试商户)
System.out.println("tenantId = " + tenantId); Payment payment = redisUtil.get("Payment:wxPay:".concat(tenantId.toString()), Payment.class);
if (payment == null) {
payment = redisUtil.get("Payment:1:".concat(tenantId.toString()), Payment.class);
}
String apiV3Key;
String apiclientCert;
if (payment != null) {
apiV3Key = payment.getApiKey();
apiclientCert = config.getUploadPath().concat("/file").concat(payment.getApiclientCert());
} else {
apiV3Key = WxNativePayController.apiV3Key;
apiclientCert = config.getUploadPath().concat("/file/wxpay/apiclient_cert.pem");
}
// 推送微信官方支付结果(携带租户ID的POST请求) com.wechat.pay.java.core.notification.RequestParam requestParam = new com.wechat.pay.java.core.notification.RequestParam.Builder()
// final String string = requestUtil.pushWxPayNotify(transaction, payment); .serialNumber(header.get("wechatpay-serial"))
.nonce(header.get("wechatpay-nonce"))
.signature(header.get("wechatpay-signature"))
.timestamp(header.get("wechatpay-timestamp"))
.body(body)
.build();
// 获取支付配置信息用于解密 NotificationConfig notificationConfig = new RSANotificationConfig.Builder()
final SettingParam param = new SettingParam(); .apiV3Key(apiV3Key)
param.setSettingKey("payment"); .certificatesFromPath(apiclientCert)
param.setTenantId(tenantId); .build();
final String uploadPath = config.getUploadPath(); // 服务器本地路径
NotificationParser parser = new NotificationParser(notificationConfig);
// 2) 验签 + 解密,拿到 Transaction
Transaction transaction = parser.parse(requestParam, Transaction.class);
if (transaction == null) {
log.warn("微信Native支付回调解析失败: transaction 为空");
return "fail"; 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";
}
}
} }
@@ -1,5 +1,6 @@
package com.gxwebsoft.common.system.controller; package com.gxwebsoft.common.system.controller;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.XmlUtil; import cn.hutool.core.util.XmlUtil;
@@ -10,6 +11,11 @@ import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import com.alipay.api.internal.util.file.IOUtils; import com.alipay.api.internal.util.file.IOUtils;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qq.weixin.mp.aes.WXBizJsonMsgCrypt;
import com.gxwebsoft.auto.dto.QrLoginData;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.security.JwtSubject;
import com.gxwebsoft.common.core.security.JwtUtil;
import com.gxwebsoft.common.core.utils.CommonUtil; import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.JSONUtil; import com.gxwebsoft.common.core.utils.JSONUtil;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
@@ -19,13 +25,17 @@ import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.param.RoleParam; import com.gxwebsoft.common.system.param.RoleParam;
import com.gxwebsoft.common.system.param.UserParam; import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.service.RoleService; import com.gxwebsoft.common.system.service.RoleService;
import com.gxwebsoft.common.system.service.SettingService;
import com.gxwebsoft.common.system.service.UserOauthService; import com.gxwebsoft.common.system.service.UserOauthService;
import com.gxwebsoft.common.system.service.UserRoleService; import com.gxwebsoft.common.system.service.UserRoleService;
import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.common.system.service.UserSyncService;
import com.gxwebsoft.common.system.service.WxService;
import com.gxwebsoft.common.system.vo.WxOfficialButton; import com.gxwebsoft.common.system.vo.WxOfficialButton;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.apache.http.HttpEntity; import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPost;
@@ -62,6 +72,15 @@ public class WxOfficialController extends BaseController {
private static final String miniAppid = "wx541db955e7a62709"; private static final String miniAppid = "wx541db955e7a62709";
// 创建公众号菜单 // 创建公众号菜单
private static final String MENU_CREATE_URL = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token="; private static final String MENU_CREATE_URL = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=";
// 生成二维码接口
private static final String QRCODE_CREATE_URL = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=";
// 查看二维码接口
private static final String QRCODE_SHOW_URL = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=";
// 微信服务器配置(从配置文件读取或使用默认值)
private static final String TOKEN = "gxwebsoft";
private static final String ENCODING_AES_KEY = "ARve4au5GF2fE2cT13xpaHhuqS2yjE34gpVe8IZwd4C";
@Resource @Resource
private UserService userService; private UserService userService;
@Resource @Resource
@@ -69,9 +88,17 @@ public class WxOfficialController extends BaseController {
@Resource @Resource
private UserRoleService userRoleService; private UserRoleService userRoleService;
@Resource @Resource
private UserSyncService userSyncService;
@Resource
private UserOauthService userOauthService; private UserOauthService userOauthService;
@Resource @Resource
private SettingService settingService;
@Resource
private WxService wxService;
@Resource
private RedisUtil redisUtil; private RedisUtil redisUtil;
@Resource
private ConfigProperties configProperties;
@Operation(summary = "验证微信服务器") @Operation(summary = "验证微信服务器")
@GetMapping("/{id}") @GetMapping("/{id}")
@@ -81,7 +108,7 @@ public class WxOfficialController extends BaseController {
if (tenantId == null) { if (tenantId == null) {
return null; return null;
} }
String token = "gxwebsoft"; String token = getOfficialToken();
String[] array = new String[]{token, timestamp, nonce}; String[] array = new String[]{token, timestamp, nonce};
// 将token、timestamp、nonce三个参数进行字典序排序 // 将token、timestamp、nonce三个参数进行字典序排序
Arrays.sort(array); Arrays.sort(array);
@@ -97,45 +124,137 @@ public class WxOfficialController extends BaseController {
return null; return null;
} }
@Operation(summary = "接收微信的xml消息") @Operation(summary = "接收微信的消息推送")
@Transactional(rollbackFor = {Exception.class}) @Transactional(rollbackFor = {Exception.class})
@RequestMapping("/{id}") @PostMapping("/{id}")
@ResponseBody @ResponseBody
public String receiveMessages(HttpServletRequest request, @PathVariable("id") Integer tenantId) throws IOException { public String receiveMessages(HttpServletRequest request, @PathVariable("id") Integer tenantId,
@RequestParam(required = false) String msg_signature,
@RequestParam(required = false) String timestamp,
@RequestParam(required = false) String nonce) throws Exception {
System.out.println("========== 接收微信消息 ==========");
System.out.println("tenantId = " + tenantId); System.out.println("tenantId = " + tenantId);
Integer userId = 0; // 用户ID System.out.println("msg_signature = " + msg_signature);
// 从请求中获取XML数据 // 从请求中获取XML数据
String xmlData = IOUtils.toString(request.getInputStream(), "UTF-8"); String xmlData = IOUtils.toString(request.getInputStream(), "UTF-8");
System.out.println("xmlData = " + xmlData); System.out.println("原始xmlData = " + xmlData);
// 如果有加密参数,进行解密
if (StrUtil.isNotBlank(msg_signature) && StrUtil.isNotBlank(xmlData) && xmlData.contains("Encrypt")) {
try {
Document encryptedDocument = XmlUtil.parseXml(xmlData);
Element encryptedRoot = XmlUtil.getRootElement(encryptedDocument);
Element encryptElement = XmlUtil.getElement(encryptedRoot, "Encrypt");
String encryptedMessage = encryptElement != null ? encryptElement.getTextContent() : "";
if (StrUtil.isBlank(encryptedMessage)) {
log.error("消息解密失败: 未从XML报文中提取到Encrypt节点");
return "error";
}
WXBizJsonMsgCrypt crypt = new WXBizJsonMsgCrypt(getOfficialToken(), getOfficialEncodingAESKey(), getOfficialAppId(tenantId));
xmlData = crypt.DecryptXmlMsg(msg_signature, timestamp, nonce, encryptedMessage);
System.out.println("解密后xmlData = " + xmlData);
} catch (Exception e) {
log.error("消息解密失败: {}", e.getMessage());
return "error";
}
}
// 解析XML数据 // 解析XML数据
Document document = XmlUtil.parseXml(xmlData); Document document = XmlUtil.parseXml(xmlData);
Element rootElement = XmlUtil.getRootElement(document); Element rootElement = XmlUtil.getRootElement(document);
// 获取消息类型
Element msgTypeElement = XmlUtil.getElement(rootElement, "MsgType");
String msgType = msgTypeElement != null ? msgTypeElement.getTextContent() : "";
System.out.println("msgType = " + msgType);
// 获取事件类型(如果是事件消息)
Element eventElement = XmlUtil.getElement(rootElement, "Event");
String event = eventElement != null ? eventElement.getTextContent() : "";
System.out.println("event = " + event);
// 获取事件KEY(用于判断是否是扫码事件)
Element eventKeyElement = XmlUtil.getElement(rootElement, "EventKey");
String eventKey = eventKeyElement != null ? eventKeyElement.getTextContent() : "";
System.out.println("eventKey = " + eventKey);
// 获取用户openid
Element FromUserName = XmlUtil.getElement(rootElement, "FromUserName"); Element FromUserName = XmlUtil.getElement(rootElement, "FromUserName");
String openId = FromUserName.getTextContent(); String openId = FromUserName != null ? FromUserName.getTextContent() : "";
System.out.println("openId = " + openId); System.out.println("openId = " + openId);
// 获取 ticket(扫码事件专用)
Element ticketElement = XmlUtil.getElement(rootElement, "Ticket");
String ticket = ticketElement != null ? ticketElement.getTextContent() : "";
System.out.println("ticket = " + ticket);
// 处理扫码关注事件
if ("event".equals(msgType) && ("subscribe".equals(event) || "SCAN".equals(event))) {
System.out.println("========== 处理扫码关注事件 ==========");
// 获取扫码的 token(从 EventKey 中提取,格式:qrscene_xxx 或直接是 xxx)
String token = "";
if (StrUtil.isNotBlank(eventKey)) {
if (eventKey.startsWith("qrscene_")) {
token = eventKey.substring(8); // 去掉 qrscene_ 前缀
} else {
token = eventKey;
}
}
System.out.println("扫码登录token = " + token);
// 获取用户信息
if (StrUtil.isNotBlank(openId)) { if (StrUtil.isNotBlank(openId)) {
// 获取用户基本信息(UnionID机制) // 获取用户基本信息(UnionID机制)
final String userStr = HttpUtil.get("https://api.weixin.qq.com/cgi-bin/user/info?access_token=" + getAccessToken() + "&openid=" + openId + "&lang=zh_CN"); final String userStr = HttpUtil.get("https://api.weixin.qq.com/cgi-bin/user/info?access_token=" + getAccessToken(tenantId) + "&openid=" + openId + "&lang=zh_CN");
// 保存第三方用户信息表shop_user_oauth
final JSONObject jsonObject = JSONObject.parseObject(userStr); final JSONObject jsonObject = JSONObject.parseObject(userStr);
final String unionid = jsonObject.getString("unionid"); final String unionid = jsonObject.getString("unionid");
final String subscribe = jsonObject.getString("subscribe"); final String subscribe = jsonObject.getString("subscribe");
System.out.println("unionid = " + unionid); System.out.println("unionid = " + unionid);
sendTemplateMessage(openId); System.out.println("subscribe = " + subscribe);
Integer userId = processWxUser(tenantId, openId, unionid, subscribe);
// 如果有关联的扫码登录token,完成登录
if (StrUtil.isNotBlank(token) && userId != null && userId > 0) {
completeQrLogin(token, userId, tenantId);
}
}
}
// 返回 success 表示处理成功
return "success";
}
/**
* 处理微信用户(关注/注册/登录)
*/
private Integer processWxUser(Integer tenantId, String openId, String unionid, String subscribe) {
Integer userId = 0;
// 关注操作 // 关注操作
if (subscribe != null && subscribe.equals("1")) { if (subscribe != null && subscribe.equals("1")) {
final int count = userOauthService.count(new LambdaQueryWrapper<UserOauth>().eq(UserOauth::getOauthType, MP_OFFICIAL).eq(UserOauth::getUnionid, unionid).eq(UserOauth::getTenantId, tenantId)); final int count = userOauthService.count(new LambdaQueryWrapper<UserOauth>()
System.out.println("count = " + count); .eq(UserOauth::getOauthType, MP_OFFICIAL)
.eq(UserOauth::getUnionid, unionid)
.eq(UserOauth::getTenantId, tenantId));
System.out.println("已绑定用户数量 = " + count);
if (count == 0) { if (count == 0) {
// 其他平台是否有注册过 // 检查其他平台是否有注册过
final List<UserOauth> list = userOauthService.list(new LambdaQueryWrapper<UserOauth>().eq(UserOauth::getUnionid, unionid).eq(UserOauth::getDeleted, 0)); final List<UserOauth> list = userOauthService.list(
new LambdaQueryWrapper<UserOauth>()
.eq(UserOauth::getUnionid, unionid)
.eq(UserOauth::getDeleted, 0));
final int size = list.size(); final int size = list.size();
// 新用户注册 // 新用户注册
if (size == 0) { if (size == 0) {
User user = new User(); User user = new User();
user.setStatus(0); user.setStatus(0);
user.setUsername("wxoff_".concat(RandomUtil.randomString(12))); user.setUsername("wxoff_".concat(RandomUtil.randomString(12)));
user.setStatus(0);
user.setNickname("微信公众号用户"); user.setNickname("微信公众号用户");
user.setPlatform(MP_OFFICIAL); user.setPlatform(MP_OFFICIAL);
user.setGradeId(1); user.setGradeId(1);
@@ -144,7 +263,7 @@ public class WxOfficialController extends BaseController {
user.setRecommend(0); user.setRecommend(0);
final RoleParam roleParam = new RoleParam(); final RoleParam roleParam = new RoleParam();
roleParam.setTenantId(tenantId); roleParam.setTenantId(tenantId);
roleParam.setRoleCode("guest"); roleParam.setRoleCode("user");
Role role = roleService.getByRoleCode(roleParam); Role role = roleService.getByRoleCode(roleParam);
user.setRoleId(role.getRoleId()); user.setRoleId(role.getRoleId());
if (userService.saveUser(user)) { if (userService.saveUser(user)) {
@@ -155,9 +274,11 @@ public class WxOfficialController extends BaseController {
userRole.setTenantId(user.getTenantId()); userRole.setTenantId(user.getTenantId());
userRole.setRoleId(user.getRoleId()); userRole.setRoleId(user.getRoleId());
userRoleService.save(userRole); userRoleService.save(userRole);
// 注意:不立即同步到 websopy,等绑定手机号后再同步
} }
System.out.println("新微信公众号用户 = " + userId); System.out.println("新微信公众号用户 userId = " + userId);
} }
// 更新 // 更新
if (!CollectionUtils.isEmpty(list)) { if (!CollectionUtils.isEmpty(list)) {
for (UserOauth item : list) { for (UserOauth item : list) {
@@ -165,8 +286,9 @@ public class WxOfficialController extends BaseController {
userId = item.getUserId(); userId = item.getUserId();
} }
} }
System.out.println("其他平台有注册过 = " + userId); System.out.println("其他平台有注册过 userId = " + userId);
} }
// 保存第三方用户记录 // 保存第三方用户记录
final UserOauth userOauth = new UserOauth(); final UserOauth userOauth = new UserOauth();
userOauth.setOauthType(MP_OFFICIAL); userOauth.setOauthType(MP_OFFICIAL);
@@ -175,12 +297,136 @@ public class WxOfficialController extends BaseController {
userOauth.setUserId(userId); userOauth.setUserId(userId);
userOauth.setTenantId(tenantId); userOauth.setTenantId(tenantId);
boolean save = userOauthService.save(userOauth); boolean save = userOauthService.save(userOauth);
System.out.println("关注微信公众号 = " + save); System.out.println("关注微信公众号保存结果 = " + save);
} else {
// 已绑定用户,获取userId
UserOauth existingUser = userOauthService.getOne(new LambdaQueryWrapper<UserOauth>()
.eq(UserOauth::getOauthType, MP_OFFICIAL)
.eq(UserOauth::getUnionid, unionid)
.eq(UserOauth::getTenantId, tenantId));
if (existingUser != null) {
userId = existingUser.getUserId();
System.out.println("已存在绑定用户 userId = " + userId);
// 即使已存在记录,也确保oauth记录存在(防止数据不一致)
boolean oauthExists = userOauthService.count(new LambdaQueryWrapper<UserOauth>()
.eq(UserOauth::getOauthType, MP_OFFICIAL)
.eq(UserOauth::getUnionid, unionid)
.eq(UserOauth::getTenantId, tenantId)
.eq(UserOauth::getOauthId, openId)) > 0;
if (!oauthExists) {
// 创建oauth记录
final UserOauth userOauth = new UserOauth();
userOauth.setOauthType(MP_OFFICIAL);
userOauth.setUnionid(unionid);
userOauth.setOauthId(openId);
userOauth.setUserId(userId);
userOauth.setTenantId(tenantId);
boolean save = userOauthService.save(userOauth);
System.out.println("补充创建oauth记录,结果 = " + save);
}
} else {
System.out.println("警告:count=1但未找到对应的绑定记录,unionid=" + unionid + ", tenantId=" + tenantId);
// 这种情况可能是数据不一致,尝试创建新的oauth记录
userId = findOrCreateUserForOauth(tenantId, openId, unionid);
}
} }
} }
return userId;
}
/**
* 完成扫码登录
*/
private void completeQrLogin(String token, Integer userId, Integer tenantId) {
try {
System.out.println("开始完成扫码登录: token=" + token + ", userId=" + userId + ", tenantId=" + tenantId);
String redisKey = "qr-login:token:" + token;
QrLoginData qrLoginData = redisUtil.get(redisKey, QrLoginData.class);
if (qrLoginData == null) {
qrLoginData = new QrLoginData();
qrLoginData.setToken(token);
qrLoginData.setCreateTime(DateUtil.formatDateTime(DateUtil.date()));
}
User user = userService.getAllByUserId(String.valueOf(userId));
if (user == null) {
log.warn("扫码登录完成时未找到用户,token={}, userId={}", token, userId);
return;
}
// 1. 检查并确保用户有合适的角色
ensureUserHasAppropriateRole(user, tenantId);
long ttlSeconds = 120L;
qrLoginData.setToken(token);
qrLoginData.setUserId(userId);
qrLoginData.setUsername(user.getUsername());
qrLoginData.setTenantId(user.getTenantId() != null ? user.getTenantId() : tenantId);
qrLoginData.setExpireTime(DateUtil.formatDateTime(DateUtil.offsetSecond(DateUtil.date(), (int) ttlSeconds)));
if (StrUtil.isBlank(user.getPhone())) {
qrLoginData.setStatus("bind_phone");
qrLoginData.setNeedBindPhone(true);
qrLoginData.setAccessToken(null);
qrLoginData.setMessage("请先绑定手机号完成登录");
} else {
qrLoginData.setStatus("confirmed");
qrLoginData.setNeedBindPhone(false);
qrLoginData.setAccessToken(buildAccessToken(user));
qrLoginData.setMessage("登录成功");
}
redisUtil.set(redisKey, qrLoginData, ttlSeconds, TimeUnit.SECONDS);
log.info("扫码登录状态已更新,token={}, userId={}, status={}, needBindPhone={}, message={}, ttlSeconds={}",
token, userId, qrLoginData.getStatus(), qrLoginData.getNeedBindPhone(),
qrLoginData.getMessage(), ttlSeconds);
System.out.println("扫码登录完成,token=" + token + ", userId=" + userId +
", status=" + qrLoginData.getStatus() + ", message=" + qrLoginData.getMessage());
} catch (Exception e) {
log.error("完成扫码登录失败", e);
System.out.println("完成扫码登录异常: " + e.getMessage());
}
}
private String buildAccessToken(User user) {
JwtSubject jwtSubject = new JwtSubject(user.getUsername(), user.getTenantId());
return JwtUtil.buildToken(jwtSubject, configProperties.getTokenExpireTime(), configProperties.getTokenKey());
}
@Operation(summary = "生成微信扫码登录二维码")
@GetMapping("/qrcode/{token}")
public ApiResult<?> generateQrCode(@PathVariable("token") String token) {
try {
// 生成带参数的二维码,scene 为 token
String url = QRCODE_CREATE_URL + getAccessToken();
// 创建临时二维码(有效期7天),scene_str 最大32个可见字符
JSONObject params = new JSONObject();
params.put("action_info", new JSONObject().put("scene", new JSONObject().put("scene_str", token)));
params.put("action_name", "QR_STR_SCENE");
params.put("expire_seconds", 604800); // 7天有效期
String result = HttpRequest.post(url)
.body(params.toJSONString())
.timeout(10000)
.execute().body();
System.out.println("生成二维码结果: " + result);
JSONObject jsonResult = JSONObject.parseObject(result);
if (jsonResult.containsKey("ticket")) {
String ticket = jsonResult.getString("ticket");
String qrCodeUrl = QRCODE_SHOW_URL + java.net.URLEncoder.encode(ticket, "UTF-8");
return success(qrCodeUrl);
} else {
return fail("生成二维码失败: " + result);
}
} catch (Exception e) {
log.error("生成二维码异常: {}", e.getMessage());
return fail("生成二维码异常: " + e.getMessage());
} }
return null;
} }
private void sendTemplateMessage(String openId) { private void sendTemplateMessage(String openId) {
@@ -268,30 +514,65 @@ public class WxOfficialController extends BaseController {
// 调用接口凭证 // 调用接口凭证
private String getAccessToken() { private String getAccessToken() {
return getAccessToken(null);
}
private String getAccessToken(Integer tenantId) {
try {
return wxService.getOfficialAccessToken(tenantId);
} catch (Exception ex) {
log.warn("从系统设置获取公众号access_token失败,回退到兼容逻辑: {}", ex.getMessage());
}
String key = MP_OFFICIAL.concat(":access_token:5"); String key = MP_OFFICIAL.concat(":access_token:5");
// 从缓存获取access_token
String value = redisUtil.get(key); String value = redisUtil.get(key);
if (value != null) { if (value != null) {
// 解析access_token
JSONObject response = JSON.parseObject(value); JSONObject response = JSON.parseObject(value);
return response.getString("access_token"); return response.getString("access_token");
} }
// 微信获取凭证接口https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
String apiUrl = "https://api.weixin.qq.com/cgi-bin/token"; String apiUrl = "https://api.weixin.qq.com/cgi-bin/token";
// 组装url参数
String url = apiUrl.concat("?grant_type=client_credential").concat("&appid=").concat(appid).concat("&secret=").concat(secret); String url = apiUrl.concat("?grant_type=client_credential").concat("&appid=").concat(appid).concat("&secret=").concat(secret);
// 执行get请求
String result = HttpUtil.get(url); String result = HttpUtil.get(url);
// 解析access_token
JSONObject response = JSON.parseObject(result); JSONObject response = JSON.parseObject(result);
if (response.getString("access_token") != null) { if (response.getString("access_token") != null) {
// 存入缓存
redisUtil.set(key, result, 7000L, TimeUnit.SECONDS); redisUtil.set(key, result, 7000L, TimeUnit.SECONDS);
return response.getString("access_token"); return response.getString("access_token");
} }
return null; return null;
} }
private String getOfficialToken() {
try {
JSONObject config = settingService.getBySettingKey("wx-official");
String token = config.getString("token");
return StrUtil.isNotBlank(token) ? token : TOKEN;
} catch (Exception ex) {
log.warn("读取公众号token配置失败,回退到硬编码值: {}", ex.getMessage());
return TOKEN;
}
}
private String getOfficialEncodingAESKey() {
try {
JSONObject config = settingService.getBySettingKey("wx-official");
String encodingAESKey = config.getString("encodingAESKey");
return StrUtil.isNotBlank(encodingAESKey) ? encodingAESKey : ENCODING_AES_KEY;
} catch (Exception ex) {
log.warn("读取公众号EncodingAESKey配置失败,回退到硬编码值: {}", ex.getMessage());
return ENCODING_AES_KEY;
}
}
private String getOfficialAppId(Integer tenantId) {
try {
return wxService.getOfficialAppId(tenantId);
} catch (Exception ex) {
log.warn("读取公众号AppId配置失败,回退到硬编码值: {}", ex.getMessage());
return appid;
}
}
@Operation(summary = "创建公众号菜单接口") @Operation(summary = "创建公众号菜单接口")
@PostMapping("/createMenu") @PostMapping("/createMenu")
public ApiResult<?> createMenu() { public ApiResult<?> createMenu() {
@@ -346,4 +627,168 @@ public class WxOfficialController extends BaseController {
return success(getAccessToken()); return success(getAccessToken());
} }
/**
* 查找或创建用户用于oauth绑定
*/
private Integer findOrCreateUserForOauth(Integer tenantId, String openId, String unionid) {
Integer userId = 0;
// 首先尝试通过unionid查找任何平台的现有用户
final List<UserOauth> list = userOauthService.list(
new LambdaQueryWrapper<UserOauth>()
.eq(UserOauth::getUnionid, unionid)
.eq(UserOauth::getDeleted, 0));
if (!CollectionUtils.isEmpty(list)) {
for (UserOauth item : list) {
if (item.getUserId() != null) {
userId = item.getUserId();
break;
}
}
System.out.println("数据不一致:通过unionid找到其他平台的用户 userId = " + userId);
}
// 如果没找到用户,创建一个新用户
if (userId == 0) {
User user = new User();
user.setStatus(0);
user.setUsername("wxoff_".concat(RandomUtil.randomString(12)));
user.setNickname("微信公众号用户");
user.setPlatform(MP_OFFICIAL);
user.setGradeId(1);
user.setPassword(userService.encodePassword(CommonUtil.randomUUID16()));
user.setTenantId(tenantId);
user.setRecommend(0);
// 尝试获取"user"角色,不行就用"guest",再不行用默认6
Role role = null;
try {
final RoleParam roleParam = new RoleParam();
roleParam.setTenantId(tenantId);
roleParam.setRoleCode("user");
role = roleService.getByRoleCode(roleParam);
} catch (Exception e) {
System.out.println("获取user角色失败,尝试guest角色");
try {
final RoleParam roleParam = new RoleParam();
roleParam.setTenantId(tenantId);
roleParam.setRoleCode("guest");
role = roleService.getByRoleCode(roleParam);
} catch (Exception ex) {
System.out.println("获取guest角色也失败,使用默认角色ID 6");
}
}
user.setRoleId(role != null ? role.getRoleId() : 6);
if (userService.saveUser(user)) {
userId = user.getUserId();
// 添加用户角色
final UserRole userRole = new UserRole();
userRole.setUserId(user.getUserId());
userRole.setTenantId(user.getTenantId());
userRole.setRoleId(user.getRoleId());
userRoleService.save(userRole);
// 注意:不立即同步到 websopy,等绑定手机号后再同步
}
System.out.println("数据不一致:创建新用户 userId = " + userId);
}
// 创建oauth记录
final UserOauth userOauth = new UserOauth();
userOauth.setOauthType(MP_OFFICIAL);
userOauth.setUnionid(unionid);
userOauth.setOauthId(openId);
userOauth.setUserId(userId);
userOauth.setTenantId(tenantId);
boolean save = userOauthService.save(userOauth);
System.out.println("创建oauth记录修复数据不一致,结果 = " + save);
return userId;
}
/**
* 确保用户有合适的角色
*/
private void ensureUserHasAppropriateRole(User user, Integer tenantId) {
try {
// 检查用户是否有有效的角色绑定
List<UserRole> userRoles = userRoleService.list(new LambdaQueryWrapper<UserRole>()
.eq(UserRole::getUserId, user.getUserId())
.eq(UserRole::getTenantId, tenantId));
if (CollectionUtils.isEmpty(userRoles)) {
System.out.println("用户 " + user.getUserId() + " 没有角色绑定,尝试分配角色");
// 获取合适的角色
Role role = null;
// 先尝试获取"user"角色
try {
final RoleParam roleParam = new RoleParam();
roleParam.setTenantId(tenantId);
roleParam.setRoleCode("user");
role = roleService.getByRoleCode(roleParam);
} catch (Exception e) {
System.out.println("获取user角色失败,尝试guest角色");
try {
final RoleParam roleParam = new RoleParam();
roleParam.setTenantId(tenantId);
roleParam.setRoleCode("guest");
role = roleService.getByRoleCode(roleParam);
} catch (Exception ex) {
System.out.println("获取guest角色也失败,使用默认角色ID 6");
}
}
Integer roleId = role != null ? role.getRoleId() : 6;
// 创建用户角色绑定
final UserRole userRole = new UserRole();
userRole.setUserId(user.getUserId());
userRole.setTenantId(tenantId);
userRole.setRoleId(roleId);
userRoleService.save(userRole);
// 更新用户的roleId
user.setRoleId(roleId);
userService.updateUser(user);
System.out.println("为用户 " + user.getUserId() + " 分配了角色ID: " + roleId);
} else {
// 检查角色的有效性
boolean hasValidRole = false;
for (UserRole userRole : userRoles) {
if (userRole.getRoleId() != null && userRole.getRoleId() > 0) {
hasValidRole = true;
// 确保用户的roleId与绑定的角色一致
if (!userRole.getRoleId().equals(user.getRoleId())) {
user.setRoleId(userRole.getRoleId());
userService.updateUser(user);
System.out.println("更新用户 " + user.getUserId() + " 的角色ID为: " + userRole.getRoleId());
}
break;
}
}
if (!hasValidRole) {
System.out.println("用户 " + user.getUserId() + " 的角色绑定无效,重新分配");
// 重新分配角色(简化逻辑,使用默认角色ID 6)
final UserRole userRole = new UserRole();
userRole.setUserId(user.getUserId());
userRole.setTenantId(tenantId);
userRole.setRoleId(6);
userRoleService.save(userRole);
user.setRoleId(6);
userService.updateUser(user);
System.out.println("重新为用户 " + user.getUserId() + " 分配了默认角色ID: 6");
}
}
} catch (Exception e) {
System.out.println("确保用户角色时发生异常: " + e.getMessage());
e.printStackTrace();
}
}
} }
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -46,9 +47,11 @@ public class AccessKey implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
} }
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -47,9 +48,11 @@ public class AuthorizeCode implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
} }
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -83,9 +84,11 @@ public class Cart implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "商品描述") @Schema(description = "商品描述")
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -56,9 +57,11 @@ public class ChatConversation implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "注册时间") @Schema(description = "注册时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
} }
@@ -6,6 +6,7 @@ import java.util.Date;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -66,9 +67,11 @@ public class ChatMessage implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "注册时间") @Schema(description = "注册时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "发送人昵称") @Schema(description = "发送人昵称")
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.entity;
import cn.hutool.core.util.DesensitizedUtil; import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -100,9 +101,11 @@ public class Company implements Serializable {
private String invoiceHeader; private String invoiceHeader;
@Schema(description = "服务开始时间") @Schema(description = "服务开始时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime; private Date startTime;
@Schema(description = "服务到期时间") @Schema(description = "服务到期时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date expirationTime; private Date expirationTime;
@Schema(description = "即将过期") @Schema(description = "即将过期")
@@ -245,9 +248,11 @@ public class Company implements Serializable {
private Integer deleted; private Integer deleted;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "是否默认企业主体") @Schema(description = "是否默认企业主体")
@@ -289,6 +294,10 @@ public class Company implements Serializable {
@TableField(exist = false) @TableField(exist = false)
private String password; private String password;
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site)")
@TableField(exist = false)
private String appCode;
@Schema(description = "手机号(脱敏)") @Schema(description = "手机号(脱敏)")
@TableField(exist = false) @TableField(exist = false)
private String mobile; private String mobile;
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -54,6 +55,7 @@ public class CompanyComment implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -37,6 +38,7 @@ public class CompanyContent implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -59,6 +60,7 @@ public class CompanyGit implements Serializable {
private Integer status; private Integer status;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "租户id") @Schema(description = "租户id")
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -47,6 +48,7 @@ public class CompanyParameter implements Serializable {
private Integer status; private Integer status;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "租户id") @Schema(description = "租户id")
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -56,6 +57,7 @@ public class CompanyUrl implements Serializable {
private Integer status; private Integer status;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "租户id") @Schema(description = "租户id")
@@ -7,6 +7,7 @@ import java.util.Date;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -66,6 +67,7 @@ public class Components implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
} }
@@ -1,6 +1,7 @@
package com.gxwebsoft.common.system.entity; package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -42,9 +43,11 @@ public class Dict implements Serializable {
private Integer deleted; private Integer deleted;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "租户id") @Schema(description = "租户id")
@@ -1,6 +1,7 @@
package com.gxwebsoft.common.system.entity; package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -33,6 +34,9 @@ public class DictData implements Serializable {
@Schema(description = "字典数据名称") @Schema(description = "字典数据名称")
private String dictDataName; private String dictDataName;
@Schema(description = "字段类型")
private String type;
@Schema(description = "字段名称") @Schema(description = "字段名称")
@TableField(exist = false) @TableField(exist = false)
private String text; private String text;
@@ -62,9 +66,11 @@ public class DictData implements Serializable {
private Integer deleted; private Integer deleted;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "字典代码") @Schema(description = "字典代码")
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
import java.util.Date; import java.util.Date;
import java.io.Serializable; import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -45,9 +46,11 @@ public class Dictionary implements Serializable {
private Integer deleted; private Integer deleted;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "租户id") @Schema(description = "租户id")
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.*;
import java.util.Date; import java.util.Date;
import java.io.Serializable; import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -51,9 +52,11 @@ public class DictionaryData implements Serializable {
private Integer deleted; private Integer deleted;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "字典代码") @Schema(description = "字典代码")
@@ -3,6 +3,7 @@ package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -48,9 +49,11 @@ public class EmailRecord implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
} }
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableLogic;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -68,9 +69,11 @@ public class Environment implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
} }
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.entity;
import cn.hutool.core.util.DesensitizedUtil; import cn.hutool.core.util.DesensitizedUtil;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -70,9 +71,11 @@ public class FileRecord implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "文件访问地址") @Schema(description = "文件访问地址")
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -57,9 +58,11 @@ public class LoginRecord implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "操作时间") @Schema(description = "操作时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "用户id") @Schema(description = "用户id")
@@ -1,6 +1,7 @@
package com.gxwebsoft.common.system.entity; package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -80,9 +81,11 @@ public class Menu implements GrantedAuthority {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "修改时间") @Schema(description = "修改时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime; private Date updateTime;
@Schema(description = "子菜单") @Schema(description = "子菜单")
@@ -1,6 +1,7 @@
package com.gxwebsoft.common.system.entity; package com.gxwebsoft.common.system.entity;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -113,6 +114,7 @@ public class Merchant implements Serializable {
private Integer tenantId; private Integer tenantId;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; private Date createTime;
@Schema(description = "默认商户管理员角色ID") @Schema(description = "默认商户管理员角色ID")

Some files were not shown because too many files have changed in this diff Show More