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

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

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

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

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

@@ -0,0 +1,72 @@
# 资源直购虚拟支付上线配置
本项目使用微信小程序虚拟支付的 `short_series_goods`(道具直购)模式,不再提供人民币充值积分功能。
## 1. 执行数据库迁移
执行 `sql/virtual_pay_resource.sql`。脚本会:
- 为资源增加分单位价格 `price_fen`
- 创建价格档位表 `app_virtual_product`
- 创建虚拟支付订单表 `app_virtual_order`
- 创建资源访问权益表 `app_resource_entitlement`
- 将已有 `is_ad=3` 资源的 `ad_number`(元)迁移为 `price_fen`(分)。
## 2. 配置虚拟支付环境变量
```text
WX_VIRTUAL_PAY_ENABLED=true
WX_VIRTUAL_PAY_OFFER_ID=微信虚拟支付OfferId
WX_VIRTUAL_PAY_APP_KEY=微信虚拟支付现网AppKey
WX_VIRTUAL_PAY_ENV=0
WX_VIRTUAL_PAY_CALLBACK_TOKEN=CHANGE_ME_TO_A_RANDOM_SECRET
```
AppKey 和小程序 Secret 不应提交到 Git生产环境应由部署平台注入。
未设置这些环境变量时,虚拟支付默认关闭。
## 3. 配置价格档位道具
在微信公众平台的虚拟支付后台按价格创建并发布道具,例如:
| 价格 | productId 示例 |
| --- | --- |
| 1 元 | `resource_100` |
| 5 元 | `resource_500` |
| 10 元 | `resource_1000` |
然后在若依后台编辑付费资源:
- 获取方式选择“付费”;
- 价格填写分,例如 5 元填写 `500`
- 同一价格的资源填写同一个已发布 `productId`
后台会自动维护“价格 -> productId”唯一映射一个 productId 不能绑定多个价格。
## 4. 配置消息推送
在小程序后台配置:
```text
URL: https://你的域名/prod-api/app/virtual-pay/callback
Token: 与 WX_VIRTUAL_PAY_CALLBACK_TOKEN 相同
数据格式: JSON
消息加密方式: 明文模式
```
支付发货通知由服务端验签、校验 OpenID/订单/productId/价格后幂等发放资源权益。退款成功通知会撤销对应权益。
回调成功时接口返回 `{"ErrCode":0,"ErrMsg":"success"}`;业务校验或处理失败时返回非零错误码,微信会自动重试。
## 5. 上线检查
1. 后端启动时确认 `WX_VIRTUAL_PAY_ENABLED=true`
2. 确认道具已审核发布并等待配置生效。
3. 用一条最低价格资源完成真机支付。
4. 检查 `app_virtual_order.status=1``app_resource_entitlement.status=1`
5. 重新进入资源详情,确认下载链接只对购买用户返回。
## 6. 启用后台订单管理
执行 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟支付订单”,支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。
虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.service.ImageUrlService;
import com.ruoyi.app.service.IAppResourceService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
@@ -16,6 +17,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import org.springframework.security.core.Authentication;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 资源Controller
@@ -30,6 +34,9 @@ public class AppResourceController extends BaseController
@Autowired
private IAppResourceService appResourceService;
@Autowired
private ImageUrlService imageUrlService;
@Resource
private AppResourceMapper appResourceMapper;
@@ -41,6 +48,7 @@ public class AppResourceController extends BaseController
{
startPage();
List<AppResource> list = appResourceService.selectAppResourceList(appResource);
imageUrlService.decorateResources(list);
return getDataTable(list);
}
@@ -70,16 +78,28 @@ public class AppResourceController extends BaseController
@GetMapping(value = "/app/{id}")
public AjaxResult getInfoApp(@PathVariable("id") Long id)
{
return success(appResourceService.selectAppResourceById(id));
return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
}
/**
* 根据用户ID获取资源详细信息
*/
@GetMapping(value = "/app/user/{id}/{userId}")
public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long userId)
public AjaxResult getResourceByUserId(@PathVariable("id") Long id, @PathVariable("userId") Long ignoredUserId)
{
return success(appResourceMapper.selectAppResourceByIdAndUserId(id, userId));
return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
}
/**
* 小程序资源详情。用户身份只从 JWT 读取,不接收客户端 userId。
*/
@GetMapping(value = "/app/user/{id}")
public AjaxResult getResourceForCurrentUser(@PathVariable("id") Long id)
{
return success(imageUrlService.decorateResource(
appResourceMapper.selectAppResourceByIdAndUserId(id, currentUserId())));
}
/**
@@ -123,4 +143,14 @@ public class AppResourceController extends BaseController
{
return toAjax( appResourceMapper.lookAddNumber(appResource.getId()));
}
private Long currentUserId()
{
Authentication authentication = SecurityUtils.getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof LoginUser)
{
return ((LoginUser) authentication.getPrincipal()).getUserId();
}
return null;
}
}

View File

@@ -0,0 +1,59 @@
package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppVirtualOrder;
import com.ruoyi.app.service.IAppVirtualOrderService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.poi.ExcelUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 虚拟支付订单管理接口。
*
* 支付订单由微信回调和主动查单流程维护,管理端仅提供只读查询和导出。
*/
@RestController
@RequestMapping("/app/virtualOrder")
public class AppVirtualOrderController extends BaseController
{
@Autowired
private IAppVirtualOrderService appVirtualOrderService;
@PreAuthorize("@ss.hasPermi('app:virtualOrder:list')")
@GetMapping("/list")
public TableDataInfo list(AppVirtualOrder order)
{
startPage();
List<AppVirtualOrder> list = appVirtualOrderService.selectAppVirtualOrderList(order);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('app:virtualOrder:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id)
{
return success(appVirtualOrderService.selectAppVirtualOrderById(id));
}
@PreAuthorize("@ss.hasPermi('app:virtualOrder:export')")
@Log(title = "虚拟支付订单", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, AppVirtualOrder order)
{
List<AppVirtualOrder> list = appVirtualOrderService.selectAppVirtualOrderList(order);
ExcelUtil<AppVirtualOrder> util = new ExcelUtil<>(AppVirtualOrder.class);
util.exportExcel(response, list, "虚拟支付订单数据");
}
}

View File

@@ -0,0 +1,107 @@
package com.ruoyi.web.controller.app;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.app.domain.request.CreateVirtualOrderRequest;
import com.ruoyi.app.service.IAppVirtualPayService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
@RestController
@RequestMapping("/app/virtual-pay")
public class AppVirtualPayController extends BaseController
{
private static final Logger log = LoggerFactory.getLogger(AppVirtualPayController.class);
private final IAppVirtualPayService virtualPayService;
private final ObjectMapper objectMapper;
public AppVirtualPayController(IAppVirtualPayService virtualPayService, ObjectMapper objectMapper)
{
this.virtualPayService = virtualPayService;
this.objectMapper = objectMapper;
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/resource-orders")
public AjaxResult createOrder(@Validated @RequestBody CreateVirtualOrderRequest request)
{
return success(virtualPayService.createOrder(request));
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/resource-orders/{orderNo}")
public AjaxResult queryOrder(@PathVariable String orderNo,
@RequestParam(defaultValue = "false") boolean sync)
{
return success(virtualPayService.queryOrder(orderNo, sync));
}
/**
* 在小程序后台保存消息推送配置时使用。
*/
@GetMapping(value = "/callback", produces = MediaType.TEXT_PLAIN_VALUE)
public String verifyCallback(@RequestParam String signature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestParam String echostr)
{
if (!virtualPayService.verifyCallbackSignature(signature, timestamp, nonce))
{
log.warn("微信虚拟支付回调 URL 验证失败");
return "";
}
return echostr;
}
/**
* 小程序消息推送请配置为 JSON 数据格式。
*/
@PostMapping("/callback")
public Map<String, Object> callback(@RequestParam String signature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestBody String body)
{
if (!virtualPayService.verifyCallbackSignature(signature, timestamp, nonce))
{
log.warn("拒绝签名无效的微信虚拟支付回调");
return callbackResponse(1, "invalid signature");
}
try
{
JsonNode json = objectMapper.readTree(body);
virtualPayService.handleCallback(json);
return callbackResponse(0, "success");
}
catch (Exception e)
{
// 返回非零 ErrCode 让微信重试,异常详情只记录在服务端日志中。
log.error("微信虚拟支付回调处理失败", e);
return callbackResponse(1, "callback failed");
}
}
private static Map<String, Object> callbackResponse(int code, String message)
{
Map<String, Object> response = new LinkedHashMap<>();
response.put("ErrCode", code);
response.put("ErrMsg", message);
return response;
}
}

View File

@@ -4,7 +4,6 @@ import com.ruoyi.app.domain.ShuiYinVo;
import com.ruoyi.app.mapper.AppPunlicMapper;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.system.service.ISysConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -21,20 +20,6 @@ public class PublicController extends BaseController {
AppPunlicMapper appPunlicMapper;
@Autowired
private ISysConfigService configService;
/**
* 获取微信openid信息
*/
@GetMapping(value = "/autoLoginWx/{code}")
public AjaxResult autoLoginWx(@PathVariable("code") String code)
{
HttpUtils httpUtils = new HttpUtils();
String appid = configService.selectConfigByKey("miniapp.wx.appId");
String secret = configService.selectConfigByKey("miniapp.wx.secret");
String param = "appid="+appid+"&secret="+secret+"&js_code="+code+"&grant_type=authorization_code";
String s = httpUtils.httpGet("https://api.weixin.qq.com/sns/jscode2session", param);
return success(s);
}
/**
* 去水印 key 免费申请联系作者微信yimoziyuan666
* @return
@@ -43,7 +28,7 @@ public class PublicController extends BaseController {
private String delWatermarkKey;
@PostMapping(value = "/delSHuiYin")
public AjaxResult delSHuiYin(HttpServletRequest request, @RequestBody ShuiYinVo shuiYinVo) {
HttpUtils httpUtils = new HttpUtils();
com.ruoyi.common.utils.http.HttpUtils httpUtils = new com.ruoyi.common.utils.http.HttpUtils();
String key = configService.selectConfigByKey("miniapp.shuiyin.key");
String param = "key="+key+"&url="+shuiYinVo.getUrl();
String s = httpUtils.httpGet("https://api.emoboy.vip/api/shuiyin/delWatermark", param);

View File

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

View File

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

View File

@@ -9,8 +9,8 @@ ruoyi:
# 实例演示开关
demoEnabled: true
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
# profile: /home/upload
profilee: D:/ruoyi/uploadPath
profile: /home/upload
# profilee: D:/ruoyi/uploadPath
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
@@ -42,10 +42,10 @@ wx:
appid: wx17b46f75e762c184 # 微信小程序appid
secret: 1fa844dc33e70f7d813f24cd2af7678b # 微信小程序密钥
merchantId: 1704387455 # 商户号
privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径测试环境
publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径测试环境
# privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径正式环境
# publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径正式环境
# privateKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\apiclient_key.pem # 商户API私钥路径测试环境
# publicKeyPath: D:\wxcert\WXCertUtil\cert\1704387455_20250112_cert\pub_key.pem # 商户API公钥路径测试环境
privateKeyPath: /home/cert/apiclient_key.pem # 商户API私钥路径正式环境
publicKeyPath: /home/cert/pub_key.pem # 商户API公钥路径正式环境
publicKeyId: PUB_KEY_ID_0117043874552025011100188700000234
merchantSerialNumber: 743FBCB9F5DFD76104A468C6AC6EDD41268634A3 # 商户API证书序列号
apiV3Key: G7kL2mN8pQ4rT1vX9yZ3bC5dF6hJ0sW1 # 商户APIV3密钥
@@ -53,6 +53,13 @@ wx:
# refundNotifyUrl: http://www.yidaima.cn:6001/app/pay/refundNotify # 退款通知地址(测试环境)
payNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/payNotify # 支付通知地址(正式环境)
refundNotifyUrl: https://feast.yidaima.cn/prod-api/app/pay/refundNotify # 退款通知地址(正式环境)
# 虚拟支付配置。生产环境请通过环境变量注入,不要提交真实 AppKey。
virtual-pay:
enabled: ${WX_VIRTUAL_PAY_ENABLED:true}
offer-id: ${WX_VIRTUAL_PAY_OFFER_ID:1450602603}
app-key: ${WX_VIRTUAL_PAY_APP_KEY:iVRy8soX3JTHd8dWQxruDtOIDjrcIKYL}
env: ${WX_VIRTUAL_PAY_ENV:0}
callback-token: ${WX_VIRTUAL_PAY_CALLBACK_TOKEN:zSgMvGAa289XyYoWDnpc1gUUPEBQ2me9}
# 日志配置
logging:

View File

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

View File

@@ -0,0 +1,29 @@
package com.ruoyi.common.wx;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 微信小程序虚拟支付配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "wx.miniapp.virtual-pay")
public class VirtualPayConfig
{
/** 是否启用虚拟支付。 */
private boolean enabled;
/** 虚拟支付 OfferId。 */
private String offerId;
/** 虚拟支付现网 AppKey。 */
private String appKey;
/** 0-正式环境1-沙箱环境。 */
private int env = 0;
/** 小程序消息推送 Token用于校验支付通知。 */
private String callbackToken;
}

View File

@@ -0,0 +1,26 @@
package com.ruoyi.common.wx;
/**
* 微信 code2Session 的服务端结果。该对象禁止返回给客户端。
*/
public class WxCodeSession
{
private final String openId;
private final String sessionKey;
public WxCodeSession(String openId, String sessionKey)
{
this.openId = openId;
this.sessionKey = sessionKey;
}
public String getOpenId()
{
return openId;
}
public String getSessionKey()
{
return sessionKey;
}
}

View File

@@ -0,0 +1,81 @@
package com.ruoyi.common.wx;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.exception.ServiceException;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* 仅在服务端使用小程序临时 code 换取 OpenID 与 session_key。
*/
@Service
public class WxCodeSessionService
{
private static final String CODE_TO_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
private final WxPayConfig wxPayConfig;
private final ObjectMapper objectMapper;
private final OkHttpClient httpClient;
public WxCodeSessionService(WxPayConfig wxPayConfig, ObjectMapper objectMapper)
{
this.wxPayConfig = wxPayConfig;
this.objectMapper = objectMapper;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build();
}
public WxCodeSession exchange(String code)
{
if (StringUtils.isBlank(code))
{
throw new ServiceException("微信登录凭证不能为空");
}
if (StringUtils.isAnyBlank(wxPayConfig.getAppid(), wxPayConfig.getSecret()))
{
throw new ServiceException("微信小程序 AppID 或 Secret 未配置");
}
HttpUrl url = HttpUrl.parse(CODE_TO_SESSION_URL).newBuilder()
.addQueryParameter("appid", wxPayConfig.getAppid())
.addQueryParameter("secret", wxPayConfig.getSecret())
.addQueryParameter("js_code", code)
.addQueryParameter("grant_type", "authorization_code")
.build();
Request request = new Request.Builder().url(url).get().build();
try (Response response = httpClient.newCall(request).execute())
{
if (!response.isSuccessful() || response.body() == null)
{
throw new ServiceException("微信登录服务暂不可用");
}
JsonNode result = objectMapper.readTree(response.body().string());
if (result.path("errcode").asInt(0) != 0)
{
throw new ServiceException("微信登录失败:" + result.path("errmsg").asText("未知错误"));
}
String openId = result.path("openid").asText();
String sessionKey = result.path("session_key").asText();
if (StringUtils.isAnyBlank(openId, sessionKey))
{
throw new ServiceException("微信登录结果不完整");
}
return new WxCodeSession(openId, sessionKey);
}
catch (IOException e)
{
throw new ServiceException("调用微信登录服务失败");
}
}
}

View File

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

View File

@@ -0,0 +1,30 @@
package com.ruoyi.office.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 网盘检测线程池。限制并发数,避免批量检测时触发平台风控。
*/
@Configuration
public class NetDiskCheckExecutorConfig
{
@Bean(name = "netDiskCheckExecutor", destroyMethod = "shutdown")
public ExecutorService netDiskCheckExecutor()
{
AtomicInteger threadNumber = new AtomicInteger(1);
ThreadFactory threadFactory = runnable ->
{
Thread thread = new Thread(runnable,
"net-disk-check-" + threadNumber.getAndIncrement());
thread.setDaemon(true);
return thread;
};
return Executors.newFixedThreadPool(4, threadFactory);
}
}

View File

@@ -10,6 +10,7 @@ import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.utils.CoverGenerator;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.IProjectLinkCheckService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -42,6 +43,8 @@ public class TtProjectInfoController extends BaseController {
private ITtProjectInfoService ttProjectInfoService;
@Autowired
private ITtCodeService ttCodeService;
@Autowired
private IProjectLinkCheckService projectLinkCheckService;
/**
@@ -76,6 +79,26 @@ public class TtProjectInfoController extends BaseController {
return success(ttProjectInfoService.selectTtProjectInfoById(id));
}
/**
* 检测单个项目的网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "检测项目网盘链接", businessType = BusinessType.OTHER)
@PostMapping("/link-check/single/{id}")
public AjaxResult checkProjectLink(@PathVariable("id") Integer id) {
return success(projectLinkCheckService.checkProject(id));
}
/**
* 检测选中项目的网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "批量检测项目网盘链接", businessType = BusinessType.OTHER)
@PostMapping("/link-check/batch")
public AjaxResult checkProjectLinks(@RequestBody Integer[] ids) {
return success(projectLinkCheckService.checkProjects(ids));
}
/**
* 新增项目清单
*/

View File

@@ -4,6 +4,7 @@ import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
/**
* 项目清单对象 tt_project_info
@@ -50,6 +51,24 @@ public class TtProjectInfo extends BaseEntity
@Excel(name = "项目百度链接")
private String projectBaiduUrl;
/** 夸克网盘最近检测状态 */
private String quarkCheckStatus;
/** 夸克网盘最近检测说明 */
private String quarkCheckMessage;
/** 夸克网盘最近检测时间 */
private Date quarkCheckedAt;
/** 百度网盘最近检测状态 */
private String baiduCheckStatus;
/** 百度网盘最近检测说明 */
private String baiduCheckMessage;
/** 百度网盘最近检测时间 */
private Date baiduCheckedAt;
public void setId(Integer id)
{
this.id = id;
@@ -131,6 +150,54 @@ public class TtProjectInfo extends BaseEntity
this.projectBaiduUrl = projectBaiduUrl;
}
public String getQuarkCheckStatus() {
return quarkCheckStatus;
}
public void setQuarkCheckStatus(String quarkCheckStatus) {
this.quarkCheckStatus = quarkCheckStatus;
}
public String getQuarkCheckMessage() {
return quarkCheckMessage;
}
public void setQuarkCheckMessage(String quarkCheckMessage) {
this.quarkCheckMessage = quarkCheckMessage;
}
public Date getQuarkCheckedAt() {
return quarkCheckedAt;
}
public void setQuarkCheckedAt(Date quarkCheckedAt) {
this.quarkCheckedAt = quarkCheckedAt;
}
public String getBaiduCheckStatus() {
return baiduCheckStatus;
}
public void setBaiduCheckStatus(String baiduCheckStatus) {
this.baiduCheckStatus = baiduCheckStatus;
}
public String getBaiduCheckMessage() {
return baiduCheckMessage;
}
public void setBaiduCheckMessage(String baiduCheckMessage) {
this.baiduCheckMessage = baiduCheckMessage;
}
public Date getBaiduCheckedAt() {
return baiduCheckedAt;
}
public void setBaiduCheckedAt(Date baiduCheckedAt) {
this.baiduCheckedAt = baiduCheckedAt;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
@@ -141,6 +208,7 @@ public class TtProjectInfo extends BaseEntity
.append("projectName1", getProjectName1())
.append("projectDesc", getProjectDesc())
.append("projectUrl", getProjectUrl())
.append("projectBaiduUrl", getProjectBaiduUrl())
.append("projectVurl", getProjectVurl())
.toString();
}

View File

@@ -0,0 +1,117 @@
package com.ruoyi.office.domain;
import java.util.Date;
/**
* 项目网盘链接检测结果对象 tt_project_link_check。
*/
public class TtProjectLinkCheck
{
private Long id;
private Integer projectId;
private String diskType;
private String linkUrl;
private String checkStatus;
private String providerCode;
private String checkMessage;
private Long responseTimeMs;
private Date checkedAt;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public Integer getProjectId()
{
return projectId;
}
public void setProjectId(Integer projectId)
{
this.projectId = projectId;
}
public String getDiskType()
{
return diskType;
}
public void setDiskType(String diskType)
{
this.diskType = diskType;
}
public String getLinkUrl()
{
return linkUrl;
}
public void setLinkUrl(String linkUrl)
{
this.linkUrl = linkUrl;
}
public String getCheckStatus()
{
return checkStatus;
}
public void setCheckStatus(String checkStatus)
{
this.checkStatus = checkStatus;
}
public String getProviderCode()
{
return providerCode;
}
public void setProviderCode(String providerCode)
{
this.providerCode = providerCode;
}
public String getCheckMessage()
{
return checkMessage;
}
public void setCheckMessage(String checkMessage)
{
this.checkMessage = checkMessage;
}
public Long getResponseTimeMs()
{
return responseTimeMs;
}
public void setResponseTimeMs(Long responseTimeMs)
{
this.responseTimeMs = responseTimeMs;
}
public Date getCheckedAt()
{
return checkedAt;
}
public void setCheckedAt(Date checkedAt)
{
this.checkedAt = checkedAt;
}
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.office.mapper;
import com.ruoyi.office.domain.TtProjectLinkCheck;
/**
* 项目网盘链接检测结果 Mapper。
*/
public interface TtProjectLinkCheckMapper
{
int upsertTtProjectLinkCheck(TtProjectLinkCheck linkCheck);
int deleteByProjectId(Integer projectId);
int deleteByProjectIds(Integer[] projectIds);
}

View File

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

View File

@@ -0,0 +1,229 @@
package com.ruoyi.office.service.impl;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.domain.TtProjectLinkCheck;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
import com.ruoyi.office.service.IProjectLinkCheckService;
import com.ruoyi.office.service.netdisk.NetDiskCheckResult;
import com.ruoyi.office.service.netdisk.NetDiskConstants;
import com.ruoyi.office.service.netdisk.NetDiskLinkChecker;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
/**
* 项目网盘链接检测服务实现。
*/
@Service
public class ProjectLinkCheckServiceImpl implements IProjectLinkCheckService
{
private static final int MAX_BATCH_SIZE = 20;
private static final int MAX_MESSAGE_LENGTH = 250;
private final TtProjectInfoMapper projectInfoMapper;
private final TtProjectLinkCheckMapper linkCheckMapper;
private final Map<String, NetDiskLinkChecker> checkerMap;
private final ExecutorService netDiskCheckExecutor;
public ProjectLinkCheckServiceImpl(
TtProjectInfoMapper projectInfoMapper,
TtProjectLinkCheckMapper linkCheckMapper,
List<NetDiskLinkChecker> checkers,
@Qualifier("netDiskCheckExecutor") ExecutorService netDiskCheckExecutor)
{
this.projectInfoMapper = projectInfoMapper;
this.linkCheckMapper = linkCheckMapper;
this.netDiskCheckExecutor = netDiskCheckExecutor;
this.checkerMap = new LinkedHashMap<>();
for (NetDiskLinkChecker checker : checkers)
{
this.checkerMap.put(checker.getDiskType(), checker);
}
}
@Override
public Map<String, Object> checkProject(Integer projectId)
{
if (projectId == null)
{
throw new ServiceException("项目编号不能为空");
}
ProjectCheckOutcome outcome = checkProjectInternal(projectId);
return buildSummary(Collections.singletonList(outcome));
}
@Override
public Map<String, Object> checkProjects(Integer[] projectIds)
{
if (projectIds == null || projectIds.length == 0)
{
throw new ServiceException("请至少选择一个项目");
}
Set<Integer> uniqueIds = new LinkedHashSet<>();
for (Integer projectId : projectIds)
{
if (projectId != null)
{
uniqueIds.add(projectId);
}
}
if (uniqueIds.isEmpty())
{
throw new ServiceException("请至少选择一个项目");
}
if (uniqueIds.size() > MAX_BATCH_SIZE)
{
throw new ServiceException("一次最多检测 " + MAX_BATCH_SIZE + " 个项目");
}
List<CompletableFuture<ProjectCheckOutcome>> futures = new ArrayList<>();
for (Integer projectId : uniqueIds)
{
futures.add(CompletableFuture.supplyAsync(
() -> checkProjectInternal(projectId), netDiskCheckExecutor));
}
List<ProjectCheckOutcome> outcomes = new ArrayList<>();
for (CompletableFuture<ProjectCheckOutcome> future : futures)
{
outcomes.add(future.join());
}
return buildSummary(outcomes);
}
private ProjectCheckOutcome checkProjectInternal(Integer projectId)
{
TtProjectInfo project = projectInfoMapper.selectTtProjectInfoById(projectId);
if (project == null)
{
throw new ServiceException("项目不存在:" + projectId);
}
List<NetDiskCheckResult> results = new ArrayList<>(2);
checkAndSave(projectId, project.getProjectUrl(), NetDiskConstants.DISK_QUARK, results);
checkAndSave(projectId, project.getProjectBaiduUrl(), NetDiskConstants.DISK_BAIDU, results);
return new ProjectCheckOutcome(project.getId(), project.getProjectNum(), results);
}
private void checkAndSave(Integer projectId, String linkUrl, String diskType,
List<NetDiskCheckResult> results)
{
if (StringUtils.isBlank(linkUrl))
{
return;
}
NetDiskLinkChecker checker = checkerMap.get(diskType);
NetDiskCheckResult result;
if (checker == null)
{
result = new NetDiskCheckResult(diskType, linkUrl,
NetDiskConstants.STATUS_UNKNOWN, "NO_CHECKER",
"未找到对应的网盘检测器", 0L);
}
else
{
result = checker.check(linkUrl);
}
TtProjectLinkCheck linkCheck = new TtProjectLinkCheck();
linkCheck.setProjectId(projectId);
linkCheck.setDiskType(diskType);
linkCheck.setLinkUrl(linkUrl);
linkCheck.setCheckStatus(result.getStatus());
linkCheck.setProviderCode(truncate(result.getProviderCode(), 32));
linkCheck.setCheckMessage(truncate(result.getMessage(), MAX_MESSAGE_LENGTH));
linkCheck.setResponseTimeMs(result.getResponseTimeMs());
linkCheck.setCheckedAt(new Date());
linkCheckMapper.upsertTtProjectLinkCheck(linkCheck);
results.add(result);
}
private Map<String, Object> buildSummary(List<ProjectCheckOutcome> outcomes)
{
int validCount = 0;
int invalidCount = 0;
int warningCount = 0;
int unknownCount = 0;
List<Map<String, Object>> resultItems = new ArrayList<>();
for (ProjectCheckOutcome outcome : outcomes)
{
for (NetDiskCheckResult result : outcome.results)
{
if (NetDiskConstants.STATUS_VALID.equals(result.getStatus()))
{
validCount++;
}
else if (NetDiskConstants.STATUS_INVALID.equals(result.getStatus()))
{
invalidCount++;
}
else if (NetDiskConstants.STATUS_UNKNOWN.equals(result.getStatus()))
{
unknownCount++;
}
else
{
warningCount++;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("projectId", outcome.projectId);
item.put("projectNum", outcome.projectNum);
item.put("diskType", result.getDiskType());
item.put("status", result.getStatus());
item.put("message", result.getMessage());
resultItems.add(item);
}
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("projectCount", outcomes.size());
summary.put("linkCount", resultItems.size());
summary.put("validCount", validCount);
summary.put("invalidCount", invalidCount);
summary.put("warningCount", warningCount);
summary.put("unknownCount", unknownCount);
summary.put("results", resultItems);
return summary;
}
private String truncate(String value, int maxLength)
{
if (value == null || value.length() <= maxLength)
{
return value;
}
return value.substring(0, maxLength);
}
private static class ProjectCheckOutcome
{
private final Integer projectId;
private final String projectNum;
private final List<NetDiskCheckResult> results;
private ProjectCheckOutcome(Integer projectId, String projectNum,
List<NetDiskCheckResult> results)
{
this.projectId = projectId;
this.projectNum = projectNum;
this.results = results;
}
}
}

View File

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

View File

@@ -0,0 +1,218 @@
package com.ruoyi.office.service.netdisk;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 百度网盘分享链接检测器。
*
* 这里只检测分享是否存在,不下载分享文件。
*/
@Component
public class BaiduNetDiskLinkChecker implements NetDiskLinkChecker
{
private static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131 Safari/537.36";
private static final int MAX_REDIRECTS = 5;
private static final Set<String> ALLOWED_HOSTS =
new HashSet<>(Arrays.asList("pan.baidu.com", "yun.baidu.com"));
private static final String[] INVALID_MARKERS = {
"页面不存在",
"你所访问的页面不存在了",
"分享的文件已经被取消了",
"分享已过期",
"该分享文件已过期",
"啊哦,你来晚了"
};
private static final String[] RISK_MARKERS = {
"访问过于频繁",
"请输入验证码",
"系统繁忙,请稍候再试"
};
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build();
@Override
public String getDiskType()
{
return NetDiskConstants.DISK_BAIDU;
}
@Override
public NetDiskCheckResult check(String linkUrl)
{
long startedAt = System.currentTimeMillis();
String url = StringUtils.trim(linkUrl);
if (!isAllowedUrl(url) || !hasSharePath(url))
{
return result(linkUrl, NetDiskConstants.STATUS_FORMAT_ERROR, "FORMAT",
"百度网盘链接格式不正确", startedAt);
}
String passcode = getQueryParameter(url, "pwd");
String currentUrl = url;
try
{
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++)
{
Request request = new Request.Builder()
.url(currentUrl)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Encoding", "identity")
.get()
.build();
try (Response response = httpClient.newCall(request).execute())
{
int httpCode = response.code();
if (httpCode >= 300 && httpCode < 400)
{
String location = response.header("Location");
if (StringUtils.isBlank(location))
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
String.valueOf(httpCode), "百度网盘返回了无目标地址的跳转", startedAt);
}
String redirectUrl = resolveUrl(currentUrl, location);
if (!isAllowedUrl(redirectUrl))
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
String.valueOf(httpCode), "百度网盘跳转到了非预期地址", startedAt);
}
currentUrl = redirectUrl;
continue;
}
String body = response.peekBody(1024L * 1024L).string();
if (httpCode == 404 || containsAny(body, INVALID_MARKERS))
{
return result(linkUrl, NetDiskConstants.STATUS_INVALID,
String.valueOf(httpCode), "分享已失效、取消或不存在", startedAt);
}
if (httpCode == 403 || httpCode == 429 || httpCode >= 500 || containsAny(body, RISK_MARKERS))
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
String.valueOf(httpCode), "百度网盘暂时拒绝检测或访问受限", startedAt);
}
if (httpCode >= 200 && httpCode < 300)
{
boolean needsCode = currentUrl.contains("/share/init");
if (needsCode && StringUtils.isBlank(passcode))
{
return result(linkUrl, NetDiskConstants.STATUS_NEED_CODE,
String.valueOf(httpCode), "分享存在,但链接中没有提取码", startedAt);
}
String message = needsCode
? "分享存在,链接中包含提取码"
: "分享链接有效";
return result(linkUrl, NetDiskConstants.STATUS_VALID,
String.valueOf(httpCode), message, startedAt);
}
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
String.valueOf(httpCode), "百度网盘返回了未识别的状态", startedAt);
}
}
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, "REDIRECT",
"百度网盘跳转次数过多", startedAt);
}
catch (IOException | RuntimeException e)
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, "NETWORK",
"检测请求失败:" + safeMessage(e), startedAt);
}
}
private NetDiskCheckResult result(String linkUrl, String status, String providerCode,
String message, long startedAt)
{
return new NetDiskCheckResult(getDiskType(), linkUrl, status, providerCode, message,
System.currentTimeMillis() - startedAt);
}
private boolean hasSharePath(String url)
{
try
{
String path = new URI(url).getPath();
return path != null && (path.startsWith("/s/") || path.startsWith("/share/"));
}
catch (URISyntaxException e)
{
return false;
}
}
private boolean isAllowedUrl(String url)
{
if (StringUtils.isBlank(url))
{
return false;
}
try
{
URI uri = new URI(url);
return "https".equalsIgnoreCase(uri.getScheme())
&& uri.getUserInfo() == null
&& (uri.getPort() == -1 || uri.getPort() == 443)
&& uri.getHost() != null
&& ALLOWED_HOSTS.contains(uri.getHost().toLowerCase());
}
catch (URISyntaxException e)
{
return false;
}
}
private String resolveUrl(String baseUrl, String location)
{
HttpUrl base = HttpUrl.parse(baseUrl);
HttpUrl resolved = base == null ? null : base.resolve(location);
return resolved == null ? null : resolved.toString();
}
private String getQueryParameter(String url, String name)
{
HttpUrl httpUrl = HttpUrl.parse(url);
return httpUrl == null ? null : httpUrl.queryParameter(name);
}
private boolean containsAny(String body, String[] markers)
{
if (StringUtils.isBlank(body))
{
return false;
}
for (String marker : markers)
{
if (body.contains(marker))
{
return true;
}
}
return false;
}
private String safeMessage(Exception e)
{
return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName());
}
}

View File

@@ -0,0 +1,55 @@
package com.ruoyi.office.service.netdisk;
/**
* 单个网盘链接的检测结果。
*/
public class NetDiskCheckResult
{
private final String diskType;
private final String linkUrl;
private final String status;
private final String providerCode;
private final String message;
private final long responseTimeMs;
public NetDiskCheckResult(String diskType, String linkUrl, String status,
String providerCode, String message, long responseTimeMs)
{
this.diskType = diskType;
this.linkUrl = linkUrl;
this.status = status;
this.providerCode = providerCode;
this.message = message;
this.responseTimeMs = responseTimeMs;
}
public String getDiskType()
{
return diskType;
}
public String getLinkUrl()
{
return linkUrl;
}
public String getStatus()
{
return status;
}
public String getProviderCode()
{
return providerCode;
}
public String getMessage()
{
return message;
}
public long getResponseTimeMs()
{
return responseTimeMs;
}
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.office.service.netdisk;
/**
* 网盘类型和检测状态常量。
*/
public final class NetDiskConstants
{
public static final String DISK_QUARK = "QUARK";
public static final String DISK_BAIDU = "BAIDU";
public static final String STATUS_VALID = "VALID";
public static final String STATUS_INVALID = "INVALID";
public static final String STATUS_NEED_CODE = "NEED_CODE";
public static final String STATUS_CODE_ERROR = "CODE_ERROR";
public static final String STATUS_FORMAT_ERROR = "FORMAT_ERROR";
public static final String STATUS_UNKNOWN = "UNKNOWN";
private NetDiskConstants()
{
}
}

View File

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

View File

@@ -0,0 +1,197 @@
package com.ruoyi.office.service.netdisk;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
/**
* 夸克网盘分享链接检测器。
*/
@Component
public class QuarkNetDiskLinkChecker implements NetDiskLinkChecker
{
private static final String TOKEN_API =
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token"
+ "?pr=ucpro&fr=pc&uc_param_str=";
private static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131 Safari/537.36";
private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
private static final Set<Integer> INVALID_CODES = new HashSet<>(Arrays.asList(
41006, 41009, 41010, 41011, 41012, 41019, 41026, 41028, 41029, 41030, 41031
));
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.followRedirects(false)
.build();
@Override
public String getDiskType()
{
return NetDiskConstants.DISK_QUARK;
}
@Override
public NetDiskCheckResult check(String linkUrl)
{
long startedAt = System.currentTimeMillis();
String url = StringUtils.trim(linkUrl);
String shareId = parseShareId(url);
if (shareId == null)
{
return result(linkUrl, NetDiskConstants.STATUS_FORMAT_ERROR, "FORMAT",
"夸克网盘链接格式不正确", startedAt);
}
String passcode = getQueryParameter(url, "pwd");
if (StringUtils.isBlank(passcode))
{
passcode = getQueryParameter(url, "passcode");
}
JSONObject payload = new JSONObject();
payload.put("pwd_id", shareId);
payload.put("passcode", StringUtils.defaultString(passcode));
payload.put("support_visit_limit_private_share", true);
Request request = new Request.Builder()
.url(TOKEN_API)
.header("User-Agent", USER_AGENT)
.header("Origin", "https://pan.quark.cn")
.header("Referer", "https://pan.quark.cn/")
.post(RequestBody.create(JSON_MEDIA_TYPE, payload.toJSONString()))
.build();
try (Response response = httpClient.newCall(request).execute())
{
String body = readBody(response.body());
JSONObject json = parseJson(body);
int providerCode = json == null || !json.containsKey("code")
? Integer.MIN_VALUE : json.getIntValue("code");
String message = json == null ? null : json.getString("message");
if (providerCode == 0 && response.isSuccessful())
{
return result(linkUrl, NetDiskConstants.STATUS_VALID, "0",
"分享链接有效", startedAt);
}
if (providerCode == 41008)
{
return result(linkUrl, NetDiskConstants.STATUS_NEED_CODE,
String.valueOf(providerCode), "分享存在,但链接中没有提取码", startedAt);
}
if (providerCode == 41007 || providerCode == 41021)
{
return result(linkUrl, NetDiskConstants.STATUS_CODE_ERROR,
String.valueOf(providerCode), "分享存在,但提取码错误", startedAt);
}
if (INVALID_CODES.contains(providerCode))
{
return result(linkUrl, NetDiskConstants.STATUS_INVALID,
String.valueOf(providerCode),
StringUtils.defaultIfBlank(message, "分享已失效、取消或不存在"), startedAt);
}
if (providerCode == 41022 || providerCode == 41023)
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
String.valueOf(providerCode), "分享内容正在审核,暂时无法确认", startedAt);
}
if (providerCode == 45058 || response.code() == 403 || response.code() == 429
|| response.code() >= 500)
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
providerCode == Integer.MIN_VALUE
? String.valueOf(response.code()) : String.valueOf(providerCode),
"夸克网盘暂时拒绝检测或访问受限", startedAt);
}
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN,
providerCode == Integer.MIN_VALUE
? String.valueOf(response.code()) : String.valueOf(providerCode),
StringUtils.defaultIfBlank(message, "夸克网盘返回了未识别的状态"), startedAt);
}
catch (IOException | RuntimeException e)
{
return result(linkUrl, NetDiskConstants.STATUS_UNKNOWN, "NETWORK",
"检测请求失败:" + safeMessage(e), startedAt);
}
}
private NetDiskCheckResult result(String linkUrl, String status, String providerCode,
String message, long startedAt)
{
return new NetDiskCheckResult(getDiskType(), linkUrl, status, providerCode, message,
System.currentTimeMillis() - startedAt);
}
private String parseShareId(String url)
{
try
{
URI uri = new URI(url);
if (!"https".equalsIgnoreCase(uri.getScheme())
|| uri.getUserInfo() != null
|| (uri.getPort() != -1 && uri.getPort() != 443)
|| uri.getHost() == null
|| !"pan.quark.cn".equalsIgnoreCase(uri.getHost()))
{
return null;
}
String[] pathParts = StringUtils.defaultString(uri.getPath()).split("/");
if (pathParts.length < 3 || !"s".equals(pathParts[1])
|| !pathParts[2].matches("[A-Za-z0-9_-]{6,64}"))
{
return null;
}
return pathParts[2];
}
catch (URISyntaxException e)
{
return null;
}
}
private String getQueryParameter(String url, String name)
{
okhttp3.HttpUrl httpUrl = okhttp3.HttpUrl.parse(url);
return httpUrl == null ? null : httpUrl.queryParameter(name);
}
private JSONObject parseJson(String body)
{
try
{
return StringUtils.isBlank(body) ? null : JSON.parseObject(body);
}
catch (RuntimeException e)
{
return null;
}
}
private String readBody(ResponseBody responseBody) throws IOException
{
return responseBody == null ? "" : responseBody.string();
}
private String safeMessage(Exception e)
{
return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getSimpleName());
}
}

View File

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

View File

@@ -16,12 +16,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</sql>
<select id="selectTtFileList" parameterType="TtFile" resultMap="TtFileResult">
<include refid="selectTtFileVo"/>
select f.file_id, f.file_name, f.file_url, f.code_name
from tt_file f
left join (
select code_name, max(code_id) as source_order
from tt_code
group by code_name
) c on c.code_name = f.code_name
<where>
<if test="fileName != null and fileName != ''"> and file_name like concat('%', #{fileName}, '%')</if>
<if test="codeName != null and codeName != ''"> and code_name like concat('%', #{codeName}, '%')</if>
<if test="fileName != null and fileName != ''"> and f.file_name like concat('%', #{fileName}, '%')</if>
<if test="codeName != null and codeName != ''"> and f.code_name like concat('%', #{codeName}, '%')</if>
</where>
ORDER BY code_name DESC, file_id ASC
ORDER BY c.source_order DESC, f.file_id ASC
</select>
<select id="selectTtFileByFileId" parameterType="Long" resultMap="TtFileResult">
@@ -68,4 +74,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{fileId}
</foreach>
</delete>
</mapper>
</mapper>

View File

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

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.office.mapper.TtProjectLinkCheckMapper">
<insert id="upsertTtProjectLinkCheck" parameterType="TtProjectLinkCheck" useGeneratedKeys="true" keyProperty="id">
insert into tt_project_link_check
(
project_id,
disk_type,
link_url,
check_status,
provider_code,
check_message,
response_time_ms,
checked_at,
create_time,
update_time
)
values
(
#{projectId},
#{diskType},
#{linkUrl},
#{checkStatus},
#{providerCode},
#{checkMessage},
#{responseTimeMs},
#{checkedAt},
now(),
now()
)
on duplicate key update
link_url = values(link_url),
check_status = values(check_status),
provider_code = values(provider_code),
check_message = values(check_message),
response_time_ms = values(response_time_ms),
checked_at = values(checked_at),
update_time = now()
</insert>
<delete id="deleteByProjectId" parameterType="Integer">
delete from tt_project_link_check where project_id = #{projectId}
</delete>
<delete id="deleteByProjectIds">
delete from tt_project_link_check
where project_id in
<foreach item="projectId" collection="array" open="(" separator="," close=")">
#{projectId}
</foreach>
</delete>
</mapper>

View File

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

View File

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

View File

@@ -0,0 +1,239 @@
package com.ruoyi.app.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
/**
* 资源虚拟支付订单。
*/
public class AppVirtualOrder extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
@Excel(name = "业务订单号")
private String orderNo;
@Excel(name = "用户ID")
private Long userId;
@Excel(name = "用户账号")
private String userName;
@Excel(name = "用户昵称")
private String nickName;
@Excel(name = "资源ID")
private Long resourceId;
@Excel(name = "资源标题")
private String resourceTitle;
@Excel(name = "微信道具ID")
private String productId;
@Excel(name = "支付金额(分)")
private Integer priceFen;
@Excel(name = "OpenID")
private String openId;
/** 0-待支付1-已支付并发货2-已退款3-已关闭。 */
@Excel(name = "订单状态", readConverterExp = "0=待支付,1=已支付并发货,2=已退款,3=已关闭")
private Integer status;
@Excel(name = "微信内部订单号")
private String wxOrderNo;
@Excel(name = "微信支付交易单号")
private String transactionId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "支付时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date payTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "发货时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date provideTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "退款时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date refundTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastQueryTime;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public String getOrderNo()
{
return orderNo;
}
public void setOrderNo(String orderNo)
{
this.orderNo = orderNo;
}
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public String getUserName()
{
return userName;
}
public void setUserName(String userName)
{
this.userName = userName;
}
public String getNickName()
{
return nickName;
}
public void setNickName(String nickName)
{
this.nickName = nickName;
}
public Long getResourceId()
{
return resourceId;
}
public void setResourceId(Long resourceId)
{
this.resourceId = resourceId;
}
public String getResourceTitle()
{
return resourceTitle;
}
public void setResourceTitle(String resourceTitle)
{
this.resourceTitle = resourceTitle;
}
public String getProductId()
{
return productId;
}
public void setProductId(String productId)
{
this.productId = productId;
}
public Integer getPriceFen()
{
return priceFen;
}
public void setPriceFen(Integer priceFen)
{
this.priceFen = priceFen;
}
public String getOpenId()
{
return openId;
}
public void setOpenId(String openId)
{
this.openId = openId;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
public String getWxOrderNo()
{
return wxOrderNo;
}
public void setWxOrderNo(String wxOrderNo)
{
this.wxOrderNo = wxOrderNo;
}
public String getTransactionId()
{
return transactionId;
}
public void setTransactionId(String transactionId)
{
this.transactionId = transactionId;
}
public Date getPayTime()
{
return payTime;
}
public void setPayTime(Date payTime)
{
this.payTime = payTime;
}
public Date getProvideTime()
{
return provideTime;
}
public void setProvideTime(Date provideTime)
{
this.provideTime = provideTime;
}
public Date getRefundTime()
{
return refundTime;
}
public void setRefundTime(Date refundTime)
{
this.refundTime = refundTime;
}
public Date getLastQueryTime()
{
return lastQueryTime;
}
public void setLastQueryTime(Date lastQueryTime)
{
this.lastQueryTime = lastQueryTime;
}
}

View File

@@ -0,0 +1,67 @@
package com.ruoyi.app.domain;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 虚拟支付价格档位与微信道具的映射。
*/
public class AppVirtualProduct extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
private String productId;
private Integer priceFen;
private String productName;
private Integer status;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public String getProductId()
{
return productId;
}
public void setProductId(String productId)
{
this.productId = productId;
}
public Integer getPriceFen()
{
return priceFen;
}
public void setPriceFen(Integer priceFen)
{
this.priceFen = priceFen;
}
public String getProductName()
{
return productName;
}
public void setProductName(String productName)
{
this.productName = productName;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.app.domain.request;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class CreateVirtualOrderRequest
{
@NotNull(message = "资源ID不能为空")
private Long resourceId;
@NotBlank(message = "微信登录凭证不能为空")
private String code;
public Long getResourceId()
{
return resourceId;
}
public void setResourceId(Long resourceId)
{
this.resourceId = resourceId;
}
public String getCode()
{
return code;
}
public void setCode(String code)
{
this.code = code;
}
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.app.mapper;
import com.ruoyi.app.domain.AppVirtualOrder;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
public interface AppVirtualOrderMapper
{
int insertAppVirtualOrder(AppVirtualOrder order);
AppVirtualOrder selectByOrderNo(String orderNo);
AppVirtualOrder selectAppVirtualOrderById(Long id);
List<AppVirtualOrder> selectAppVirtualOrderList(AppVirtualOrder order);
int countAnyEntitlement(@Param("userId") Long userId, @Param("resourceId") Long resourceId);
int markPaid(@Param("orderNo") String orderNo,
@Param("wxOrderNo") String wxOrderNo,
@Param("transactionId") String transactionId,
@Param("payTime") Date payTime);
int insertEntitlement(AppVirtualOrder order);
int markRefunded(@Param("orderNo") String orderNo, @Param("refundTime") Date refundTime);
int deleteEntitlementByOrderNo(String orderNo);
int markClosed(String orderNo);
int markQuerying(String orderNo);
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.app.mapper;
import com.ruoyi.app.domain.AppVirtualProduct;
public interface AppVirtualProductMapper
{
AppVirtualProduct selectActiveByPrice(Integer priceFen);
AppVirtualProduct selectByProductId(String productId);
int insertAppVirtualProduct(AppVirtualProduct product);
int updateProductIdByPrice(AppVirtualProduct product);
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.app.service;
import com.ruoyi.app.domain.AppVirtualOrder;
import java.util.List;
/**
* 虚拟支付订单管理服务。
*/
public interface IAppVirtualOrderService
{
AppVirtualOrder selectAppVirtualOrderById(Long id);
List<AppVirtualOrder> selectAppVirtualOrderList(AppVirtualOrder order);
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.app.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.ruoyi.app.domain.request.CreateVirtualOrderRequest;
import java.util.Map;
public interface IAppVirtualPayService
{
Map<String, Object> createOrder(CreateVirtualOrderRequest request);
Map<String, Object> queryOrder(String orderNo, boolean sync);
boolean verifyCallbackSignature(String signature, String timestamp, String nonce);
void handleCallback(JsonNode body);
}

View File

@@ -0,0 +1,131 @@
package com.ruoyi.app.service;
import com.ruoyi.app.domain.AppBlogArticle;
import com.ruoyi.app.domain.AppResource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Builds CDN image variants without persisting derived URLs in the database.
*/
@Component
public class ImageUrlService
{
@Value("${app.image.cdn-domain:https://img.yidaima.cn}")
private String cdnDomain;
@Value("${app.image.article-card-operation:imageView2/1/w/600/h/375/format/webp/q/75/ignore-error/1}")
private String articleCardOperation;
@Value("${app.image.resource-card-operation:imageView2/1/w/500/h/400/format/webp/q/75/ignore-error/1}")
private String resourceCardOperation;
public void decorateArticles(List<AppBlogArticle> articles)
{
if (articles == null)
{
return;
}
for (AppBlogArticle article : articles)
{
decorateArticle(article);
}
}
public AppBlogArticle decorateArticle(AppBlogArticle article)
{
if (article == null)
{
return null;
}
String originalUrl = normalizeOriginalUrl(article.getShowImg());
article.setShowImg(originalUrl);
article.setShowImgThumb(buildThumbnailUrl(originalUrl, articleCardOperation));
decorateResource(article.getAppResource());
return article;
}
public void decorateResources(List<AppResource> resources)
{
if (resources == null)
{
return;
}
for (AppResource resource : resources)
{
decorateResource(resource);
}
}
public AppResource decorateResource(AppResource resource)
{
if (resource == null)
{
return null;
}
String originalUrl = normalizeOriginalUrl(resource.getShowImg());
resource.setShowImg(originalUrl);
resource.setShowImgThumb(buildThumbnailUrl(originalUrl, resourceCardOperation));
return resource;
}
public String normalizeOriginalUrl(String originalUrl)
{
if (originalUrl == null)
{
return null;
}
String url = originalUrl.trim();
String host = cdnHost();
String httpPrefix = "http://" + host + "/";
if (url.startsWith(httpPrefix))
{
return "https://" + url.substring("http://".length());
}
String protocolRelativePrefix = "//" + host + "/";
if (url.startsWith(protocolRelativePrefix))
{
return "https:" + url;
}
return url;
}
private String buildThumbnailUrl(String originalUrl, String operation)
{
if (originalUrl == null || originalUrl.isEmpty())
{
return originalUrl;
}
String cdnPrefix = "https://" + cdnHost() + "/";
if (!originalUrl.startsWith(cdnPrefix))
{
return originalUrl;
}
// Avoid corrupting signed or already processed URLs.
if (originalUrl.indexOf('?') >= 0)
{
return originalUrl;
}
return originalUrl + "?" + operation;
}
private String cdnHost()
{
String value = cdnDomain == null ? "img.yidaima.cn" : cdnDomain.trim();
if (value.startsWith("https://"))
{
value = value.substring("https://".length());
}
else if (value.startsWith("http://"))
{
value = value.substring("http://".length());
}
while (value.endsWith("/"))
{
value = value.substring(0, value.length() - 1);
}
return value;
}
}

View File

@@ -11,6 +11,9 @@ import com.ruoyi.app.domain.AppResourceList;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.service.IAppResourceService;
import com.ruoyi.app.domain.AppVirtualProduct;
import com.ruoyi.app.mapper.AppVirtualProductMapper;
import com.ruoyi.common.exception.ServiceException;
/**
* 资源Service业务层处理
@@ -24,6 +27,9 @@ public class AppResourceServiceImpl implements IAppResourceService
@Autowired
private AppResourceMapper appResourceMapper;
@Autowired
private AppVirtualProductMapper virtualProductMapper;
/**
* 查询资源
*
@@ -58,6 +64,7 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override
public int insertAppResource(AppResource appResource)
{
configureVirtualProduct(appResource);
appResource.setCreateTime(DateUtils.getNowDate());
int rows = appResourceMapper.insertAppResource(appResource);
insertAppResourceList(appResource);
@@ -74,6 +81,7 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override
public int updateAppResource(AppResource appResource)
{
configureVirtualProduct(appResource);
appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId());
insertAppResourceList(appResource);
return appResourceMapper.updateAppResource(appResource);
@@ -130,4 +138,26 @@ public class AppResourceServiceImpl implements IAppResourceService
}
}
}
/**
* 付费资源按价格档位自动匹配已配置的微信道具。
*/
private void configureVirtualProduct(AppResource resource)
{
if (resource.getIsAd() == null || resource.getIsAd() != 3L)
{
resource.setPriceFen(0);
return;
}
if (resource.getPriceFen() == null || resource.getPriceFen() <= 0)
{
throw new ServiceException("付费资源价格必须大于0分");
}
AppVirtualProduct priceProduct = virtualProductMapper.selectActiveByPrice(resource.getPriceFen());
if (priceProduct == null || StringUtils.isBlank(priceProduct.getProductId()))
{
throw new ServiceException("当前价格档位未配置微信道具,请先配置价格与道具映射");
}
resource.setVirtualProductId(priceProduct.getProductId());
}
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.app.service.impl;
import com.ruoyi.app.domain.AppVirtualOrder;
import com.ruoyi.app.mapper.AppVirtualOrderMapper;
import com.ruoyi.app.service.IAppVirtualOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 虚拟支付订单管理服务实现。
*/
@Service
public class AppVirtualOrderServiceImpl implements IAppVirtualOrderService
{
@Autowired
private AppVirtualOrderMapper appVirtualOrderMapper;
@Override
public AppVirtualOrder selectAppVirtualOrderById(Long id)
{
return appVirtualOrderMapper.selectAppVirtualOrderById(id);
}
@Override
public List<AppVirtualOrder> selectAppVirtualOrderList(AppVirtualOrder order)
{
return appVirtualOrderMapper.selectAppVirtualOrderList(order);
}
}

View File

@@ -0,0 +1,529 @@
package com.ruoyi.app.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.app.domain.AppIntegralRecord;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.domain.AppVirtualOrder;
import com.ruoyi.app.domain.AppVirtualProduct;
import com.ruoyi.app.domain.request.CreateVirtualOrderRequest;
import com.ruoyi.app.mapper.AppIntegralRecordMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.mapper.AppVirtualOrderMapper;
import com.ruoyi.app.mapper.AppVirtualProductMapper;
import com.ruoyi.app.service.IAppVirtualPayService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.wx.VirtualPayConfig;
import com.ruoyi.common.wx.WxCodeSession;
import com.ruoyi.common.wx.WxCodeSessionService;
import com.ruoyi.common.wx.WxPayConfig;
import com.ruoyi.system.mapper.SysUserMapper;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class AppVirtualPayServiceImpl implements IAppVirtualPayService
{
private static final Logger log = LoggerFactory.getLogger(AppVirtualPayServiceImpl.class);
private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
private static final String QUERY_ORDER_URI = "/xpay/query_order";
private static final String QUERY_ORDER_URL = "https://api.weixin.qq.com/xpay/query_order";
private static final String NOTIFY_GOODS_URI = "/xpay/notify_provide_goods";
private static final String NOTIFY_GOODS_URL = "https://api.weixin.qq.com/xpay/notify_provide_goods";
private final VirtualPayConfig virtualPayConfig;
private final WxPayConfig wxPayConfig;
private final WxCodeSessionService wxCodeSessionService;
private final AppResourceMapper appResourceMapper;
private final AppIntegralRecordMapper integralRecordMapper;
private final AppVirtualProductMapper virtualProductMapper;
private final AppVirtualOrderMapper virtualOrderMapper;
private final SysUserMapper sysUserMapper;
private final ObjectMapper objectMapper;
private final OkHttpClient httpClient;
private volatile String cachedAccessToken;
private volatile long accessTokenExpiresAt;
public AppVirtualPayServiceImpl(VirtualPayConfig virtualPayConfig,
WxPayConfig wxPayConfig,
WxCodeSessionService wxCodeSessionService,
AppResourceMapper appResourceMapper,
AppIntegralRecordMapper integralRecordMapper,
AppVirtualProductMapper virtualProductMapper,
AppVirtualOrderMapper virtualOrderMapper,
SysUserMapper sysUserMapper,
ObjectMapper objectMapper)
{
this.virtualPayConfig = virtualPayConfig;
this.wxPayConfig = wxPayConfig;
this.wxCodeSessionService = wxCodeSessionService;
this.appResourceMapper = appResourceMapper;
this.integralRecordMapper = integralRecordMapper;
this.virtualProductMapper = virtualProductMapper;
this.virtualOrderMapper = virtualOrderMapper;
this.sysUserMapper = sysUserMapper;
this.objectMapper = objectMapper;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
}
@Override
@Transactional
public Map<String, Object> createOrder(CreateVirtualOrderRequest request)
{
checkEnabled();
Long userId = SecurityUtils.getUserId();
SysUser user = sysUserMapper.selectUserById(userId);
if (user == null || StringUtils.isBlank(user.getOpenId()))
{
throw new ServiceException("当前账号未绑定微信");
}
WxCodeSession codeSession = wxCodeSessionService.exchange(request.getCode());
if (!MessageDigest.isEqual(user.getOpenId().getBytes(StandardCharsets.UTF_8),
codeSession.getOpenId().getBytes(StandardCharsets.UTF_8)))
{
throw new ServiceException("微信身份与当前登录账号不一致");
}
AppResource resource = appResourceMapper.selectAppResourceById(request.getResourceId());
if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 3L)
{
throw new ServiceException("该资源不支持虚拟支付");
}
if (resource.getPriceFen() == null || resource.getPriceFen() <= 0)
{
throw new ServiceException("资源价格未配置");
}
if (virtualOrderMapper.countAnyEntitlement(userId, resource.getId()) > 0)
{
throw new ServiceException("该资源已经解锁");
}
AppVirtualProduct product = virtualProductMapper.selectActiveByPrice(resource.getPriceFen());
if (product == null || StringUtils.isBlank(product.getProductId()))
{
throw new ServiceException("当前价格档位尚未配置微信道具");
}
AppVirtualOrder order = new AppVirtualOrder();
order.setOrderNo(generateOrderNo());
order.setUserId(userId);
order.setResourceId(resource.getId());
order.setProductId(product.getProductId());
order.setPriceFen(resource.getPriceFen());
order.setOpenId(codeSession.getOpenId());
order.setStatus(0);
order.setCreateTime(DateUtils.getNowDate());
virtualOrderMapper.insertAppVirtualOrder(order);
LinkedHashMap<String, Object> signDataMap = new LinkedHashMap<>();
signDataMap.put("offerId", virtualPayConfig.getOfferId());
signDataMap.put("buyQuantity", 1);
signDataMap.put("env", virtualPayConfig.getEnv());
signDataMap.put("currencyType", "CNY");
signDataMap.put("productId", product.getProductId());
signDataMap.put("goodsPrice", resource.getPriceFen());
signDataMap.put("outTradeNo", order.getOrderNo());
signDataMap.put("attach", order.getOrderNo());
try
{
String signData = objectMapper.writeValueAsString(signDataMap);
Map<String, Object> result = new LinkedHashMap<>();
result.put("orderNo", order.getOrderNo());
result.put("signData", signData);
result.put("paySig", hmacSha256(virtualPayConfig.getAppKey(),
"requestVirtualPayment&" + signData));
result.put("signature", hmacSha256(codeSession.getSessionKey(), signData));
result.put("mode", "short_series_goods");
return result;
}
catch (JsonProcessingException e)
{
throw new ServiceException("生成虚拟支付参数失败");
}
}
@Override
@Transactional
public Map<String, Object> queryOrder(String orderNo, boolean sync)
{
Long userId = SecurityUtils.getUserId();
AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo);
if (order == null || !userId.equals(order.getUserId()))
{
throw new ServiceException("订单不存在");
}
if (sync && order.getStatus() == 0 && virtualOrderMapper.markQuerying(orderNo) == 1)
{
syncOrderFromWechat(order);
order = virtualOrderMapper.selectByOrderNo(orderNo);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("orderNo", order.getOrderNo());
result.put("resourceId", order.getResourceId());
result.put("status", order.getStatus());
result.put("unlocked", order.getStatus() == 1);
return result;
}
@Override
public boolean verifyCallbackSignature(String signature, String timestamp, String nonce)
{
if (StringUtils.isAnyBlank(signature, timestamp, nonce, virtualPayConfig.getCallbackToken()))
{
return false;
}
String[] values = {virtualPayConfig.getCallbackToken(), timestamp, nonce};
Arrays.sort(values);
String actual = sha1(values[0] + values[1] + values[2]);
return MessageDigest.isEqual(actual.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}
@Override
@Transactional
public void handleCallback(JsonNode body)
{
String event = body.path("Event").asText();
String orderNo = body.path("OutTradeNo").asText();
log.info("收到微信虚拟支付回调: event={}, orderNo={}", event, orderNo);
if ("xpay_goods_deliver_notify".equals(event))
{
handleGoodsDeliver(body);
}
else if ("xpay_refund_notify".equals(event))
{
handleRefund(body);
}
else
{
// 消息推送 URL 可能同时接收其他小程序事件,未知事件不应触发微信反复重试。
log.debug("忽略非资源支付回调事件: {}", event);
}
}
private void handleGoodsDeliver(JsonNode body)
{
String orderNo = body.path("OutTradeNo").asText();
AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo);
if (order == null)
{
throw new ServiceException("虚拟支付订单不存在");
}
JsonNode goods = body.path("GoodsInfo");
if (!order.getOpenId().equals(text(body, "OpenId", "openid"))
|| virtualPayConfig.getEnv() != integer(body, "Env", "env")
|| !order.getProductId().equals(goods.path("ProductId").asText())
|| order.getPriceFen() != goods.path("ActualPrice").asInt(-1)
|| goods.path("Quantity").asInt(0) != 1
|| !orderNo.equals(goods.path("Attach").asText()))
{
throw new ServiceException("虚拟支付通知与本地订单不一致");
}
JsonNode wxPayInfo = body.path("WeChatPayInfo");
Date paidTime = fromUnixSeconds(wxPayInfo.path("PaidTime").asLong(0));
grantOrder(order,
wxPayInfo.path("MchOrderNo").asText(null),
wxPayInfo.path("TransactionId").asText(null),
paidTime);
}
private void handleRefund(JsonNode body)
{
if (body.path("RetCode").asInt(-1) != 0)
{
return;
}
String orderNo = text(body, "MchOrderId", "MchOrderNo", "OutTradeNo");
AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo);
if (order == null)
{
throw new ServiceException("退款对应的虚拟支付订单不存在");
}
int refundFee = body.path("RefundFee").asInt(-1);
if (!order.getOpenId().equals(text(body, "OpenId", "openid"))
|| refundFee <= 0
|| refundFee > order.getPriceFen())
{
throw new ServiceException("虚拟支付退款通知与本地订单不一致");
}
revokeOrder(order, fromUnixSeconds(body.path("RefundSuccTimestamp").asLong(0)));
}
private void syncOrderFromWechat(AppVirtualOrder order)
{
try
{
String accessToken = getAccessToken();
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("openid", order.getOpenId());
payload.put("env", virtualPayConfig.getEnv());
payload.put("order_id", order.getOrderNo());
String body = objectMapper.writeValueAsString(payload);
String paySig = hmacSha256(virtualPayConfig.getAppKey(), QUERY_ORDER_URI + "&" + body);
HttpUrl url = HttpUrl.parse(QUERY_ORDER_URL).newBuilder()
.addQueryParameter("access_token", accessToken)
.addQueryParameter("pay_sig", paySig)
.build();
Request httpRequest = new Request.Builder()
.url(url)
.post(RequestBody.create(JSON_MEDIA_TYPE, body))
.build();
try (Response response = httpClient.newCall(httpRequest).execute())
{
if (!response.isSuccessful() || response.body() == null)
{
return;
}
JsonNode result = objectMapper.readTree(response.body().string());
if (result.path("errcode").asInt(-1) != 0)
{
return;
}
JsonNode wxOrder = result.path("order");
if (!order.getOrderNo().equals(wxOrder.path("order_id").asText())
|| wxOrder.path("order_fee").asInt(-1) != order.getPriceFen())
{
return;
}
int status = wxOrder.path("status").asInt(-1);
if (status >= 2 && status <= 4
&& wxOrder.path("paid_fee").asInt(-1) == order.getPriceFen())
{
grantOrder(order,
wxOrder.path("wx_order_id").asText(null),
wxOrder.path("wxpay_order_id").asText(null),
fromUnixSeconds(wxOrder.path("paid_time").asLong(0)));
if (status == 2)
{
notifyProvideGoods(accessToken, order.getOrderNo());
}
}
else if (status == 5 || status == 8)
{
revokeOrder(order, fromUnixSeconds(wxOrder.path("paid_time").asLong(0)));
}
else if (status == 6)
{
virtualOrderMapper.markClosed(order.getOrderNo());
}
}
}
catch (IOException ignored)
{
// 查询仅作为回调丢失时的兜底,不影响客户端继续轮询本地状态。
}
}
private void notifyProvideGoods(String accessToken, String orderNo)
{
try
{
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
payload.put("order_id", orderNo);
payload.put("env", virtualPayConfig.getEnv());
String body = objectMapper.writeValueAsString(payload);
String paySig = hmacSha256(virtualPayConfig.getAppKey(), NOTIFY_GOODS_URI + "&" + body);
HttpUrl url = HttpUrl.parse(NOTIFY_GOODS_URL).newBuilder()
.addQueryParameter("access_token", accessToken)
.addQueryParameter("pay_sig", paySig)
.build();
Request request = new Request.Builder()
.url(url)
.post(RequestBody.create(JSON_MEDIA_TYPE, body))
.build();
try (Response ignored = httpClient.newCall(request).execute())
{
// 微信侧失败时仍会继续推送发货通知,保持本地发货幂等即可。
}
}
catch (Exception ignored)
{
}
}
private void grantOrder(AppVirtualOrder order, String wxOrderNo, String transactionId, Date paidTime)
{
int changed = virtualOrderMapper.markPaid(order.getOrderNo(), wxOrderNo, transactionId, paidTime);
if (changed == 1)
{
order.setStatus(1);
virtualOrderMapper.insertEntitlement(order);
AppIntegralRecord purchaseRecord = new AppIntegralRecord();
purchaseRecord.setSource("资源购买");
purchaseRecord.setIsAdd(3L);
// 现金消费使用分作为最小单位,避免小数金额精度丢失。
purchaseRecord.setIntegralNumber(order.getPriceFen().longValue());
purchaseRecord.setUserId(order.getUserId());
purchaseRecord.setResourceId(order.getResourceId());
purchaseRecord.setIntegralTime(DateUtils.getNowDate());
if (integralRecordMapper.insertAppIntegralRecord(purchaseRecord) != 1)
{
throw new ServiceException("生成资源购买记录失败");
}
}
}
private void revokeOrder(AppVirtualOrder order, Date refundTime)
{
if (virtualOrderMapper.markRefunded(order.getOrderNo(), refundTime) == 1)
{
virtualOrderMapper.deleteEntitlementByOrderNo(order.getOrderNo());
}
}
private synchronized String getAccessToken() throws IOException
{
long now = System.currentTimeMillis();
if (StringUtils.isNotBlank(cachedAccessToken) && now < accessTokenExpiresAt)
{
return cachedAccessToken;
}
HttpUrl url = HttpUrl.parse(ACCESS_TOKEN_URL).newBuilder()
.addQueryParameter("grant_type", "client_credential")
.addQueryParameter("appid", wxPayConfig.getAppid())
.addQueryParameter("secret", wxPayConfig.getSecret())
.build();
Request request = new Request.Builder().url(url).get().build();
try (Response response = httpClient.newCall(request).execute())
{
if (!response.isSuccessful() || response.body() == null)
{
throw new IOException("get access token failed");
}
JsonNode result = objectMapper.readTree(response.body().string());
String token = result.path("access_token").asText();
if (StringUtils.isBlank(token))
{
throw new IOException("empty access token");
}
int expiresIn = result.path("expires_in").asInt(7200);
cachedAccessToken = token;
accessTokenExpiresAt = now + Math.max(60, expiresIn - 300) * 1000L;
return token;
}
}
private void checkEnabled()
{
if (!virtualPayConfig.isEnabled())
{
throw new ServiceException("虚拟支付暂未启用");
}
if (StringUtils.isAnyBlank(virtualPayConfig.getOfferId(), virtualPayConfig.getAppKey()))
{
throw new ServiceException("虚拟支付参数未配置完整");
}
}
private static String generateOrderNo()
{
String time = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
return "VP" + time + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
private static String hmacSha256(String key, String value)
{
try
{
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return toHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
}
catch (Exception e)
{
throw new ServiceException("虚拟支付签名失败");
}
}
private static String sha1(String value)
{
try
{
return toHex(MessageDigest.getInstance("SHA-1")
.digest(value.getBytes(StandardCharsets.UTF_8)));
}
catch (Exception e)
{
return "";
}
}
private static String toHex(byte[] bytes)
{
StringBuilder result = new StringBuilder(bytes.length * 2);
for (byte value : bytes)
{
result.append(String.format("%02x", value & 0xff));
}
return result.toString();
}
private static Date fromUnixSeconds(long seconds)
{
return seconds > 0 ? new Date(seconds * 1000L) : new Date();
}
private static String text(JsonNode node, String... fieldNames)
{
for (String fieldName : fieldNames)
{
String value = node.path(fieldName).asText();
if (StringUtils.isNotBlank(value))
{
return value;
}
}
return "";
}
private static int integer(JsonNode node, String... fieldNames)
{
for (String fieldName : fieldNames)
{
if (node.has(fieldName))
{
return node.path(fieldName).asInt(-1);
}
}
return -1;
}
}

View File

@@ -145,7 +145,7 @@ public interface SysUserMapper
/**
* 减少积分
*/
@Update("update sys_user SET integral = integral-#{integral} where user_id = #{userId}")
@Update("update sys_user SET integral = integral-#{integral} where user_id = #{userId} and integral >= #{integral}")
public int delIntegralByUserId(@Param("integral") int integral ,@Param("userId")Long userId);
/**

View File

@@ -14,6 +14,8 @@
<result property="isShow" column="is_show" />
<result property="isAd" column="is_ad" />
<result property="adNumber" column="ad_number" />
<result property="priceFen" column="price_fen" />
<result property="virtualProductId" column="virtual_product_id" />
<result property="downNum" column="down_num" />
<result property="weight" column="weight" />
<result property="delFlag" column="del_flag" />
@@ -35,27 +37,34 @@
</resultMap>
<sql id="selectAppResourceVo">
select id, resource_title, show_img, `explain`, resource_type, keyword, is_show, is_ad, ad_number, down_num, weight, del_flag, create_by, create_time, remark from app_resource
select a.id, a.resource_title, a.show_img, a.`explain`, a.resource_type, a.keyword,
a.is_show, a.is_ad, a.ad_number, a.price_fen,
(select vp.product_id from app_virtual_product vp
where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id,
a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark
from app_resource a
</sql>
<select id="selectAppResourceList" parameterType="AppResource" resultMap="AppResourceResult">
<include refid="selectAppResourceVo"/>
<where>
<if test="resourceTitle != null and resourceTitle != ''"> and resource_title like concat('%', #{resourceTitle}, '%')</if>
<if test="showImg != null and showImg != ''"> and show_img = #{showImg}</if>
<if test="explain != null and explain != ''"> and `explain` = #{explain}</if>
<if test="resourceType != null "> and resource_type = #{resourceType}</if>
<if test="isShow != null "> and is_show = #{isShow}</if>
<if test="isAd != null "> and is_ad = #{isAd}</if>
<if test="adNumber != null "> and ad_number = #{adNumber}</if>
<if test="downNum != null "> and down_num = #{downNum}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="keyword != null and keyword != ''"> and (keyword like concat('%', #{keyword}, '%') or resource_title like concat('%', #{keyword}, '%'))</if>
</where> ORDER BY weight desc, create_time desc
<if test="resourceTitle != null and resourceTitle != ''"> and a.resource_title like concat('%', #{resourceTitle}, '%')</if>
<if test="showImg != null and showImg != ''"> and a.show_img = #{showImg}</if>
<if test="explain != null and explain != ''"> and a.`explain` = #{explain}</if>
<if test="resourceType != null "> and a.resource_type = #{resourceType}</if>
<if test="isShow != null "> and a.is_show = #{isShow}</if>
<if test="isAd != null "> and a.is_ad = #{isAd}</if>
<if test="adNumber != null "> and a.ad_number = #{adNumber}</if>
<if test="downNum != null "> and a.down_num = #{downNum}</if>
<if test="weight != null "> and a.weight = #{weight}</if>
<if test="keyword != null and keyword != ''"> and (a.keyword like concat('%', #{keyword}, '%') or a.resource_title like concat('%', #{keyword}, '%'))</if>
</where> ORDER BY a.weight desc, a.create_time desc
</select>
<select id="selectAppResourceById" parameterType="Long" resultMap="AppResourceAppResourceListResult">
select a.id, a.resource_title, a.show_img, a.`explain`,a.`keyword`, a.resource_type, a.is_show, a.is_ad, a.ad_number, a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
select a.id, a.resource_title, a.show_img, a.`explain`,a.`keyword`, a.resource_type, a.is_show, a.is_ad, a.ad_number, a.price_fen,
(select vp.product_id from app_virtual_product vp where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id,
a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url, b.password as sub_password, b.app_resource_id as sub_app_resource_id
from app_resource a
left join app_resource_list b on b.app_resource_id = a.id
@@ -73,6 +82,7 @@
<if test="isShow != null">is_show,</if>
<if test="isAd != null">is_ad,</if>
<if test="adNumber != null">ad_number,</if>
<if test="priceFen != null">price_fen,</if>
<if test="downNum != null">down_num,</if>
<if test="weight != null">weight,</if>
<if test="delFlag != null">del_flag,</if>
@@ -89,6 +99,7 @@
<if test="isShow != null">#{isShow},</if>
<if test="isAd != null">#{isAd},</if>
<if test="adNumber != null">#{adNumber},</if>
<if test="priceFen != null">#{priceFen},</if>
<if test="downNum != null">#{downNum},</if>
<if test="weight != null">#{weight},</if>
<if test="delFlag != null">#{delFlag},</if>
@@ -109,6 +120,7 @@
<if test="isShow != null">is_show = #{isShow},</if>
<if test="isAd != null">is_ad = #{isAd},</if>
<if test="adNumber != null">ad_number = #{adNumber},</if>
<if test="priceFen != null">price_fen = #{priceFen},</if>
<if test="downNum != null">down_num = #{downNum},</if>
<if test="weight != null">weight = #{weight},</if>
<if test="delFlag != null">del_flag = #{delFlag},</if>
@@ -149,16 +161,35 @@
</insert>
<select id="selectAppResourceByIdAndUserId" resultMap="AppResourceAppResourceListResult">
select a.id, a.resource_title, a.show_img, a.`explain`, a.`keyword`, a.resource_type, a.is_show,
select a.id, a.resource_title, a.show_img, a.`explain`, a.`keyword`, a.resource_type, a.is_show,
CASE
WHEN (SELECT COUNT(1) FROM app_integral_record WHERE resource_id = #{id} AND user_id = #{userId}) > 0 THEN 0
WHEN #{userId} is not null and (
(SELECT COUNT(1) FROM app_integral_record
WHERE resource_id = #{id} AND user_id = #{userId}
AND source = '资源兑换' AND is_add = 1) > 0
OR
(SELECT COUNT(1) FROM app_resource_entitlement WHERE resource_id = #{id} AND user_id = #{userId} AND status = 1) > 0
) THEN 0
ELSE a.is_ad
END as is_ad,
a.ad_number, a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
END as is_ad,
a.ad_number, a.price_fen,
(select vp.product_id from app_virtual_product vp where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id,
a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url,
b.password as sub_password, b.app_resource_id as sub_app_resource_id
from app_resource a
left join app_resource_list b on b.app_resource_id = a.id
and (
a.is_ad not in (2, 3)
or (
#{userId} is not null and (
exists(select 1 from app_integral_record ir
where ir.resource_id = a.id and ir.user_id = #{userId}
and ir.source = '资源兑换' and ir.is_add = 1)
or exists(select 1 from app_resource_entitlement re where re.resource_id = a.id and re.user_id = #{userId} and re.status = 1)
)
)
)
where a.id = #{id}
</select>
</mapper>
</mapper>

View File

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

View File

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

View File

@@ -51,7 +51,7 @@
<sql id="selectUserVo">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, u.integral,
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_statusselectUserVo, u.open_id as openId
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status, u.open_id
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
left join sys_user_role ur on u.user_id = ur.user_id
@@ -232,4 +232,4 @@
select date_format(create_time,'%Y-%m-%d') as date, count(*) as count from sys_user where del_flag = '0' and create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) group by date_format(create_time,'%Y-%m-%d') order by date
</select>
</mapper>
</mapper>

View File

@@ -0,0 +1,18 @@
import request from '@/utils/request'
// 查询虚拟支付订单列表
export function listVirtualOrder(query) {
return request({
url: '/app/virtualOrder/list',
method: 'get',
params: query
})
}
// 查询虚拟支付订单详情
export function getVirtualOrder(id) {
return request({
url: '/app/virtualOrder/' + id,
method: 'get'
})
}

View File

@@ -43,6 +43,23 @@ export function delProject(id) {
})
}
// 检测单个项目的网盘链接
export function checkProjectLinks(id) {
return request({
url: '/office/project/link-check/single/' + id,
method: 'post'
})
}
// 检测选中项目的网盘链接
export function checkSelectedProjectLinks(ids) {
return request({
url: '/office/project/link-check/batch',
method: 'post',
data: ids
})
}
export function matchCode(id) {
return request({
url: '/office/project/matchCode/' + id,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
-- 资源详情页会同时按用户和资源判断积分兑换权限。
-- 缺少下面两个索引时,带登录态的详情请求会在 JOIN 子查询中反复全表扫描。
ALTER TABLE `app_integral_record`
ADD INDEX `idx_integral_record_user_resource` (`user_id`, `resource_id`);
ALTER TABLE `app_resource_list`
ADD INDEX `idx_resource_list_resource` (`app_resource_id`);

View File

@@ -0,0 +1,19 @@
-- 项目清单:百度网盘、夸克网盘链接检测结果
-- 部署本功能前执行一次。
CREATE TABLE IF NOT EXISTS `tt_project_link_check` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`project_id` int NOT NULL COMMENT '项目ID',
`disk_type` varchar(16) NOT NULL COMMENT '网盘类型QUARK/BAIDU',
`link_url` varchar(1000) NOT NULL COMMENT '本次检测的链接快照',
`check_status` varchar(20) NOT NULL COMMENT 'VALID/INVALID/NEED_CODE/CODE_ERROR/FORMAT_ERROR/UNKNOWN',
`provider_code` varchar(32) DEFAULT NULL COMMENT '平台状态码或HTTP状态码',
`check_message` varchar(255) DEFAULT NULL COMMENT '检测结果说明',
`response_time_ms` bigint DEFAULT NULL COMMENT '检测耗时(毫秒)',
`checked_at` datetime NOT NULL COMMENT '检测时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_project_disk_type` (`project_id`, `disk_type`),
KEY `idx_check_status_time` (`check_status`, `checked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目网盘链接检测结果';

View File

@@ -0,0 +1,50 @@
-- 虚拟支付订单管理菜单(可重复执行)
-- 默认放在现有“支付订单”菜单的同级目录下;若未找到,则作为一级菜单显示。
SET @virtual_order_parent_id := COALESCE(
(SELECT parent_id FROM sys_menu WHERE perms = 'app:order:list' LIMIT 1),
0
);
SET @virtual_order_order_num := COALESCE(
(SELECT MAX(order_num) + 1 FROM sys_menu WHERE parent_id = @virtual_order_parent_id),
1
);
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT
'虚拟支付订单', @virtual_order_parent_id, @virtual_order_order_num,
'virtualOrder', 'app/virtualOrder/index', '', 1, 0,
'C', '0', '0', 'app:virtualOrder:list', 'money', 'admin', NOW(), '', NULL,
'虚拟支付订单只读查询菜单'
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE perms = 'app:virtualOrder:list'
);
SET @virtual_order_menu_id := (
SELECT menu_id FROM sys_menu WHERE perms = 'app:virtualOrder:list' LIMIT 1
);
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT
'虚拟订单查询', @virtual_order_menu_id, 1, '', '', '', 1, 0,
'F', '0', '0', 'app:virtualOrder:query', '#', 'admin', NOW(), '', NULL, ''
WHERE @virtual_order_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM sys_menu WHERE perms = 'app:virtualOrder:query'
);
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache,
menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT
'虚拟订单导出', @virtual_order_menu_id, 2, '', '', '', 1, 0,
'F', '0', '0', 'app:virtualOrder:export', '#', 'admin', NOW(), '', NULL, ''
WHERE @virtual_order_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM sys_menu WHERE perms = 'app:virtualOrder:export'
);

View File

@@ -0,0 +1,79 @@
-- 资源直购虚拟支付改造
-- 执行前请先备份数据库。适用于当前项目的 MySQL 数据库。
ALTER TABLE app_resource
ADD COLUMN price_fen INT NOT NULL DEFAULT 0 COMMENT '虚拟支付价格,单位分' AFTER ad_number;
-- 兼容已有“is_ad=3 且 ad_number 表示人民币元”的资源。
UPDATE app_resource
SET price_fen = ad_number * 100
WHERE is_ad = 3 AND price_fen = 0 AND ad_number > 0;
CREATE TABLE app_virtual_product (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
product_id VARCHAR(64) NOT NULL COMMENT '微信虚拟道具productId',
price_fen INT NOT NULL COMMENT '价格档位,单位分',
product_name VARCHAR(100) NOT NULL COMMENT '档位名称',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_virtual_product_id (product_id),
UNIQUE KEY uk_virtual_product_price (price_fen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信虚拟道具价格档位';
CREATE TABLE app_virtual_order (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
order_no VARCHAR(32) NOT NULL COMMENT '业务订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
resource_id BIGINT NOT NULL COMMENT '资源ID',
product_id VARCHAR(64) NOT NULL COMMENT '微信虚拟道具ID',
price_fen INT NOT NULL COMMENT '支付金额,单位分',
open_id VARCHAR(64) NOT NULL COMMENT '微信OpenID',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付发货 2已退款 3已关闭',
wx_order_no VARCHAR(64) NULL COMMENT '微信内部订单号',
transaction_id VARCHAR(64) NULL COMMENT '微信支付交易单号',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
pay_time DATETIME NULL,
provide_time DATETIME NULL,
refund_time DATETIME NULL,
last_query_time DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_virtual_order_no (order_no),
KEY idx_virtual_order_user (user_id, create_time),
KEY idx_virtual_order_resource (resource_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源虚拟支付订单';
CREATE TABLE app_resource_entitlement (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT '用户ID',
resource_id BIGINT NOT NULL COMMENT '资源ID',
order_no VARCHAR(32) NOT NULL COMMENT '来源虚拟支付订单号',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0已撤销',
granted_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_time DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_resource_entitlement_order (order_no),
UNIQUE KEY uk_resource_entitlement_user_resource (user_id, resource_id),
KEY idx_resource_entitlement_resource (resource_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='付费资源访问权益';
-- 执行迁移后:
-- 1. 在微信公众平台创建各价格档位的道具并发布;
-- 2. 在若依后台编辑付费资源,填写“价格(分)”和对应的 productId
-- 3. 同一价格填写同一个 productId后台会自动维护 app_virtual_product 映射。
-- 当前小程序已发布的微信虚拟道具价格映射。
-- product_id 必须与微信后台保持完全一致(包括 resourse 的现有拼写)。
INSERT INTO app_virtual_product
(product_id, price_fen, product_name, status, create_time)
VALUES
('resourse_1', 100, '1元资源', 1, NOW()),
('resourse_10', 1000, '10元资源', 1, NOW()),
('resourse_50', 5000, '50元资源', 1, NOW()),
('resourse_99', 9900, '99元资源', 1, NOW()),
('resourse_300', 30000, '300元资源', 1, NOW())
ON DUPLICATE KEY UPDATE
product_name = VALUES(product_name),
status = 1,
update_time = NOW();