feat: 新增虚拟支付订单与网盘链接检测功能
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
}
|
||||
72
docs/virtual-pay-setup.md
Normal file
72
docs/virtual-pay-setup.md
Normal 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。
|
||||
|
||||
虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。
|
||||
@@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app;
|
||||
|
||||
import com.ruoyi.app.domain.AppBlogArticle;
|
||||
import com.ruoyi.app.mapper.AppBlogArticleMapper;
|
||||
import com.ruoyi.app.service.ImageUrlService;
|
||||
import com.ruoyi.app.service.IAppBlogArticleService;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
@@ -32,6 +33,9 @@ public class AppBlogArticleController extends BaseController {
|
||||
@Autowired
|
||||
private IAppBlogArticleService appBlogArticleService;
|
||||
|
||||
@Autowired
|
||||
private ImageUrlService imageUrlService;
|
||||
|
||||
@Resource
|
||||
private AppBlogArticleMapper appBlogArticleMapper;
|
||||
|
||||
@@ -45,6 +49,7 @@ public class AppBlogArticleController extends BaseController {
|
||||
public TableDataInfo list(AppBlogArticle appBlogArticle) {
|
||||
startPage();
|
||||
List<AppBlogArticle> list = appBlogArticleService.selectAppBlogArticleList(appBlogArticle);
|
||||
imageUrlService.decorateArticles(list);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
@@ -72,6 +77,7 @@ public class AppBlogArticleController extends BaseController {
|
||||
@GetMapping(value = "/app/{id}")
|
||||
public AjaxResult appGetInfo(@PathVariable("id") Long id) {
|
||||
AppBlogArticle appBlogArticle = appBlogArticleService.selectAppBlogArticleById(id);
|
||||
imageUrlService.decorateArticle(appBlogArticle);
|
||||
List<String> list = new ArrayList<>();
|
||||
List<TtFile> picList = fileService.selectTtFileByCodeName(appBlogArticle.getTitle());
|
||||
for (TtFile ttFile : picList) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.ruoyi.app.domain.AppIntegralRecord;
|
||||
import com.ruoyi.app.domain.AppLotteryGoods;
|
||||
import com.ruoyi.app.domain.AppLotteryLog;
|
||||
import com.ruoyi.app.mapper.AppIntegralRecordMapper;
|
||||
import com.ruoyi.app.mapper.AppResourceMapper;
|
||||
import com.ruoyi.app.domain.AppResource;
|
||||
import com.ruoyi.app.service.IAppIntegralRecordService;
|
||||
import com.ruoyi.app.service.IAppLotteryGoodsService;
|
||||
import com.ruoyi.app.service.IAppLotteryLogService;
|
||||
@@ -42,6 +44,8 @@ public class AppIntegralRecordController extends BaseController
|
||||
private AppIntegralRecordMapper appIntegralRecordMapper;
|
||||
@Resource
|
||||
private SysUserMapper sysUserMapper;
|
||||
@Resource
|
||||
private AppResourceMapper appResourceMapper;
|
||||
@Autowired
|
||||
private IAppLotteryGoodsService appLotteryGoodsService;
|
||||
@Autowired
|
||||
@@ -187,19 +191,38 @@ public class AppIntegralRecordController extends BaseController
|
||||
* 积分记录,增减用户积分通用
|
||||
*/
|
||||
@Transactional
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@PostMapping("/resource")
|
||||
public AjaxResult resource(@RequestBody AppIntegralRecord appIntegralRecord)
|
||||
{
|
||||
Date date = new Date();
|
||||
appIntegralRecord.setIntegralTime(date);
|
||||
appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord);
|
||||
if (appIntegralRecord.getIsAdd() == 0){
|
||||
sysUserMapper.addIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId());
|
||||
}else {
|
||||
sysUserMapper.delIntegralByUserId(Math.toIntExact(appIntegralRecord.getIntegralNumber()), appIntegralRecord.getUserId());
|
||||
Long userId = getUserId();
|
||||
AppResource resource = appResourceMapper.selectAppResourceById(appIntegralRecord.getResourceId());
|
||||
if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 2L
|
||||
|| resource.getAdNumber() == null || resource.getAdNumber() <= 0)
|
||||
{
|
||||
return error("该资源不支持积分兑换");
|
||||
}
|
||||
|
||||
return toAjax(1);
|
||||
AppIntegralRecord exists = new AppIntegralRecord();
|
||||
exists.setUserId(userId);
|
||||
exists.setResourceId(resource.getId());
|
||||
if (appIntegralRecordMapper.selectAppIntegralRecordCount(exists) > 0)
|
||||
{
|
||||
return success("资源已解锁");
|
||||
}
|
||||
|
||||
int points = Math.toIntExact(resource.getAdNumber());
|
||||
if (sysUserMapper.delIntegralByUserId(points, userId) != 1)
|
||||
{
|
||||
return error("积分不足");
|
||||
}
|
||||
appIntegralRecord.setSource("资源兑换");
|
||||
appIntegralRecord.setIsAdd(1L);
|
||||
appIntegralRecord.setIntegralNumber(resource.getAdNumber());
|
||||
appIntegralRecord.setUserId(userId);
|
||||
appIntegralRecord.setIntegralTime(new Date());
|
||||
appIntegralRecordService.insertAppIntegralRecord(appIntegralRecord);
|
||||
return success("兑换成功");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app;
|
||||
|
||||
import com.ruoyi.app.domain.AppResource;
|
||||
import com.ruoyi.app.mapper.AppResourceMapper;
|
||||
import com.ruoyi.app.service.ImageUrlService;
|
||||
import com.ruoyi.app.service.IAppResourceService;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
@@ -16,6 +17,9 @@ import org.springframework.web.bind.annotation.*;
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.List;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
|
||||
/**
|
||||
* 资源Controller
|
||||
@@ -30,6 +34,9 @@ public class AppResourceController extends BaseController
|
||||
@Autowired
|
||||
private IAppResourceService appResourceService;
|
||||
|
||||
@Autowired
|
||||
private ImageUrlService imageUrlService;
|
||||
|
||||
@Resource
|
||||
private AppResourceMapper appResourceMapper;
|
||||
|
||||
@@ -41,6 +48,7 @@ public class AppResourceController extends BaseController
|
||||
{
|
||||
startPage();
|
||||
List<AppResource> list = appResourceService.selectAppResourceList(appResource);
|
||||
imageUrlService.decorateResources(list);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
@@ -70,16 +78,28 @@ public class AppResourceController extends BaseController
|
||||
@GetMapping(value = "/app/{id}")
|
||||
public AjaxResult getInfoApp(@PathVariable("id") Long id)
|
||||
{
|
||||
return success(appResourceService.selectAppResourceById(id));
|
||||
return success(imageUrlService.decorateResource(
|
||||
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID获取资源详细信息
|
||||
*/
|
||||
@GetMapping(value = "/app/user/{id}/{userId}")
|
||||
public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long userId)
|
||||
public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long ignoredUserId)
|
||||
{
|
||||
return success(appResourceMapper.selectAppResourceByIdAndUserId(id, userId));
|
||||
return success(imageUrlService.decorateResource(
|
||||
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序资源详情。用户身份只从 JWT 读取,不接收客户端 userId。
|
||||
*/
|
||||
@GetMapping(value = "/app/user/{id}")
|
||||
public AjaxResult getResourceForCurrentUser(@PathVariable("id") Long id)
|
||||
{
|
||||
return success(imageUrlService.decorateResource(
|
||||
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,4 +143,14 @@ public class AppResourceController extends BaseController
|
||||
{
|
||||
return toAjax( appResourceMapper.lookAddNumber(appResource.getId()));
|
||||
}
|
||||
|
||||
private Long currentUserId()
|
||||
{
|
||||
Authentication authentication = SecurityUtils.getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof LoginUser)
|
||||
{
|
||||
return ((LoginUser) authentication.getPrincipal()).getUserId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "虚拟支付订单数据");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import com.ruoyi.app.domain.ShuiYinVo;
|
||||
import com.ruoyi.app.mapper.AppPunlicMapper;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.http.HttpUtils;
|
||||
import com.ruoyi.system.service.ISysConfigService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -21,20 +20,6 @@ public class PublicController extends BaseController {
|
||||
AppPunlicMapper appPunlicMapper;
|
||||
@Autowired
|
||||
private ISysConfigService configService;
|
||||
/**
|
||||
* 获取微信openid信息
|
||||
*/
|
||||
@GetMapping(value = "/autoLoginWx/{code}")
|
||||
public AjaxResult autoLoginWx(@PathVariable("code") String code)
|
||||
{
|
||||
HttpUtils httpUtils = new HttpUtils();
|
||||
String appid = configService.selectConfigByKey("miniapp.wx.appId");
|
||||
String secret = configService.selectConfigByKey("miniapp.wx.secret");
|
||||
String param = "appid="+appid+"&secret="+secret+"&js_code="+code+"&grant_type=authorization_code";
|
||||
String s = httpUtils.httpGet("https://api.weixin.qq.com/sns/jscode2session", param);
|
||||
return success(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 去水印 key 免费申请联系作者微信:yimoziyuan666
|
||||
* @return
|
||||
@@ -43,7 +28,7 @@ public class PublicController extends BaseController {
|
||||
private String delWatermarkKey;
|
||||
@PostMapping(value = "/delSHuiYin")
|
||||
public AjaxResult delSHuiYin(HttpServletRequest request, @RequestBody ShuiYinVo shuiYinVo) {
|
||||
HttpUtils httpUtils = new HttpUtils();
|
||||
com.ruoyi.common.utils.http.HttpUtils httpUtils = new com.ruoyi.common.utils.http.HttpUtils();
|
||||
String key = configService.selectConfigByKey("miniapp.shuiyin.key");
|
||||
String param = "key="+key+"&url="+shuiYinVo.getUrl();
|
||||
String s = httpUtils.httpGet("https://api.emoboy.vip/api/shuiyin/delWatermark", param);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.ruoyi.web.controller.app;
|
||||
|
||||
import com.ruoyi.app.service.IAppPayOrderService;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.wx.*;
|
||||
import com.wechat.pay.java.service.payments.jsapi.model.PrepayWithRequestPaymentResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
@@ -26,20 +25,6 @@ import java.io.IOException;
|
||||
public class WxMiniappPayController extends BaseController {
|
||||
@Autowired
|
||||
private WxMiniappPayService wxMiniappPayService;
|
||||
@Autowired
|
||||
private IAppPayOrderService appPayOrderService;
|
||||
|
||||
/**
|
||||
* 预支付订单/统一下单
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping("/createOrder")
|
||||
public Response<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
|
||||
*/
|
||||
@PostMapping("/queryOrder")
|
||||
@PreAuthorize("@ss.hasPermi('app:order:query')")
|
||||
public Response queryOrder(@Validated @RequestBody QueryOrderReq req) {
|
||||
log.info("------根据支付订单号查询订单------");
|
||||
return this.wxMiniappPayService.queryOrder(req);
|
||||
@@ -81,6 +67,7 @@ public class WxMiniappPayController extends BaseController {
|
||||
* @return
|
||||
*/
|
||||
@PostMapping("/queryOrderByOutTradeNo")
|
||||
@PreAuthorize("@ss.hasPermi('app:order:query')")
|
||||
public Response queryOrderByOutTradeNo(@Validated @RequestBody QueryOrderReq req) {
|
||||
log.info("------根据商户订单号查询订单------");
|
||||
return this.wxMiniappPayService.queryOrderByOutTradeNo(req);
|
||||
@@ -93,6 +80,7 @@ public class WxMiniappPayController extends BaseController {
|
||||
*/
|
||||
|
||||
@PostMapping("/closeOrder")
|
||||
@PreAuthorize("@ss.hasPermi('app:order:edit')")
|
||||
public Response closeOrder(@Validated @RequestBody QueryOrderReq req) {
|
||||
log.info("------微信小程序支付关闭订单------");
|
||||
return this.wxMiniappPayService.closeOrder(req);
|
||||
@@ -104,6 +92,7 @@ public class WxMiniappPayController extends BaseController {
|
||||
* @return
|
||||
*/
|
||||
@PostMapping("/refund")
|
||||
@PreAuthorize("@ss.hasPermi('app:order:refund')")
|
||||
public Response refund(@Validated @RequestBody RefundOrderReq req) {
|
||||
log.info("------微信支付退款------");
|
||||
return this.wxMiniappPayService.refund(req);
|
||||
@@ -115,6 +104,7 @@ public class WxMiniappPayController extends BaseController {
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/queryByOutRefundNo")
|
||||
@PreAuthorize("@ss.hasPermi('app:order:query')")
|
||||
public Response queryByOutRefundNo(String outRefundNo) {
|
||||
log.info("------微信支付查询单笔退款------");
|
||||
return this.wxMiniappPayService.queryByOutRefundNo(outRefundNo);
|
||||
@@ -134,4 +124,4 @@ public class WxMiniappPayController extends BaseController {
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.framework.web.service.SysLoginService;
|
||||
import com.ruoyi.framework.web.service.SysPermissionService;
|
||||
import com.ruoyi.system.service.ISysMenuService;
|
||||
import com.ruoyi.common.wx.WxCodeSession;
|
||||
import com.ruoyi.common.wx.WxCodeSessionService;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
@@ -36,6 +38,9 @@ public class SysLoginController
|
||||
@Autowired
|
||||
private SysPermissionService permissionService;
|
||||
|
||||
@Autowired
|
||||
private WxCodeSessionService wxCodeSessionService;
|
||||
|
||||
/**
|
||||
* 登录方法
|
||||
*
|
||||
@@ -55,15 +60,13 @@ public class SysLoginController
|
||||
|
||||
|
||||
/**
|
||||
* 微信openID登陆
|
||||
* @param
|
||||
* @return
|
||||
* 使用微信临时 code 登录。OpenID 与 session_key 只在服务端获取。
|
||||
*/
|
||||
@PostMapping("/wxLogin")
|
||||
public AjaxResult wxLogin(@RequestBody LoginBody loginBody) {
|
||||
AjaxResult ajax = AjaxResult.success();
|
||||
// 生成令牌
|
||||
String token = loginService.wxLogin(loginBody.getOpenId(),loginBody.getOldUserId());
|
||||
WxCodeSession codeSession = wxCodeSessionService.exchange(loginBody.getCode());
|
||||
String token = loginService.wxLogin(codeSession.getOpenId(), loginBody.getOldUserId());
|
||||
ajax.put(Constants.TOKEN, token);
|
||||
return ajax;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ ruoyi:
|
||||
# 实例演示开关
|
||||
demoEnabled: true
|
||||
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
|
||||
# profile: /home/upload
|
||||
profilee: D:/ruoyi/uploadPath
|
||||
profile: /home/upload
|
||||
# profilee: D:/ruoyi/uploadPath
|
||||
# 获取ip地址开关
|
||||
addressEnabled: false
|
||||
# 验证码类型 math 数字计算 char 字符验证
|
||||
@@ -42,10 +42,10 @@ wx:
|
||||
appid: wx17b46f75e762c184 # 微信小程序appid
|
||||
secret: 1fa844dc33e70f7d813f24cd2af7678b # 微信小程序密钥
|
||||
merchantId: 1704387455 # 商户号
|
||||
privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径(测试环境)
|
||||
publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径(测试环境)
|
||||
# privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径(正式环境)
|
||||
# publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径(正式环境)
|
||||
# privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径(测试环境)
|
||||
# publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径(测试环境)
|
||||
privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径(正式环境)
|
||||
publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径(正式环境)
|
||||
publicKeyId: PUB_KEY_ID_0117043874552025011100188700000234
|
||||
merchantSerialNumber: 743FBCB9F5DFD76104A468C6AC6EDD41268634A3 # 商户API证书序列号
|
||||
apiV3Key: G7kL2mN8pQ4rT1vX9yZ3bC5dF6hJ0sW1 # 商户APIV3密钥
|
||||
@@ -53,6 +53,13 @@ wx:
|
||||
# refundNotifyUrl: http://www.yidaima.cn:6001/app/pay/refundNotify # 退款通知地址(测试环境)
|
||||
payNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/payNotify # 支付通知地址(正式环境)
|
||||
refundNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/refundNotify # 退款通知地址(正式环境)
|
||||
# 虚拟支付配置。生产环境请通过环境变量注入,不要提交真实 AppKey。
|
||||
virtual-pay:
|
||||
enabled: ${WX_VIRTUAL_PAY_ENABLED:true}
|
||||
offer-id: ${WX_VIRTUAL_PAY_OFFER_ID:1450602603}
|
||||
app-key: ${WX_VIRTUAL_PAY_APP_KEY:iVRy8soX3JTHd8dWQxruDtOIDjrcIKYL}
|
||||
env: ${WX_VIRTUAL_PAY_ENV:0}
|
||||
callback-token: ${WX_VIRTUAL_PAY_CALLBACK_TOKEN:zSgMvGAa289XyYoWDnpc1gUUPEBQ2me9}
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- 日志存放路径 -->
|
||||
<property name="log.path" value="D:/ruoyi/log" />
|
||||
<!-- <property name="log.path" value="/home/ruoyi/logs" />-->
|
||||
<!-- <property name="log.path" value="D:/ruoyi/log" />-->
|
||||
<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" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("调用微信登录服务失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,6 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
|
||||
"/app/**/list",
|
||||
"/app/**/app/**",
|
||||
"/system/dict/data/type/**",
|
||||
"/app/public/autoLoginWx/**",
|
||||
"/app/public/delSHuiYin",
|
||||
"/app/public/getSysSet",
|
||||
"/app/pay/notify",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import javax.servlet.http.HttpServletResponse;
|
||||
import com.ruoyi.common.utils.CoverGenerator;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.service.ITtCodeService;
|
||||
import com.ruoyi.office.service.IProjectLinkCheckService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -42,6 +43,8 @@ public class TtProjectInfoController extends BaseController {
|
||||
private ITtProjectInfoService ttProjectInfoService;
|
||||
@Autowired
|
||||
private ITtCodeService ttCodeService;
|
||||
@Autowired
|
||||
private IProjectLinkCheckService projectLinkCheckService;
|
||||
|
||||
|
||||
/**
|
||||
@@ -76,6 +79,26 @@ public class TtProjectInfoController extends BaseController {
|
||||
return success(ttProjectInfoService.selectTtProjectInfoById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测单个项目的网盘链接。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('office:project:edit')")
|
||||
@Log(title = "检测项目网盘链接", businessType = BusinessType.OTHER)
|
||||
@PostMapping("/link-check/single/{id}")
|
||||
public AjaxResult checkProjectLink(@PathVariable("id") Integer id) {
|
||||
return success(projectLinkCheckService.checkProject(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测选中项目的网盘链接。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('office:project:edit')")
|
||||
@Log(title = "批量检测项目网盘链接", businessType = BusinessType.OTHER)
|
||||
@PostMapping("/link-check/batch")
|
||||
public AjaxResult checkProjectLinks(@RequestBody Integer[] ids) {
|
||||
return success(projectLinkCheckService.checkProjects(ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增项目清单
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.apache.commons.lang3.builder.ToStringBuilder;
|
||||
import org.apache.commons.lang3.builder.ToStringStyle;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 项目清单对象 tt_project_info
|
||||
@@ -50,6 +51,24 @@ public class TtProjectInfo extends BaseEntity
|
||||
@Excel(name = "项目百度链接")
|
||||
private String projectBaiduUrl;
|
||||
|
||||
/** 夸克网盘最近检测状态 */
|
||||
private String quarkCheckStatus;
|
||||
|
||||
/** 夸克网盘最近检测说明 */
|
||||
private String quarkCheckMessage;
|
||||
|
||||
/** 夸克网盘最近检测时间 */
|
||||
private Date quarkCheckedAt;
|
||||
|
||||
/** 百度网盘最近检测状态 */
|
||||
private String baiduCheckStatus;
|
||||
|
||||
/** 百度网盘最近检测说明 */
|
||||
private String baiduCheckMessage;
|
||||
|
||||
/** 百度网盘最近检测时间 */
|
||||
private Date baiduCheckedAt;
|
||||
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
@@ -131,6 +150,54 @@ public class TtProjectInfo extends BaseEntity
|
||||
this.projectBaiduUrl = projectBaiduUrl;
|
||||
}
|
||||
|
||||
public String getQuarkCheckStatus() {
|
||||
return quarkCheckStatus;
|
||||
}
|
||||
|
||||
public void setQuarkCheckStatus(String quarkCheckStatus) {
|
||||
this.quarkCheckStatus = quarkCheckStatus;
|
||||
}
|
||||
|
||||
public String getQuarkCheckMessage() {
|
||||
return quarkCheckMessage;
|
||||
}
|
||||
|
||||
public void setQuarkCheckMessage(String quarkCheckMessage) {
|
||||
this.quarkCheckMessage = quarkCheckMessage;
|
||||
}
|
||||
|
||||
public Date getQuarkCheckedAt() {
|
||||
return quarkCheckedAt;
|
||||
}
|
||||
|
||||
public void setQuarkCheckedAt(Date quarkCheckedAt) {
|
||||
this.quarkCheckedAt = quarkCheckedAt;
|
||||
}
|
||||
|
||||
public String getBaiduCheckStatus() {
|
||||
return baiduCheckStatus;
|
||||
}
|
||||
|
||||
public void setBaiduCheckStatus(String baiduCheckStatus) {
|
||||
this.baiduCheckStatus = baiduCheckStatus;
|
||||
}
|
||||
|
||||
public String getBaiduCheckMessage() {
|
||||
return baiduCheckMessage;
|
||||
}
|
||||
|
||||
public void setBaiduCheckMessage(String baiduCheckMessage) {
|
||||
this.baiduCheckMessage = baiduCheckMessage;
|
||||
}
|
||||
|
||||
public Date getBaiduCheckedAt() {
|
||||
return baiduCheckedAt;
|
||||
}
|
||||
|
||||
public void setBaiduCheckedAt(Date baiduCheckedAt) {
|
||||
this.baiduCheckedAt = baiduCheckedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
|
||||
@@ -141,6 +208,7 @@ public class TtProjectInfo extends BaseEntity
|
||||
.append("projectName1", getProjectName1())
|
||||
.append("projectDesc", getProjectDesc())
|
||||
.append("projectUrl", getProjectUrl())
|
||||
.append("projectBaiduUrl", getProjectBaiduUrl())
|
||||
.append("projectVurl", getProjectVurl())
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import java.util.List;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.mapper.TtCodeMapper;
|
||||
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.ruoyi.office.mapper.TtProjectInfoMapper;
|
||||
import com.ruoyi.office.domain.TtProjectInfo;
|
||||
import com.ruoyi.office.service.ITtProjectInfoService;
|
||||
@@ -25,6 +27,8 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
|
||||
private TtProjectInfoMapper ttProjectInfoMapper;
|
||||
@Autowired
|
||||
private TtCodeMapper ttCodeMapper;
|
||||
@Autowired
|
||||
private TtProjectLinkCheckMapper ttProjectLinkCheckMapper;
|
||||
|
||||
/**
|
||||
* 查询项目清单
|
||||
@@ -81,8 +85,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int deleteTtProjectInfoByIds(Integer[] ids)
|
||||
{
|
||||
ttProjectLinkCheckMapper.deleteByProjectIds(ids);
|
||||
return ttProjectInfoMapper.deleteTtProjectInfoByIds(ids);
|
||||
}
|
||||
|
||||
@@ -93,8 +99,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int deleteTtProjectInfoById(Integer id)
|
||||
{
|
||||
ttProjectLinkCheckMapper.deleteByProjectId(id);
|
||||
return ttProjectInfoMapper.deleteTtProjectInfoById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ruoyi.office.service.netdisk;
|
||||
|
||||
/**
|
||||
* 网盘链接检测器。
|
||||
*/
|
||||
public interface NetDiskLinkChecker
|
||||
{
|
||||
String getDiskType();
|
||||
|
||||
NetDiskCheckResult check(String linkUrl);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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="videoFile != null and videoFile != ''"> and video_file = #{videoFile}</if>
|
||||
</where>
|
||||
ORDER BY code_name DESC
|
||||
ORDER BY code_id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectTtCodeByCodeId" parameterType="Long" resultMap="TtCodeResult">
|
||||
@@ -106,4 +106,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
#{codeId}
|
||||
</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
</mapper>
|
||||
|
||||
@@ -16,12 +16,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</sql>
|
||||
|
||||
<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>
|
||||
<if test="fileName != null and fileName != ''"> and file_name like concat('%', #{fileName}, '%')</if>
|
||||
<if test="codeName != null and codeName != ''"> and code_name like concat('%', #{codeName}, '%')</if>
|
||||
<if test="fileName != null and fileName != ''"> and f.file_name like concat('%', #{fileName}, '%')</if>
|
||||
<if test="codeName != null and codeName != ''"> and f.code_name like concat('%', #{codeName}, '%')</if>
|
||||
</where>
|
||||
ORDER BY code_name DESC, file_id ASC
|
||||
ORDER BY c.source_order DESC, f.file_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectTtFileByFileId" parameterType="Long" resultMap="TtFileResult">
|
||||
@@ -68,4 +74,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
#{fileId}
|
||||
</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
</mapper>
|
||||
|
||||
@@ -14,34 +14,64 @@
|
||||
<result property="projectUrl" column="project_url" />
|
||||
<result property="projectVurl" column="project_vurl" />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<select id="selectTtProjectInfoList" parameterType="TtProjectInfo" resultMap="TtProjectInfoResult">
|
||||
<include refid="selectTtProjectInfoVo"/>
|
||||
<where>
|
||||
<if test="projectNum != null and projectNum != ''"> and project_num like concat('%', #{projectNum}, '%')</if>
|
||||
<if test="projectNum1 != null and projectNum1 != ''"> and project_num1 = #{projectNum1}</if>
|
||||
<if test="projectName != null and projectName != ''"> and project_name like concat('%', #{projectName}, '%')</if>
|
||||
<if test="projectName1 != null and projectName1 != ''"> and project_name1 like concat('%', #{projectName1}, '%')</if>
|
||||
<if test="projectDesc != null and projectDesc != ''"> and project_desc = #{projectDesc}</if>
|
||||
<if test="projectUrl != null and projectUrl != ''"> and project_url = #{projectUrl}</if>
|
||||
<if test="projectVurl != null and projectVurl != ''"> and project_vurl = #{projectVurl}</if>
|
||||
<if test="projectBaiduUrl != null and projectBaiduUrl != ''"> and project_baidu_url = #{projectBaiduUrl}</if>
|
||||
<if test="projectNum != null and projectNum != ''"> and p.project_num like concat('%', #{projectNum}, '%')</if>
|
||||
<if test="projectNum1 != null and projectNum1 != ''"> and p.project_num1 = #{projectNum1}</if>
|
||||
<if test="projectName != null and projectName != ''"> and p.project_name like concat('%', #{projectName}, '%')</if>
|
||||
<if test="projectName1 != null and projectName1 != ''"> and p.project_name1 like concat('%', #{projectName1}, '%')</if>
|
||||
<if test="projectDesc != null and projectDesc != ''"> and p.project_desc = #{projectDesc}</if>
|
||||
<if test="projectUrl != null and projectUrl != ''"> and p.project_url = #{projectUrl}</if>
|
||||
<if test="projectVurl != null and projectVurl != ''"> and p.project_vurl = #{projectVurl}</if>
|
||||
<if test="projectBaiduUrl != null and projectBaiduUrl != ''"> and p.project_baidu_url = #{projectBaiduUrl}</if>
|
||||
</where>
|
||||
ORDER BY p.id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectTtProjectInfoById" parameterType="Integer" resultMap="TtProjectInfoResult">
|
||||
<include refid="selectTtProjectInfoVo"/>
|
||||
where id = #{id}
|
||||
where p.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectTtProjectInfoByName" parameterType="String" resultMap="TtProjectInfoResult">
|
||||
<include refid="selectTtProjectInfoVo"/>
|
||||
where project_name1 = #{codeName}
|
||||
where p.project_name1 = #{codeName}
|
||||
</select>
|
||||
|
||||
<insert id="insertTtProjectInfo" parameterType="TtProjectInfo" useGeneratedKeys="true" keyProperty="id">
|
||||
@@ -96,8 +126,8 @@
|
||||
|
||||
<select id="lastUpdateList" resultMap="TtProjectInfoResult" parameterType="string">
|
||||
<include refid="selectTtProjectInfoVo"/>
|
||||
where project_vurl is not null
|
||||
<if test="searchKey != null and searchKey != ''">AND IFNULL(project_name1, project_name) like CONCAT('%',#{searchKey},'%') </if>
|
||||
ORDER BY project_name DESC
|
||||
where p.project_vurl is not null
|
||||
<if test="searchKey != null and searchKey != ''">AND IFNULL(p.project_name1, p.project_name) like CONCAT('%',#{searchKey},'%') </if>
|
||||
ORDER BY p.project_name DESC
|
||||
</select>
|
||||
</mapper>
|
||||
</mapper>
|
||||
|
||||
@@ -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>
|
||||
@@ -40,6 +40,9 @@ public class AppBlogArticle extends BaseEntity
|
||||
@Excel(name = "封面图url")
|
||||
private String showImg;
|
||||
|
||||
/** List-card thumbnail URL (not persisted). */
|
||||
private String showImgThumb;
|
||||
|
||||
/** 关联已有资源(关联app_resource id) */
|
||||
@Excel(name = "关联已有资源", readConverterExp = "关=联app_resource,i=d")
|
||||
private Long appResourceId;
|
||||
@@ -159,6 +162,15 @@ public class AppBlogArticle extends BaseEntity
|
||||
{
|
||||
return showImg;
|
||||
}
|
||||
public void setShowImgThumb(String showImgThumb)
|
||||
{
|
||||
this.showImgThumb = showImgThumb;
|
||||
}
|
||||
|
||||
public String getShowImgThumb()
|
||||
{
|
||||
return showImgThumb;
|
||||
}
|
||||
public void setAppResourceId(Long appResourceId)
|
||||
{
|
||||
this.appResourceId = appResourceId;
|
||||
|
||||
@@ -27,6 +27,9 @@ public class AppResource extends BaseEntity
|
||||
@Excel(name = "封面图")
|
||||
private String showImg;
|
||||
|
||||
/** List-card thumbnail URL (not persisted). */
|
||||
private String showImgThumb;
|
||||
|
||||
/** 说明 */
|
||||
@Excel(name = "说明")
|
||||
private String explain;
|
||||
@@ -51,6 +54,13 @@ public class AppResource extends BaseEntity
|
||||
@Excel(name = "需要观看几次广告解锁")
|
||||
private Long adNumber;
|
||||
|
||||
/** 虚拟支付价格,单位分 */
|
||||
@Excel(name = "付费价格(分)")
|
||||
private Integer priceFen;
|
||||
|
||||
/** 同价格档位对应的微信虚拟道具ID(非 app_resource 表字段) */
|
||||
private String virtualProductId;
|
||||
|
||||
/** 下载次数 */
|
||||
@Excel(name = "下载次数")
|
||||
private Long downNum;
|
||||
@@ -100,6 +110,15 @@ public class AppResource extends BaseEntity
|
||||
{
|
||||
return showImg;
|
||||
}
|
||||
public void setShowImgThumb(String showImgThumb)
|
||||
{
|
||||
this.showImgThumb = showImgThumb;
|
||||
}
|
||||
|
||||
public String getShowImgThumb()
|
||||
{
|
||||
return showImgThumb;
|
||||
}
|
||||
public void setExplain(String explain)
|
||||
{
|
||||
this.explain = explain;
|
||||
@@ -145,6 +164,26 @@ public class AppResource extends BaseEntity
|
||||
{
|
||||
return adNumber;
|
||||
}
|
||||
|
||||
public Integer getPriceFen()
|
||||
{
|
||||
return priceFen;
|
||||
}
|
||||
|
||||
public void setPriceFen(Integer priceFen)
|
||||
{
|
||||
this.priceFen = priceFen;
|
||||
}
|
||||
|
||||
public String getVirtualProductId()
|
||||
{
|
||||
return virtualProductId;
|
||||
}
|
||||
|
||||
public void setVirtualProductId(String virtualProductId)
|
||||
{
|
||||
this.virtualProductId = virtualProductId;
|
||||
}
|
||||
public void setDownNum(Long downNum)
|
||||
{
|
||||
this.downNum = downNum;
|
||||
@@ -194,6 +233,8 @@ public class AppResource extends BaseEntity
|
||||
.append("isShow", getIsShow())
|
||||
.append("isAd", getIsAd())
|
||||
.append("adNumber", getAdNumber())
|
||||
.append("priceFen", getPriceFen())
|
||||
.append("virtualProductId", getVirtualProductId())
|
||||
.append("downNum", getDownNum())
|
||||
.append("weight", getWeight())
|
||||
.append("delFlag", getDelFlag())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import com.ruoyi.app.domain.AppResourceList;
|
||||
import com.ruoyi.app.mapper.AppResourceMapper;
|
||||
import com.ruoyi.app.domain.AppResource;
|
||||
import com.ruoyi.app.service.IAppResourceService;
|
||||
import com.ruoyi.app.domain.AppVirtualProduct;
|
||||
import com.ruoyi.app.mapper.AppVirtualProductMapper;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
|
||||
/**
|
||||
* 资源Service业务层处理
|
||||
@@ -24,6 +27,9 @@ public class AppResourceServiceImpl implements IAppResourceService
|
||||
@Autowired
|
||||
private AppResourceMapper appResourceMapper;
|
||||
|
||||
@Autowired
|
||||
private AppVirtualProductMapper virtualProductMapper;
|
||||
|
||||
/**
|
||||
* 查询资源
|
||||
*
|
||||
@@ -58,6 +64,7 @@ public class AppResourceServiceImpl implements IAppResourceService
|
||||
@Override
|
||||
public int insertAppResource(AppResource appResource)
|
||||
{
|
||||
configureVirtualProduct(appResource);
|
||||
appResource.setCreateTime(DateUtils.getNowDate());
|
||||
int rows = appResourceMapper.insertAppResource(appResource);
|
||||
insertAppResourceList(appResource);
|
||||
@@ -74,6 +81,7 @@ public class AppResourceServiceImpl implements IAppResourceService
|
||||
@Override
|
||||
public int updateAppResource(AppResource appResource)
|
||||
{
|
||||
configureVirtualProduct(appResource);
|
||||
appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId());
|
||||
insertAppResourceList(appResource);
|
||||
return appResourceMapper.updateAppResource(appResource);
|
||||
@@ -130,4 +138,26 @@ public class AppResourceServiceImpl implements IAppResourceService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 付费资源按价格档位自动匹配已配置的微信道具。
|
||||
*/
|
||||
private void configureVirtualProduct(AppResource resource)
|
||||
{
|
||||
if (resource.getIsAd() == null || resource.getIsAd() != 3L)
|
||||
{
|
||||
resource.setPriceFen(0);
|
||||
return;
|
||||
}
|
||||
if (resource.getPriceFen() == null || resource.getPriceFen() <= 0)
|
||||
{
|
||||
throw new ServiceException("付费资源价格必须大于0分");
|
||||
}
|
||||
AppVirtualProduct priceProduct = virtualProductMapper.selectActiveByPrice(resource.getPriceFen());
|
||||
if (priceProduct == null || StringUtils.isBlank(priceProduct.getProductId()))
|
||||
{
|
||||
throw new ServiceException("当前价格档位未配置微信道具,请先配置价格与道具映射");
|
||||
}
|
||||
resource.setVirtualProductId(priceProduct.getProductId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<result property="isShow" column="is_show" />
|
||||
<result property="isAd" column="is_ad" />
|
||||
<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="weight" column="weight" />
|
||||
<result property="delFlag" column="del_flag" />
|
||||
@@ -35,27 +37,34 @@
|
||||
</resultMap>
|
||||
|
||||
<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>
|
||||
|
||||
<select id="selectAppResourceList" parameterType="AppResource" resultMap="AppResourceResult">
|
||||
<include refid="selectAppResourceVo"/>
|
||||
<where>
|
||||
<if test="resourceTitle != null and resourceTitle != ''"> and resource_title like concat('%', #{resourceTitle}, '%')</if>
|
||||
<if test="showImg != null and showImg != ''"> and show_img = #{showImg}</if>
|
||||
<if test="explain != null and explain != ''"> and `explain` = #{explain}</if>
|
||||
<if test="resourceType != null "> and resource_type = #{resourceType}</if>
|
||||
<if test="isShow != null "> and is_show = #{isShow}</if>
|
||||
<if test="isAd != null "> and is_ad = #{isAd}</if>
|
||||
<if test="adNumber != null "> and ad_number = #{adNumber}</if>
|
||||
<if test="downNum != null "> and down_num = #{downNum}</if>
|
||||
<if test="weight != null "> and weight = #{weight}</if>
|
||||
<if test="keyword != null and keyword != ''"> and (keyword like concat('%', #{keyword}, '%') or resource_title like concat('%', #{keyword}, '%'))</if>
|
||||
</where> ORDER BY weight desc, create_time desc
|
||||
<if test="resourceTitle != null and resourceTitle != ''"> and a.resource_title like concat('%', #{resourceTitle}, '%')</if>
|
||||
<if test="showImg != null and showImg != ''"> and a.show_img = #{showImg}</if>
|
||||
<if test="explain != null and explain != ''"> and a.`explain` = #{explain}</if>
|
||||
<if test="resourceType != null "> and a.resource_type = #{resourceType}</if>
|
||||
<if test="isShow != null "> and a.is_show = #{isShow}</if>
|
||||
<if test="isAd != null "> and a.is_ad = #{isAd}</if>
|
||||
<if test="adNumber != null "> and a.ad_number = #{adNumber}</if>
|
||||
<if test="downNum != null "> and a.down_num = #{downNum}</if>
|
||||
<if test="weight != null "> and a.weight = #{weight}</if>
|
||||
<if test="keyword != null and keyword != ''"> and (a.keyword like concat('%', #{keyword}, '%') or a.resource_title like concat('%', #{keyword}, '%'))</if>
|
||||
</where> ORDER BY a.weight desc, a.create_time desc
|
||||
</select>
|
||||
|
||||
<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
|
||||
from app_resource a
|
||||
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="isAd != null">is_ad,</if>
|
||||
<if test="adNumber != null">ad_number,</if>
|
||||
<if test="priceFen != null">price_fen,</if>
|
||||
<if test="downNum != null">down_num,</if>
|
||||
<if test="weight != null">weight,</if>
|
||||
<if test="delFlag != null">del_flag,</if>
|
||||
@@ -89,6 +99,7 @@
|
||||
<if test="isShow != null">#{isShow},</if>
|
||||
<if test="isAd != null">#{isAd},</if>
|
||||
<if test="adNumber != null">#{adNumber},</if>
|
||||
<if test="priceFen != null">#{priceFen},</if>
|
||||
<if test="downNum != null">#{downNum},</if>
|
||||
<if test="weight != null">#{weight},</if>
|
||||
<if test="delFlag != null">#{delFlag},</if>
|
||||
@@ -109,6 +120,7 @@
|
||||
<if test="isShow != null">is_show = #{isShow},</if>
|
||||
<if test="isAd != null">is_ad = #{isAd},</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="weight != null">weight = #{weight},</if>
|
||||
<if test="delFlag != null">del_flag = #{delFlag},</if>
|
||||
@@ -149,16 +161,35 @@
|
||||
</insert>
|
||||
|
||||
<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
|
||||
WHEN (SELECT COUNT(1) FROM app_integral_record WHERE resource_id = #{id} AND user_id = #{userId}) > 0 THEN 0
|
||||
WHEN #{userId} is not null and (
|
||||
(SELECT COUNT(1) FROM app_integral_record
|
||||
WHERE resource_id = #{id} AND user_id = #{userId}
|
||||
AND source = '资源兑换' AND is_add = 1) > 0
|
||||
OR
|
||||
(SELECT COUNT(1) FROM app_resource_entitlement WHERE resource_id = #{id} AND user_id = #{userId} AND status = 1) > 0
|
||||
) THEN 0
|
||||
ELSE a.is_ad
|
||||
END as is_ad,
|
||||
a.ad_number, a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
|
||||
END as is_ad,
|
||||
a.ad_number, a.price_fen,
|
||||
(select vp.product_id from app_virtual_product vp where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id,
|
||||
a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
|
||||
b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url,
|
||||
b.password as sub_password, b.app_resource_id as sub_app_resource_id
|
||||
from app_resource a
|
||||
left join app_resource_list b on b.app_resource_id = a.id
|
||||
and (
|
||||
a.is_ad not in (2, 3)
|
||||
or (
|
||||
#{userId} is not null and (
|
||||
exists(select 1 from app_integral_record ir
|
||||
where ir.resource_id = a.id and ir.user_id = #{userId}
|
||||
and ir.source = '资源兑换' and ir.is_add = 1)
|
||||
or exists(select 1 from app_resource_entitlement re where re.resource_id = a.id and re.user_id = #{userId} and re.status = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
where a.id = #{id}
|
||||
</select>
|
||||
</mapper>
|
||||
</mapper>
|
||||
|
||||
@@ -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 >= #{params.beginTime}
|
||||
</if>
|
||||
<if test="params.endTime != null and params.endTime != ''">
|
||||
and vo.create_time <= 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 < date_sub(now(), interval 5 second))
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -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>
|
||||
@@ -51,7 +51,7 @@
|
||||
<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,
|
||||
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
|
||||
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_statusselectUserVo, u.open_id as openId
|
||||
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status, u.open_id
|
||||
from sys_user u
|
||||
left join sys_dept d on u.dept_id = d.dept_id
|
||||
left join sys_user_role ur on u.user_id = ur.user_id
|
||||
@@ -232,4 +232,4 @@
|
||||
select date_format(create_time,'%Y-%m-%d') as date, count(*) as count from sys_user where del_flag = '0' and create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) group by date_format(create_time,'%Y-%m-%d') order by date
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
</mapper>
|
||||
|
||||
18
ruoyi-ui/src/api/app/virtualOrder.js
Normal file
18
ruoyi-ui/src/api/app/virtualOrder.js
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,23 @@ export function delProject(id) {
|
||||
})
|
||||
}
|
||||
|
||||
// 检测单个项目的网盘链接
|
||||
export function checkProjectLinks(id) {
|
||||
return request({
|
||||
url: '/office/project/link-check/single/' + id,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 检测选中项目的网盘链接
|
||||
export function checkSelectedProjectLinks(ids) {
|
||||
return request({
|
||||
url: '/office/project/link-check/batch',
|
||||
method: 'post',
|
||||
data: ids
|
||||
})
|
||||
}
|
||||
|
||||
export function matchCode(id) {
|
||||
return request({
|
||||
url: '/office/project/matchCode/' + id,
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</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="weight" />
|
||||
<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-input v-model="form.adNumber" placeholder="请输入需要兑换多少积分解锁" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.isAd == 3" label="付费金额" prop="adNumber">
|
||||
<el-input v-model="form.adNumber" placeholder="请输入需要付费多少金额解锁" />
|
||||
<el-form-item v-if="form.isAd == 3" label="价格(分)" prop="priceFen">
|
||||
<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 label="权重" prop="weight">
|
||||
<el-input v-model="form.weight" placeholder="请输入权重" />
|
||||
@@ -260,6 +267,11 @@
|
||||
message: "需要广告不能为空",
|
||||
trigger: "change"
|
||||
}],
|
||||
priceFen: [{
|
||||
required: true,
|
||||
message: "付费价格不能为空",
|
||||
trigger: "blur"
|
||||
}],
|
||||
},
|
||||
directory:"appimg/fengmian/"
|
||||
};
|
||||
@@ -303,6 +315,8 @@
|
||||
isShow: null,
|
||||
isAd: null,
|
||||
adNumber: null,
|
||||
priceFen: null,
|
||||
virtualProductId: null,
|
||||
delFlag: null,
|
||||
createBy: null,
|
||||
createTime: null,
|
||||
|
||||
254
ruoyi-ui/src/views/app/virtualOrder/index.vue
Normal file
254
ruoyi-ui/src/views/app/virtualOrder/index.vue
Normal 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>
|
||||
@@ -82,6 +82,18 @@
|
||||
v-hasPermi="['office:project:export']"
|
||||
>导出</el-button>
|
||||
</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>
|
||||
</el-row>
|
||||
|
||||
@@ -93,8 +105,38 @@
|
||||
<el-table-column label="源码名称" align="center" prop="projectName1" />
|
||||
<el-table-column label="项目技术" align="center" prop="projectNum1" />
|
||||
<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" 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" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
@@ -131,6 +173,14 @@
|
||||
<!-- v-hasPermi="['office:code:edit']"-->
|
||||
<!-- >匹配源码-->
|
||||
<!-- </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
|
||||
size="mini"
|
||||
type="text"
|
||||
@@ -178,7 +228,7 @@
|
||||
<el-form-item label="夸克网盘链接" prop="projectUrl">
|
||||
<el-input v-model="form.projectUrl" placeholder="请输入项目链接" />
|
||||
</el-form-item>
|
||||
<el-form-item label="百度网盘链接" prop="projectUrl">
|
||||
<el-form-item label="百度网盘链接" prop="projectBaiduUrl">
|
||||
<el-input v-model="form.projectBaiduUrl" placeholder="请输入项目链接" />
|
||||
</el-form-item>
|
||||
<el-form-item label="演示视频链接" prop="projectVurl">
|
||||
@@ -205,7 +255,16 @@
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
export default {
|
||||
@@ -214,6 +273,10 @@ export default {
|
||||
return {
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
// 批量检测状态
|
||||
batchChecking: false,
|
||||
// 正在进行单条检测的项目
|
||||
checkingIds: [],
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
@@ -302,6 +365,99 @@ export default {
|
||||
this.single = selection.length !== 1
|
||||
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() {
|
||||
this.reset()
|
||||
@@ -484,3 +640,19 @@ export default {
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
8
sql/optimize_resource_detail_indexes.sql
Normal file
8
sql/optimize_resource_detail_indexes.sql
Normal 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`);
|
||||
19
sql/project_link_check.sql
Normal file
19
sql/project_link_check.sql
Normal 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='项目网盘链接检测结果';
|
||||
50
sql/virtual_pay_order_menu.sql
Normal file
50
sql/virtual_pay_order_menu.sql
Normal 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'
|
||||
);
|
||||
79
sql/virtual_pay_resource.sql
Normal file
79
sql/virtual_pay_resource.sql
Normal 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();
|
||||
Reference in New Issue
Block a user