From 2250f744d16cc55dfefd4810f202795bb429b17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Tue, 28 Jul 2026 13:42:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E8=AE=A2=E5=8D=95=E4=B8=8E=E7=BD=91=E7=9B=98?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=A3=80=E6=B5=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 + docs/virtual-pay-setup.md | 72 +++ .../app/AppBlogArticleController.java | 6 + .../app/AppIntegralRecordController.java | 39 +- .../controller/app/AppResourceController.java | 36 +- .../app/AppVirtualOrderController.java | 59 ++ .../app/AppVirtualPayController.java | 107 ++++ .../web/controller/app/PublicController.java | 17 +- .../app/WxMiniappPayController.java | 24 +- .../controller/system/SysLoginController.java | 13 +- .../src/main/resources/application.yml | 19 +- ruoyi-admin/src/main/resources/logback.xml | 4 +- .../com/ruoyi/common/wx/VirtualPayConfig.java | 29 + .../com/ruoyi/common/wx/WxCodeSession.java | 26 + .../ruoyi/common/wx/WxCodeSessionService.java | 81 +++ .../framework/config/SecurityConfig.java | 1 - .../config/NetDiskCheckExecutorConfig.java | 30 + .../controller/TtProjectInfoController.java | 23 + .../ruoyi/office/domain/TtProjectInfo.java | 68 +++ .../office/domain/TtProjectLinkCheck.java | 117 ++++ .../mapper/TtProjectLinkCheckMapper.java | 15 + .../service/IProjectLinkCheckService.java | 13 + .../impl/ProjectLinkCheckServiceImpl.java | 229 ++++++++ .../impl/TtProjectInfoServiceImpl.java | 8 + .../netdisk/BaiduNetDiskLinkChecker.java | 218 ++++++++ .../service/netdisk/NetDiskCheckResult.java | 55 ++ .../service/netdisk/NetDiskConstants.java | 21 + .../service/netdisk/NetDiskLinkChecker.java | 11 + .../netdisk/QuarkNetDiskLinkChecker.java | 197 +++++++ .../resources/mapper/office/TtCodeMapper.xml | 4 +- .../resources/mapper/office/TtFileMapper.xml | 16 +- .../mapper/office/TtProjectInfoMapper.xml | 60 +- .../office/TtProjectLinkCheckMapper.xml | 55 ++ .../com/ruoyi/app/domain/AppBlogArticle.java | 12 + .../com/ruoyi/app/domain/AppResource.java | 41 ++ .../com/ruoyi/app/domain/AppVirtualOrder.java | 239 ++++++++ .../ruoyi/app/domain/AppVirtualProduct.java | 67 +++ .../request/CreateVirtualOrderRequest.java | 33 ++ .../app/mapper/AppVirtualOrderMapper.java | 35 ++ .../app/mapper/AppVirtualProductMapper.java | 14 + .../app/service/IAppVirtualOrderService.java | 15 + .../app/service/IAppVirtualPayService.java | 17 + .../ruoyi/app/service/ImageUrlService.java | 131 +++++ .../service/impl/AppResourceServiceImpl.java | 30 + .../impl/AppVirtualOrderServiceImpl.java | 31 + .../impl/AppVirtualPayServiceImpl.java | 529 ++++++++++++++++++ .../ruoyi/system/mapper/SysUserMapper.java | 2 +- .../mapper/app/AppResourceMapper.xml | 67 ++- .../mapper/app/AppVirtualOrderMapper.xml | 150 +++++ .../mapper/app/AppVirtualProductMapper.xml | 40 ++ .../resources/mapper/system/SysUserMapper.xml | 4 +- ruoyi-ui/src/api/app/virtualOrder.js | 18 + ruoyi-ui/src/api/office/project.js | 17 + ruoyi-ui/src/views/app/resource/index.vue | 18 +- ruoyi-ui/src/views/app/virtualOrder/index.vue | 254 +++++++++ ruoyi-ui/src/views/office/project/index.vue | 178 +++++- sql/optimize_resource_detail_indexes.sql | 8 + sql/project_link_check.sql | 19 + sql/virtual_pay_order_menu.sql | 50 ++ sql/virtual_pay_resource.sql | 79 +++ 60 files changed, 3668 insertions(+), 106 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 docs/virtual-pay-setup.md create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualOrderController.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualPayController.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/wx/VirtualPayConfig.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSession.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSessionService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/config/NetDiskCheckExecutorConfig.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectLinkCheck.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtProjectLinkCheckMapper.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/IProjectLinkCheckService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/impl/ProjectLinkCheckServiceImpl.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/BaiduNetDiskLinkChecker.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskCheckResult.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskConstants.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskLinkChecker.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/QuarkNetDiskLinkChecker.java create mode 100644 ruoyi-office/src/main/resources/mapper/office/TtProjectLinkCheckMapper.xml create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualOrder.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualProduct.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/request/CreateVirtualOrderRequest.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualOrderMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualProductMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualOrderService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualPayService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/ImageUrlService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualOrderServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualPayServiceImpl.java create mode 100644 ruoyi-system/src/main/resources/mapper/app/AppVirtualOrderMapper.xml create mode 100644 ruoyi-system/src/main/resources/mapper/app/AppVirtualProductMapper.xml create mode 100644 ruoyi-ui/src/api/app/virtualOrder.js create mode 100644 ruoyi-ui/src/views/app/virtualOrder/index.vue create mode 100644 sql/optimize_resource_detail_indexes.sql create mode 100644 sql/project_link_check.sql create mode 100644 sql/virtual_pay_order_menu.sql create mode 100644 sql/virtual_pay_resource.sql diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5480842 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "kiroAgent.configureMCP": "Disabled" +} \ No newline at end of file diff --git a/docs/virtual-pay-setup.md b/docs/virtual-pay-setup.md new file mode 100644 index 0000000..54dbfe9 --- /dev/null +++ b/docs/virtual-pay-setup.md @@ -0,0 +1,72 @@ +# 资源直购虚拟支付上线配置 + +本项目使用微信小程序虚拟支付的 `short_series_goods`(道具直购)模式,不再提供人民币充值积分功能。 + +## 1. 执行数据库迁移 + +执行 `sql/virtual_pay_resource.sql`。脚本会: + +- 为资源增加分单位价格 `price_fen`; +- 创建价格档位表 `app_virtual_product`; +- 创建虚拟支付订单表 `app_virtual_order`; +- 创建资源访问权益表 `app_resource_entitlement`; +- 将已有 `is_ad=3` 资源的 `ad_number`(元)迁移为 `price_fen`(分)。 + +## 2. 配置虚拟支付环境变量 + +```text +WX_VIRTUAL_PAY_ENABLED=true +WX_VIRTUAL_PAY_OFFER_ID=微信虚拟支付OfferId +WX_VIRTUAL_PAY_APP_KEY=微信虚拟支付现网AppKey +WX_VIRTUAL_PAY_ENV=0 +WX_VIRTUAL_PAY_CALLBACK_TOKEN=CHANGE_ME_TO_A_RANDOM_SECRET +``` + +AppKey 和小程序 Secret 不应提交到 Git,生产环境应由部署平台注入。 +未设置这些环境变量时,虚拟支付默认关闭。 + +## 3. 配置价格档位道具 + +在微信公众平台的虚拟支付后台按价格创建并发布道具,例如: + +| 价格 | productId 示例 | +| --- | --- | +| 1 元 | `resource_100` | +| 5 元 | `resource_500` | +| 10 元 | `resource_1000` | + +然后在若依后台编辑付费资源: + +- 获取方式选择“付费”; +- 价格填写分,例如 5 元填写 `500`; +- 同一价格的资源填写同一个已发布 `productId`。 + +后台会自动维护“价格 -> productId”唯一映射;一个 productId 不能绑定多个价格。 + +## 4. 配置消息推送 + +在小程序后台配置: + +```text +URL: https://你的域名/prod-api/app/virtual-pay/callback +Token: 与 WX_VIRTUAL_PAY_CALLBACK_TOKEN 相同 +数据格式: JSON +消息加密方式: 明文模式 +``` + +支付发货通知由服务端验签、校验 OpenID/订单/productId/价格后幂等发放资源权益。退款成功通知会撤销对应权益。 +回调成功时接口返回 `{"ErrCode":0,"ErrMsg":"success"}`;业务校验或处理失败时返回非零错误码,微信会自动重试。 + +## 5. 上线检查 + +1. 后端启动时确认 `WX_VIRTUAL_PAY_ENABLED=true`。 +2. 确认道具已审核发布并等待配置生效。 +3. 用一条最低价格资源完成真机支付。 +4. 检查 `app_virtual_order.status=1` 和 `app_resource_entitlement.status=1`。 +5. 重新进入资源详情,确认下载链接只对购买用户返回。 + +## 6. 启用后台订单管理 + +执行 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟支付订单”,支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。 + +虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppBlogArticleController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppBlogArticleController.java index 76fb569..cf415ff 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppBlogArticleController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppBlogArticleController.java @@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app; import com.ruoyi.app.domain.AppBlogArticle; import com.ruoyi.app.mapper.AppBlogArticleMapper; +import com.ruoyi.app.service.ImageUrlService; import com.ruoyi.app.service.IAppBlogArticleService; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; @@ -32,6 +33,9 @@ public class AppBlogArticleController extends BaseController { @Autowired private IAppBlogArticleService appBlogArticleService; + @Autowired + private ImageUrlService imageUrlService; + @Resource private AppBlogArticleMapper appBlogArticleMapper; @@ -45,6 +49,7 @@ public class AppBlogArticleController extends BaseController { public TableDataInfo list(AppBlogArticle appBlogArticle) { startPage(); List list = appBlogArticleService.selectAppBlogArticleList(appBlogArticle); + imageUrlService.decorateArticles(list); return getDataTable(list); } @@ -72,6 +77,7 @@ public class AppBlogArticleController extends BaseController { @GetMapping(value = "/app/{id}") public AjaxResult appGetInfo(@PathVariable("id") Long id) { AppBlogArticle appBlogArticle = appBlogArticleService.selectAppBlogArticleById(id); + imageUrlService.decorateArticle(appBlogArticle); List list = new ArrayList<>(); List picList = fileService.selectTtFileByCodeName(appBlogArticle.getTitle()); for (TtFile ttFile : picList) { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppIntegralRecordController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppIntegralRecordController.java index 9d50397..53342e9 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppIntegralRecordController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppIntegralRecordController.java @@ -4,6 +4,8 @@ import com.ruoyi.app.domain.AppIntegralRecord; import com.ruoyi.app.domain.AppLotteryGoods; import com.ruoyi.app.domain.AppLotteryLog; import com.ruoyi.app.mapper.AppIntegralRecordMapper; +import com.ruoyi.app.mapper.AppResourceMapper; +import com.ruoyi.app.domain.AppResource; import com.ruoyi.app.service.IAppIntegralRecordService; import com.ruoyi.app.service.IAppLotteryGoodsService; import com.ruoyi.app.service.IAppLotteryLogService; @@ -42,6 +44,8 @@ public class AppIntegralRecordController extends BaseController private AppIntegralRecordMapper appIntegralRecordMapper; @Resource private SysUserMapper sysUserMapper; + @Resource + private AppResourceMapper appResourceMapper; @Autowired private IAppLotteryGoodsService appLotteryGoodsService; @Autowired @@ -187,19 +191,38 @@ public class AppIntegralRecordController extends BaseController * 积分记录,增减用户积分通用 */ @Transactional + @PreAuthorize("isAuthenticated()") @PostMapping("/resource") public AjaxResult resource(@RequestBody AppIntegralRecord appIntegralRecord) { - Date date = new Date(); - appIntegralRecord.setIntegralTime(date); - appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord); - if (appIntegralRecord.getIsAdd() == 0){ - sysUserMapper.addIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId()); - }else { - sysUserMapper.delIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId()); + Long userId = getUserId(); + AppResource resource = appResourceMapper.selectAppResourceById(appIntegralRecord.getResourceId()); + if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 2L + || resource.getAdNumber() == null || resource.getAdNumber() <= 0) + { + return error("该资源不支持积分兑换"); } - return toAjax(1); + AppIntegralRecord exists = new AppIntegralRecord(); + exists.setUserId(userId); + exists.setResourceId(resource.getId()); + if (appIntegralRecordMapper.selectAppIntegralRecordCount(exists) > 0) + { + return success("资源已解锁"); + } + + int points = Math.toIntExact(resource.getAdNumber()); + if (sysUserMapper.delIntegralByUserId(points, userId) != 1) + { + return error("积分不足"); + } + appIntegralRecord.setSource("资源兑换"); + appIntegralRecord.setIsAdd(1L); + appIntegralRecord.setIntegralNumber(resource.getAdNumber()); + appIntegralRecord.setUserId(userId); + appIntegralRecord.setIntegralTime(new Date()); + appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord); + return success("兑换成功"); } /** diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppResourceController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppResourceController.java index cabc9b2..55fad67 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppResourceController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppResourceController.java @@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app; import com.ruoyi.app.domain.AppResource; import com.ruoyi.app.mapper.AppResourceMapper; +import com.ruoyi.app.service.ImageUrlService; import com.ruoyi.app.service.IAppResourceService; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; @@ -16,6 +17,9 @@ import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; import java.util.List; +import org.springframework.security.core.Authentication; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.utils.SecurityUtils; /** * 资源Controller @@ -30,6 +34,9 @@ public class AppResourceController extends BaseController @Autowired private IAppResourceService appResourceService; + @Autowired + private ImageUrlService imageUrlService; + @Resource private AppResourceMapper appResourceMapper; @@ -41,6 +48,7 @@ public class AppResourceController extends BaseController { startPage(); List list = appResourceService.selectAppResourceList(appResource); + imageUrlService.decorateResources(list); return getDataTable(list); } @@ -70,16 +78,28 @@ public class AppResourceController extends BaseController @GetMapping(value = "/app/{id}") public AjaxResult getInfoApp(@PathVariable("id") Long id) { - return success(appResourceService.selectAppResourceById(id)); + return success(imageUrlService.decorateResource( + appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId()))); } /** * 根据用户ID获取资源详细信息 */ @GetMapping(value = "/app/user/{id}/{userId}") - public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long userId) + public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long ignoredUserId) { - return success(appResourceMapper.selectAppResourceByIdAndUserId(id, userId)); + return success(imageUrlService.decorateResource( + appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId()))); + } + + /** + * 小程序资源详情。用户身份只从 JWT 读取,不接收客户端 userId。 + */ + @GetMapping(value = "/app/user/{id}") + public AjaxResult getResourceForCurrentUser(@PathVariable("id") Long id) + { + return success(imageUrlService.decorateResource( + appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId()))); } /** @@ -123,4 +143,14 @@ public class AppResourceController extends BaseController { return toAjax( appResourceMapper.lookAddNumber(appResource.getId())); } + + private Long currentUserId() + { + Authentication authentication = SecurityUtils.getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof LoginUser) + { + return ((LoginUser) authentication.getPrincipal()).getUserId(); + } + return null; + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualOrderController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualOrderController.java new file mode 100644 index 0000000..9822f81 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualOrderController.java @@ -0,0 +1,59 @@ +package com.ruoyi.web.controller.app; + +import com.ruoyi.app.domain.AppVirtualOrder; +import com.ruoyi.app.service.IAppVirtualOrderService; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 虚拟支付订单管理接口。 + * + * 支付订单由微信回调和主动查单流程维护,管理端仅提供只读查询和导出。 + */ +@RestController +@RequestMapping("/app/virtualOrder") +public class AppVirtualOrderController extends BaseController +{ + @Autowired + private IAppVirtualOrderService appVirtualOrderService; + + @PreAuthorize("@ss.hasPermi('app:virtualOrder:list')") + @GetMapping("/list") + public TableDataInfo list(AppVirtualOrder order) + { + startPage(); + List list = appVirtualOrderService.selectAppVirtualOrderList(order); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('app:virtualOrder:query')") + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable Long id) + { + return success(appVirtualOrderService.selectAppVirtualOrderById(id)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualOrder:export')") + @Log(title = "虚拟支付订单", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, AppVirtualOrder order) + { + List list = appVirtualOrderService.selectAppVirtualOrderList(order); + ExcelUtil util = new ExcelUtil<>(AppVirtualOrder.class); + util.exportExcel(response, list, "虚拟支付订单数据"); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualPayController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualPayController.java new file mode 100644 index 0000000..bacec19 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualPayController.java @@ -0,0 +1,107 @@ +package com.ruoyi.web.controller.app; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.app.domain.request.CreateVirtualOrderRequest; +import com.ruoyi.app.service.IAppVirtualPayService; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/app/virtual-pay") +public class AppVirtualPayController extends BaseController +{ + private static final Logger log = LoggerFactory.getLogger(AppVirtualPayController.class); + + private final IAppVirtualPayService virtualPayService; + private final ObjectMapper objectMapper; + + public AppVirtualPayController(IAppVirtualPayService virtualPayService, ObjectMapper objectMapper) + { + this.virtualPayService = virtualPayService; + this.objectMapper = objectMapper; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/resource-orders") + public AjaxResult createOrder(@Validated @RequestBody CreateVirtualOrderRequest request) + { + return success(virtualPayService.createOrder(request)); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/resource-orders/{orderNo}") + public AjaxResult queryOrder(@PathVariable String orderNo, + @RequestParam(defaultValue = "false") boolean sync) + { + return success(virtualPayService.queryOrder(orderNo, sync)); + } + + /** + * 在小程序后台保存消息推送配置时使用。 + */ + @GetMapping(value = "/callback", produces = MediaType.TEXT_PLAIN_VALUE) + public String verifyCallback(@RequestParam String signature, + @RequestParam String timestamp, + @RequestParam String nonce, + @RequestParam String echostr) + { + if (!virtualPayService.verifyCallbackSignature(signature, timestamp, nonce)) + { + log.warn("微信虚拟支付回调 URL 验证失败"); + return ""; + } + return echostr; + } + + /** + * 小程序消息推送请配置为 JSON 数据格式。 + */ + @PostMapping("/callback") + public Map callback(@RequestParam String signature, + @RequestParam String timestamp, + @RequestParam String nonce, + @RequestBody String body) + { + if (!virtualPayService.verifyCallbackSignature(signature, timestamp, nonce)) + { + log.warn("拒绝签名无效的微信虚拟支付回调"); + return callbackResponse(1, "invalid signature"); + } + try + { + JsonNode json = objectMapper.readTree(body); + virtualPayService.handleCallback(json); + return callbackResponse(0, "success"); + } + catch (Exception e) + { + // 返回非零 ErrCode 让微信重试,异常详情只记录在服务端日志中。 + log.error("微信虚拟支付回调处理失败", e); + return callbackResponse(1, "callback failed"); + } + } + + private static Map callbackResponse(int code, String message) + { + Map response = new LinkedHashMap<>(); + response.put("ErrCode", code); + response.put("ErrMsg", message); + return response; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/PublicController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/PublicController.java index 98a984c..573efd9 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/PublicController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/PublicController.java @@ -4,7 +4,6 @@ import com.ruoyi.app.domain.ShuiYinVo; import com.ruoyi.app.mapper.AppPunlicMapper; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.utils.http.HttpUtils; import com.ruoyi.system.service.ISysConfigService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -21,20 +20,6 @@ public class PublicController extends BaseController { AppPunlicMapper appPunlicMapper; @Autowired private ISysConfigService configService; - /** - * 获取微信openid信息 - */ - @GetMapping(value = "/autoLoginWx/{code}") - public AjaxResult autoLoginWx(@PathVariable("code") String code) - { - HttpUtils httpUtils = new HttpUtils(); - String appid = configService.selectConfigByKey("miniapp.wx.appId"); - String secret = configService.selectConfigByKey("miniapp.wx.secret"); - String param = "appid="+appid+"&secret="+secret+"&js_code="+code+"&grant_type=authorization_code"; - String s = httpUtils.httpGet("https://api.weixin.qq.com/sns/jscode2session", param); - return success(s); - } - /** * 去水印 key 免费申请联系作者微信:yimoziyuan666 * @return @@ -43,7 +28,7 @@ public class PublicController extends BaseController { private String delWatermarkKey; @PostMapping(value = "/delSHuiYin") public AjaxResult delSHuiYin(HttpServletRequest request, @RequestBody ShuiYinVo shuiYinVo) { - HttpUtils httpUtils = new HttpUtils(); + com.ruoyi.common.utils.http.HttpUtils httpUtils = new com.ruoyi.common.utils.http.HttpUtils(); String key = configService.selectConfigByKey("miniapp.shuiyin.key"); String param = "key="+key+"&url="+shuiYinVo.getUrl(); String s = httpUtils.httpGet("https://api.emoboy.vip/api/shuiyin/delWatermark", param); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/WxMiniappPayController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/WxMiniappPayController.java index 0372ac3..4ac3a72 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/WxMiniappPayController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/WxMiniappPayController.java @@ -1,13 +1,12 @@ package com.ruoyi.web.controller.app; -import com.ruoyi.app.service.IAppPayOrderService; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.wx.*; -import com.wechat.pay.java.service.payments.jsapi.model.PrepayWithRequestPaymentResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; import javax.servlet.http.HttpServletRequest; import java.io.IOException; @@ -26,20 +25,6 @@ import java.io.IOException; public class WxMiniappPayController extends BaseController { @Autowired private WxMiniappPayService wxMiniappPayService; - @Autowired - private IAppPayOrderService appPayOrderService; - - /** - * 预支付订单/统一下单 - * @param req - * @return - */ - @RequestMapping("/createOrder") - public Response createOrder(@Validated @RequestBody CreateOrderReq req) { - log.info("------预支付订单/统一下单------"); - //微信小程序登录用户openid,用户标识 说明:用户在商户appid下的唯一标识。 - return this.wxMiniappPayService.createOrder(req); - } /** * 支付回调 @@ -65,6 +50,7 @@ public class WxMiniappPayController extends BaseController { * @return */ @PostMapping("/queryOrder") + @PreAuthorize("@ss.hasPermi('app:order:query')") public Response queryOrder(@Validated @RequestBody QueryOrderReq req) { log.info("------根据支付订单号查询订单------"); return this.wxMiniappPayService.queryOrder(req); @@ -81,6 +67,7 @@ public class WxMiniappPayController extends BaseController { * @return */ @PostMapping("/queryOrderByOutTradeNo") + @PreAuthorize("@ss.hasPermi('app:order:query')") public Response queryOrderByOutTradeNo(@Validated @RequestBody QueryOrderReq req) { log.info("------根据商户订单号查询订单------"); return this.wxMiniappPayService.queryOrderByOutTradeNo(req); @@ -93,6 +80,7 @@ public class WxMiniappPayController extends BaseController { */ @PostMapping("/closeOrder") + @PreAuthorize("@ss.hasPermi('app:order:edit')") public Response closeOrder(@Validated @RequestBody QueryOrderReq req) { log.info("------微信小程序支付关闭订单------"); return this.wxMiniappPayService.closeOrder(req); @@ -104,6 +92,7 @@ public class WxMiniappPayController extends BaseController { * @return */ @PostMapping("/refund") + @PreAuthorize("@ss.hasPermi('app:order:refund')") public Response refund(@Validated @RequestBody RefundOrderReq req) { log.info("------微信支付退款------"); return this.wxMiniappPayService.refund(req); @@ -115,6 +104,7 @@ public class WxMiniappPayController extends BaseController { * @return */ @GetMapping("/queryByOutRefundNo") + @PreAuthorize("@ss.hasPermi('app:order:query')") public Response queryByOutRefundNo(String outRefundNo) { log.info("------微信支付查询单笔退款------"); return this.wxMiniappPayService.queryByOutRefundNo(outRefundNo); @@ -134,4 +124,4 @@ public class WxMiniappPayController extends BaseController { -} \ No newline at end of file +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java index 2857c2d..50aeeda 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java @@ -16,6 +16,8 @@ import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.framework.web.service.SysLoginService; import com.ruoyi.framework.web.service.SysPermissionService; import com.ruoyi.system.service.ISysMenuService; +import com.ruoyi.common.wx.WxCodeSession; +import com.ruoyi.common.wx.WxCodeSessionService; import javax.servlet.http.HttpServletRequest; @@ -36,6 +38,9 @@ public class SysLoginController @Autowired private SysPermissionService permissionService; + @Autowired + private WxCodeSessionService wxCodeSessionService; + /** * 登录方法 * @@ -55,15 +60,13 @@ public class SysLoginController /** - * 微信openID登陆 - * @param - * @return + * 使用微信临时 code 登录。OpenID 与 session_key 只在服务端获取。 */ @PostMapping("/wxLogin") public AjaxResult wxLogin(@RequestBody LoginBody loginBody) { AjaxResult ajax = AjaxResult.success(); - // 生成令牌 - String token = loginService.wxLogin(loginBody.getOpenId(),loginBody.getOldUserId()); + WxCodeSession codeSession = wxCodeSessionService.exchange(loginBody.getCode()); + String token = loginService.wxLogin(codeSession.getOpenId(), loginBody.getOldUserId()); ajax.put(Constants.TOKEN, token); return ajax; } diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 090902e..baa1af9 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -9,8 +9,8 @@ ruoyi: # 实例演示开关 demoEnabled: true # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) -# profile: /home/upload - profilee: D:/ruoyi/uploadPath + profile: /home/upload +# profilee: D:/ruoyi/uploadPath # 获取ip地址开关 addressEnabled: false # 验证码类型 math 数字计算 char 字符验证 @@ -42,10 +42,10 @@ wx: appid: wx17b46f75e762c184 # 微信小程序appid secret: 1fa844dc33e70f7d813f24cd2af7678b # 微信小程序密钥 merchantId: 1704387455 # 商户号 - privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径(测试环境) - publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径(测试环境) -# privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径(正式环境) -# publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径(正式环境) +# privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径(测试环境) +# publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径(测试环境) + privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径(正式环境) + publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径(正式环境) publicKeyId: PUB_KEY_ID_0117043874552025011100188700000234 merchantSerialNumber: 743FBCB9F5DFD76104A468C6AC6EDD41268634A3 # 商户API证书序列号 apiV3Key: G7kL2mN8pQ4rT1vX9yZ3bC5dF6hJ0sW1 # 商户APIV3密钥 @@ -53,6 +53,13 @@ wx: # refundNotifyUrl: http://www.yidaima.cn:6001/app/pay/refundNotify # 退款通知地址(测试环境) payNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/payNotify # 支付通知地址(正式环境) refundNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/refundNotify # 退款通知地址(正式环境) + # 虚拟支付配置。生产环境请通过环境变量注入,不要提交真实 AppKey。 + virtual-pay: + enabled: ${WX_VIRTUAL_PAY_ENABLED:true} + offer-id: ${WX_VIRTUAL_PAY_OFFER_ID:1450602603} + app-key: ${WX_VIRTUAL_PAY_APP_KEY:iVRy8soX3JTHd8dWQxruDtOIDjrcIKYL} + env: ${WX_VIRTUAL_PAY_ENV:0} + callback-token: ${WX_VIRTUAL_PAY_CALLBACK_TOKEN:zSgMvGAa289XyYoWDnpc1gUUPEBQ2me9} # 日志配置 logging: diff --git a/ruoyi-admin/src/main/resources/logback.xml b/ruoyi-admin/src/main/resources/logback.xml index 6fb741e..bd056c4 100644 --- a/ruoyi-admin/src/main/resources/logback.xml +++ b/ruoyi-admin/src/main/resources/logback.xml @@ -1,8 +1,8 @@ - - + + diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/wx/VirtualPayConfig.java b/ruoyi-common/src/main/java/com/ruoyi/common/wx/VirtualPayConfig.java new file mode 100644 index 0000000..3c9f2d4 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/wx/VirtualPayConfig.java @@ -0,0 +1,29 @@ +package com.ruoyi.common.wx; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 微信小程序虚拟支付配置。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "wx.miniapp.virtual-pay") +public class VirtualPayConfig +{ + /** 是否启用虚拟支付。 */ + private boolean enabled; + + /** 虚拟支付 OfferId。 */ + private String offerId; + + /** 虚拟支付现网 AppKey。 */ + private String appKey; + + /** 0-正式环境,1-沙箱环境。 */ + private int env = 0; + + /** 小程序消息推送 Token,用于校验支付通知。 */ + private String callbackToken; +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSession.java b/ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSession.java new file mode 100644 index 0000000..f268482 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSession.java @@ -0,0 +1,26 @@ +package com.ruoyi.common.wx; + +/** + * 微信 code2Session 的服务端结果。该对象禁止返回给客户端。 + */ +public class WxCodeSession +{ + private final String openId; + private final String sessionKey; + + public WxCodeSession(String openId, String sessionKey) + { + this.openId = openId; + this.sessionKey = sessionKey; + } + + public String getOpenId() + { + return openId; + } + + public String getSessionKey() + { + return sessionKey; + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSessionService.java b/ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSessionService.java new file mode 100644 index 0000000..d37db8d --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/wx/WxCodeSessionService.java @@ -0,0 +1,81 @@ +package com.ruoyi.common.wx; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.exception.ServiceException; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * 仅在服务端使用小程序临时 code 换取 OpenID 与 session_key。 + */ +@Service +public class WxCodeSessionService +{ + private static final String CODE_TO_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"; + + private final WxPayConfig wxPayConfig; + private final ObjectMapper objectMapper; + private final OkHttpClient httpClient; + + public WxCodeSessionService(WxPayConfig wxPayConfig, ObjectMapper objectMapper) + { + this.wxPayConfig = wxPayConfig; + this.objectMapper = objectMapper; + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .build(); + } + + public WxCodeSession exchange(String code) + { + if (StringUtils.isBlank(code)) + { + throw new ServiceException("微信登录凭证不能为空"); + } + if (StringUtils.isAnyBlank(wxPayConfig.getAppid(), wxPayConfig.getSecret())) + { + throw new ServiceException("微信小程序 AppID 或 Secret 未配置"); + } + + HttpUrl url = HttpUrl.parse(CODE_TO_SESSION_URL).newBuilder() + .addQueryParameter("appid", wxPayConfig.getAppid()) + .addQueryParameter("secret", wxPayConfig.getSecret()) + .addQueryParameter("js_code", code) + .addQueryParameter("grant_type", "authorization_code") + .build(); + Request request = new Request.Builder().url(url).get().build(); + + try (Response response = httpClient.newCall(request).execute()) + { + if (!response.isSuccessful() || response.body() == null) + { + throw new ServiceException("微信登录服务暂不可用"); + } + JsonNode result = objectMapper.readTree(response.body().string()); + if (result.path("errcode").asInt(0) != 0) + { + throw new ServiceException("微信登录失败:" + result.path("errmsg").asText("未知错误")); + } + String openId = result.path("openid").asText(); + String sessionKey = result.path("session_key").asText(); + if (StringUtils.isAnyBlank(openId, sessionKey)) + { + throw new ServiceException("微信登录结果不完整"); + } + return new WxCodeSession(openId, sessionKey); + } + catch (IOException e) + { + throw new ServiceException("调用微信登录服务失败"); + } + } +} diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index c98eccd..fb23f84 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -117,7 +117,6 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter "/app/**/list", "/app/**/app/**", "/system/dict/data/type/**", - "/app/public/autoLoginWx/**", "/app/public/delSHuiYin", "/app/public/getSysSet", "/app/pay/notify", diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/config/NetDiskCheckExecutorConfig.java b/ruoyi-office/src/main/java/com/ruoyi/office/config/NetDiskCheckExecutorConfig.java new file mode 100644 index 0000000..9547b19 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/config/NetDiskCheckExecutorConfig.java @@ -0,0 +1,30 @@ +package com.ruoyi.office.config; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 网盘检测线程池。限制并发数,避免批量检测时触发平台风控。 + */ +@Configuration +public class NetDiskCheckExecutorConfig +{ + @Bean(name = "netDiskCheckExecutor", destroyMethod = "shutdown") + public ExecutorService netDiskCheckExecutor() + { + AtomicInteger threadNumber = new AtomicInteger(1); + ThreadFactory threadFactory = runnable -> + { + Thread thread = new Thread(runnable, + "net-disk-check-" + threadNumber.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; + return Executors.newFixedThreadPool(4, threadFactory); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtProjectInfoController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtProjectInfoController.java index 4b1f552..e2c1cf6 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtProjectInfoController.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtProjectInfoController.java @@ -10,6 +10,7 @@ import javax.servlet.http.HttpServletResponse; import com.ruoyi.common.utils.CoverGenerator; import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.service.ITtCodeService; +import com.ruoyi.office.service.IProjectLinkCheckService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; @@ -42,6 +43,8 @@ public class TtProjectInfoController extends BaseController { private ITtProjectInfoService ttProjectInfoService; @Autowired private ITtCodeService ttCodeService; + @Autowired + private IProjectLinkCheckService projectLinkCheckService; /** @@ -76,6 +79,26 @@ public class TtProjectInfoController extends BaseController { return success(ttProjectInfoService.selectTtProjectInfoById(id)); } + /** + * 检测单个项目的网盘链接。 + */ + @PreAuthorize("@ss.hasPermi('office:project:edit')") + @Log(title = "检测项目网盘链接", businessType = BusinessType.OTHER) + @PostMapping("/link-check/single/{id}") + public AjaxResult checkProjectLink(@PathVariable("id") Integer id) { + return success(projectLinkCheckService.checkProject(id)); + } + + /** + * 检测选中项目的网盘链接。 + */ + @PreAuthorize("@ss.hasPermi('office:project:edit')") + @Log(title = "批量检测项目网盘链接", businessType = BusinessType.OTHER) + @PostMapping("/link-check/batch") + public AjaxResult checkProjectLinks(@RequestBody Integer[] ids) { + return success(projectLinkCheckService.checkProjects(ids)); + } + /** * 新增项目清单 */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectInfo.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectInfo.java index ae0918c..67168dc 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectInfo.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectInfo.java @@ -4,6 +4,7 @@ import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; +import java.util.Date; /** * 项目清单对象 tt_project_info @@ -50,6 +51,24 @@ public class TtProjectInfo extends BaseEntity @Excel(name = "项目百度链接") private String projectBaiduUrl; + /** 夸克网盘最近检测状态 */ + private String quarkCheckStatus; + + /** 夸克网盘最近检测说明 */ + private String quarkCheckMessage; + + /** 夸克网盘最近检测时间 */ + private Date quarkCheckedAt; + + /** 百度网盘最近检测状态 */ + private String baiduCheckStatus; + + /** 百度网盘最近检测说明 */ + private String baiduCheckMessage; + + /** 百度网盘最近检测时间 */ + private Date baiduCheckedAt; + public void setId(Integer id) { this.id = id; @@ -131,6 +150,54 @@ public class TtProjectInfo extends BaseEntity this.projectBaiduUrl = projectBaiduUrl; } + public String getQuarkCheckStatus() { + return quarkCheckStatus; + } + + public void setQuarkCheckStatus(String quarkCheckStatus) { + this.quarkCheckStatus = quarkCheckStatus; + } + + public String getQuarkCheckMessage() { + return quarkCheckMessage; + } + + public void setQuarkCheckMessage(String quarkCheckMessage) { + this.quarkCheckMessage = quarkCheckMessage; + } + + public Date getQuarkCheckedAt() { + return quarkCheckedAt; + } + + public void setQuarkCheckedAt(Date quarkCheckedAt) { + this.quarkCheckedAt = quarkCheckedAt; + } + + public String getBaiduCheckStatus() { + return baiduCheckStatus; + } + + public void setBaiduCheckStatus(String baiduCheckStatus) { + this.baiduCheckStatus = baiduCheckStatus; + } + + public String getBaiduCheckMessage() { + return baiduCheckMessage; + } + + public void setBaiduCheckMessage(String baiduCheckMessage) { + this.baiduCheckMessage = baiduCheckMessage; + } + + public Date getBaiduCheckedAt() { + return baiduCheckedAt; + } + + public void setBaiduCheckedAt(Date baiduCheckedAt) { + this.baiduCheckedAt = baiduCheckedAt; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -141,6 +208,7 @@ public class TtProjectInfo extends BaseEntity .append("projectName1", getProjectName1()) .append("projectDesc", getProjectDesc()) .append("projectUrl", getProjectUrl()) + .append("projectBaiduUrl", getProjectBaiduUrl()) .append("projectVurl", getProjectVurl()) .toString(); } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectLinkCheck.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectLinkCheck.java new file mode 100644 index 0000000..c546a0d --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtProjectLinkCheck.java @@ -0,0 +1,117 @@ +package com.ruoyi.office.domain; + +import java.util.Date; + +/** + * 项目网盘链接检测结果对象 tt_project_link_check。 + */ +public class TtProjectLinkCheck +{ + private Long id; + + private Integer projectId; + + private String diskType; + + private String linkUrl; + + private String checkStatus; + + private String providerCode; + + private String checkMessage; + + private Long responseTimeMs; + + private Date checkedAt; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public Integer getProjectId() + { + return projectId; + } + + public void setProjectId(Integer projectId) + { + this.projectId = projectId; + } + + public String getDiskType() + { + return diskType; + } + + public void setDiskType(String diskType) + { + this.diskType = diskType; + } + + public String getLinkUrl() + { + return linkUrl; + } + + public void setLinkUrl(String linkUrl) + { + this.linkUrl = linkUrl; + } + + public String getCheckStatus() + { + return checkStatus; + } + + public void setCheckStatus(String checkStatus) + { + this.checkStatus = checkStatus; + } + + public String getProviderCode() + { + return providerCode; + } + + public void setProviderCode(String providerCode) + { + this.providerCode = providerCode; + } + + public String getCheckMessage() + { + return checkMessage; + } + + public void setCheckMessage(String checkMessage) + { + this.checkMessage = checkMessage; + } + + public Long getResponseTimeMs() + { + return responseTimeMs; + } + + public void setResponseTimeMs(Long responseTimeMs) + { + this.responseTimeMs = responseTimeMs; + } + + public Date getCheckedAt() + { + return checkedAt; + } + + public void setCheckedAt(Date checkedAt) + { + this.checkedAt = checkedAt; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtProjectLinkCheckMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtProjectLinkCheckMapper.java new file mode 100644 index 0000000..b975219 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtProjectLinkCheckMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.office.mapper; + +import com.ruoyi.office.domain.TtProjectLinkCheck; + +/** + * 项目网盘链接检测结果 Mapper。 + */ +public interface TtProjectLinkCheckMapper +{ + int upsertTtProjectLinkCheck(TtProjectLinkCheck linkCheck); + + int deleteByProjectId(Integer projectId); + + int deleteByProjectIds(Integer[] projectIds); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/IProjectLinkCheckService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/IProjectLinkCheckService.java new file mode 100644 index 0000000..4085262 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/IProjectLinkCheckService.java @@ -0,0 +1,13 @@ +package com.ruoyi.office.service; + +import java.util.Map; + +/** + * 项目网盘链接检测服务。 + */ +public interface IProjectLinkCheckService +{ + Map checkProject(Integer projectId); + + Map checkProjects(Integer[] projectIds); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/ProjectLinkCheckServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/ProjectLinkCheckServiceImpl.java new file mode 100644 index 0000000..575d527 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/ProjectLinkCheckServiceImpl.java @@ -0,0 +1,229 @@ +package com.ruoyi.office.service.impl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.office.domain.TtProjectInfo; +import com.ruoyi.office.domain.TtProjectLinkCheck; +import com.ruoyi.office.mapper.TtProjectInfoMapper; +import com.ruoyi.office.mapper.TtProjectLinkCheckMapper; +import com.ruoyi.office.service.IProjectLinkCheckService; +import com.ruoyi.office.service.netdisk.NetDiskCheckResult; +import com.ruoyi.office.service.netdisk.NetDiskConstants; +import com.ruoyi.office.service.netdisk.NetDiskLinkChecker; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +/** + * 项目网盘链接检测服务实现。 + */ +@Service +public class ProjectLinkCheckServiceImpl implements IProjectLinkCheckService +{ + private static final int MAX_BATCH_SIZE = 20; + private static final int MAX_MESSAGE_LENGTH = 250; + + private final TtProjectInfoMapper projectInfoMapper; + private final TtProjectLinkCheckMapper linkCheckMapper; + private final Map checkerMap; + private final ExecutorService netDiskCheckExecutor; + + public ProjectLinkCheckServiceImpl( + TtProjectInfoMapper projectInfoMapper, + TtProjectLinkCheckMapper linkCheckMapper, + List checkers, + @Qualifier("netDiskCheckExecutor") ExecutorService netDiskCheckExecutor) + { + this.projectInfoMapper = projectInfoMapper; + this.linkCheckMapper = linkCheckMapper; + this.netDiskCheckExecutor = netDiskCheckExecutor; + this.checkerMap = new LinkedHashMap<>(); + for (NetDiskLinkChecker checker : checkers) + { + this.checkerMap.put(checker.getDiskType(), checker); + } + } + + @Override + public Map checkProject(Integer projectId) + { + if (projectId == null) + { + throw new ServiceException("项目编号不能为空"); + } + ProjectCheckOutcome outcome = checkProjectInternal(projectId); + return buildSummary(Collections.singletonList(outcome)); + } + + @Override + public Map checkProjects(Integer[] projectIds) + { + if (projectIds == null || projectIds.length == 0) + { + throw new ServiceException("请至少选择一个项目"); + } + + Set uniqueIds = new LinkedHashSet<>(); + for (Integer projectId : projectIds) + { + if (projectId != null) + { + uniqueIds.add(projectId); + } + } + if (uniqueIds.isEmpty()) + { + throw new ServiceException("请至少选择一个项目"); + } + if (uniqueIds.size() > MAX_BATCH_SIZE) + { + throw new ServiceException("一次最多检测 " + MAX_BATCH_SIZE + " 个项目"); + } + + List> futures = new ArrayList<>(); + for (Integer projectId : uniqueIds) + { + futures.add(CompletableFuture.supplyAsync( + () -> checkProjectInternal(projectId), netDiskCheckExecutor)); + } + + List outcomes = new ArrayList<>(); + for (CompletableFuture future : futures) + { + outcomes.add(future.join()); + } + return buildSummary(outcomes); + } + + private ProjectCheckOutcome checkProjectInternal(Integer projectId) + { + TtProjectInfo project = projectInfoMapper.selectTtProjectInfoById(projectId); + if (project == null) + { + throw new ServiceException("项目不存在:" + projectId); + } + + List results = new ArrayList<>(2); + checkAndSave(projectId, project.getProjectUrl(), NetDiskConstants.DISK_QUARK, results); + checkAndSave(projectId, project.getProjectBaiduUrl(), NetDiskConstants.DISK_BAIDU, results); + return new ProjectCheckOutcome(project.getId(), project.getProjectNum(), results); + } + + private void checkAndSave(Integer projectId, String linkUrl, String diskType, + List results) + { + if (StringUtils.isBlank(linkUrl)) + { + return; + } + + NetDiskLinkChecker checker = checkerMap.get(diskType); + NetDiskCheckResult result; + if (checker == null) + { + result = new NetDiskCheckResult(diskType, linkUrl, + NetDiskConstants.STATUS_UNKNOWN, "NO_CHECKER", + "未找到对应的网盘检测器", 0L); + } + else + { + result = checker.check(linkUrl); + } + + TtProjectLinkCheck linkCheck = new TtProjectLinkCheck(); + linkCheck.setProjectId(projectId); + linkCheck.setDiskType(diskType); + linkCheck.setLinkUrl(linkUrl); + linkCheck.setCheckStatus(result.getStatus()); + linkCheck.setProviderCode(truncate(result.getProviderCode(), 32)); + linkCheck.setCheckMessage(truncate(result.getMessage(), MAX_MESSAGE_LENGTH)); + linkCheck.setResponseTimeMs(result.getResponseTimeMs()); + linkCheck.setCheckedAt(new Date()); + linkCheckMapper.upsertTtProjectLinkCheck(linkCheck); + results.add(result); + } + + private Map buildSummary(List outcomes) + { + int validCount = 0; + int invalidCount = 0; + int warningCount = 0; + int unknownCount = 0; + List> resultItems = new ArrayList<>(); + + for (ProjectCheckOutcome outcome : outcomes) + { + for (NetDiskCheckResult result : outcome.results) + { + if (NetDiskConstants.STATUS_VALID.equals(result.getStatus())) + { + validCount++; + } + else if (NetDiskConstants.STATUS_INVALID.equals(result.getStatus())) + { + invalidCount++; + } + else if (NetDiskConstants.STATUS_UNKNOWN.equals(result.getStatus())) + { + unknownCount++; + } + else + { + warningCount++; + } + + Map item = new LinkedHashMap<>(); + item.put("projectId", outcome.projectId); + item.put("projectNum", outcome.projectNum); + item.put("diskType", result.getDiskType()); + item.put("status", result.getStatus()); + item.put("message", result.getMessage()); + resultItems.add(item); + } + } + + Map summary = new LinkedHashMap<>(); + summary.put("projectCount", outcomes.size()); + summary.put("linkCount", resultItems.size()); + summary.put("validCount", validCount); + summary.put("invalidCount", invalidCount); + summary.put("warningCount", warningCount); + summary.put("unknownCount", unknownCount); + summary.put("results", resultItems); + return summary; + } + + private String truncate(String value, int maxLength) + { + if (value == null || value.length() <= maxLength) + { + return value; + } + return value.substring(0, maxLength); + } + + private static class ProjectCheckOutcome + { + private final Integer projectId; + private final String projectNum; + private final List results; + + private ProjectCheckOutcome(Integer projectId, String projectNum, + List results) + { + this.projectId = projectId; + this.projectNum = projectNum; + this.results = results; + } + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java index 5c74d56..b587c1e 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java @@ -6,8 +6,10 @@ import java.util.List; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.mapper.TtCodeMapper; +import com.ruoyi.office.mapper.TtProjectLinkCheckMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.ruoyi.office.mapper.TtProjectInfoMapper; import com.ruoyi.office.domain.TtProjectInfo; import com.ruoyi.office.service.ITtProjectInfoService; @@ -25,6 +27,8 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService private TtProjectInfoMapper ttProjectInfoMapper; @Autowired private TtCodeMapper ttCodeMapper; + @Autowired + private TtProjectLinkCheckMapper ttProjectLinkCheckMapper; /** * 查询项目清单 @@ -81,8 +85,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService * @return 结果 */ @Override + @Transactional public int deleteTtProjectInfoByIds(Integer[] ids) { + ttProjectLinkCheckMapper.deleteByProjectIds(ids); return ttProjectInfoMapper.deleteTtProjectInfoByIds(ids); } @@ -93,8 +99,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService * @return 结果 */ @Override + @Transactional public int deleteTtProjectInfoById(Integer id) { + ttProjectLinkCheckMapper.deleteByProjectId(id); return ttProjectInfoMapper.deleteTtProjectInfoById(id); } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/BaiduNetDiskLinkChecker.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/BaiduNetDiskLinkChecker.java new file mode 100644 index 0000000..5ec4997 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/BaiduNetDiskLinkChecker.java @@ -0,0 +1,218 @@ +package com.ruoyi.office.service.netdisk; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +/** + * 百度网盘分享链接检测器。 + * + * 这里只检测分享是否存在,不下载分享文件。 + */ +@Component +public class BaiduNetDiskLinkChecker implements NetDiskLinkChecker +{ + private static final String USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131 Safari/537.36"; + private static final int MAX_REDIRECTS = 5; + private static final Set ALLOWED_HOSTS = + new HashSet<>(Arrays.asList("pan.baidu.com", "yun.baidu.com")); + private static final String[] INVALID_MARKERS = { + "页面不存在", + "你所访问的页面不存在了", + "分享的文件已经被取消了", + "分享已过期", + "该分享文件已过期", + "啊哦,你来晚了" + }; + private static final String[] RISK_MARKERS = { + "访问过于频繁", + "请输入验证码", + "系统繁忙,请稍候再试" + }; + + private final OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .build(); + + @Override + public String getDiskType() + { + return NetDiskConstants.DISK_BAIDU; + } + + @Override + public NetDiskCheckResult check(String linkUrl) + { + long startedAt = System.currentTimeMillis(); + String url = StringUtils.trim(linkUrl); + if (!isAllowedUrl(url) || !hasSharePath(url)) + { + return result(linkUrl, NetDiskConstants.STATUS_FORMAT_ERROR, "FORMAT", + "百度网盘链接格式不正确", startedAt); + } + + String passcode = getQueryParameter(url, "pwd"); + String currentUrl = url; + try + { + for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) + { + Request request = new Request.Builder() + .url(currentUrl) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Encoding", "identity") + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) + { + int httpCode = response.code(); + if (httpCode >= 300 && httpCode < 400) + { + String location = response.header("Location"); + if (StringUtils.isBlank(location)) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + String.valueOf(httpCode), "百度网盘返回了无目标地址的跳转", startedAt); + } + String redirectUrl = resolveUrl(currentUrl, location); + if (!isAllowedUrl(redirectUrl)) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + String.valueOf(httpCode), "百度网盘跳转到了非预期地址", startedAt); + } + currentUrl = redirectUrl; + continue; + } + + String body = response.peekBody(1024L * 1024L).string(); + if (httpCode == 404 || containsAny(body, INVALID_MARKERS)) + { + return result(linkUrl, NetDiskConstants.STATUS_INVALID, + String.valueOf(httpCode), "分享已失效、取消或不存在", startedAt); + } + if (httpCode == 403 || httpCode == 429 || httpCode >= 500 || containsAny(body, RISK_MARKERS)) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + String.valueOf(httpCode), "百度网盘暂时拒绝检测或访问受限", startedAt); + } + if (httpCode >= 200 && httpCode < 300) + { + boolean needsCode = currentUrl.contains("/share/init"); + if (needsCode && StringUtils.isBlank(passcode)) + { + return result(linkUrl, NetDiskConstants.STATUS_NEED_CODE, + String.valueOf(httpCode), "分享存在,但链接中没有提取码", startedAt); + } + String message = needsCode + ? "分享存在,链接中包含提取码" + : "分享链接有效"; + return result(linkUrl, NetDiskConstants.STATUS_VALID, + String.valueOf(httpCode), message, startedAt); + } + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + String.valueOf(httpCode), "百度网盘返回了未识别的状态", startedAt); + } + } + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, "REDIRECT", + "百度网盘跳转次数过多", startedAt); + } + catch (IOException | RuntimeException e) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, "NETWORK", + "检测请求失败:" + safeMessage(e), startedAt); + } + } + + private NetDiskCheckResult result(String linkUrl, String status, String providerCode, + String message, long startedAt) + { + return new NetDiskCheckResult(getDiskType(), linkUrl, status, providerCode, message, + System.currentTimeMillis() - startedAt); + } + + private boolean hasSharePath(String url) + { + try + { + String path = new URI(url).getPath(); + return path != null && (path.startsWith("/s/") || path.startsWith("/share/")); + } + catch (URISyntaxException e) + { + return false; + } + } + + private boolean isAllowedUrl(String url) + { + if (StringUtils.isBlank(url)) + { + return false; + } + try + { + URI uri = new URI(url); + return "https".equalsIgnoreCase(uri.getScheme()) + && uri.getUserInfo() == null + && (uri.getPort() == -1 || uri.getPort() == 443) + && uri.getHost() != null + && ALLOWED_HOSTS.contains(uri.getHost().toLowerCase()); + } + catch (URISyntaxException e) + { + return false; + } + } + + private String resolveUrl(String baseUrl, String location) + { + HttpUrl base = HttpUrl.parse(baseUrl); + HttpUrl resolved = base == null ? null : base.resolve(location); + return resolved == null ? null : resolved.toString(); + } + + private String getQueryParameter(String url, String name) + { + HttpUrl httpUrl = HttpUrl.parse(url); + return httpUrl == null ? null : httpUrl.queryParameter(name); + } + + private boolean containsAny(String body, String[] markers) + { + if (StringUtils.isBlank(body)) + { + return false; + } + for (String marker : markers) + { + if (body.contains(marker)) + { + return true; + } + } + return false; + } + + private String safeMessage(Exception e) + { + return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskCheckResult.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskCheckResult.java new file mode 100644 index 0000000..238429d --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskCheckResult.java @@ -0,0 +1,55 @@ +package com.ruoyi.office.service.netdisk; + +/** + * 单个网盘链接的检测结果。 + */ +public class NetDiskCheckResult +{ + private final String diskType; + private final String linkUrl; + private final String status; + private final String providerCode; + private final String message; + private final long responseTimeMs; + + public NetDiskCheckResult(String diskType, String linkUrl, String status, + String providerCode, String message, long responseTimeMs) + { + this.diskType = diskType; + this.linkUrl = linkUrl; + this.status = status; + this.providerCode = providerCode; + this.message = message; + this.responseTimeMs = responseTimeMs; + } + + public String getDiskType() + { + return diskType; + } + + public String getLinkUrl() + { + return linkUrl; + } + + public String getStatus() + { + return status; + } + + public String getProviderCode() + { + return providerCode; + } + + public String getMessage() + { + return message; + } + + public long getResponseTimeMs() + { + return responseTimeMs; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskConstants.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskConstants.java new file mode 100644 index 0000000..63efa54 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskConstants.java @@ -0,0 +1,21 @@ +package com.ruoyi.office.service.netdisk; + +/** + * 网盘类型和检测状态常量。 + */ +public final class NetDiskConstants +{ + public static final String DISK_QUARK = "QUARK"; + public static final String DISK_BAIDU = "BAIDU"; + + public static final String STATUS_VALID = "VALID"; + public static final String STATUS_INVALID = "INVALID"; + public static final String STATUS_NEED_CODE = "NEED_CODE"; + public static final String STATUS_CODE_ERROR = "CODE_ERROR"; + public static final String STATUS_FORMAT_ERROR = "FORMAT_ERROR"; + public static final String STATUS_UNKNOWN = "UNKNOWN"; + + private NetDiskConstants() + { + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskLinkChecker.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskLinkChecker.java new file mode 100644 index 0000000..4f22420 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/NetDiskLinkChecker.java @@ -0,0 +1,11 @@ +package com.ruoyi.office.service.netdisk; + +/** + * 网盘链接检测器。 + */ +public interface NetDiskLinkChecker +{ + String getDiskType(); + + NetDiskCheckResult check(String linkUrl); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/QuarkNetDiskLinkChecker.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/QuarkNetDiskLinkChecker.java new file mode 100644 index 0000000..921f9ad --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/netdisk/QuarkNetDiskLinkChecker.java @@ -0,0 +1,197 @@ +package com.ruoyi.office.service.netdisk; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +/** + * 夸克网盘分享链接检测器。 + */ +@Component +public class QuarkNetDiskLinkChecker implements NetDiskLinkChecker +{ + private static final String TOKEN_API = + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token" + + "?pr=ucpro&fr=pc&uc_param_str="; + private static final String USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131 Safari/537.36"; + private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8"); + private static final Set INVALID_CODES = new HashSet<>(Arrays.asList( + 41006, 41009, 41010, 41011, 41012, 41019, 41026, 41028, 41029, 41030, 41031 + )); + + private final OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .followRedirects(false) + .build(); + + @Override + public String getDiskType() + { + return NetDiskConstants.DISK_QUARK; + } + + @Override + public NetDiskCheckResult check(String linkUrl) + { + long startedAt = System.currentTimeMillis(); + String url = StringUtils.trim(linkUrl); + String shareId = parseShareId(url); + if (shareId == null) + { + return result(linkUrl, NetDiskConstants.STATUS_FORMAT_ERROR, "FORMAT", + "夸克网盘链接格式不正确", startedAt); + } + + String passcode = getQueryParameter(url, "pwd"); + if (StringUtils.isBlank(passcode)) + { + passcode = getQueryParameter(url, "passcode"); + } + + JSONObject payload = new JSONObject(); + payload.put("pwd_id", shareId); + payload.put("passcode", StringUtils.defaultString(passcode)); + payload.put("support_visit_limit_private_share", true); + + Request request = new Request.Builder() + .url(TOKEN_API) + .header("User-Agent", USER_AGENT) + .header("Origin", "https://pan.quark.cn") + .header("Referer", "https://pan.quark.cn/") + .post(RequestBody.create(JSON_MEDIA_TYPE, payload.toJSONString())) + .build(); + + try (Response response = httpClient.newCall(request).execute()) + { + String body = readBody(response.body()); + JSONObject json = parseJson(body); + int providerCode = json == null || !json.containsKey("code") + ? Integer.MIN_VALUE : json.getIntValue("code"); + String message = json == null ? null : json.getString("message"); + + if (providerCode == 0 && response.isSuccessful()) + { + return result(linkUrl, NetDiskConstants.STATUS_VALID, "0", + "分享链接有效", startedAt); + } + if (providerCode == 41008) + { + return result(linkUrl, NetDiskConstants.STATUS_NEED_CODE, + String.valueOf(providerCode), "分享存在,但链接中没有提取码", startedAt); + } + if (providerCode == 41007 || providerCode == 41021) + { + return result(linkUrl, NetDiskConstants.STATUS_CODE_ERROR, + String.valueOf(providerCode), "分享存在,但提取码错误", startedAt); + } + if (INVALID_CODES.contains(providerCode)) + { + return result(linkUrl, NetDiskConstants.STATUS_INVALID, + String.valueOf(providerCode), + StringUtils.defaultIfBlank(message, "分享已失效、取消或不存在"), startedAt); + } + if (providerCode == 41022 || providerCode == 41023) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + String.valueOf(providerCode), "分享内容正在审核,暂时无法确认", startedAt); + } + if (providerCode == 45058 || response.code() == 403 || response.code() == 429 + || response.code() >= 500) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + providerCode == Integer.MIN_VALUE + ? String.valueOf(response.code()) : String.valueOf(providerCode), + "夸克网盘暂时拒绝检测或访问受限", startedAt); + } + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, + providerCode == Integer.MIN_VALUE + ? String.valueOf(response.code()) : String.valueOf(providerCode), + StringUtils.defaultIfBlank(message, "夸克网盘返回了未识别的状态"), startedAt); + } + catch (IOException | RuntimeException e) + { + return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, "NETWORK", + "检测请求失败:" + safeMessage(e), startedAt); + } + } + + private NetDiskCheckResult result(String linkUrl, String status, String providerCode, + String message, long startedAt) + { + return new NetDiskCheckResult(getDiskType(), linkUrl, status, providerCode, message, + System.currentTimeMillis() - startedAt); + } + + private String parseShareId(String url) + { + try + { + URI uri = new URI(url); + if (!"https".equalsIgnoreCase(uri.getScheme()) + || uri.getUserInfo() != null + || (uri.getPort() != -1 && uri.getPort() != 443) + || uri.getHost() == null + || !"pan.quark.cn".equalsIgnoreCase(uri.getHost())) + { + return null; + } + String[] pathParts = StringUtils.defaultString(uri.getPath()).split("/"); + if (pathParts.length < 3 || !"s".equals(pathParts[1]) + || !pathParts[2].matches("[A-Za-z0-9_-]{6,64}")) + { + return null; + } + return pathParts[2]; + } + catch (URISyntaxException e) + { + return null; + } + } + + private String getQueryParameter(String url, String name) + { + okhttp3.HttpUrl httpUrl = okhttp3.HttpUrl.parse(url); + return httpUrl == null ? null : httpUrl.queryParameter(name); + } + + private JSONObject parseJson(String body) + { + try + { + return StringUtils.isBlank(body) ? null : JSON.parseObject(body); + } + catch (RuntimeException e) + { + return null; + } + } + + private String readBody(ResponseBody responseBody) throws IOException + { + return responseBody == null ? "" : responseBody.string(); + } + + private String safeMessage(Exception e) + { + return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName()); + } +} diff --git a/ruoyi-office/src/main/resources/mapper/office/TtCodeMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtCodeMapper.xml index 7e1daa9..91410ea 100644 --- a/ruoyi-office/src/main/resources/mapper/office/TtCodeMapper.xml +++ b/ruoyi-office/src/main/resources/mapper/office/TtCodeMapper.xml @@ -36,7 +36,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and picture_file = #{pictureFile} and video_file = #{videoFile} - ORDER BY code_name DESC + ORDER BY code_id DESC - + select f.file_id, f.file_name, f.file_url, f.code_name + from tt_file f + left join ( + select code_name, max(code_id) as source_order + from tt_code + group by code_name + ) c on c.code_name = f.code_name - and file_name like concat('%', #{fileName}, '%') - and code_name like concat('%', #{codeName}, '%') + and f.file_name like concat('%', #{fileName}, '%') + and f.code_name like concat('%', #{codeName}, '%') - ORDER BY code_name DESC, file_id ASC + ORDER BY c.source_order DESC, f.file_id ASC - and project_num like concat('%', #{projectNum}, '%') - and project_num1 = #{projectNum1} - and project_name like concat('%', #{projectName}, '%') - and project_name1 like concat('%', #{projectName1}, '%') - and project_desc = #{projectDesc} - and project_url = #{projectUrl} - and project_vurl = #{projectVurl} - and project_baidu_url = #{projectBaiduUrl} + and p.project_num like concat('%', #{projectNum}, '%') + and p.project_num1 = #{projectNum1} + and p.project_name like concat('%', #{projectName}, '%') + and p.project_name1 like concat('%', #{projectName1}, '%') + and p.project_desc = #{projectDesc} + and p.project_url = #{projectUrl} + and p.project_vurl = #{projectVurl} + and p.project_baidu_url = #{projectBaiduUrl} + ORDER BY p.id DESC @@ -96,8 +126,8 @@ - \ No newline at end of file + diff --git a/ruoyi-office/src/main/resources/mapper/office/TtProjectLinkCheckMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtProjectLinkCheckMapper.xml new file mode 100644 index 0000000..8dff8b7 --- /dev/null +++ b/ruoyi-office/src/main/resources/mapper/office/TtProjectLinkCheckMapper.xml @@ -0,0 +1,55 @@ + + + + + + insert into tt_project_link_check + ( + project_id, + disk_type, + link_url, + check_status, + provider_code, + check_message, + response_time_ms, + checked_at, + create_time, + update_time + ) + values + ( + #{projectId}, + #{diskType}, + #{linkUrl}, + #{checkStatus}, + #{providerCode}, + #{checkMessage}, + #{responseTimeMs}, + #{checkedAt}, + now(), + now() + ) + on duplicate key update + link_url = values(link_url), + check_status = values(check_status), + provider_code = values(provider_code), + check_message = values(check_message), + response_time_ms = values(response_time_ms), + checked_at = values(checked_at), + update_time = now() + + + + delete from tt_project_link_check where project_id = #{projectId} + + + + delete from tt_project_link_check + where project_id in + + #{projectId} + + + diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppBlogArticle.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppBlogArticle.java index e6a9d2a..4263437 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppBlogArticle.java +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppBlogArticle.java @@ -40,6 +40,9 @@ public class AppBlogArticle extends BaseEntity @Excel(name = "封面图url") private String showImg; + /** List-card thumbnail URL (not persisted). */ + private String showImgThumb; + /** 关联已有资源(关联app_resource id) */ @Excel(name = "关联已有资源", readConverterExp = "关=联app_resource,i=d") private Long appResourceId; @@ -159,6 +162,15 @@ public class AppBlogArticle extends BaseEntity { return showImg; } + public void setShowImgThumb(String showImgThumb) + { + this.showImgThumb = showImgThumb; + } + + public String getShowImgThumb() + { + return showImgThumb; + } public void setAppResourceId(Long appResourceId) { this.appResourceId = appResourceId; diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppResource.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppResource.java index 516c6f8..bca3818 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppResource.java +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppResource.java @@ -27,6 +27,9 @@ public class AppResource extends BaseEntity @Excel(name = "封面图") private String showImg; + /** List-card thumbnail URL (not persisted). */ + private String showImgThumb; + /** 说明 */ @Excel(name = "说明") private String explain; @@ -51,6 +54,13 @@ public class AppResource extends BaseEntity @Excel(name = "需要观看几次广告解锁") private Long adNumber; + /** 虚拟支付价格,单位分 */ + @Excel(name = "付费价格(分)") + private Integer priceFen; + + /** 同价格档位对应的微信虚拟道具ID(非 app_resource 表字段) */ + private String virtualProductId; + /** 下载次数 */ @Excel(name = "下载次数") private Long downNum; @@ -100,6 +110,15 @@ public class AppResource extends BaseEntity { return showImg; } + public void setShowImgThumb(String showImgThumb) + { + this.showImgThumb = showImgThumb; + } + + public String getShowImgThumb() + { + return showImgThumb; + } public void setExplain(String explain) { this.explain = explain; @@ -145,6 +164,26 @@ public class AppResource extends BaseEntity { return adNumber; } + + public Integer getPriceFen() + { + return priceFen; + } + + public void setPriceFen(Integer priceFen) + { + this.priceFen = priceFen; + } + + public String getVirtualProductId() + { + return virtualProductId; + } + + public void setVirtualProductId(String virtualProductId) + { + this.virtualProductId = virtualProductId; + } public void setDownNum(Long downNum) { this.downNum = downNum; @@ -194,6 +233,8 @@ public class AppResource extends BaseEntity .append("isShow", getIsShow()) .append("isAd", getIsAd()) .append("adNumber", getAdNumber()) + .append("priceFen", getPriceFen()) + .append("virtualProductId", getVirtualProductId()) .append("downNum", getDownNum()) .append("weight", getWeight()) .append("delFlag", getDelFlag()) diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualOrder.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualOrder.java new file mode 100644 index 0000000..6b2e888 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualOrder.java @@ -0,0 +1,239 @@ +package com.ruoyi.app.domain; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +import java.util.Date; + +/** + * 资源虚拟支付订单。 + */ +public class AppVirtualOrder extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + + @Excel(name = "业务订单号") + private String orderNo; + + @Excel(name = "用户ID") + private Long userId; + + @Excel(name = "用户账号") + private String userName; + + @Excel(name = "用户昵称") + private String nickName; + + @Excel(name = "资源ID") + private Long resourceId; + + @Excel(name = "资源标题") + private String resourceTitle; + + @Excel(name = "微信道具ID") + private String productId; + + @Excel(name = "支付金额(分)") + private Integer priceFen; + + @Excel(name = "OpenID") + private String openId; + + /** 0-待支付,1-已支付并发货,2-已退款,3-已关闭。 */ + @Excel(name = "订单状态", readConverterExp = "0=待支付,1=已支付并发货,2=已退款,3=已关闭") + private Integer status; + + @Excel(name = "微信内部订单号") + private String wxOrderNo; + + @Excel(name = "微信支付交易单号") + private String transactionId; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "支付时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date payTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "发货时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date provideTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Excel(name = "退款时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") + private Date refundTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastQueryTime; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getOrderNo() + { + return orderNo; + } + + public void setOrderNo(String orderNo) + { + this.orderNo = orderNo; + } + + public Long getUserId() + { + return userId; + } + + public void setUserId(Long userId) + { + this.userId = userId; + } + + public String getUserName() + { + return userName; + } + + public void setUserName(String userName) + { + this.userName = userName; + } + + public String getNickName() + { + return nickName; + } + + public void setNickName(String nickName) + { + this.nickName = nickName; + } + + public Long getResourceId() + { + return resourceId; + } + + public void setResourceId(Long resourceId) + { + this.resourceId = resourceId; + } + + public String getResourceTitle() + { + return resourceTitle; + } + + public void setResourceTitle(String resourceTitle) + { + this.resourceTitle = resourceTitle; + } + + public String getProductId() + { + return productId; + } + + public void setProductId(String productId) + { + this.productId = productId; + } + + public Integer getPriceFen() + { + return priceFen; + } + + public void setPriceFen(Integer priceFen) + { + this.priceFen = priceFen; + } + + public String getOpenId() + { + return openId; + } + + public void setOpenId(String openId) + { + this.openId = openId; + } + + public Integer getStatus() + { + return status; + } + + public void setStatus(Integer status) + { + this.status = status; + } + + public String getWxOrderNo() + { + return wxOrderNo; + } + + public void setWxOrderNo(String wxOrderNo) + { + this.wxOrderNo = wxOrderNo; + } + + public String getTransactionId() + { + return transactionId; + } + + public void setTransactionId(String transactionId) + { + this.transactionId = transactionId; + } + + public Date getPayTime() + { + return payTime; + } + + public void setPayTime(Date payTime) + { + this.payTime = payTime; + } + + public Date getProvideTime() + { + return provideTime; + } + + public void setProvideTime(Date provideTime) + { + this.provideTime = provideTime; + } + + public Date getRefundTime() + { + return refundTime; + } + + public void setRefundTime(Date refundTime) + { + this.refundTime = refundTime; + } + + public Date getLastQueryTime() + { + return lastQueryTime; + } + + public void setLastQueryTime(Date lastQueryTime) + { + this.lastQueryTime = lastQueryTime; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualProduct.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualProduct.java new file mode 100644 index 0000000..169743f --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppVirtualProduct.java @@ -0,0 +1,67 @@ +package com.ruoyi.app.domain; + +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 虚拟支付价格档位与微信道具的映射。 + */ +public class AppVirtualProduct extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String productId; + private Integer priceFen; + private String productName; + private Integer status; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getProductId() + { + return productId; + } + + public void setProductId(String productId) + { + this.productId = productId; + } + + public Integer getPriceFen() + { + return priceFen; + } + + public void setPriceFen(Integer priceFen) + { + this.priceFen = priceFen; + } + + public String getProductName() + { + return productName; + } + + public void setProductName(String productName) + { + this.productName = productName; + } + + public Integer getStatus() + { + return status; + } + + public void setStatus(Integer status) + { + this.status = status; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/request/CreateVirtualOrderRequest.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/request/CreateVirtualOrderRequest.java new file mode 100644 index 0000000..4c7a5b2 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/request/CreateVirtualOrderRequest.java @@ -0,0 +1,33 @@ +package com.ruoyi.app.domain.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateVirtualOrderRequest +{ + @NotNull(message = "资源ID不能为空") + private Long resourceId; + + @NotBlank(message = "微信登录凭证不能为空") + private String code; + + public Long getResourceId() + { + return resourceId; + } + + public void setResourceId(Long resourceId) + { + this.resourceId = resourceId; + } + + public String getCode() + { + return code; + } + + public void setCode(String code) + { + this.code = code; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualOrderMapper.java b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualOrderMapper.java new file mode 100644 index 0000000..cf216ca --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualOrderMapper.java @@ -0,0 +1,35 @@ +package com.ruoyi.app.mapper; + +import com.ruoyi.app.domain.AppVirtualOrder; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; +import java.util.List; + +public interface AppVirtualOrderMapper +{ + int insertAppVirtualOrder(AppVirtualOrder order); + + AppVirtualOrder selectByOrderNo(String orderNo); + + AppVirtualOrder selectAppVirtualOrderById(Long id); + + List selectAppVirtualOrderList(AppVirtualOrder order); + + int countAnyEntitlement(@Param("userId") Long userId, @Param("resourceId") Long resourceId); + + int markPaid(@Param("orderNo") String orderNo, + @Param("wxOrderNo") String wxOrderNo, + @Param("transactionId") String transactionId, + @Param("payTime") Date payTime); + + int insertEntitlement(AppVirtualOrder order); + + int markRefunded(@Param("orderNo") String orderNo, @Param("refundTime") Date refundTime); + + int deleteEntitlementByOrderNo(String orderNo); + + int markClosed(String orderNo); + + int markQuerying(String orderNo); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualProductMapper.java b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualProductMapper.java new file mode 100644 index 0000000..c045013 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppVirtualProductMapper.java @@ -0,0 +1,14 @@ +package com.ruoyi.app.mapper; + +import com.ruoyi.app.domain.AppVirtualProduct; + +public interface AppVirtualProductMapper +{ + AppVirtualProduct selectActiveByPrice(Integer priceFen); + + AppVirtualProduct selectByProductId(String productId); + + int insertAppVirtualProduct(AppVirtualProduct product); + + int updateProductIdByPrice(AppVirtualProduct product); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualOrderService.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualOrderService.java new file mode 100644 index 0000000..f70aa5f --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualOrderService.java @@ -0,0 +1,15 @@ +package com.ruoyi.app.service; + +import com.ruoyi.app.domain.AppVirtualOrder; + +import java.util.List; + +/** + * 虚拟支付订单管理服务。 + */ +public interface IAppVirtualOrderService +{ + AppVirtualOrder selectAppVirtualOrderById(Long id); + + List selectAppVirtualOrderList(AppVirtualOrder order); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualPayService.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualPayService.java new file mode 100644 index 0000000..0ccd7af --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualPayService.java @@ -0,0 +1,17 @@ +package com.ruoyi.app.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ruoyi.app.domain.request.CreateVirtualOrderRequest; + +import java.util.Map; + +public interface IAppVirtualPayService +{ + Map createOrder(CreateVirtualOrderRequest request); + + Map queryOrder(String orderNo, boolean sync); + + boolean verifyCallbackSignature(String signature, String timestamp, String nonce); + + void handleCallback(JsonNode body); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/ImageUrlService.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/ImageUrlService.java new file mode 100644 index 0000000..9e791f4 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/ImageUrlService.java @@ -0,0 +1,131 @@ +package com.ruoyi.app.service; + +import com.ruoyi.app.domain.AppBlogArticle; +import com.ruoyi.app.domain.AppResource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Builds CDN image variants without persisting derived URLs in the database. + */ +@Component +public class ImageUrlService +{ + @Value("${app.image.cdn-domain:https://img.yidaima.cn}") + private String cdnDomain; + + @Value("${app.image.article-card-operation:imageView2/1/w/600/h/375/format/webp/q/75/ignore-error/1}") + private String articleCardOperation; + + @Value("${app.image.resource-card-operation:imageView2/1/w/500/h/400/format/webp/q/75/ignore-error/1}") + private String resourceCardOperation; + + public void decorateArticles(List articles) + { + if (articles == null) + { + return; + } + for (AppBlogArticle article : articles) + { + decorateArticle(article); + } + } + + public AppBlogArticle decorateArticle(AppBlogArticle article) + { + if (article == null) + { + return null; + } + String originalUrl = normalizeOriginalUrl(article.getShowImg()); + article.setShowImg(originalUrl); + article.setShowImgThumb(buildThumbnailUrl(originalUrl, articleCardOperation)); + decorateResource(article.getAppResource()); + return article; + } + + public void decorateResources(List resources) + { + if (resources == null) + { + return; + } + for (AppResource resource : resources) + { + decorateResource(resource); + } + } + + public AppResource decorateResource(AppResource resource) + { + if (resource == null) + { + return null; + } + String originalUrl = normalizeOriginalUrl(resource.getShowImg()); + resource.setShowImg(originalUrl); + resource.setShowImgThumb(buildThumbnailUrl(originalUrl, resourceCardOperation)); + return resource; + } + + public String normalizeOriginalUrl(String originalUrl) + { + if (originalUrl == null) + { + return null; + } + String url = originalUrl.trim(); + String host = cdnHost(); + String httpPrefix = "http://" + host + "/"; + if (url.startsWith(httpPrefix)) + { + return "https://" + url.substring("http://".length()); + } + String protocolRelativePrefix = "//" + host + "/"; + if (url.startsWith(protocolRelativePrefix)) + { + return "https:" + url; + } + return url; + } + + private String buildThumbnailUrl(String originalUrl, String operation) + { + if (originalUrl == null || originalUrl.isEmpty()) + { + return originalUrl; + } + String cdnPrefix = "https://" + cdnHost() + "/"; + if (!originalUrl.startsWith(cdnPrefix)) + { + return originalUrl; + } + // Avoid corrupting signed or already processed URLs. + if (originalUrl.indexOf('?') >= 0) + { + return originalUrl; + } + return originalUrl + "?" + operation; + } + + private String cdnHost() + { + String value = cdnDomain == null ? "img.yidaima.cn" : cdnDomain.trim(); + if (value.startsWith("https://")) + { + value = value.substring("https://".length()); + } + else if (value.startsWith("http://")) + { + value = value.substring("http://".length()); + } + while (value.endsWith("/")) + { + value = value.substring(0, value.length() - 1); + } + return value; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppResourceServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppResourceServiceImpl.java index 00d168d..ebb7585 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppResourceServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppResourceServiceImpl.java @@ -11,6 +11,9 @@ import com.ruoyi.app.domain.AppResourceList; import com.ruoyi.app.mapper.AppResourceMapper; import com.ruoyi.app.domain.AppResource; import com.ruoyi.app.service.IAppResourceService; +import com.ruoyi.app.domain.AppVirtualProduct; +import com.ruoyi.app.mapper.AppVirtualProductMapper; +import com.ruoyi.common.exception.ServiceException; /** * 资源Service业务层处理 @@ -24,6 +27,9 @@ public class AppResourceServiceImpl implements IAppResourceService @Autowired private AppResourceMapper appResourceMapper; + @Autowired + private AppVirtualProductMapper virtualProductMapper; + /** * 查询资源 * @@ -58,6 +64,7 @@ public class AppResourceServiceImpl implements IAppResourceService @Override public int insertAppResource(AppResource appResource) { + configureVirtualProduct(appResource); appResource.setCreateTime(DateUtils.getNowDate()); int rows = appResourceMapper.insertAppResource(appResource); insertAppResourceList(appResource); @@ -74,6 +81,7 @@ public class AppResourceServiceImpl implements IAppResourceService @Override public int updateAppResource(AppResource appResource) { + configureVirtualProduct(appResource); appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId()); insertAppResourceList(appResource); return appResourceMapper.updateAppResource(appResource); @@ -130,4 +138,26 @@ public class AppResourceServiceImpl implements IAppResourceService } } } + + /** + * 付费资源按价格档位自动匹配已配置的微信道具。 + */ + private void configureVirtualProduct(AppResource resource) + { + if (resource.getIsAd() == null || resource.getIsAd() != 3L) + { + resource.setPriceFen(0); + return; + } + if (resource.getPriceFen() == null || resource.getPriceFen() <= 0) + { + throw new ServiceException("付费资源价格必须大于0分"); + } + AppVirtualProduct priceProduct = virtualProductMapper.selectActiveByPrice(resource.getPriceFen()); + if (priceProduct == null || StringUtils.isBlank(priceProduct.getProductId())) + { + throw new ServiceException("当前价格档位未配置微信道具,请先配置价格与道具映射"); + } + resource.setVirtualProductId(priceProduct.getProductId()); + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualOrderServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualOrderServiceImpl.java new file mode 100644 index 0000000..33950e1 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualOrderServiceImpl.java @@ -0,0 +1,31 @@ +package com.ruoyi.app.service.impl; + +import com.ruoyi.app.domain.AppVirtualOrder; +import com.ruoyi.app.mapper.AppVirtualOrderMapper; +import com.ruoyi.app.service.IAppVirtualOrderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 虚拟支付订单管理服务实现。 + */ +@Service +public class AppVirtualOrderServiceImpl implements IAppVirtualOrderService +{ + @Autowired + private AppVirtualOrderMapper appVirtualOrderMapper; + + @Override + public AppVirtualOrder selectAppVirtualOrderById(Long id) + { + return appVirtualOrderMapper.selectAppVirtualOrderById(id); + } + + @Override + public List selectAppVirtualOrderList(AppVirtualOrder order) + { + return appVirtualOrderMapper.selectAppVirtualOrderList(order); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualPayServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualPayServiceImpl.java new file mode 100644 index 0000000..38d096c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualPayServiceImpl.java @@ -0,0 +1,529 @@ +package com.ruoyi.app.service.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.app.domain.AppIntegralRecord; +import com.ruoyi.app.domain.AppResource; +import com.ruoyi.app.domain.AppVirtualOrder; +import com.ruoyi.app.domain.AppVirtualProduct; +import com.ruoyi.app.domain.request.CreateVirtualOrderRequest; +import com.ruoyi.app.mapper.AppIntegralRecordMapper; +import com.ruoyi.app.mapper.AppResourceMapper; +import com.ruoyi.app.mapper.AppVirtualOrderMapper; +import com.ruoyi.app.mapper.AppVirtualProductMapper; +import com.ruoyi.app.service.IAppVirtualPayService; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.wx.VirtualPayConfig; +import com.ruoyi.common.wx.WxCodeSession; +import com.ruoyi.common.wx.WxCodeSessionService; +import com.ruoyi.common.wx.WxPayConfig; +import com.ruoyi.system.mapper.SysUserMapper; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +public class AppVirtualPayServiceImpl implements IAppVirtualPayService +{ + private static final Logger log = LoggerFactory.getLogger(AppVirtualPayServiceImpl.class); + private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8"); + private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; + private static final String QUERY_ORDER_URI = "/xpay/query_order"; + private static final String QUERY_ORDER_URL = "https://api.weixin.qq.com/xpay/query_order"; + private static final String NOTIFY_GOODS_URI = "/xpay/notify_provide_goods"; + private static final String NOTIFY_GOODS_URL = "https://api.weixin.qq.com/xpay/notify_provide_goods"; + + private final VirtualPayConfig virtualPayConfig; + private final WxPayConfig wxPayConfig; + private final WxCodeSessionService wxCodeSessionService; + private final AppResourceMapper appResourceMapper; + private final AppIntegralRecordMapper integralRecordMapper; + private final AppVirtualProductMapper virtualProductMapper; + private final AppVirtualOrderMapper virtualOrderMapper; + private final SysUserMapper sysUserMapper; + private final ObjectMapper objectMapper; + private final OkHttpClient httpClient; + + private volatile String cachedAccessToken; + private volatile long accessTokenExpiresAt; + + public AppVirtualPayServiceImpl(VirtualPayConfig virtualPayConfig, + WxPayConfig wxPayConfig, + WxCodeSessionService wxCodeSessionService, + AppResourceMapper appResourceMapper, + AppIntegralRecordMapper integralRecordMapper, + AppVirtualProductMapper virtualProductMapper, + AppVirtualOrderMapper virtualOrderMapper, + SysUserMapper sysUserMapper, + ObjectMapper objectMapper) + { + this.virtualPayConfig = virtualPayConfig; + this.wxPayConfig = wxPayConfig; + this.wxCodeSessionService = wxCodeSessionService; + this.appResourceMapper = appResourceMapper; + this.integralRecordMapper = integralRecordMapper; + this.virtualProductMapper = virtualProductMapper; + this.virtualOrderMapper = virtualOrderMapper; + this.sysUserMapper = sysUserMapper; + this.objectMapper = objectMapper; + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + + @Override + @Transactional + public Map createOrder(CreateVirtualOrderRequest request) + { + checkEnabled(); + Long userId = SecurityUtils.getUserId(); + SysUser user = sysUserMapper.selectUserById(userId); + if (user == null || StringUtils.isBlank(user.getOpenId())) + { + throw new ServiceException("当前账号未绑定微信"); + } + + WxCodeSession codeSession = wxCodeSessionService.exchange(request.getCode()); + if (!MessageDigest.isEqual(user.getOpenId().getBytes(StandardCharsets.UTF_8), + codeSession.getOpenId().getBytes(StandardCharsets.UTF_8))) + { + throw new ServiceException("微信身份与当前登录账号不一致"); + } + + AppResource resource = appResourceMapper.selectAppResourceById(request.getResourceId()); + if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 3L) + { + throw new ServiceException("该资源不支持虚拟支付"); + } + if (resource.getPriceFen() == null || resource.getPriceFen() <= 0) + { + throw new ServiceException("资源价格未配置"); + } + if (virtualOrderMapper.countAnyEntitlement(userId, resource.getId()) > 0) + { + throw new ServiceException("该资源已经解锁"); + } + + AppVirtualProduct product = virtualProductMapper.selectActiveByPrice(resource.getPriceFen()); + if (product == null || StringUtils.isBlank(product.getProductId())) + { + throw new ServiceException("当前价格档位尚未配置微信道具"); + } + + AppVirtualOrder order = new AppVirtualOrder(); + order.setOrderNo(generateOrderNo()); + order.setUserId(userId); + order.setResourceId(resource.getId()); + order.setProductId(product.getProductId()); + order.setPriceFen(resource.getPriceFen()); + order.setOpenId(codeSession.getOpenId()); + order.setStatus(0); + order.setCreateTime(DateUtils.getNowDate()); + virtualOrderMapper.insertAppVirtualOrder(order); + + LinkedHashMap signDataMap = new LinkedHashMap<>(); + signDataMap.put("offerId", virtualPayConfig.getOfferId()); + signDataMap.put("buyQuantity", 1); + signDataMap.put("env", virtualPayConfig.getEnv()); + signDataMap.put("currencyType", "CNY"); + signDataMap.put("productId", product.getProductId()); + signDataMap.put("goodsPrice", resource.getPriceFen()); + signDataMap.put("outTradeNo", order.getOrderNo()); + signDataMap.put("attach", order.getOrderNo()); + + try + { + String signData = objectMapper.writeValueAsString(signDataMap); + Map result = new LinkedHashMap<>(); + result.put("orderNo", order.getOrderNo()); + result.put("signData", signData); + result.put("paySig", hmacSha256(virtualPayConfig.getAppKey(), + "requestVirtualPayment&" + signData)); + result.put("signature", hmacSha256(codeSession.getSessionKey(), signData)); + result.put("mode", "short_series_goods"); + return result; + } + catch (JsonProcessingException e) + { + throw new ServiceException("生成虚拟支付参数失败"); + } + } + + @Override + @Transactional + public Map queryOrder(String orderNo, boolean sync) + { + Long userId = SecurityUtils.getUserId(); + AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo); + if (order == null || !userId.equals(order.getUserId())) + { + throw new ServiceException("订单不存在"); + } + + if (sync && order.getStatus() == 0 && virtualOrderMapper.markQuerying(orderNo) == 1) + { + syncOrderFromWechat(order); + order = virtualOrderMapper.selectByOrderNo(orderNo); + } + + Map result = new LinkedHashMap<>(); + result.put("orderNo", order.getOrderNo()); + result.put("resourceId", order.getResourceId()); + result.put("status", order.getStatus()); + result.put("unlocked", order.getStatus() == 1); + return result; + } + + @Override + public boolean verifyCallbackSignature(String signature, String timestamp, String nonce) + { + if (StringUtils.isAnyBlank(signature, timestamp, nonce, virtualPayConfig.getCallbackToken())) + { + return false; + } + String[] values = {virtualPayConfig.getCallbackToken(), timestamp, nonce}; + Arrays.sort(values); + String actual = sha1(values[0] + values[1] + values[2]); + return MessageDigest.isEqual(actual.getBytes(StandardCharsets.UTF_8), + signature.getBytes(StandardCharsets.UTF_8)); + } + + @Override + @Transactional + public void handleCallback(JsonNode body) + { + String event = body.path("Event").asText(); + String orderNo = body.path("OutTradeNo").asText(); + log.info("收到微信虚拟支付回调: event={}, orderNo={}", event, orderNo); + if ("xpay_goods_deliver_notify".equals(event)) + { + handleGoodsDeliver(body); + } + else if ("xpay_refund_notify".equals(event)) + { + handleRefund(body); + } + else + { + // 消息推送 URL 可能同时接收其他小程序事件,未知事件不应触发微信反复重试。 + log.debug("忽略非资源支付回调事件: {}", event); + } + } + + private void handleGoodsDeliver(JsonNode body) + { + String orderNo = body.path("OutTradeNo").asText(); + AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo); + if (order == null) + { + throw new ServiceException("虚拟支付订单不存在"); + } + + JsonNode goods = body.path("GoodsInfo"); + if (!order.getOpenId().equals(text(body, "OpenId", "openid")) + || virtualPayConfig.getEnv() != integer(body, "Env", "env") + || !order.getProductId().equals(goods.path("ProductId").asText()) + || order.getPriceFen() != goods.path("ActualPrice").asInt(-1) + || goods.path("Quantity").asInt(0) != 1 + || !orderNo.equals(goods.path("Attach").asText())) + { + throw new ServiceException("虚拟支付通知与本地订单不一致"); + } + + JsonNode wxPayInfo = body.path("WeChatPayInfo"); + Date paidTime = fromUnixSeconds(wxPayInfo.path("PaidTime").asLong(0)); + grantOrder(order, + wxPayInfo.path("MchOrderNo").asText(null), + wxPayInfo.path("TransactionId").asText(null), + paidTime); + } + + private void handleRefund(JsonNode body) + { + if (body.path("RetCode").asInt(-1) != 0) + { + return; + } + String orderNo = text(body, "MchOrderId", "MchOrderNo", "OutTradeNo"); + AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo); + if (order == null) + { + throw new ServiceException("退款对应的虚拟支付订单不存在"); + } + int refundFee = body.path("RefundFee").asInt(-1); + if (!order.getOpenId().equals(text(body, "OpenId", "openid")) + || refundFee <= 0 + || refundFee > order.getPriceFen()) + { + throw new ServiceException("虚拟支付退款通知与本地订单不一致"); + } + revokeOrder(order, fromUnixSeconds(body.path("RefundSuccTimestamp").asLong(0))); + } + + private void syncOrderFromWechat(AppVirtualOrder order) + { + try + { + String accessToken = getAccessToken(); + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("openid", order.getOpenId()); + payload.put("env", virtualPayConfig.getEnv()); + payload.put("order_id", order.getOrderNo()); + String body = objectMapper.writeValueAsString(payload); + String paySig = hmacSha256(virtualPayConfig.getAppKey(), QUERY_ORDER_URI + "&" + body); + + HttpUrl url = HttpUrl.parse(QUERY_ORDER_URL).newBuilder() + .addQueryParameter("access_token", accessToken) + .addQueryParameter("pay_sig", paySig) + .build(); + Request httpRequest = new Request.Builder() + .url(url) + .post(RequestBody.create(JSON_MEDIA_TYPE, body)) + .build(); + try (Response response = httpClient.newCall(httpRequest).execute()) + { + if (!response.isSuccessful() || response.body() == null) + { + return; + } + JsonNode result = objectMapper.readTree(response.body().string()); + if (result.path("errcode").asInt(-1) != 0) + { + return; + } + JsonNode wxOrder = result.path("order"); + if (!order.getOrderNo().equals(wxOrder.path("order_id").asText()) + || wxOrder.path("order_fee").asInt(-1) != order.getPriceFen()) + { + return; + } + int status = wxOrder.path("status").asInt(-1); + if (status >= 2 && status <= 4 + && wxOrder.path("paid_fee").asInt(-1) == order.getPriceFen()) + { + grantOrder(order, + wxOrder.path("wx_order_id").asText(null), + wxOrder.path("wxpay_order_id").asText(null), + fromUnixSeconds(wxOrder.path("paid_time").asLong(0))); + if (status == 2) + { + notifyProvideGoods(accessToken, order.getOrderNo()); + } + } + else if (status == 5 || status == 8) + { + revokeOrder(order, fromUnixSeconds(wxOrder.path("paid_time").asLong(0))); + } + else if (status == 6) + { + virtualOrderMapper.markClosed(order.getOrderNo()); + } + } + } + catch (IOException ignored) + { + // 查询仅作为回调丢失时的兜底,不影响客户端继续轮询本地状态。 + } + } + + private void notifyProvideGoods(String accessToken, String orderNo) + { + try + { + LinkedHashMap payload = new LinkedHashMap<>(); + payload.put("order_id", orderNo); + payload.put("env", virtualPayConfig.getEnv()); + String body = objectMapper.writeValueAsString(payload); + String paySig = hmacSha256(virtualPayConfig.getAppKey(), NOTIFY_GOODS_URI + "&" + body); + HttpUrl url = HttpUrl.parse(NOTIFY_GOODS_URL).newBuilder() + .addQueryParameter("access_token", accessToken) + .addQueryParameter("pay_sig", paySig) + .build(); + Request request = new Request.Builder() + .url(url) + .post(RequestBody.create(JSON_MEDIA_TYPE, body)) + .build(); + try (Response ignored = httpClient.newCall(request).execute()) + { + // 微信侧失败时仍会继续推送发货通知,保持本地发货幂等即可。 + } + } + catch (Exception ignored) + { + } + } + + private void grantOrder(AppVirtualOrder order, String wxOrderNo, String transactionId, Date paidTime) + { + int changed = virtualOrderMapper.markPaid(order.getOrderNo(), wxOrderNo, transactionId, paidTime); + if (changed == 1) + { + order.setStatus(1); + virtualOrderMapper.insertEntitlement(order); + + AppIntegralRecord purchaseRecord = new AppIntegralRecord(); + purchaseRecord.setSource("资源购买"); + purchaseRecord.setIsAdd(3L); + // 现金消费使用分作为最小单位,避免小数金额精度丢失。 + purchaseRecord.setIntegralNumber(order.getPriceFen().longValue()); + purchaseRecord.setUserId(order.getUserId()); + purchaseRecord.setResourceId(order.getResourceId()); + purchaseRecord.setIntegralTime(DateUtils.getNowDate()); + if (integralRecordMapper.insertAppIntegralRecord(purchaseRecord) != 1) + { + throw new ServiceException("生成资源购买记录失败"); + } + } + } + + private void revokeOrder(AppVirtualOrder order, Date refundTime) + { + if (virtualOrderMapper.markRefunded(order.getOrderNo(), refundTime) == 1) + { + virtualOrderMapper.deleteEntitlementByOrderNo(order.getOrderNo()); + } + } + + private synchronized String getAccessToken() throws IOException + { + long now = System.currentTimeMillis(); + if (StringUtils.isNotBlank(cachedAccessToken) && now < accessTokenExpiresAt) + { + return cachedAccessToken; + } + HttpUrl url = HttpUrl.parse(ACCESS_TOKEN_URL).newBuilder() + .addQueryParameter("grant_type", "client_credential") + .addQueryParameter("appid", wxPayConfig.getAppid()) + .addQueryParameter("secret", wxPayConfig.getSecret()) + .build(); + Request request = new Request.Builder().url(url).get().build(); + try (Response response = httpClient.newCall(request).execute()) + { + if (!response.isSuccessful() || response.body() == null) + { + throw new IOException("get access token failed"); + } + JsonNode result = objectMapper.readTree(response.body().string()); + String token = result.path("access_token").asText(); + if (StringUtils.isBlank(token)) + { + throw new IOException("empty access token"); + } + int expiresIn = result.path("expires_in").asInt(7200); + cachedAccessToken = token; + accessTokenExpiresAt = now + Math.max(60, expiresIn - 300) * 1000L; + return token; + } + } + + private void checkEnabled() + { + if (!virtualPayConfig.isEnabled()) + { + throw new ServiceException("虚拟支付暂未启用"); + } + if (StringUtils.isAnyBlank(virtualPayConfig.getOfferId(), virtualPayConfig.getAppKey())) + { + throw new ServiceException("虚拟支付参数未配置完整"); + } + } + + private static String generateOrderNo() + { + String time = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + return "VP" + time + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + private static String hmacSha256(String key, String value) + { + try + { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return toHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } + catch (Exception e) + { + throw new ServiceException("虚拟支付签名失败"); + } + } + + private static String sha1(String value) + { + try + { + return toHex(MessageDigest.getInstance("SHA-1") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } + catch (Exception e) + { + return ""; + } + } + + private static String toHex(byte[] bytes) + { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) + { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } + + private static Date fromUnixSeconds(long seconds) + { + return seconds > 0 ? new Date(seconds * 1000L) : new Date(); + } + + private static String text(JsonNode node, String... fieldNames) + { + for (String fieldName : fieldNames) + { + String value = node.path(fieldName).asText(); + if (StringUtils.isNotBlank(value)) + { + return value; + } + } + return ""; + } + + private static int integer(JsonNode node, String... fieldNames) + { + for (String fieldName : fieldNames) + { + if (node.has(fieldName)) + { + return node.path(fieldName).asInt(-1); + } + } + return -1; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java index 75684e7..5931e5b 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java @@ -145,7 +145,7 @@ public interface SysUserMapper /** * 减少积分 */ - @Update("update sys_user SET integral = integral-#{integral} where user_id = #{userId}") + @Update("update sys_user SET integral = integral-#{integral} where user_id = #{userId} and integral >= #{integral}") public int delIntegralByUserId(@Param("integral") int integral ,@Param("userId")Long userId); /** diff --git a/ruoyi-system/src/main/resources/mapper/app/AppResourceMapper.xml b/ruoyi-system/src/main/resources/mapper/app/AppResourceMapper.xml index 5c4166e..130e1a1 100644 --- a/ruoyi-system/src/main/resources/mapper/app/AppResourceMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/app/AppResourceMapper.xml @@ -14,6 +14,8 @@ + + @@ -35,27 +37,34 @@ - select id, resource_title, show_img, `explain`, resource_type, keyword, is_show, is_ad, ad_number, down_num, weight, del_flag, create_by, create_time, remark from app_resource + select a.id, a.resource_title, a.show_img, a.`explain`, a.resource_type, a.keyword, + a.is_show, a.is_ad, a.ad_number, a.price_fen, + (select vp.product_id from app_virtual_product vp + where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id, + a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark + from app_resource a - select a.id, a.resource_title, a.show_img, a.`explain`, a.`keyword`, a.resource_type, a.is_show, + select a.id, a.resource_title, a.show_img, a.`explain`, a.`keyword`, a.resource_type, a.is_show, CASE - WHEN (SELECT COUNT(1) FROM app_integral_record WHERE resource_id = #{id} AND user_id = #{userId}) > 0 THEN 0 + WHEN #{userId} is not null and ( + (SELECT COUNT(1) FROM app_integral_record + WHERE resource_id = #{id} AND user_id = #{userId} + AND source = '资源兑换' AND is_add = 1) > 0 + OR + (SELECT COUNT(1) FROM app_resource_entitlement WHERE resource_id = #{id} AND user_id = #{userId} AND status = 1) > 0 + ) THEN 0 ELSE a.is_ad - END as is_ad, - a.ad_number, a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark, + END as is_ad, + a.ad_number, a.price_fen, + (select vp.product_id from app_virtual_product vp where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id, + a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark, b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url, b.password as sub_password, b.app_resource_id as sub_app_resource_id from app_resource a left join app_resource_list b on b.app_resource_id = a.id + and ( + a.is_ad not in (2, 3) + or ( + #{userId} is not null and ( + exists(select 1 from app_integral_record ir + where ir.resource_id = a.id and ir.user_id = #{userId} + and ir.source = '资源兑换' and ir.is_add = 1) + or exists(select 1 from app_resource_entitlement re where re.resource_id = a.id and re.user_id = #{userId} and re.status = 1) + ) + ) + ) where a.id = #{id} - \ No newline at end of file + diff --git a/ruoyi-system/src/main/resources/mapper/app/AppVirtualOrderMapper.xml b/ruoyi-system/src/main/resources/mapper/app/AppVirtualOrderMapper.xml new file mode 100644 index 0000000..734cebb --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/app/AppVirtualOrderMapper.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + select vo.id, vo.order_no, vo.user_id, u.user_name, u.nick_name, + vo.resource_id, r.resource_title, vo.product_id, vo.price_fen, + vo.open_id, vo.status, vo.wx_order_no, vo.transaction_id, + vo.create_time, vo.pay_time, vo.provide_time, vo.refund_time, vo.last_query_time + from app_virtual_order vo + left join sys_user u on u.user_id = vo.user_id + left join app_resource r on r.id = vo.resource_id + + + + insert into app_virtual_order + (order_no, user_id, resource_id, product_id, price_fen, open_id, status, create_time) + values + (#{orderNo}, #{userId}, #{resourceId}, #{productId}, #{priceFen}, #{openId}, #{status}, #{createTime}) + + + + + + + + + + + + update app_virtual_order + set status = 1, + wx_order_no = coalesce(#{wxOrderNo}, wx_order_no), + transaction_id = coalesce(#{transactionId}, transaction_id), + pay_time = coalesce(#{payTime}, pay_time), + provide_time = now() + where order_no = #{orderNo} and status = 0 + + + + insert into app_resource_entitlement + (user_id, resource_id, order_no, status, granted_time) + values + (#{userId}, #{resourceId}, #{orderNo}, 1, now()) + on duplicate key update + order_no = values(order_no), + status = 1, + granted_time = now(), + revoked_time = null + + + + update app_virtual_order + set status = 2, refund_time = #{refundTime} + where order_no = #{orderNo} and status in (0, 1) + + + + update app_resource_entitlement + set status = 0, revoked_time = now() + where order_no = #{orderNo} and status = 1 + + + + update app_virtual_order set status = 3 + where order_no = #{orderNo} and status = 0 + + + + update app_virtual_order + set last_query_time = now() + where order_no = #{orderNo} + and status = 0 + and (last_query_time is null or last_query_time < date_sub(now(), interval 5 second)) + + diff --git a/ruoyi-system/src/main/resources/mapper/app/AppVirtualProductMapper.xml b/ruoyi-system/src/main/resources/mapper/app/AppVirtualProductMapper.xml new file mode 100644 index 0000000..3a2c2a4 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/app/AppVirtualProductMapper.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + insert into app_virtual_product(product_id, price_fen, product_name, status, create_time) + values(#{productId}, #{priceFen}, #{productName}, #{status}, #{createTime}) + + + + update app_virtual_product + set product_id = #{productId}, product_name = #{productName}, status = 1, update_time = #{updateTime} + where price_fen = #{priceFen} + + diff --git a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index ae614c1..ca2739e 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -51,7 +51,7 @@ select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, u.integral, d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status, - r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_statusselectUserVo, u.open_id as openId + r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status, u.open_id from sys_user u left join sys_dept d on u.dept_id = d.dept_id left join sys_user_role ur on u.user_id = ur.user_id @@ -232,4 +232,4 @@ select date_format(create_time,'%Y-%m-%d') as date, count(*) as count from sys_user where del_flag = '0' and create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) group by date_format(create_time,'%Y-%m-%d') order by date - \ No newline at end of file + diff --git a/ruoyi-ui/src/api/app/virtualOrder.js b/ruoyi-ui/src/api/app/virtualOrder.js new file mode 100644 index 0000000..4eb9b9c --- /dev/null +++ b/ruoyi-ui/src/api/app/virtualOrder.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 查询虚拟支付订单列表 +export function listVirtualOrder(query) { + return request({ + url: '/app/virtualOrder/list', + method: 'get', + params: query + }) +} + +// 查询虚拟支付订单详情 +export function getVirtualOrder(id) { + return request({ + url: '/app/virtualOrder/' + id, + method: 'get' + }) +} diff --git a/ruoyi-ui/src/api/office/project.js b/ruoyi-ui/src/api/office/project.js index a9f73ea..5c4c243 100644 --- a/ruoyi-ui/src/api/office/project.js +++ b/ruoyi-ui/src/api/office/project.js @@ -43,6 +43,23 @@ export function delProject(id) { }) } +// 检测单个项目的网盘链接 +export function checkProjectLinks(id) { + return request({ + url: '/office/project/link-check/single/' + id, + method: 'post' + }) +} + +// 检测选中项目的网盘链接 +export function checkSelectedProjectLinks(ids) { + return request({ + url: '/office/project/link-check/batch', + method: 'post', + data: ids + }) +} + export function matchCode(id) { return request({ url: '/office/project/matchCode/' + id, diff --git a/ruoyi-ui/src/views/app/resource/index.vue b/ruoyi-ui/src/views/app/resource/index.vue index c0887e5..fdcda3d 100644 --- a/ruoyi-ui/src/views/app/resource/index.vue +++ b/ruoyi-ui/src/views/app/resource/index.vue @@ -76,6 +76,12 @@ - + + + @@ -134,8 +140,9 @@ - - + + + 系统将按价格自动匹配已配置的微信道具 @@ -260,6 +267,11 @@ message: "需要广告不能为空", trigger: "change" }], + priceFen: [{ + required: true, + message: "付费价格不能为空", + trigger: "blur" + }], }, directory:"appimg/fengmian/" }; @@ -303,6 +315,8 @@ isShow: null, isAd: null, adNumber: null, + priceFen: null, + virtualProductId: null, delFlag: null, createBy: null, createTime: null, diff --git a/ruoyi-ui/src/views/app/virtualOrder/index.vue b/ruoyi-ui/src/views/app/virtualOrder/index.vue new file mode 100644 index 0000000..799f1db --- /dev/null +++ b/ruoyi-ui/src/views/app/virtualOrder/index.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/ruoyi-ui/src/views/office/project/index.vue b/ruoyi-ui/src/views/office/project/index.vue index c1e5989..5d5e8de 100644 --- a/ruoyi-ui/src/views/office/project/index.vue +++ b/ruoyi-ui/src/views/office/project/index.vue @@ -82,6 +82,18 @@ v-hasPermi="['office:project:export']" >导出 + + 检测选中 + @@ -93,8 +105,38 @@ - + + + + + + diff --git a/sql/optimize_resource_detail_indexes.sql b/sql/optimize_resource_detail_indexes.sql new file mode 100644 index 0000000..5d83ba6 --- /dev/null +++ b/sql/optimize_resource_detail_indexes.sql @@ -0,0 +1,8 @@ +-- 资源详情页会同时按用户和资源判断积分兑换权限。 +-- 缺少下面两个索引时,带登录态的详情请求会在 JOIN 子查询中反复全表扫描。 + +ALTER TABLE `app_integral_record` + ADD INDEX `idx_integral_record_user_resource` (`user_id`, `resource_id`); + +ALTER TABLE `app_resource_list` + ADD INDEX `idx_resource_list_resource` (`app_resource_id`); diff --git a/sql/project_link_check.sql b/sql/project_link_check.sql new file mode 100644 index 0000000..da6ec4c --- /dev/null +++ b/sql/project_link_check.sql @@ -0,0 +1,19 @@ +-- 项目清单:百度网盘、夸克网盘链接检测结果 +-- 部署本功能前执行一次。 + +CREATE TABLE IF NOT EXISTS `tt_project_link_check` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `project_id` int NOT NULL COMMENT '项目ID', + `disk_type` varchar(16) NOT NULL COMMENT '网盘类型:QUARK/BAIDU', + `link_url` varchar(1000) NOT NULL COMMENT '本次检测的链接快照', + `check_status` varchar(20) NOT NULL COMMENT 'VALID/INVALID/NEED_CODE/CODE_ERROR/FORMAT_ERROR/UNKNOWN', + `provider_code` varchar(32) DEFAULT NULL COMMENT '平台状态码或HTTP状态码', + `check_message` varchar(255) DEFAULT NULL COMMENT '检测结果说明', + `response_time_ms` bigint DEFAULT NULL COMMENT '检测耗时(毫秒)', + `checked_at` datetime NOT NULL COMMENT '检测时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_project_disk_type` (`project_id`, `disk_type`), + KEY `idx_check_status_time` (`check_status`, `checked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目网盘链接检测结果'; diff --git a/sql/virtual_pay_order_menu.sql b/sql/virtual_pay_order_menu.sql new file mode 100644 index 0000000..7188be7 --- /dev/null +++ b/sql/virtual_pay_order_menu.sql @@ -0,0 +1,50 @@ +-- 虚拟支付订单管理菜单(可重复执行) +-- 默认放在现有“支付订单”菜单的同级目录下;若未找到,则作为一级菜单显示。 + +SET @virtual_order_parent_id := COALESCE( + (SELECT parent_id FROM sys_menu WHERE perms = 'app:order:list' LIMIT 1), + 0 +); + +SET @virtual_order_order_num := COALESCE( + (SELECT MAX(order_num) + 1 FROM sys_menu WHERE parent_id = @virtual_order_parent_id), + 1 +); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT + '虚拟支付订单', @virtual_order_parent_id, @virtual_order_order_num, + 'virtualOrder', 'app/virtualOrder/index', '', 1, 0, + 'C', '0', '0', 'app:virtualOrder:list', 'money', 'admin', NOW(), '', NULL, + '虚拟支付订单只读查询菜单' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE perms = 'app:virtualOrder:list' +); + +SET @virtual_order_menu_id := ( + SELECT menu_id FROM sys_menu WHERE perms = 'app:virtualOrder:list' LIMIT 1 +); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT + '虚拟订单查询', @virtual_order_menu_id, 1, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualOrder:query', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_order_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE perms = 'app:virtualOrder:query' + ); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT + '虚拟订单导出', @virtual_order_menu_id, 2, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualOrder:export', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_order_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE perms = 'app:virtualOrder:export' + ); diff --git a/sql/virtual_pay_resource.sql b/sql/virtual_pay_resource.sql new file mode 100644 index 0000000..dcf6245 --- /dev/null +++ b/sql/virtual_pay_resource.sql @@ -0,0 +1,79 @@ +-- 资源直购虚拟支付改造 +-- 执行前请先备份数据库。适用于当前项目的 MySQL 数据库。 + +ALTER TABLE app_resource + ADD COLUMN price_fen INT NOT NULL DEFAULT 0 COMMENT '虚拟支付价格,单位分' AFTER ad_number; + +-- 兼容已有“is_ad=3 且 ad_number 表示人民币元”的资源。 +UPDATE app_resource +SET price_fen = ad_number * 100 +WHERE is_ad = 3 AND price_fen = 0 AND ad_number > 0; + +CREATE TABLE app_virtual_product ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + product_id VARCHAR(64) NOT NULL COMMENT '微信虚拟道具productId', + price_fen INT NOT NULL COMMENT '价格档位,单位分', + product_name VARCHAR(100) NOT NULL COMMENT '档位名称', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_virtual_product_id (product_id), + UNIQUE KEY uk_virtual_product_price (price_fen) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信虚拟道具价格档位'; + +CREATE TABLE app_virtual_order ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', + user_id BIGINT NOT NULL COMMENT '用户ID', + resource_id BIGINT NOT NULL COMMENT '资源ID', + product_id VARCHAR(64) NOT NULL COMMENT '微信虚拟道具ID', + price_fen INT NOT NULL COMMENT '支付金额,单位分', + open_id VARCHAR(64) NOT NULL COMMENT '微信OpenID', + status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付发货 2已退款 3已关闭', + wx_order_no VARCHAR(64) NULL COMMENT '微信内部订单号', + transaction_id VARCHAR(64) NULL COMMENT '微信支付交易单号', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + pay_time DATETIME NULL, + provide_time DATETIME NULL, + refund_time DATETIME NULL, + last_query_time DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_virtual_order_no (order_no), + KEY idx_virtual_order_user (user_id, create_time), + KEY idx_virtual_order_resource (resource_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源虚拟支付订单'; + +CREATE TABLE app_resource_entitlement ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + user_id BIGINT NOT NULL COMMENT '用户ID', + resource_id BIGINT NOT NULL COMMENT '资源ID', + order_no VARCHAR(32) NOT NULL COMMENT '来源虚拟支付订单号', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0已撤销', + granted_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_time DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_resource_entitlement_order (order_no), + UNIQUE KEY uk_resource_entitlement_user_resource (user_id, resource_id), + KEY idx_resource_entitlement_resource (resource_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='付费资源访问权益'; + +-- 执行迁移后: +-- 1. 在微信公众平台创建各价格档位的道具并发布; +-- 2. 在若依后台编辑付费资源,填写“价格(分)”和对应的 productId; +-- 3. 同一价格填写同一个 productId,后台会自动维护 app_virtual_product 映射。 + +-- 当前小程序已发布的微信虚拟道具价格映射。 +-- product_id 必须与微信后台保持完全一致(包括 resourse 的现有拼写)。 +INSERT INTO app_virtual_product + (product_id, price_fen, product_name, status, create_time) +VALUES + ('resourse_1', 100, '1元资源', 1, NOW()), + ('resourse_10', 1000, '10元资源', 1, NOW()), + ('resourse_50', 5000, '50元资源', 1, NOW()), + ('resourse_99', 9900, '99元资源', 1, NOW()), + ('resourse_300', 30000, '300元资源', 1, NOW()) +ON DUPLICATE KEY UPDATE + product_name = VALUES(product_name), + status = 1, + update_time = NOW();