feat: 新增虚拟支付订单与网盘链接检测功能

This commit is contained in:
王鹏
2026-07-28 13:42:30 +08:00
parent 4070179f46
commit 2250f744d1
60 changed files with 3668 additions and 106 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"kiroAgent.configureMCP": "Disabled"
}

72
docs/virtual-pay-setup.md Normal file
View File

@@ -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。
虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。

View File

@@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppBlogArticle; import com.ruoyi.app.domain.AppBlogArticle;
import com.ruoyi.app.mapper.AppBlogArticleMapper; import com.ruoyi.app.mapper.AppBlogArticleMapper;
import com.ruoyi.app.service.ImageUrlService;
import com.ruoyi.app.service.IAppBlogArticleService; import com.ruoyi.app.service.IAppBlogArticleService;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
@@ -32,6 +33,9 @@ public class AppBlogArticleController extends BaseController {
@Autowired @Autowired
private IAppBlogArticleService appBlogArticleService; private IAppBlogArticleService appBlogArticleService;
@Autowired
private ImageUrlService imageUrlService;
@Resource @Resource
private AppBlogArticleMapper appBlogArticleMapper; private AppBlogArticleMapper appBlogArticleMapper;
@@ -45,6 +49,7 @@ public class AppBlogArticleController extends BaseController {
public TableDataInfo list(AppBlogArticle appBlogArticle) { public TableDataInfo list(AppBlogArticle appBlogArticle) {
startPage(); startPage();
List<AppBlogArticle> list = appBlogArticleService.selectAppBlogArticleList(appBlogArticle); List<AppBlogArticle> list = appBlogArticleService.selectAppBlogArticleList(appBlogArticle);
imageUrlService.decorateArticles(list);
return getDataTable(list); return getDataTable(list);
} }
@@ -72,6 +77,7 @@ public class AppBlogArticleController extends BaseController {
@GetMapping(value = "/app/{id}") @GetMapping(value = "/app/{id}")
public AjaxResult appGetInfo(@PathVariable("id") Long id) { public AjaxResult appGetInfo(@PathVariable("id") Long id) {
AppBlogArticle appBlogArticle = appBlogArticleService.selectAppBlogArticleById(id); AppBlogArticle appBlogArticle = appBlogArticleService.selectAppBlogArticleById(id);
imageUrlService.decorateArticle(appBlogArticle);
List<String> list = new ArrayList<>(); List<String> list = new ArrayList<>();
List<TtFile> picList = fileService.selectTtFileByCodeName(appBlogArticle.getTitle()); List<TtFile> picList = fileService.selectTtFileByCodeName(appBlogArticle.getTitle());
for (TtFile ttFile : picList) { for (TtFile ttFile : picList) {

View File

@@ -4,6 +4,8 @@ import com.ruoyi.app.domain.AppIntegralRecord;
import com.ruoyi.app.domain.AppLotteryGoods; import com.ruoyi.app.domain.AppLotteryGoods;
import com.ruoyi.app.domain.AppLotteryLog; import com.ruoyi.app.domain.AppLotteryLog;
import com.ruoyi.app.mapper.AppIntegralRecordMapper; 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.IAppIntegralRecordService;
import com.ruoyi.app.service.IAppLotteryGoodsService; import com.ruoyi.app.service.IAppLotteryGoodsService;
import com.ruoyi.app.service.IAppLotteryLogService; import com.ruoyi.app.service.IAppLotteryLogService;
@@ -42,6 +44,8 @@ public class AppIntegralRecordController extends BaseController
private AppIntegralRecordMapper appIntegralRecordMapper; private AppIntegralRecordMapper appIntegralRecordMapper;
@Resource @Resource
private SysUserMapper sysUserMapper; private SysUserMapper sysUserMapper;
@Resource
private AppResourceMapper appResourceMapper;
@Autowired @Autowired
private IAppLotteryGoodsService appLotteryGoodsService; private IAppLotteryGoodsService appLotteryGoodsService;
@Autowired @Autowired
@@ -187,19 +191,38 @@ public class AppIntegralRecordController extends BaseController
* 积分记录,增减用户积分通用 * 积分记录,增减用户积分通用
*/ */
@Transactional @Transactional
@PreAuthorize("isAuthenticated()")
@PostMapping("/resource") @PostMapping("/resource")
public AjaxResult resource(@RequestBody AppIntegralRecord appIntegralRecord) public AjaxResult resource(@RequestBody AppIntegralRecord appIntegralRecord)
{ {
Date date = new Date(); Long userId = getUserId();
appIntegralRecord.setIntegralTime(date); AppResource resource = appResourceMapper.selectAppResourceById(appIntegralRecord.getResourceId());
appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord); if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 2L
if (appIntegralRecord.getIsAdd() == 0){ || resource.getAdNumber() == null || resource.getAdNumber() <= 0)
sysUserMapper.addIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId()); {
}else { return error("该资源不支持积分兑换");
sysUserMapper.delIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId());
} }
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("兑换成功");
} }
/** /**

View File

@@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppResource; import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.mapper.AppResourceMapper; import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.service.ImageUrlService;
import com.ruoyi.app.service.IAppResourceService; import com.ruoyi.app.service.IAppResourceService;
import com.ruoyi.common.annotation.Log; import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
@@ -16,6 +17,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.util.List; 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 * 资源Controller
@@ -30,6 +34,9 @@ public class AppResourceController extends BaseController
@Autowired @Autowired
private IAppResourceService appResourceService; private IAppResourceService appResourceService;
@Autowired
private ImageUrlService imageUrlService;
@Resource @Resource
private AppResourceMapper appResourceMapper; private AppResourceMapper appResourceMapper;
@@ -41,6 +48,7 @@ public class AppResourceController extends BaseController
{ {
startPage(); startPage();
List<AppResource> list = appResourceService.selectAppResourceList(appResource); List<AppResource> list = appResourceService.selectAppResourceList(appResource);
imageUrlService.decorateResources(list);
return getDataTable(list); return getDataTable(list);
} }
@@ -70,16 +78,28 @@ public class AppResourceController extends BaseController
@GetMapping(value = "/app/{id}") @GetMapping(value = "/app/{id}")
public AjaxResult getInfoApp(@PathVariable("id") Long id) public AjaxResult getInfoApp(@PathVariable("id") Long id)
{ {
return success(appResourceService.selectAppResourceById(id)); return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
} }
/** /**
* 根据用户ID获取资源详细信息 * 根据用户ID获取资源详细信息
*/ */
@GetMapping(value = "/app/user/{id}/{userId}") @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())); 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;
}
} }

View File

@@ -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<AppVirtualOrder> 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<AppVirtualOrder> list = appVirtualOrderService.selectAppVirtualOrderList(order);
ExcelUtil<AppVirtualOrder> util = new ExcelUtil<>(AppVirtualOrder.class);
util.exportExcel(response, list, "虚拟支付订单数据");
}
}

View File

@@ -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<String, Object> 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<String, Object> callbackResponse(int code, String message)
{
Map<String, Object> response = new LinkedHashMap<>();
response.put("ErrCode", code);
response.put("ErrMsg", message);
return response;
}
}

View File

@@ -4,7 +4,6 @@ import com.ruoyi.app.domain.ShuiYinVo;
import com.ruoyi.app.mapper.AppPunlicMapper; import com.ruoyi.app.mapper.AppPunlicMapper;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.system.service.ISysConfigService; import com.ruoyi.system.service.ISysConfigService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -21,20 +20,6 @@ public class PublicController extends BaseController {
AppPunlicMapper appPunlicMapper; AppPunlicMapper appPunlicMapper;
@Autowired @Autowired
private ISysConfigService configService; 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 * 去水印 key 免费申请联系作者微信yimoziyuan666
* @return * @return
@@ -43,7 +28,7 @@ public class PublicController extends BaseController {
private String delWatermarkKey; private String delWatermarkKey;
@PostMapping(value = "/delSHuiYin") @PostMapping(value = "/delSHuiYin")
public AjaxResult delSHuiYin(HttpServletRequest request, @RequestBody ShuiYinVo shuiYinVo) { 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 key = configService.selectConfigByKey("miniapp.shuiyin.key");
String param = "key="+key+"&url="+shuiYinVo.getUrl(); String param = "key="+key+"&url="+shuiYinVo.getUrl();
String s = httpUtils.httpGet("https://api.emoboy.vip/api/shuiyin/delWatermark", param); String s = httpUtils.httpGet("https://api.emoboy.vip/api/shuiyin/delWatermark", param);

View File

@@ -1,13 +1,12 @@
package com.ruoyi.web.controller.app; package com.ruoyi.web.controller.app;
import com.ruoyi.app.service.IAppPayOrderService;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.wx.*; import com.ruoyi.common.wx.*;
import com.wechat.pay.java.service.payments.jsapi.model.PrepayWithRequestPaymentResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.io.IOException; import java.io.IOException;
@@ -26,20 +25,6 @@ import java.io.IOException;
public class WxMiniappPayController extends BaseController { public class WxMiniappPayController extends BaseController {
@Autowired @Autowired
private WxMiniappPayService wxMiniappPayService; private WxMiniappPayService wxMiniappPayService;
@Autowired
private IAppPayOrderService appPayOrderService;
/**
* 预支付订单/统一下单
* @param req
* @return
*/
@RequestMapping("/createOrder")
public Response<PrepayWithRequestPaymentResponse> createOrder(@Validated @RequestBody CreateOrderReq req) {
log.info("------预支付订单/统一下单------");
//微信小程序登录用户openid用户标识 说明用户在商户appid下的唯一标识。
return this.wxMiniappPayService.createOrder(req);
}
/** /**
* 支付回调 * 支付回调
@@ -65,6 +50,7 @@ public class WxMiniappPayController extends BaseController {
* @return * @return
*/ */
@PostMapping("/queryOrder") @PostMapping("/queryOrder")
@PreAuthorize("@ss.hasPermi('app:order:query')")
public Response queryOrder(@Validated @RequestBody QueryOrderReq req) { public Response queryOrder(@Validated @RequestBody QueryOrderReq req) {
log.info("------根据支付订单号查询订单------"); log.info("------根据支付订单号查询订单------");
return this.wxMiniappPayService.queryOrder(req); return this.wxMiniappPayService.queryOrder(req);
@@ -81,6 +67,7 @@ public class WxMiniappPayController extends BaseController {
* @return * @return
*/ */
@PostMapping("/queryOrderByOutTradeNo") @PostMapping("/queryOrderByOutTradeNo")
@PreAuthorize("@ss.hasPermi('app:order:query')")
public Response queryOrderByOutTradeNo(@Validated @RequestBody QueryOrderReq req) { public Response queryOrderByOutTradeNo(@Validated @RequestBody QueryOrderReq req) {
log.info("------根据商户订单号查询订单------"); log.info("------根据商户订单号查询订单------");
return this.wxMiniappPayService.queryOrderByOutTradeNo(req); return this.wxMiniappPayService.queryOrderByOutTradeNo(req);
@@ -93,6 +80,7 @@ public class WxMiniappPayController extends BaseController {
*/ */
@PostMapping("/closeOrder") @PostMapping("/closeOrder")
@PreAuthorize("@ss.hasPermi('app:order:edit')")
public Response closeOrder(@Validated @RequestBody QueryOrderReq req) { public Response closeOrder(@Validated @RequestBody QueryOrderReq req) {
log.info("------微信小程序支付关闭订单------"); log.info("------微信小程序支付关闭订单------");
return this.wxMiniappPayService.closeOrder(req); return this.wxMiniappPayService.closeOrder(req);
@@ -104,6 +92,7 @@ public class WxMiniappPayController extends BaseController {
* @return * @return
*/ */
@PostMapping("/refund") @PostMapping("/refund")
@PreAuthorize("@ss.hasPermi('app:order:refund')")
public Response refund(@Validated @RequestBody RefundOrderReq req) { public Response refund(@Validated @RequestBody RefundOrderReq req) {
log.info("------微信支付退款------"); log.info("------微信支付退款------");
return this.wxMiniappPayService.refund(req); return this.wxMiniappPayService.refund(req);
@@ -115,6 +104,7 @@ public class WxMiniappPayController extends BaseController {
* @return * @return
*/ */
@GetMapping("/queryByOutRefundNo") @GetMapping("/queryByOutRefundNo")
@PreAuthorize("@ss.hasPermi('app:order:query')")
public Response queryByOutRefundNo(String outRefundNo) { public Response queryByOutRefundNo(String outRefundNo) {
log.info("------微信支付查询单笔退款------"); log.info("------微信支付查询单笔退款------");
return this.wxMiniappPayService.queryByOutRefundNo(outRefundNo); return this.wxMiniappPayService.queryByOutRefundNo(outRefundNo);

View File

@@ -16,6 +16,8 @@ import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.SysLoginService; import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.SysPermissionService; import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.system.service.ISysMenuService; import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.common.wx.WxCodeSession;
import com.ruoyi.common.wx.WxCodeSessionService;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -36,6 +38,9 @@ public class SysLoginController
@Autowired @Autowired
private SysPermissionService permissionService; private SysPermissionService permissionService;
@Autowired
private WxCodeSessionService wxCodeSessionService;
/** /**
* 登录方法 * 登录方法
* *
@@ -55,15 +60,13 @@ public class SysLoginController
/** /**
* 微信openID登陆 * 使用微信临时 code 登录。OpenID 与 session_key 只在服务端获取。
* @param
* @return
*/ */
@PostMapping("/wxLogin") @PostMapping("/wxLogin")
public AjaxResult wxLogin(@RequestBody LoginBody loginBody) { public AjaxResult wxLogin(@RequestBody LoginBody loginBody) {
AjaxResult ajax = AjaxResult.success(); AjaxResult ajax = AjaxResult.success();
// 生成令牌 WxCodeSession codeSession = wxCodeSessionService.exchange(loginBody.getCode());
String token = loginService.wxLogin(loginBody.getOpenId(),loginBody.getOldUserId()); String token = loginService.wxLogin(codeSession.getOpenId(), loginBody.getOldUserId());
ajax.put(Constants.TOKEN, token); ajax.put(Constants.TOKEN, token);
return ajax; return ajax;
} }

View File

@@ -9,8 +9,8 @@ ruoyi:
# 实例演示开关 # 实例演示开关
demoEnabled: true demoEnabled: true
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath # 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
# profile: /home/upload profile: /home/upload
profilee: D:/ruoyi/uploadPath # profilee: D:/ruoyi/uploadPath
# 获取ip地址开关 # 获取ip地址开关
addressEnabled: false addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证 # 验证码类型 math 数字计算 char 字符验证
@@ -42,10 +42,10 @@ wx:
appid: wx17b46f75e762c184 # 微信小程序appid appid: wx17b46f75e762c184 # 微信小程序appid
secret: 1fa844dc33e70f7d813f24cd2af7678b # 微信小程序密钥 secret: 1fa844dc33e70f7d813f24cd2af7678b # 微信小程序密钥
merchantId: 1704387455 # 商户号 merchantId: 1704387455 # 商户号
privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_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公钥路径测试环境 # publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径测试环境
# privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径正式环境 privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径正式环境
# publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径正式环境 publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径正式环境
publicKeyId: PUB_KEY_ID_0117043874552025011100188700000234 publicKeyId: PUB_KEY_ID_0117043874552025011100188700000234
merchantSerialNumber: 743FBCB9F5DFD76104A468C6AC6EDD41268634A3 # 商户API证书序列号 merchantSerialNumber: 743FBCB9F5DFD76104A468C6AC6EDD41268634A3 # 商户API证书序列号
apiV3Key: G7kL2mN8pQ4rT1vX9yZ3bC5dF6hJ0sW1 # 商户APIV3密钥 apiV3Key: G7kL2mN8pQ4rT1vX9yZ3bC5dF6hJ0sW1 # 商户APIV3密钥
@@ -53,6 +53,13 @@ wx:
# refundNotifyUrl: http://www.yidaima.cn:6001/app/pay/refundNotify # 退款通知地址(测试环境) # refundNotifyUrl: http://www.yidaima.cn:6001/app/pay/refundNotify # 退款通知地址(测试环境)
payNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/payNotify # 支付通知地址(正式环境) payNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/payNotify # 支付通知地址(正式环境)
refundNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/refundNotify # 退款通知地址(正式环境) 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: logging:

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration> <configuration>
<!-- 日志存放路径 --> <!-- 日志存放路径 -->
<property name="log.path" value="D:/ruoyi/log" /> <!-- <property name="log.path" value="D:/ruoyi/log" />-->
<!-- <property name="log.path" value="/home/ruoyi/logs" />--> <property name="log.path" value="/home/ruoyi/logs" />
<!-- 日志输出格式 --> <!-- 日志输出格式 -->
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" /> <property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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("调用微信登录服务失败");
}
}
}

View File

@@ -117,7 +117,6 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
"/app/**/list", "/app/**/list",
"/app/**/app/**", "/app/**/app/**",
"/system/dict/data/type/**", "/system/dict/data/type/**",
"/app/public/autoLoginWx/**",
"/app/public/delSHuiYin", "/app/public/delSHuiYin",
"/app/public/getSysSet", "/app/public/getSysSet",
"/app/pay/notify", "/app/pay/notify",

View File

@@ -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);
}
}

View File

@@ -10,6 +10,7 @@ import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.utils.CoverGenerator; import com.ruoyi.common.utils.CoverGenerator;
import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.service.ITtCodeService; import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.IProjectLinkCheckService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -42,6 +43,8 @@ public class TtProjectInfoController extends BaseController {
private ITtProjectInfoService ttProjectInfoService; private ITtProjectInfoService ttProjectInfoService;
@Autowired @Autowired
private ITtCodeService ttCodeService; private ITtCodeService ttCodeService;
@Autowired
private IProjectLinkCheckService projectLinkCheckService;
/** /**
@@ -76,6 +79,26 @@ public class TtProjectInfoController extends BaseController {
return success(ttProjectInfoService.selectTtProjectInfoById(id)); 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));
}
/** /**
* 新增项目清单 * 新增项目清单
*/ */

View File

@@ -4,6 +4,7 @@ import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle; import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity; import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
/** /**
* 项目清单对象 tt_project_info * 项目清单对象 tt_project_info
@@ -50,6 +51,24 @@ public class TtProjectInfo extends BaseEntity
@Excel(name = "项目百度链接") @Excel(name = "项目百度链接")
private String projectBaiduUrl; 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) public void setId(Integer id)
{ {
this.id = id; this.id = id;
@@ -131,6 +150,54 @@ public class TtProjectInfo extends BaseEntity
this.projectBaiduUrl = projectBaiduUrl; 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 @Override
public String toString() { public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
@@ -141,6 +208,7 @@ public class TtProjectInfo extends BaseEntity
.append("projectName1", getProjectName1()) .append("projectName1", getProjectName1())
.append("projectDesc", getProjectDesc()) .append("projectDesc", getProjectDesc())
.append("projectUrl", getProjectUrl()) .append("projectUrl", getProjectUrl())
.append("projectBaiduUrl", getProjectBaiduUrl())
.append("projectVurl", getProjectVurl()) .append("projectVurl", getProjectVurl())
.toString(); .toString();
} }

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.office.service;
import java.util.Map;
/**
* 项目网盘链接检测服务。
*/
public interface IProjectLinkCheckService
{
Map<String, Object> checkProject(Integer projectId);
Map<String, Object> checkProjects(Integer[] projectIds);
}

View File

@@ -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<String, NetDiskLinkChecker> checkerMap;
private final ExecutorService netDiskCheckExecutor;
public ProjectLinkCheckServiceImpl(
TtProjectInfoMapper projectInfoMapper,
TtProjectLinkCheckMapper linkCheckMapper,
List<NetDiskLinkChecker> 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<String, Object> checkProject(Integer projectId)
{
if (projectId == null)
{
throw new ServiceException("项目编号不能为空");
}
ProjectCheckOutcome outcome = checkProjectInternal(projectId);
return buildSummary(Collections.singletonList(outcome));
}
@Override
public Map<String, Object> checkProjects(Integer[] projectIds)
{
if (projectIds == null || projectIds.length == 0)
{
throw new ServiceException("请至少选择一个项目");
}
Set<Integer> 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<CompletableFuture<ProjectCheckOutcome>> futures = new ArrayList<>();
for (Integer projectId : uniqueIds)
{
futures.add(CompletableFuture.supplyAsync(
() -> checkProjectInternal(projectId), netDiskCheckExecutor));
}
List<ProjectCheckOutcome> outcomes = new ArrayList<>();
for (CompletableFuture<ProjectCheckOutcome> 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<NetDiskCheckResult> 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<NetDiskCheckResult> 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<String, Object> buildSummary(List<ProjectCheckOutcome> outcomes)
{
int validCount = 0;
int invalidCount = 0;
int warningCount = 0;
int unknownCount = 0;
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<NetDiskCheckResult> results;
private ProjectCheckOutcome(Integer projectId, String projectNum,
List<NetDiskCheckResult> results)
{
this.projectId = projectId;
this.projectNum = projectNum;
this.results = results;
}
}
}

View File

@@ -6,8 +6,10 @@ import java.util.List;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.mapper.TtCodeMapper; import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.office.mapper.TtProjectInfoMapper; import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.domain.TtProjectInfo; import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.service.ITtProjectInfoService; import com.ruoyi.office.service.ITtProjectInfoService;
@@ -25,6 +27,8 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
private TtProjectInfoMapper ttProjectInfoMapper; private TtProjectInfoMapper ttProjectInfoMapper;
@Autowired @Autowired
private TtCodeMapper ttCodeMapper; private TtCodeMapper ttCodeMapper;
@Autowired
private TtProjectLinkCheckMapper ttProjectLinkCheckMapper;
/** /**
* 查询项目清单 * 查询项目清单
@@ -81,8 +85,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
* @return 结果 * @return 结果
*/ */
@Override @Override
@Transactional
public int deleteTtProjectInfoByIds(Integer[] ids) public int deleteTtProjectInfoByIds(Integer[] ids)
{ {
ttProjectLinkCheckMapper.deleteByProjectIds(ids);
return ttProjectInfoMapper.deleteTtProjectInfoByIds(ids); return ttProjectInfoMapper.deleteTtProjectInfoByIds(ids);
} }
@@ -93,8 +99,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
* @return 结果 * @return 结果
*/ */
@Override @Override
@Transactional
public int deleteTtProjectInfoById(Integer id) public int deleteTtProjectInfoById(Integer id)
{ {
ttProjectLinkCheckMapper.deleteByProjectId(id);
return ttProjectInfoMapper.deleteTtProjectInfoById(id); return ttProjectInfoMapper.deleteTtProjectInfoById(id);
} }

View File

@@ -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<String> 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());
}
}

View File

@@ -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;
}
}

View File

@@ -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()
{
}
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.office.service.netdisk;
/**
* 网盘链接检测器。
*/
public interface NetDiskLinkChecker
{
String getDiskType();
NetDiskCheckResult check(String linkUrl);
}

View File

@@ -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<Integer> 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());
}
}

View File

@@ -36,7 +36,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="pictureFile != null and pictureFile != ''"> and picture_file = #{pictureFile}</if> <if test="pictureFile != null and pictureFile != ''"> and picture_file = #{pictureFile}</if>
<if test="videoFile != null and videoFile != ''"> and video_file = #{videoFile}</if> <if test="videoFile != null and videoFile != ''"> and video_file = #{videoFile}</if>
</where> </where>
ORDER BY code_name DESC ORDER BY code_id DESC
</select> </select>
<select id="selectTtCodeByCodeId" parameterType="Long" resultMap="TtCodeResult"> <select id="selectTtCodeByCodeId" parameterType="Long" resultMap="TtCodeResult">

View File

@@ -16,12 +16,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</sql> </sql>
<select id="selectTtFileList" parameterType="TtFile" resultMap="TtFileResult"> <select id="selectTtFileList" parameterType="TtFile" resultMap="TtFileResult">
<include refid="selectTtFileVo"/> 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
<where> <where>
<if test="fileName != null and fileName != ''"> and file_name like concat('%', #{fileName}, '%')</if> <if test="fileName != null and fileName != ''"> and f.file_name like concat('%', #{fileName}, '%')</if>
<if test="codeName != null and codeName != ''"> and code_name like concat('%', #{codeName}, '%')</if> <if test="codeName != null and codeName != ''"> and f.code_name like concat('%', #{codeName}, '%')</if>
</where> </where>
ORDER BY code_name DESC, file_id ASC ORDER BY c.source_order DESC, f.file_id ASC
</select> </select>
<select id="selectTtFileByFileId" parameterType="Long" resultMap="TtFileResult"> <select id="selectTtFileByFileId" parameterType="Long" resultMap="TtFileResult">

View File

@@ -14,34 +14,64 @@
<result property="projectUrl" column="project_url" /> <result property="projectUrl" column="project_url" />
<result property="projectVurl" column="project_vurl" /> <result property="projectVurl" column="project_vurl" />
<result property="projectBaiduUrl" column="project_baidu_url" /> <result property="projectBaiduUrl" column="project_baidu_url" />
<result property="quarkCheckStatus" column="quark_check_status" />
<result property="quarkCheckMessage" column="quark_check_message" />
<result property="quarkCheckedAt" column="quark_checked_at" />
<result property="baiduCheckStatus" column="baidu_check_status" />
<result property="baiduCheckMessage" column="baidu_check_message" />
<result property="baiduCheckedAt" column="baidu_checked_at" />
</resultMap> </resultMap>
<sql id="selectTtProjectInfoVo"> <sql id="selectTtProjectInfoVo">
select id, project_num, project_num1, project_name, project_name1, project_desc, project_url, project_vurl, project_baidu_url from tt_project_info select p.id,
p.project_num,
p.project_num1,
p.project_name,
p.project_name1,
p.project_desc,
p.project_url,
p.project_vurl,
p.project_baidu_url,
quark_check.check_status as quark_check_status,
quark_check.check_message as quark_check_message,
quark_check.checked_at as quark_checked_at,
baidu_check.check_status as baidu_check_status,
baidu_check.check_message as baidu_check_message,
baidu_check.checked_at as baidu_checked_at
from tt_project_info p
left join tt_project_link_check quark_check
on quark_check.project_id = p.id
and quark_check.disk_type = 'QUARK'
and quark_check.link_url = p.project_url
left join tt_project_link_check baidu_check
on baidu_check.project_id = p.id
and baidu_check.disk_type = 'BAIDU'
and baidu_check.link_url = p.project_baidu_url
</sql> </sql>
<select id="selectTtProjectInfoList" parameterType="TtProjectInfo" resultMap="TtProjectInfoResult"> <select id="selectTtProjectInfoList" parameterType="TtProjectInfo" resultMap="TtProjectInfoResult">
<include refid="selectTtProjectInfoVo"/> <include refid="selectTtProjectInfoVo"/>
<where> <where>
<if test="projectNum != null and projectNum != ''"> and project_num like concat('%', #{projectNum}, '%')</if> <if test="projectNum != null and projectNum != ''"> and p.project_num like concat('%', #{projectNum}, '%')</if>
<if test="projectNum1 != null and projectNum1 != ''"> and project_num1 = #{projectNum1}</if> <if test="projectNum1 != null and projectNum1 != ''"> and p.project_num1 = #{projectNum1}</if>
<if test="projectName != null and projectName != ''"> and project_name like concat('%', #{projectName}, '%')</if> <if test="projectName != null and projectName != ''"> and p.project_name like concat('%', #{projectName}, '%')</if>
<if test="projectName1 != null and projectName1 != ''"> and project_name1 like concat('%', #{projectName1}, '%')</if> <if test="projectName1 != null and projectName1 != ''"> and p.project_name1 like concat('%', #{projectName1}, '%')</if>
<if test="projectDesc != null and projectDesc != ''"> and project_desc = #{projectDesc}</if> <if test="projectDesc != null and projectDesc != ''"> and p.project_desc = #{projectDesc}</if>
<if test="projectUrl != null and projectUrl != ''"> and project_url = #{projectUrl}</if> <if test="projectUrl != null and projectUrl != ''"> and p.project_url = #{projectUrl}</if>
<if test="projectVurl != null and projectVurl != ''"> and project_vurl = #{projectVurl}</if> <if test="projectVurl != null and projectVurl != ''"> and p.project_vurl = #{projectVurl}</if>
<if test="projectBaiduUrl != null and projectBaiduUrl != ''"> and project_baidu_url = #{projectBaiduUrl}</if> <if test="projectBaiduUrl != null and projectBaiduUrl != ''"> and p.project_baidu_url = #{projectBaiduUrl}</if>
</where> </where>
ORDER BY p.id DESC
</select> </select>
<select id="selectTtProjectInfoById" parameterType="Integer" resultMap="TtProjectInfoResult"> <select id="selectTtProjectInfoById" parameterType="Integer" resultMap="TtProjectInfoResult">
<include refid="selectTtProjectInfoVo"/> <include refid="selectTtProjectInfoVo"/>
where id = #{id} where p.id = #{id}
</select> </select>
<select id="selectTtProjectInfoByName" parameterType="String" resultMap="TtProjectInfoResult"> <select id="selectTtProjectInfoByName" parameterType="String" resultMap="TtProjectInfoResult">
<include refid="selectTtProjectInfoVo"/> <include refid="selectTtProjectInfoVo"/>
where project_name1 = #{codeName} where p.project_name1 = #{codeName}
</select> </select>
<insert id="insertTtProjectInfo" parameterType="TtProjectInfo" useGeneratedKeys="true" keyProperty="id"> <insert id="insertTtProjectInfo" parameterType="TtProjectInfo" useGeneratedKeys="true" keyProperty="id">
@@ -96,8 +126,8 @@
<select id="lastUpdateList" resultMap="TtProjectInfoResult" parameterType="string"> <select id="lastUpdateList" resultMap="TtProjectInfoResult" parameterType="string">
<include refid="selectTtProjectInfoVo"/> <include refid="selectTtProjectInfoVo"/>
where project_vurl is not null where p.project_vurl is not null
<if test="searchKey != null and searchKey != ''">AND IFNULL(project_name1, project_name) like CONCAT('%',#{searchKey},'%') </if> <if test="searchKey != null and searchKey != ''">AND IFNULL(p.project_name1, p.project_name) like CONCAT('%',#{searchKey},'%') </if>
ORDER BY project_name DESC ORDER BY p.project_name DESC
</select> </select>
</mapper> </mapper>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.office.mapper.TtProjectLinkCheckMapper">
<insert id="upsertTtProjectLinkCheck" parameterType="TtProjectLinkCheck" useGeneratedKeys="true" keyProperty="id">
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()
</insert>
<delete id="deleteByProjectId" parameterType="Integer">
delete from tt_project_link_check where project_id = #{projectId}
</delete>
<delete id="deleteByProjectIds">
delete from tt_project_link_check
where project_id in
<foreach item="projectId" collection="array" open="(" separator="," close=")">
#{projectId}
</foreach>
</delete>
</mapper>

View File

@@ -40,6 +40,9 @@ public class AppBlogArticle extends BaseEntity
@Excel(name = "封面图url") @Excel(name = "封面图url")
private String showImg; private String showImg;
/** List-card thumbnail URL (not persisted). */
private String showImgThumb;
/** 关联已有资源关联app_resource id */ /** 关联已有资源关联app_resource id */
@Excel(name = "关联已有资源", readConverterExp = "关=联app_resource,i=d") @Excel(name = "关联已有资源", readConverterExp = "关=联app_resource,i=d")
private Long appResourceId; private Long appResourceId;
@@ -159,6 +162,15 @@ public class AppBlogArticle extends BaseEntity
{ {
return showImg; return showImg;
} }
public void setShowImgThumb(String showImgThumb)
{
this.showImgThumb = showImgThumb;
}
public String getShowImgThumb()
{
return showImgThumb;
}
public void setAppResourceId(Long appResourceId) public void setAppResourceId(Long appResourceId)
{ {
this.appResourceId = appResourceId; this.appResourceId = appResourceId;

View File

@@ -27,6 +27,9 @@ public class AppResource extends BaseEntity
@Excel(name = "封面图") @Excel(name = "封面图")
private String showImg; private String showImg;
/** List-card thumbnail URL (not persisted). */
private String showImgThumb;
/** 说明 */ /** 说明 */
@Excel(name = "说明") @Excel(name = "说明")
private String explain; private String explain;
@@ -51,6 +54,13 @@ public class AppResource extends BaseEntity
@Excel(name = "需要观看几次广告解锁") @Excel(name = "需要观看几次广告解锁")
private Long adNumber; private Long adNumber;
/** 虚拟支付价格,单位分 */
@Excel(name = "付费价格(分)")
private Integer priceFen;
/** 同价格档位对应的微信虚拟道具ID非 app_resource 表字段) */
private String virtualProductId;
/** 下载次数 */ /** 下载次数 */
@Excel(name = "下载次数") @Excel(name = "下载次数")
private Long downNum; private Long downNum;
@@ -100,6 +110,15 @@ public class AppResource extends BaseEntity
{ {
return showImg; return showImg;
} }
public void setShowImgThumb(String showImgThumb)
{
this.showImgThumb = showImgThumb;
}
public String getShowImgThumb()
{
return showImgThumb;
}
public void setExplain(String explain) public void setExplain(String explain)
{ {
this.explain = explain; this.explain = explain;
@@ -145,6 +164,26 @@ public class AppResource extends BaseEntity
{ {
return adNumber; 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) public void setDownNum(Long downNum)
{ {
this.downNum = downNum; this.downNum = downNum;
@@ -194,6 +233,8 @@ public class AppResource extends BaseEntity
.append("isShow", getIsShow()) .append("isShow", getIsShow())
.append("isAd", getIsAd()) .append("isAd", getIsAd())
.append("adNumber", getAdNumber()) .append("adNumber", getAdNumber())
.append("priceFen", getPriceFen())
.append("virtualProductId", getVirtualProductId())
.append("downNum", getDownNum()) .append("downNum", getDownNum())
.append("weight", getWeight()) .append("weight", getWeight())
.append("delFlag", getDelFlag()) .append("delFlag", getDelFlag())

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<AppVirtualOrder> 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);
}

View File

@@ -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);
}

View File

@@ -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<AppVirtualOrder> selectAppVirtualOrderList(AppVirtualOrder order);
}

View File

@@ -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<String, Object> createOrder(CreateVirtualOrderRequest request);
Map<String, Object> queryOrder(String orderNo, boolean sync);
boolean verifyCallbackSignature(String signature, String timestamp, String nonce);
void handleCallback(JsonNode body);
}

View File

@@ -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<AppBlogArticle> 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<AppResource> 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;
}
}

View File

@@ -11,6 +11,9 @@ import com.ruoyi.app.domain.AppResourceList;
import com.ruoyi.app.mapper.AppResourceMapper; import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.domain.AppResource; import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.service.IAppResourceService; 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业务层处理 * 资源Service业务层处理
@@ -24,6 +27,9 @@ public class AppResourceServiceImpl implements IAppResourceService
@Autowired @Autowired
private AppResourceMapper appResourceMapper; private AppResourceMapper appResourceMapper;
@Autowired
private AppVirtualProductMapper virtualProductMapper;
/** /**
* 查询资源 * 查询资源
* *
@@ -58,6 +64,7 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override @Override
public int insertAppResource(AppResource appResource) public int insertAppResource(AppResource appResource)
{ {
configureVirtualProduct(appResource);
appResource.setCreateTime(DateUtils.getNowDate()); appResource.setCreateTime(DateUtils.getNowDate());
int rows = appResourceMapper.insertAppResource(appResource); int rows = appResourceMapper.insertAppResource(appResource);
insertAppResourceList(appResource); insertAppResourceList(appResource);
@@ -74,6 +81,7 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override @Override
public int updateAppResource(AppResource appResource) public int updateAppResource(AppResource appResource)
{ {
configureVirtualProduct(appResource);
appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId()); appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId());
insertAppResourceList(appResource); insertAppResourceList(appResource);
return appResourceMapper.updateAppResource(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());
}
} }

View File

@@ -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<AppVirtualOrder> selectAppVirtualOrderList(AppVirtualOrder order)
{
return appVirtualOrderMapper.selectAppVirtualOrderList(order);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}

View File

@@ -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); public int delIntegralByUserId(@Param("integral") int integral ,@Param("userId")Long userId);
/** /**

View File

@@ -14,6 +14,8 @@
<result property="isShow" column="is_show" /> <result property="isShow" column="is_show" />
<result property="isAd" column="is_ad" /> <result property="isAd" column="is_ad" />
<result property="adNumber" column="ad_number" /> <result property="adNumber" column="ad_number" />
<result property="priceFen" column="price_fen" />
<result property="virtualProductId" column="virtual_product_id" />
<result property="downNum" column="down_num" /> <result property="downNum" column="down_num" />
<result property="weight" column="weight" /> <result property="weight" column="weight" />
<result property="delFlag" column="del_flag" /> <result property="delFlag" column="del_flag" />
@@ -35,27 +37,34 @@
</resultMap> </resultMap>
<sql id="selectAppResourceVo"> <sql id="selectAppResourceVo">
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
</sql> </sql>
<select id="selectAppResourceList" parameterType="AppResource" resultMap="AppResourceResult"> <select id="selectAppResourceList" parameterType="AppResource" resultMap="AppResourceResult">
<include refid="selectAppResourceVo"/> <include refid="selectAppResourceVo"/>
<where> <where>
<if test="resourceTitle != null and resourceTitle != ''"> and resource_title like concat('%', #{resourceTitle}, '%')</if> <if test="resourceTitle != null and resourceTitle != ''"> and a.resource_title like concat('%', #{resourceTitle}, '%')</if>
<if test="showImg != null and showImg != ''"> and show_img = #{showImg}</if> <if test="showImg != null and showImg != ''"> and a.show_img = #{showImg}</if>
<if test="explain != null and explain != ''"> and `explain` = #{explain}</if> <if test="explain != null and explain != ''"> and a.`explain` = #{explain}</if>
<if test="resourceType != null "> and resource_type = #{resourceType}</if> <if test="resourceType != null "> and a.resource_type = #{resourceType}</if>
<if test="isShow != null "> and is_show = #{isShow}</if> <if test="isShow != null "> and a.is_show = #{isShow}</if>
<if test="isAd != null "> and is_ad = #{isAd}</if> <if test="isAd != null "> and a.is_ad = #{isAd}</if>
<if test="adNumber != null "> and ad_number = #{adNumber}</if> <if test="adNumber != null "> and a.ad_number = #{adNumber}</if>
<if test="downNum != null "> and down_num = #{downNum}</if> <if test="downNum != null "> and a.down_num = #{downNum}</if>
<if test="weight != null "> and weight = #{weight}</if> <if test="weight != null "> and a.weight = #{weight}</if>
<if test="keyword != null and keyword != ''"> and (keyword like concat('%', #{keyword}, '%') or resource_title like concat('%', #{keyword}, '%'))</if> <if test="keyword != null and keyword != ''"> and (a.keyword like concat('%', #{keyword}, '%') or a.resource_title like concat('%', #{keyword}, '%'))</if>
</where> ORDER BY weight desc, create_time desc </where> ORDER BY a.weight desc, a.create_time desc
</select> </select>
<select id="selectAppResourceById" parameterType="Long" resultMap="AppResourceAppResourceListResult"> <select id="selectAppResourceById" parameterType="Long" resultMap="AppResourceAppResourceListResult">
select a.id, a.resource_title, a.show_img, a.`explain`,a.`keyword`, a.resource_type, a.is_show, a.is_ad, a.ad_number, a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark, select a.id, a.resource_title, a.show_img, a.`explain`,a.`keyword`, a.resource_type, 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,
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 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 from app_resource a
left join app_resource_list b on b.app_resource_id = a.id left join app_resource_list b on b.app_resource_id = a.id
@@ -73,6 +82,7 @@
<if test="isShow != null">is_show,</if> <if test="isShow != null">is_show,</if>
<if test="isAd != null">is_ad,</if> <if test="isAd != null">is_ad,</if>
<if test="adNumber != null">ad_number,</if> <if test="adNumber != null">ad_number,</if>
<if test="priceFen != null">price_fen,</if>
<if test="downNum != null">down_num,</if> <if test="downNum != null">down_num,</if>
<if test="weight != null">weight,</if> <if test="weight != null">weight,</if>
<if test="delFlag != null">del_flag,</if> <if test="delFlag != null">del_flag,</if>
@@ -89,6 +99,7 @@
<if test="isShow != null">#{isShow},</if> <if test="isShow != null">#{isShow},</if>
<if test="isAd != null">#{isAd},</if> <if test="isAd != null">#{isAd},</if>
<if test="adNumber != null">#{adNumber},</if> <if test="adNumber != null">#{adNumber},</if>
<if test="priceFen != null">#{priceFen},</if>
<if test="downNum != null">#{downNum},</if> <if test="downNum != null">#{downNum},</if>
<if test="weight != null">#{weight},</if> <if test="weight != null">#{weight},</if>
<if test="delFlag != null">#{delFlag},</if> <if test="delFlag != null">#{delFlag},</if>
@@ -109,6 +120,7 @@
<if test="isShow != null">is_show = #{isShow},</if> <if test="isShow != null">is_show = #{isShow},</if>
<if test="isAd != null">is_ad = #{isAd},</if> <if test="isAd != null">is_ad = #{isAd},</if>
<if test="adNumber != null">ad_number = #{adNumber},</if> <if test="adNumber != null">ad_number = #{adNumber},</if>
<if test="priceFen != null">price_fen = #{priceFen},</if>
<if test="downNum != null">down_num = #{downNum},</if> <if test="downNum != null">down_num = #{downNum},</if>
<if test="weight != null">weight = #{weight},</if> <if test="weight != null">weight = #{weight},</if>
<if test="delFlag != null">del_flag = #{delFlag},</if> <if test="delFlag != null">del_flag = #{delFlag},</if>
@@ -151,14 +163,33 @@
<select id="selectAppResourceByIdAndUserId" resultMap="AppResourceAppResourceListResult"> <select id="selectAppResourceByIdAndUserId" resultMap="AppResourceAppResourceListResult">
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 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 ELSE a.is_ad
END as 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, 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.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 b.password as sub_password, b.app_resource_id as sub_app_resource_id
from app_resource a from app_resource a
left join app_resource_list b on b.app_resource_id = a.id 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} where a.id = #{id}
</select> </select>
</mapper> </mapper>

View File

@@ -0,0 +1,150 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.app.mapper.AppVirtualOrderMapper">
<resultMap id="AppVirtualOrderResult" type="AppVirtualOrder">
<id property="id" column="id"/>
<result property="orderNo" column="order_no"/>
<result property="userId" column="user_id"/>
<result property="userName" column="user_name"/>
<result property="nickName" column="nick_name"/>
<result property="resourceId" column="resource_id"/>
<result property="resourceTitle" column="resource_title"/>
<result property="productId" column="product_id"/>
<result property="priceFen" column="price_fen"/>
<result property="openId" column="open_id"/>
<result property="status" column="status"/>
<result property="wxOrderNo" column="wx_order_no"/>
<result property="transactionId" column="transaction_id"/>
<result property="createTime" column="create_time"/>
<result property="payTime" column="pay_time"/>
<result property="provideTime" column="provide_time"/>
<result property="refundTime" column="refund_time"/>
<result property="lastQueryTime" column="last_query_time"/>
</resultMap>
<sql id="selectAppVirtualOrderVo">
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
</sql>
<insert id="insertAppVirtualOrder" parameterType="AppVirtualOrder" useGeneratedKeys="true" keyProperty="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})
</insert>
<select id="selectByOrderNo" resultMap="AppVirtualOrderResult">
select id, order_no, user_id, resource_id, product_id, price_fen, open_id, status,
wx_order_no, transaction_id, create_time, pay_time, provide_time, refund_time, last_query_time
from app_virtual_order
where order_no = #{orderNo}
limit 1
</select>
<select id="selectAppVirtualOrderById" parameterType="Long" resultMap="AppVirtualOrderResult">
<include refid="selectAppVirtualOrderVo"/>
where vo.id = #{id}
</select>
<select id="selectAppVirtualOrderList" parameterType="AppVirtualOrder" resultMap="AppVirtualOrderResult">
<include refid="selectAppVirtualOrderVo"/>
<where>
<if test="orderNo != null and orderNo != ''">
and vo.order_no like concat('%', #{orderNo}, '%')
</if>
<if test="transactionId != null and transactionId != ''">
and vo.transaction_id like concat('%', #{transactionId}, '%')
</if>
<if test="wxOrderNo != null and wxOrderNo != ''">
and vo.wx_order_no like concat('%', #{wxOrderNo}, '%')
</if>
<if test="userId != null">and vo.user_id = #{userId}</if>
<if test="userName != null and userName != ''">
and (u.user_name like concat('%', #{userName}, '%')
or u.nick_name like concat('%', #{userName}, '%'))
</if>
<if test="resourceId != null">and vo.resource_id = #{resourceId}</if>
<if test="productId != null and productId != ''">
and vo.product_id like concat('%', #{productId}, '%')
</if>
<if test="openId != null and openId != ''">
and vo.open_id like concat('%', #{openId}, '%')
</if>
<if test="status != null">and vo.status = #{status}</if>
<if test="params.beginTime != null and params.beginTime != ''">
and vo.create_time &gt;= #{params.beginTime}
</if>
<if test="params.endTime != null and params.endTime != ''">
and vo.create_time &lt;= concat(#{params.endTime}, ' 23:59:59')
</if>
</where>
order by vo.create_time desc, vo.id desc
</select>
<select id="countAnyEntitlement" resultType="int">
select
(select count(1) from app_resource_entitlement
where user_id = #{userId} and resource_id = #{resourceId} and status = 1)
+
(select count(1) from app_integral_record
where user_id = #{userId}
and resource_id = #{resourceId}
and source = '资源兑换'
and is_add = 1)
</select>
<update id="markPaid">
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
</update>
<insert id="insertEntitlement" parameterType="AppVirtualOrder">
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
</insert>
<update id="markRefunded">
update app_virtual_order
set status = 2, refund_time = #{refundTime}
where order_no = #{orderNo} and status in (0, 1)
</update>
<update id="deleteEntitlementByOrderNo">
update app_resource_entitlement
set status = 0, revoked_time = now()
where order_no = #{orderNo} and status = 1
</update>
<update id="markClosed">
update app_virtual_order set status = 3
where order_no = #{orderNo} and status = 0
</update>
<update id="markQuerying">
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 &lt; date_sub(now(), interval 5 second))
</update>
</mapper>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.app.mapper.AppVirtualProductMapper">
<resultMap id="AppVirtualProductResult" type="AppVirtualProduct">
<id property="id" column="id"/>
<result property="productId" column="product_id"/>
<result property="priceFen" column="price_fen"/>
<result property="productName" column="product_name"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<select id="selectActiveByPrice" resultMap="AppVirtualProductResult">
select id, product_id, price_fen, product_name, status, create_time, update_time
from app_virtual_product
where price_fen = #{priceFen} and status = 1
limit 1
</select>
<select id="selectByProductId" resultMap="AppVirtualProductResult">
select id, product_id, price_fen, product_name, status, create_time, update_time
from app_virtual_product
where product_id = #{productId}
limit 1
</select>
<insert id="insertAppVirtualProduct" parameterType="AppVirtualProduct" useGeneratedKeys="true" keyProperty="id">
insert into app_virtual_product(product_id, price_fen, product_name, status, create_time)
values(#{productId}, #{priceFen}, #{productName}, #{status}, #{createTime})
</insert>
<update id="updateProductIdByPrice" parameterType="AppVirtualProduct">
update app_virtual_product
set product_id = #{productId}, product_name = #{productName}, status = 1, update_time = #{updateTime}
where price_fen = #{priceFen}
</update>
</mapper>

View File

@@ -51,7 +51,7 @@
<sql id="selectUserVo"> <sql id="selectUserVo">
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, 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, 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 from sys_user u
left join sys_dept d on u.dept_id = d.dept_id 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 left join sys_user_role ur on u.user_id = ur.user_id

View File

@@ -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'
})
}

View File

@@ -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) { export function matchCode(id) {
return request({ return request({
url: '/office/project/matchCode/' + id, url: '/office/project/matchCode/' + id,

View File

@@ -76,6 +76,12 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="付费价格" align="center" prop="priceFen">
<template slot-scope="scope">
<span v-if="scope.row.isAd == 3">¥{{ (scope.row.priceFen / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="下载次数" align="center" prop="downNum" /> <el-table-column label="下载次数" align="center" prop="downNum" />
<el-table-column label="权重" align="center" prop="weight" /> <el-table-column label="权重" align="center" prop="weight" />
<el-table-column label="创建时间" align="center" prop="createTime" width="160px" /> <el-table-column label="创建时间" align="center" prop="createTime" width="160px" />
@@ -134,8 +140,9 @@
<el-form-item v-if="form.isAd == 2" label="兑换积分" prop="adNumber"> <el-form-item v-if="form.isAd == 2" label="兑换积分" prop="adNumber">
<el-input v-model="form.adNumber" placeholder="请输入需要兑换多少积分解锁" /> <el-input v-model="form.adNumber" placeholder="请输入需要兑换多少积分解锁" />
</el-form-item> </el-form-item>
<el-form-item v-if="form.isAd == 3" label="付费金额" prop="adNumber"> <el-form-item v-if="form.isAd == 3" label="价格(分)" prop="priceFen">
<el-input v-model="form.adNumber" placeholder="请输入需要付费多少金额解锁" /> <el-input-number v-model="form.priceFen" :min="1" :step="100" controls-position="right" />
<span class="form-tip">系统将按价格自动匹配已配置的微信道具</span>
</el-form-item> </el-form-item>
<el-form-item label="权重" prop="weight"> <el-form-item label="权重" prop="weight">
<el-input v-model="form.weight" placeholder="请输入权重" /> <el-input v-model="form.weight" placeholder="请输入权重" />
@@ -260,6 +267,11 @@
message: "需要广告不能为空", message: "需要广告不能为空",
trigger: "change" trigger: "change"
}], }],
priceFen: [{
required: true,
message: "付费价格不能为空",
trigger: "blur"
}],
}, },
directory:"appimg/fengmian/" directory:"appimg/fengmian/"
}; };
@@ -303,6 +315,8 @@
isShow: null, isShow: null,
isAd: null, isAd: null,
adNumber: null, adNumber: null,
priceFen: null,
virtualProductId: null,
delFlag: null, delFlag: null,
createBy: null, createBy: null,
createTime: null, createTime: null,

View File

@@ -0,0 +1,254 @@
<template>
<div class="app-container">
<el-form
v-show="showSearch"
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
label-width="84px"
>
<el-form-item label="业务订单号" prop="orderNo">
<el-input
v-model="queryParams.orderNo"
placeholder="请输入业务订单号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="微信交易号" prop="transactionId">
<el-input
v-model="queryParams.transactionId"
placeholder="请输入微信交易号"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="用户" prop="userName">
<el-input
v-model="queryParams.userName"
placeholder="账号或昵称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="资源ID" prop="resourceId">
<el-input
v-model="queryParams.resourceId"
placeholder="请输入资源ID"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="订单状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
style="width: 240px"
value-format="yyyy-MM-dd"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['app:virtualOrder:export']"
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
>导出</el-button>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="orderList">
<el-table-column label="ID" align="center" prop="id" width="80" />
<el-table-column label="业务订单号" prop="orderNo" min-width="210" show-overflow-tooltip />
<el-table-column label="用户" min-width="150">
<template slot-scope="scope">
<div>{{ scope.row.nickName || scope.row.userName || '-' }}</div>
<div class="secondary-text">{{ scope.row.userName || '-' }} / ID: {{ scope.row.userId }}</div>
</template>
</el-table-column>
<el-table-column label="资源" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">
<div>{{ scope.row.resourceTitle || '-' }}</div>
<div class="secondary-text">ID: {{ scope.row.resourceId }}</div>
</template>
</el-table-column>
<el-table-column label="金额" align="right" width="100">
<template slot-scope="scope">{{ formatPrice(scope.row.priceFen) }}</template>
</el-table-column>
<el-table-column label="状态" align="center" width="120">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="small">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="微信道具ID" prop="productId" min-width="140" show-overflow-tooltip />
<el-table-column label="微信交易号" prop="transactionId" min-width="190" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" width="165" />
<el-table-column label="支付时间" prop="payTime" width="165" />
<el-table-column label="操作" align="center" width="80" fixed="right">
<template slot-scope="scope">
<el-button
v-hasPermi="['app:virtualOrder:query']"
type="text"
icon="el-icon-view"
size="mini"
@click="handleView(scope.row)"
>详情</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<el-dialog title="虚拟支付订单详情" :visible.sync="detailOpen" width="820px" append-to-body>
<el-descriptions v-if="detail.id" :column="2" border>
<el-descriptions-item label="业务订单号" :span="2">{{ detail.orderNo }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="支付金额">{{ formatPrice(detail.priceFen) }}{{ detail.priceFen }} </el-descriptions-item>
<el-descriptions-item label="用户账号">{{ detail.userName || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户昵称">{{ detail.nickName || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.userId }}</el-descriptions-item>
<el-descriptions-item label="OpenID">{{ detail.openId || '-' }}</el-descriptions-item>
<el-descriptions-item label="资源ID">{{ detail.resourceId }}</el-descriptions-item>
<el-descriptions-item label="资源标题">{{ detail.resourceTitle || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信道具ID">{{ detail.productId || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信内部订单号">{{ detail.wxOrderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信交易号" :span="2">{{ detail.transactionId || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付时间">{{ detail.payTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="发货时间">{{ detail.provideTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="退款时间">{{ detail.refundTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="最近同步时间" :span="2">{{ detail.lastQueryTime || '-' }}</el-descriptions-item>
</el-descriptions>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getVirtualOrder, listVirtualOrder } from '@/api/app/virtualOrder'
export default {
name: 'VirtualOrder',
data() {
return {
loading: true,
showSearch: true,
total: 0,
orderList: [],
dateRange: [],
detailOpen: false,
detail: {},
statusOptions: [
{ value: 0, label: '待支付' },
{ value: 1, label: '已支付发货' },
{ value: 2, label: '已退款' },
{ value: 3, label: '已关闭' }
],
queryParams: {
pageNum: 1,
pageSize: 10,
orderNo: undefined,
transactionId: undefined,
userName: undefined,
resourceId: undefined,
status: undefined
}
}
},
created() {
this.getList()
},
methods: {
getList() {
this.loading = true
listVirtualOrder(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
this.orderList = response.rows
this.total = response.total
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.dateRange = []
this.resetForm('queryForm')
this.handleQuery()
},
handleView(row) {
getVirtualOrder(row.id).then(response => {
this.detail = response.data
this.detailOpen = true
})
},
handleExport() {
const query = this.addDateRange({ ...this.queryParams, params: {}}, this.dateRange)
this.download('app/virtualOrder/export', query, `virtual_order_${new Date().getTime()}.xlsx`)
},
formatPrice(priceFen) {
if (priceFen === null || priceFen === undefined) {
return '0.00'
}
return (Number(priceFen) / 100).toFixed(2)
},
statusLabel(status) {
const option = this.statusOptions.find(item => item.value === status)
return option ? option.label : '未知状态'
},
statusType(status) {
return {
0: 'warning',
1: 'success',
2: 'danger',
3: 'info'
}[status] || 'info'
}
}
}
</script>
<style scoped>
.secondary-text {
margin-top: 2px;
color: #909399;
font-size: 12px;
}
</style>

View File

@@ -82,6 +82,18 @@
v-hasPermi="['office:project:export']" v-hasPermi="['office:project:export']"
>导出</el-button> >导出</el-button>
</el-col> </el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-connection"
size="mini"
:disabled="multiple"
:loading="batchChecking"
@click="handleCheckSelected"
v-hasPermi="['office:project:edit']"
>检测选中</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row> </el-row>
@@ -93,8 +105,38 @@
<el-table-column label="源码名称" align="center" prop="projectName1" /> <el-table-column label="源码名称" align="center" prop="projectName1" />
<el-table-column label="项目技术" align="center" prop="projectNum1" /> <el-table-column label="项目技术" align="center" prop="projectNum1" />
<el-table-column label="项目描述" align="center" prop="projectDesc" /> <el-table-column label="项目描述" align="center" prop="projectDesc" />
<el-table-column label="项目链接" align="center" prop="projectUrl" /> <el-table-column label="夸克网盘链接" align="center" prop="projectUrl" />
<el-table-column label="项目百度链接" align="center" prop="projectBaiduUrl" /> <el-table-column label="项目百度链接" align="center" prop="projectBaiduUrl" />
<el-table-column label="网盘状态" align="center" width="175">
<template slot-scope="scope">
<div class="link-check-status">
<span class="disk-name">夸克</span>
<el-tooltip
:content="checkTooltip(scope.row.quarkCheckStatus, scope.row.quarkCheckMessage, scope.row.quarkCheckedAt, scope.row.projectUrl)"
placement="top"
>
<el-tag
size="mini"
effect="plain"
:type="statusType(scope.row.quarkCheckStatus)"
>{{ statusText(scope.row.quarkCheckStatus, scope.row.projectUrl) }}</el-tag>
</el-tooltip>
</div>
<div class="link-check-status">
<span class="disk-name">百度</span>
<el-tooltip
:content="checkTooltip(scope.row.baiduCheckStatus, scope.row.baiduCheckMessage, scope.row.baiduCheckedAt, scope.row.projectBaiduUrl)"
placement="top"
>
<el-tag
size="mini"
effect="plain"
:type="statusType(scope.row.baiduCheckStatus)"
>{{ statusText(scope.row.baiduCheckStatus, scope.row.projectBaiduUrl) }}</el-tag>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column label="演示视频链接" align="center" prop="projectVurl" /> <el-table-column label="演示视频链接" align="center" prop="projectVurl" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope"> <template slot-scope="scope">
@@ -131,6 +173,14 @@
<!-- v-hasPermi="['office:code:edit']"--> <!-- v-hasPermi="['office:code:edit']"-->
<!-- >匹配源码--> <!-- >匹配源码-->
<!-- </el-button>--> <!-- </el-button>-->
<el-button
size="mini"
type="text"
icon="el-icon-connection"
:loading="isChecking(scope.row.id)"
@click="handleCheckRow(scope.row)"
v-hasPermi="['office:project:edit']"
>检测链接</el-button>
<el-button <el-button
size="mini" size="mini"
type="text" type="text"
@@ -178,7 +228,7 @@
<el-form-item label="夸克网盘链接" prop="projectUrl"> <el-form-item label="夸克网盘链接" prop="projectUrl">
<el-input v-model="form.projectUrl" placeholder="请输入项目链接" /> <el-input v-model="form.projectUrl" placeholder="请输入项目链接" />
</el-form-item> </el-form-item>
<el-form-item label="百度网盘链接" prop="projectUrl"> <el-form-item label="百度网盘链接" prop="projectBaiduUrl">
<el-input v-model="form.projectBaiduUrl" placeholder="请输入项目链接" /> <el-input v-model="form.projectBaiduUrl" placeholder="请输入项目链接" />
</el-form-item> </el-form-item>
<el-form-item label="演示视频链接" prop="projectVurl"> <el-form-item label="演示视频链接" prop="projectVurl">
@@ -205,7 +255,16 @@
</template> </template>
<script> <script>
import { listProject, getProject, delProject, addProject, updateProject, matchCode } from '@/api/office/project' import {
listProject,
getProject,
delProject,
addProject,
updateProject,
matchCode,
checkProjectLinks,
checkSelectedProjectLinks
} from '@/api/office/project'
import { getToken } from '@/utils/auth' import { getToken } from '@/utils/auth'
export default { export default {
@@ -214,6 +273,10 @@ export default {
return { return {
// 遮罩层 // 遮罩层
loading: true, loading: true,
// 批量检测状态
batchChecking: false,
// 正在进行单条检测的项目
checkingIds: [],
// 选中数组 // 选中数组
ids: [], ids: [],
// 非单个禁用 // 非单个禁用
@@ -302,6 +365,99 @@ export default {
this.single = selection.length !== 1 this.single = selection.length !== 1
this.multiple = !selection.length this.multiple = !selection.length
}, },
/** 检测单个项目的网盘链接 */
handleCheckRow(row) {
if (this.isChecking(row.id)) {
return
}
this.checkingIds.push(row.id)
checkProjectLinks(row.id).then(response => {
this.showCheckSummary(response.data)
this.getList()
}).then(() => {
this.removeCheckingId(row.id)
}).catch(() => {
this.removeCheckingId(row.id)
})
},
/** 检测选中的项目网盘链接 */
handleCheckSelected() {
if (!this.ids.length) {
this.$modal.msgWarning('请至少选择一个项目')
return
}
if (this.ids.length > 20) {
this.$modal.msgWarning('一次最多检测 20 个项目')
return
}
const ids = this.ids.slice()
this.$modal.confirm('确认检测选中的 ' + ids.length + ' 个项目吗?').then(() => {
this.batchChecking = true
return checkSelectedProjectLinks(ids)
}).then(response => {
this.showCheckSummary(response.data)
this.getList()
this.batchChecking = false
}).catch(() => {
this.batchChecking = false
})
},
isChecking(id) {
return this.checkingIds.indexOf(id) !== -1
},
removeCheckingId(id) {
this.checkingIds = this.checkingIds.filter(item => item !== id)
},
showCheckSummary(summary) {
if (!summary || !summary.linkCount) {
this.$modal.msgWarning('所选项目没有填写百度或夸克网盘链接')
return
}
const message = '检测完成:有效 ' + summary.validCount +
',失效 ' + summary.invalidCount +
',需处理 ' + summary.warningCount +
',异常 ' + summary.unknownCount
if (summary.invalidCount > 0 || summary.warningCount > 0) {
this.$modal.msgWarning(message)
} else {
this.$modal.msgSuccess(message)
}
},
statusText(status, linkUrl) {
if (!linkUrl) {
return '无链接'
}
const statusMap = {
VALID: '有效',
INVALID: '已失效',
NEED_CODE: '缺提取码',
CODE_ERROR: '提取码错误',
FORMAT_ERROR: '格式错误',
UNKNOWN: '检测异常'
}
return statusMap[status] || '未检测'
},
statusType(status) {
const typeMap = {
VALID: 'success',
INVALID: 'danger',
NEED_CODE: 'warning',
CODE_ERROR: 'warning',
FORMAT_ERROR: 'warning',
UNKNOWN: 'info'
}
return typeMap[status] || 'info'
},
checkTooltip(status, message, checkedAt, linkUrl) {
if (!linkUrl) {
return '未填写链接'
}
if (!status) {
return '尚未检测'
}
const timeText = checkedAt ? ';检测时间:' + this.parseTime(checkedAt) : ''
return (message || this.statusText(status, linkUrl)) + timeText
},
/** 新增按钮操作 */ /** 新增按钮操作 */
handleAdd() { handleAdd() {
this.reset() this.reset()
@@ -484,3 +640,19 @@ export default {
} }
} }
</script> </script>
<style scoped>
.link-check-status {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
}
.disk-name {
width: 32px;
color: #606266;
text-align: right;
}
</style>

View File

@@ -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`);

View File

@@ -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='项目网盘链接检测结果';

View File

@@ -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'
);

View File

@@ -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();