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