Compare commits

...

15 Commits

Author SHA1 Message Date
王鹏
a1a806f266 feat: support article demo videos and category counts 2026-08-17 15:50:17 +08:00
王鹏
2bf0a6db59 支持自定义积分充值来源 2026-07-30 16:39:08 +08:00
王鹏
4bda30f9c2 feat: 支持S/K精品源码付费规格转换 2026-07-30 14:25:22 +08:00
王鹏
e9d627df9c feat: 优化代码生成及文案模板渲染 2026-07-29 16:17:49 +08:00
王鹏
c631d058c1 fix 2026-07-29 14:36:54 +08:00
王鹏
349f1c0374 feat: add dashboard and virtual pay enhancements 2026-07-29 14:34:54 +08:00
王鹏
2250f744d1 feat: 新增虚拟支付订单与网盘链接检测功能 2026-07-28 13:42:30 +08:00
王鹏
4070179f46 fix: 开放接口支持模糊匹配项目名,添加 TtFile 导入
- TtCodeMapper.xml: selectTtCodeByCodeName 改为模糊匹配
- TtCopyTemplateController: 添加 TtFile import,var 改为显式类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:40:39 +08:00
王鹏
b169a6662c fix: HtmlUtils 特殊字符改用 Unicode 转义避免编译错误
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:30:19 +08:00
王鹏
eb6339c9a7 feat: 新增开放接口,支持通过模板名称和项目名称生成文案
- 新增 HtmlUtils 工具类,支持 HTML 转纯文本
- 新增 selectTtCodeByCodeName 方法,支持按名称查询源码
- 新增 /office/copyTemplate/open/generate 开放接口
  无需登录,参数:templateName(模板名称)、codeName(项目名称)
  返回:填充占位符后的完整文案内容

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:28:15 +08:00
王鹏
6940541216 feat: 新增文案模板管理功能,源码明细页按钮支持动态配置
- 新增 tt_copy_template 表及 CRUD 后端接口
- 新增文案模板管理页面,支持 {变量} 占位符动态替换
- 源码明细页复制按钮由硬编码改为数据库驱动
- 支持占位符:codeName、projectCode、projectName、codeDesc、codeEnvironment、codeTechnology、diskLink、screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:20:32 +08:00
王鹏
5e7bbe6c9d fixed 2026-03-31 20:33:20 +08:00
王鹏
a48757160a fixed 2026-03-31 19:36:34 +08:00
王鹏
7647f8ad75 fixed 2025-12-05 16:42:20 +08:00
王鹏
c390e4a033 add 付费资源 2025-12-05 14:18:36 +08:00
118 changed files with 8805 additions and 756 deletions

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

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

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

@@ -0,0 +1,88 @@
# 资源直购虚拟支付上线配置
本项目使用微信小程序虚拟支付的 `short_series_goods`(道具直购)模式,不再提供人民币充值积分功能。
## 1. 执行数据库迁移
首次部署依次执行:
1. `sql/virtual_pay_resource.sql`
2. `sql/virtual_pay_resource_specs.sql`
3. `sql/virtual_pay_order_guard.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` |
然后在若依后台编辑付费资源:
- 获取方式选择“付费”;
- 在“资源规格”中按版本从低到高填写排序,例如源码版 `1`、文档版 `2`、部署版 `3`
- 高排序版本自动包含所有低排序版本,且版本价格必须随排序递增;
- 为每个版本填写价格,例如 5 元填写 `500`
- 规格价格必须存在于 `app_virtual_product` 的已启用价格档位中,系统会自动匹配对应的 `productId`
- 所有可能产生的升级差价也必须配置价格档位。例如版本价格分别为 `9900``19900``29900` 分,还需配置 `10000``20000` 分两个差价档位。
后台会自动维护“价格 -> 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,8 @@ package com.ruoyi.web.controller.app;
import com.ruoyi.app.domain.AppBlogArticle;
import com.ruoyi.app.mapper.AppBlogArticleMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
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;
@@ -18,7 +20,9 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 文章Controller
@@ -32,9 +36,15 @@ public class AppBlogArticleController extends BaseController {
@Autowired
private IAppBlogArticleService appBlogArticleService;
@Autowired
private ImageUrlService imageUrlService;
@Resource
private AppBlogArticleMapper appBlogArticleMapper;
@Resource
private AppResourceMapper appResourceMapper;
@Resource
private ITtFileService fileService;
@@ -45,6 +55,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 +83,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) {
@@ -81,6 +93,19 @@ public class AppBlogArticleController extends BaseController {
return success(appBlogArticle);
}
/**
* 获取首页四个分类的可见内容数量
*/
@GetMapping(value = "/app/homeCategoryCounts")
public AjaxResult homeCategoryCounts() {
Map<String, Long> counts = new LinkedHashMap<>();
counts.put("highQuality", appBlogArticleMapper.countByArticleType(7L, 1L));
counts.put("resource", appResourceMapper.countByIsShow(1L));
counts.put("integral", appBlogArticleMapper.countByArticleType(4L, 1L));
counts.put("free", appBlogArticleMapper.countByArticleType(8L, 1L));
return success(counts);
}
/**
* 新增文章
*/

View File

@@ -0,0 +1,28 @@
package com.ruoyi.web.controller.app;
import com.ruoyi.app.service.IAppDashboardService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 首页业务指标接口。
*/
@RestController
@RequestMapping("/app/dashboard")
public class AppDashboardController extends BaseController
{
@Autowired
private IAppDashboardService appDashboardService;
@PreAuthorize("@ss.hasAnyPermi('app:virtualOrder:list,app:order:list,system:user:list')")
@GetMapping("/summary")
public AjaxResult summary()
{
return success(appDashboardService.selectDashboardSummary());
}
}

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;
@@ -13,6 +15,7 @@ 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 com.ruoyi.framework.web.service.PermissionService;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysConfigService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -42,6 +45,10 @@ public class AppIntegralRecordController extends BaseController
private AppIntegralRecordMapper appIntegralRecordMapper;
@Resource
private SysUserMapper sysUserMapper;
@Resource
private AppResourceMapper appResourceMapper;
@Resource
private PermissionService permissionService;
@Autowired
private IAppLotteryGoodsService appLotteryGoodsService;
@Autowired
@@ -50,14 +57,30 @@ public class AppIntegralRecordController extends BaseController
/**
* 查询积分记录列表
*/
@PreAuthorize("isAuthenticated()")
@GetMapping("/list")
public TableDataInfo list(AppIntegralRecord appIntegralRecord)
{
startPage();
if (!permissionService.hasPermi("app:appIntegra:list"))
{
return getDataTable(appIntegralRecordMapper.selectMyIntegralRecordList(getUserId()));
}
List<AppIntegralRecord> list = appIntegralRecordService.selectAppIntegralRecordList(appIntegralRecord);
return getDataTable(list);
}
/**
* 小程序“积分记录”,只查询当前登录用户的积分增减,不包含现金购买记录。
*/
@PreAuthorize("isAuthenticated()")
@GetMapping("/my")
public TableDataInfo myList()
{
startPage();
return getDataTable(appIntegralRecordMapper.selectMyIntegralRecordList(getUserId()));
}
/**
* 导出积分记录列表
*/
@@ -187,19 +210,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,125 @@
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 com.ruoyi.common.core.page.TableDataInfo;
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.DeleteMapping;
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")
public TableDataInfo listMyOrders()
{
startPage();
return getDataTable(virtualPayService.listMyOrders());
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/resource-orders/{orderNo}")
public AjaxResult queryOrder(@PathVariable String orderNo,
@RequestParam(defaultValue = "false") boolean sync)
{
return success(virtualPayService.queryOrder(orderNo, sync));
}
@PreAuthorize("isAuthenticated()")
@DeleteMapping("/resource-orders/{orderNo}")
public AjaxResult cancelOrder(@PathVariable String orderNo)
{
virtualPayService.cancelOrder(orderNo);
return success("订单已取消");
}
/**
* 在小程序后台保存消息推送配置时使用。
*/
@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,60 @@
package com.ruoyi.common.utils;
import java.util.regex.Pattern;
/**
* HTML工具类
*
* @author ruoyi
*/
public class HtmlUtils {
/**
* 将HTML内容转换为纯文本
* 移除所有HTML标签将&nbsp;等转义字符转换为普通空格,合并多余空白符
*/
public static String htmlToText(String html) {
if (StringUtils.isEmpty(html)) {
return "";
}
String text = html;
// 移除script和style标签及其内容
text = Pattern.compile("<script[^>]*?>.*?</script>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(text).replaceAll("");
text = Pattern.compile("<style[^>]*?>.*?</style>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(text).replaceAll("");
// 移除HTML注释
text = Pattern.compile("<!--.*?-->", Pattern.DOTALL).matcher(text).replaceAll("");
// 移除所有HTML标签
text = Pattern.compile("<[^>]+>").matcher(text).replaceAll("");
// 转义字符转换
text = text.replaceAll("&nbsp;", " ");
text = text.replaceAll("&amp;", "&");
text = text.replaceAll("&lt;", "<");
text = text.replaceAll("&gt;", ">");
text = text.replaceAll("&quot;", "\"");
text = text.replaceAll("&apos;", "'");
text = text.replaceAll("&#39;", "'");
text = text.replaceAll("&mdash;", "\u2014");
text = text.replaceAll("&ndash;", "\u2013");
text = text.replaceAll("&hellip;", "\u2026");
text = text.replaceAll("&ldquo;", "\u201C");
text = text.replaceAll("&rdquo;", "\u201D");
text = text.replaceAll("&lsquo;", "\u2018");
text = text.replaceAll("&rsquo;", "\u2019");
text = text.replaceAll("\\r\\n", "\n");
text = text.replaceAll("\\r", "\n");
text = text.replaceAll("\\n\\n+", "\n\n");
// 合并每行首尾空白并移除全空行
String[] lines = text.split("\n");
StringBuilder sb = new StringBuilder();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.length() > 0) {
if (sb.length() > 0) {
sb.append("\n");
}
sb.append(trimmed);
}
}
return sb.toString();
}
}

View File

@@ -19,6 +19,8 @@ public class CreateOrderReq {
private Long points;
private Long resourceId;
public Long getUserId() {
return userId;
}
@@ -42,4 +44,12 @@ public class CreateOrderReq {
public void setPoints(Long points) {
this.points = points;
}
public Long getResourceId() {
return resourceId;
}
public void setResourceId(Long resourceId) {
this.resourceId = resourceId;
}
}

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

@@ -28,6 +28,18 @@
<artifactId>ruoyi-system</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
</project>

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

@@ -103,26 +103,14 @@ public class TtCodeController extends BaseController
return toAjax(ttCodeService.deleteTtCodeByCodeIds(codeIds));
}
/**
* 转文章
*/
@PreAuthorize("@ss.hasPermi('office:code:edit')")
@Log(title = "源码管理", businessType = BusinessType.UPDATE)
@GetMapping("/transToArticle/{codeId}")
public AjaxResult transToArticle(@PathVariable Long codeId)
{
String msg = ttCodeService.transToArticle(codeId);
return success(msg);
}
/**
* 转文章
*/
@PreAuthorize("@ss.hasPermi('office:code:edit')")
@Log(title = "转文章(南音)", businessType = BusinessType.UPDATE)
@GetMapping("/transToArticle1/{codeId}")
public AjaxResult transToArticle1(@PathVariable Long codeId, @RequestParam String coverUrl) {
String msg = ttCodeService.transToArticle1(codeId, coverUrl);
public AjaxResult transToArticle1(@PathVariable Long codeId, @RequestParam Long templateId) {
String msg = ttCodeService.transToArticle1(codeId, templateId);
return success(msg);
}
}

View File

@@ -0,0 +1,140 @@
package com.ruoyi.office.controller;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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 com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.service.CopyTemplateRenderer;
import com.ruoyi.office.service.ITtCodeService;
import com.ruoyi.office.service.ITtCopyTemplateService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 文案模板Controller
*
* @author ruoyi
* @date 2026-04-10
*/
@RestController
@RequestMapping("/office/copyTemplate")
public class TtCopyTemplateController extends BaseController {
@Autowired
private ITtCopyTemplateService ttCopyTemplateService;
@Autowired
private ITtCodeService ttCodeService;
@Autowired
private CopyTemplateRenderer copyTemplateRenderer;
/**
* 查询文案模板列表
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:list')")
@GetMapping("/list")
public TableDataInfo list(TtCopyTemplate ttCopyTemplate) {
startPage();
List<TtCopyTemplate> list = ttCopyTemplateService.selectTtCopyTemplateList(ttCopyTemplate);
return getDataTable(list);
}
/**
* 查询所有启用的文案模板(供源码明细页动态按钮使用,无需分页权限)
*/
@GetMapping("/enabled")
public AjaxResult listEnabled() {
return success(ttCopyTemplateService.selectEnabledTemplates());
}
/**
* 导出文案模板列表
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:export')")
@Log(title = "文案模板", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TtCopyTemplate ttCopyTemplate) {
List<TtCopyTemplate> list = ttCopyTemplateService.selectTtCopyTemplateList(ttCopyTemplate);
ExcelUtil<TtCopyTemplate> util = new ExcelUtil<TtCopyTemplate>(TtCopyTemplate.class);
util.exportExcel(response, list, "文案模板数据");
}
/**
* 获取文案模板详细信息
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:query')")
@GetMapping(value = "/{templateId}")
public AjaxResult getInfo(@PathVariable("templateId") Long templateId) {
return success(ttCopyTemplateService.selectTtCopyTemplateByTemplateId(templateId));
}
/**
* 新增文案模板
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:add')")
@Log(title = "文案模板", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TtCopyTemplate ttCopyTemplate) {
return toAjax(ttCopyTemplateService.insertTtCopyTemplate(ttCopyTemplate));
}
/**
* 修改文案模板
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:edit')")
@Log(title = "文案模板", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TtCopyTemplate ttCopyTemplate) {
return toAjax(ttCopyTemplateService.updateTtCopyTemplate(ttCopyTemplate));
}
/**
* 删除文案模板
*/
@PreAuthorize("@ss.hasPermi('office:copyTemplate:remove')")
@Log(title = "文案模板", businessType = BusinessType.DELETE)
@DeleteMapping("/{templateIds}")
public AjaxResult remove(@PathVariable Long[] templateIds) {
return toAjax(ttCopyTemplateService.deleteTtCopyTemplateByTemplateIds(templateIds));
}
/**
* 开放接口:通过文案模板名称和项目名称生成文案
* 无需登录,浏览器可直接访问
*/
@Anonymous
@GetMapping("/open/generate")
public AjaxResult generateByOpen(@RequestParam String templateName, @RequestParam String codeName) {
// 1. 查询模板
TtCopyTemplate template = ttCopyTemplateService.selectEnabledTemplates().stream()
.filter(t -> templateName.equals(t.getTemplateName()))
.findFirst()
.orElse(null);
if (template == null) {
return error("未找到名称为【" + templateName + "】的启用模板");
}
// 2. 查询源码
TtCode code = ttCodeService.selectTtCodeByCodeName(codeName);
if (code == null) {
return error("未找到名称为【" + codeName + "】的源码项目");
}
return success(copyTemplateRenderer.render(template, code));
}
}

View File

@@ -8,8 +8,10 @@ import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.utils.CoverGenerator;
import com.ruoyi.office.domain.ProjectLinkImportResult;
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;
@@ -19,7 +21,9 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
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 org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
@@ -42,6 +46,8 @@ public class TtProjectInfoController extends BaseController {
private ITtProjectInfoService ttProjectInfoService;
@Autowired
private ITtCodeService ttCodeService;
@Autowired
private IProjectLinkCheckService projectLinkCheckService;
/**
@@ -67,6 +73,18 @@ public class TtProjectInfoController extends BaseController {
util.exportExcel(response, list, "项目清单数据");
}
/**
* 按项目名称导入夸克或百度网盘链接。
*/
@PreAuthorize("@ss.hasPermi('office:project:edit')")
@Log(title = "导入项目网盘链接", businessType = BusinessType.IMPORT)
@PostMapping("/import-links")
public AjaxResult importLinks(@RequestParam("file") MultipartFile file,
@RequestParam("diskType") String diskType) {
ProjectLinkImportResult result = ttProjectInfoService.importProjectLinks(file, diskType);
return AjaxResult.success(result.buildMessage(), result);
}
/**
* 获取项目清单详细信息
*/
@@ -76,6 +94,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

@@ -0,0 +1,71 @@
package com.ruoyi.office.domain;
/**
* 项目网盘链接导入明细。
*/
public class ProjectLinkImportDetail
{
/** 原文件中的行号。 */
private int rowNumber;
/** 项目名称。 */
private String projectName;
/** SKIPPED / FAILED。 */
private String resultType;
/** 跳过或失败原因。 */
private String reason;
public ProjectLinkImportDetail()
{
}
public ProjectLinkImportDetail(int rowNumber, String projectName, String resultType, String reason)
{
this.rowNumber = rowNumber;
this.projectName = projectName;
this.resultType = resultType;
this.reason = reason;
}
public int getRowNumber()
{
return rowNumber;
}
public void setRowNumber(int rowNumber)
{
this.rowNumber = rowNumber;
}
public String getProjectName()
{
return projectName;
}
public void setProjectName(String projectName)
{
this.projectName = projectName;
}
public String getResultType()
{
return resultType;
}
public void setResultType(String resultType)
{
this.resultType = resultType;
}
public String getReason()
{
return reason;
}
public void setReason(String reason)
{
this.reason = reason;
}
}

View File

@@ -0,0 +1,98 @@
package com.ruoyi.office.domain;
import java.util.ArrayList;
import java.util.List;
/**
* 项目网盘链接导入结果。
*/
public class ProjectLinkImportResult
{
private int totalCount;
private int addedCount;
private int updatedCount;
private int unchangedCount;
private int skippedCount;
private int failedCount;
private final List<ProjectLinkImportDetail> details = new ArrayList<>();
public void addSkipped(int rowNumber, String projectName, String reason)
{
skippedCount++;
details.add(new ProjectLinkImportDetail(rowNumber, projectName, "SKIPPED", reason));
}
public void addFailed(int rowNumber, String projectName, String reason)
{
failedCount++;
details.add(new ProjectLinkImportDetail(rowNumber, projectName, "FAILED", reason));
}
public String buildMessage()
{
return "导入完成:共 " + totalCount + " 条,新增 " + addedCount + " 条,更新 "
+ updatedCount + " 条,未变化 " + unchangedCount + " 条,跳过 "
+ skippedCount + " 条,失败 " + failedCount + "";
}
public int getTotalCount()
{
return totalCount;
}
public void setTotalCount(int totalCount)
{
this.totalCount = totalCount;
}
public int getAddedCount()
{
return addedCount;
}
public void incrementAddedCount()
{
addedCount++;
}
public int getUpdatedCount()
{
return updatedCount;
}
public void incrementUpdatedCount()
{
updatedCount++;
}
public int getUnchangedCount()
{
return unchangedCount;
}
public void incrementUnchangedCount()
{
unchangedCount++;
}
public int getSkippedCount()
{
return skippedCount;
}
public int getFailedCount()
{
return failedCount;
}
public List<ProjectLinkImportDetail> getDetails()
{
return details;
}
}

View File

@@ -0,0 +1,67 @@
package com.ruoyi.office.domain;
/**
* 项目网盘链接导入文件中的一行原始数据。
*/
public class ProjectLinkImportRow
{
private int rowNumber;
private String projectName;
private String shareAddress;
private String extractCode;
private String shareStatus;
public int getRowNumber()
{
return rowNumber;
}
public void setRowNumber(int rowNumber)
{
this.rowNumber = rowNumber;
}
public String getProjectName()
{
return projectName;
}
public void setProjectName(String projectName)
{
this.projectName = projectName;
}
public String getShareAddress()
{
return shareAddress;
}
public void setShareAddress(String shareAddress)
{
this.shareAddress = shareAddress;
}
public String getExtractCode()
{
return extractCode;
}
public void setExtractCode(String extractCode)
{
this.extractCode = extractCode;
}
public String getShareStatus()
{
return shareStatus;
}
public void setShareStatus(String shareStatus)
{
this.shareStatus = shareStatus;
}
}

View File

@@ -40,11 +40,29 @@ public class TtCode extends BaseEntity {
private String codeEnvironment;
/**
* 项目技术
* 其他技术
*/
@Excel(name = "项目技术")
@Excel(name = "其他技术")
private String codeTechnology;
/**
* 前端
*/
@Excel(name = "前端")
private String frontendTechnology;
/**
* 后端
*/
@Excel(name = "后端")
private String backendTechnology;
/**
* 数据库
*/
@Excel(name = "数据库")
private String databaseTechnology;
/**
* 来源
*/
@@ -123,6 +141,30 @@ public class TtCode extends BaseEntity {
return codeTechnology;
}
public void setFrontendTechnology(String frontendTechnology) {
this.frontendTechnology = frontendTechnology;
}
public String getFrontendTechnology() {
return frontendTechnology;
}
public void setBackendTechnology(String backendTechnology) {
this.backendTechnology = backendTechnology;
}
public String getBackendTechnology() {
return backendTechnology;
}
public void setDatabaseTechnology(String databaseTechnology) {
this.databaseTechnology = databaseTechnology;
}
public String getDatabaseTechnology() {
return databaseTechnology;
}
public void setCodeSource(String codeSource) {
this.codeSource = codeSource;
}
@@ -187,6 +229,9 @@ public class TtCode extends BaseEntity {
.append("codeDesc", getCodeDesc())
.append("codeEnvironment", getCodeEnvironment())
.append("codeTechnology", getCodeTechnology())
.append("frontendTechnology", getFrontendTechnology())
.append("backendTechnology", getBackendTechnology())
.append("databaseTechnology", getDatabaseTechnology())
.append("codeSource", getCodeSource())
.append("paymentType", getPaymentType())
.append("diskLink", getDiskLink())

View File

@@ -0,0 +1,88 @@
package com.ruoyi.office.domain;
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;
/**
* 文案模板对象 tt_copy_template
*
* @author ruoyi
* @date 2026-04-10
*/
public class TtCopyTemplate extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 模板ID */
private Long templateId;
/** 按钮显示名称 */
@Excel(name = "按钮名称")
private String templateName;
/** 文案模板内容,支持 {变量} 占位符 */
@Excel(name = "模板内容")
private String templateBody;
/** 排序号 */
@Excel(name = "排序")
private Integer sortNum;
/** 状态0正常 1停用 */
@Excel(name = "状态")
private String status;
public void setTemplateId(Long templateId) {
this.templateId = templateId;
}
public Long getTemplateId() {
return templateId;
}
public void setTemplateName(String templateName) {
this.templateName = templateName;
}
public String getTemplateName() {
return templateName;
}
public void setTemplateBody(String templateBody) {
this.templateBody = templateBody;
}
public String getTemplateBody() {
return templateBody;
}
public void setSortNum(Integer sortNum) {
this.sortNum = sortNum;
}
public Integer getSortNum() {
return sortNum;
}
public void setStatus(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("templateId", getTemplateId())
.append("templateName", getTemplateName())
.append("templateBody", getTemplateBody())
.append("sortNum", getSortNum())
.append("status", getStatus())
.append("createTime", getCreateTime())
.append("updateTime", getUpdateTime())
.toString();
}
}

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

@@ -13,12 +13,20 @@ public interface TtCodeMapper
{
/**
* 查询源码管理
*
*
* @param codeId 源码管理主键
* @return 源码管理
*/
public TtCode selectTtCodeByCodeId(Long codeId);
/**
* 根据项目名称查询源码管理
*
* @param codeName 项目名称
* @return 源码管理
*/
public TtCode selectTtCodeByCodeName(String codeName);
/**
* 查询源码管理列表
*

View File

@@ -0,0 +1,48 @@
package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtCopyTemplate;
/**
* 文案模板Mapper接口
*
* @author ruoyi
* @date 2026-04-10
*/
public interface TtCopyTemplateMapper {
/**
* 查询文案模板
*/
public TtCopyTemplate selectTtCopyTemplateByTemplateId(Long templateId);
/**
* 查询文案模板列表
*/
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
/**
* 查询所有启用的文案模板(按排序号升序)
*/
public List<TtCopyTemplate> selectEnabledTemplates();
/**
* 新增文案模板
*/
public int insertTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 修改文案模板
*/
public int updateTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 批量删除文案模板
*/
public int deleteTtCopyTemplateByTemplateIds(Long[] templateIds);
/**
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.office.mapper;
import java.util.List;
import com.ruoyi.office.domain.TtProjectInfo;
import org.apache.ibatis.annotations.Param;
/**
* 项目清单Mapper接口
@@ -62,4 +63,10 @@ public interface TtProjectInfoMapper
List<TtProjectInfo> lastUpdateList(String searchKey);
TtProjectInfo selectTtProjectInfoByName(String codeName);
int updateProjectQuarkUrl(@Param("id") Integer id, @Param("projectUrl") String projectUrl);
int updateProjectBaiduUrl(@Param("id") Integer id, @Param("projectBaiduUrl") String projectBaiduUrl);
int updateProjectNumIfBlank(@Param("id") Integer id, @Param("projectNum") String projectNum);
}

View File

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

View File

@@ -0,0 +1,99 @@
package com.ruoyi.office.service;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import com.ruoyi.common.utils.HtmlUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
/**
* 文案模板占位符渲染器。
*/
@Component
public class CopyTemplateRenderer
{
private static final Pattern PROJECT_CODE_PATTERN = Pattern.compile("【(.+?)】");
private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile("实现的(.+?)$");
public String render(TtCopyTemplate template, TtCode code)
{
String codeName = value(code.getCodeName());
String projectCode = extractProjectCode(codeName);
String projectName = extractProjectName(codeName);
String content = value(template.getTemplateBody());
content = content.replace("{codeName}", codeName);
content = content.replace("{projectCode}", projectCode);
content = content.replace("{projectName}", projectName);
content = content.replace("{codeDesc}", toPlainDescription(code.getCodeDesc()));
content = content.replace("{codeEnvironment}", value(code.getCodeEnvironment()));
content = content.replace("{frontendTechnology}", value(code.getFrontendTechnology()));
content = content.replace("{backendTechnology}", value(code.getBackendTechnology()));
content = content.replace("{databaseTechnology}", value(code.getDatabaseTechnology()));
content = content.replace("{codeTechnology}", value(code.getCodeTechnology()));
content = content.replace("{diskLink}", value(code.getDiskLink()));
content = content.replace("{screenshots}", buildScreenshots(code.getFileList()));
return content;
}
private String extractProjectCode(String codeName)
{
Matcher matcher = PROJECT_CODE_PATTERN.matcher(codeName);
return matcher.find() ? matcher.group(1) : "";
}
private String extractProjectName(String codeName)
{
String projectName = PROJECT_CODE_PATTERN.matcher(codeName).replaceFirst("").trim();
if (projectName.startsWith("基于"))
{
Matcher matcher = PROJECT_NAME_PATTERN.matcher(projectName);
if (matcher.find())
{
return matcher.group(1);
}
}
return projectName;
}
private String toPlainDescription(String codeDesc)
{
if (StringUtils.isEmpty(codeDesc))
{
return "";
}
return HtmlUtils.htmlToText(codeDesc.replaceAll("(?i)</p>", "</p>\n").trim());
}
private String buildScreenshots(List<TtFile> fileList)
{
if (fileList == null || fileList.isEmpty())
{
return "请前往微信小程序:南音源码库。查看项目详情!\n\n";
}
StringBuilder screenshots = new StringBuilder();
int index = 0;
for (TtFile file : fileList)
{
screenshots.append(++index)
.append(".")
.append(value(file.getFileName()))
.append("\n![](")
.append(value(file.getFileUrl()))
.append(")\n\n");
}
return screenshots.toString();
}
private String value(String value)
{
return value == null ? "" : value;
}
}

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

@@ -19,6 +19,14 @@ public interface ITtCodeService {
*/
public TtCode selectTtCodeByCodeId(Long codeId);
/**
* 根据项目名称查询源码管理
*
* @param codeName 项目名称
* @return 源码管理
*/
public TtCode selectTtCodeByCodeName(String codeName);
/**
* 查询源码管理列表
*
@@ -59,16 +67,8 @@ public interface ITtCodeService {
*/
public int deleteTtCodeByCodeId(Long codeId);
/**
* 转文章
*
* @param codeId 源码管理主键
* @return 结果
*/
public String transToArticle(Long codeId);
/**
* 转文章(南音)
*/
public String transToArticle1(Long codeId, String coverUrl);
public String transToArticle1(Long codeId, Long templateId);
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtCopyTemplate;
/**
* 文案模板Service接口
*
* @author ruoyi
* @date 2026-04-10
*/
public interface ITtCopyTemplateService {
/**
* 查询文案模板
*/
public TtCopyTemplate selectTtCopyTemplateByTemplateId(Long templateId);
/**
* 查询文案模板列表(带分页、筛选)
*/
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
/**
* 查询所有启用的文案模板(供源码明细页按钮使用)
*/
public List<TtCopyTemplate> selectEnabledTemplates();
/**
* 新增文案模板
*/
public int insertTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 修改文案模板
*/
public int updateTtCopyTemplate(TtCopyTemplate ttCopyTemplate);
/**
* 批量删除文案模板
*/
public int deleteTtCopyTemplateByTemplateIds(Long[] templateIds);
/**
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}

View File

@@ -2,6 +2,8 @@ package com.ruoyi.office.service;
import java.util.List;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import org.springframework.web.multipart.MultipartFile;
/**
* 项目清单Service接口
@@ -64,4 +66,13 @@ public interface ITtProjectInfoService
List<?> lastUpdateList(String searchKey);
TtProjectInfo selectTtProjectInfoByName(String codeName);
/**
* 导入夸克或百度网盘分享链接。
*
* @param file 网盘客户端导出的 CSV/Excel 文件
* @param diskType QUARK/BAIDU
* @return 导入结果
*/
ProjectLinkImportResult importProjectLinks(MultipartFile file, String diskType);
}

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

@@ -3,21 +3,28 @@ package com.ruoyi.office.service.impl;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ruoyi.app.domain.AppBlogArticle;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.domain.AppResourceList;
import com.ruoyi.app.mapper.AppBlogArticleMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.service.IAppResourceService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.TtArticles;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.mapper.TtArticlesMapper;
import com.ruoyi.office.mapper.TtCopyTemplateMapper;
import com.ruoyi.office.mapper.TtFileMapper;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.service.CopyTemplateRenderer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.service.ITtCodeService;
@@ -30,18 +37,34 @@ import com.ruoyi.office.service.ITtCodeService;
*/
@Service
public class TtCodeServiceImpl implements ITtCodeService {
private static final Pattern PREMIUM_PROJECT_CODE_PATTERN =
Pattern.compile("(?i)(?:^|【)\\s*([SK])\\s*\\d+");
private static final long SOURCE_SHARE_RESOURCE_TYPE = 5L;
private static final long PREMIUM_PROJECT_RESOURCE_TYPE = 6L;
private static final long SOURCE_SHARE_ARTICLE_TYPE = 4L;
private static final long PREMIUM_SOURCE_ARTICLE_TYPE = 7L;
private static final long POINTS_ACCESS_TYPE = 2L;
private static final long PAID_ACCESS_TYPE = 3L;
private static final String BASIC_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT";
private static final String DEPLOYMENT_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT + 项目部署";
private static final String DEPLOYMENT_CONTACT = "调试部署加微信forfeastcoding";
@Autowired
private TtCodeMapper ttCodeMapper;
@Autowired
private TtArticlesMapper ttArticlesMapper;
@Autowired
private TtFileMapper fileMapper;
@Autowired
private AppBlogArticleMapper appBlogArticleMapper;
@Autowired
private AppResourceMapper appResourceMapper;
@Autowired
private IAppResourceService appResourceService;
@Autowired
private TtProjectInfoMapper ttProjectInfoMapper;
@Autowired
private TtCopyTemplateMapper ttCopyTemplateMapper;
@Autowired
private CopyTemplateRenderer copyTemplateRenderer;
/**
* 查询源码管理
@@ -58,6 +81,23 @@ public class TtCodeServiceImpl implements ITtCodeService {
return ttCode;
}
/**
* 根据项目名称查询源码管理
*
* @param codeName 项目名称
* @return 源码管理
*/
@Override
public TtCode selectTtCodeByCodeName(String codeName) {
TtCode ttCode = ttCodeMapper.selectTtCodeByCodeName(codeName);
if (ttCode != null) {
TtFile file = new TtFile();
file.setCodeName(ttCode.getCodeName());
ttCode.setFileList(fileMapper.selectTtFileList(file));
}
return ttCode;
}
/**
* 查询源码管理列表
*
@@ -114,105 +154,228 @@ public class TtCodeServiceImpl implements ITtCodeService {
}
@Override
public String transToArticle(Long codeId) {
TtCode ttCode = ttCodeMapper.selectTtCodeByCodeId(codeId);
TtArticles ttArticles = new TtArticles();
ttArticles.setTitle(ttCode.getCodeName());
List<TtArticles> list = ttArticlesMapper.selectTtArticlesList(ttArticles);
if (!list.isEmpty()) {
return "不能重复转文章!";
@Transactional(rollbackFor = Exception.class)
public String transToArticle1(Long codeId, Long templateId) {
TtCopyTemplate template = ttCopyTemplateMapper.selectTtCopyTemplateByTemplateId(templateId);
if (template == null || !"0".equals(template.getStatus())) {
throw new ServiceException("所选文案模板不存在或已停用");
}
ttArticles.setAuthor("Feast");
ttArticles.setCategory(ttCode.getPaymentType());
if (ttCode.getPaymentType().equals("2")) {
ttArticles.setAttachmentUrl(ttCode.getDiskLink());
}
StringBuffer content = new StringBuffer();
content.append("<p><strong>### 项目描述</strong></p>");
content.append(ttCode.getCodeDesc());
content.append("<p><strong>### 运行环境</strong></p>");
content.append(ttCode.getCodeEnvironment());
content.append("<p><strong>### 项目技术</strong></p>");
content.append(ttCode.getCodeTechnology());
ttArticles.setContent(content.toString());
ttArticlesMapper.insertTtArticles(ttArticles);
return "源码转文章成功!";
}
@Override
public String transToArticle1(Long codeId, String coverUrl) {
TtCode ttCode = ttCodeMapper.selectTtCodeByCodeId(codeId);
if (ttCode == null) {
throw new ServiceException("源码项目不存在");
}
TtProjectInfo ttProjectInfo = ttProjectInfoMapper.selectTtProjectInfoByName(ttCode.getCodeName());
ttCode.setDiskLink(ttProjectInfo.getProjectBaiduUrl());
ttCode.setPublishFlag("Y");
ttCodeMapper.updateTtCode(ttCode);
StringBuffer content = new StringBuffer();
content.append("<p><strong>### 项目描述</strong></p>");
content.append(ttCode.getCodeDesc());
content.append("<p><strong>### 运行环境</strong></p>");
content.append(ttCode.getCodeEnvironment());
content.append("<p><strong>### 项目技术</strong></p>");
content.append(ttCode.getCodeTechnology());
content.append("<p><strong>### 演示视频</strong></p>");
content.append("请移步首页-<strong>视频资源</strong>,搜索<strong>项目编号</strong>查看");
if (ttProjectInfo == null) {
throw new ServiceException("未找到对应的项目清单信息");
}
String projectSeries = resolveProjectSeries(ttCode, ttProjectInfo);
boolean premiumProject = isPremiumProject(projectSeries);
if (premiumProject && StringUtils.isEmpty(ttProjectInfo.getProjectUrl())) {
throw new ServiceException("该项目未配置夸克网盘链接,无法生成付费规格");
}
ttCode.setDiskLink(premiumProject
? ttProjectInfo.getProjectUrl() : ttProjectInfo.getProjectBaiduUrl());
TtFile file = new TtFile();
file.setCodeName(ttCode.getCodeName());
List<TtFile> fileList = fileMapper.selectTtFileList(file);
AppBlogArticle article = new AppBlogArticle();
article.setTitle(ttCode.getCodeName());
List<AppBlogArticle> list = appBlogArticleMapper.selectAppBlogArticleList(article);
if (!list.isEmpty()) {
ttCode.setFileList(fileList);
String content = copyTemplateRenderer.render(template, ttCode);
String firstImageUrl = getFirstImageUrl(fileList);
AppBlogArticle existingArticle = appBlogArticleMapper.selectAppBlogArticleByTitle(ttCode.getCodeName());
if (existingArticle != null) {
if (repairIncompleteConversion(existingArticle, ttProjectInfo, content,
firstImageUrl, projectSeries)) {
markCodePublished(ttCode);
return "源码转文章成功,已补全资源链接!";
}
return "不能重复转文章!";
}
//新增资源
AppResource resource = new AppResource();
resource.setExplain(content.toString());
resource.setExplain(content);
resource.setResourceTitle(ttCode.getCodeName());
if (!fileList.isEmpty()) {
resource.setShowImg(fileList.get(0).getFileUrl());
if (!StringUtils.isEmpty(firstImageUrl)) {
resource.setShowImg(firstImageUrl);
}
resource.setResourceType(5L);
configureResource(resource, projectSeries);
resource.setIsShow(0L);
resource.setIsAd(2L);
resource.setAdNumber(100L);
resource.setCreateTime(new Date());
resource.setShowImg(coverUrl);
appResourceMapper.insertAppResource(resource);
List<AppResourceList> resourceList = buildResourceList(ttProjectInfo, null, projectSeries);
resource.setAppResourceListList(resourceList);
appResourceService.insertAppResource(resource);
//新增文章
article.setArticleType(4L);
AppBlogArticle article = new AppBlogArticle();
article.setTitle(ttCode.getCodeName());
article.setArticleType(premiumProject
? PREMIUM_SOURCE_ARTICLE_TYPE : SOURCE_SHARE_ARTICLE_TYPE);
article.setIsRecommendation(1L);
article.setIsShow(1L);
article.setIsAd(1L);
if (!fileList.isEmpty()) {
article.setShowImg(fileList.get(0).getFileUrl());
if (!StringUtils.isEmpty(firstImageUrl)) {
article.setShowImg(firstImageUrl);
}
article.setContentInfo(content.toString());
article.setContentInfo(content);
article.setAppResourceId(resource.getId());
article.setCreateTime(new Date());
article.setShowImg(coverUrl);
appBlogArticleMapper.insertAppBlogArticle(article);
//新增资源网盘链接
List<AppResourceList> resourceList = new ArrayList<AppResourceList>();
//百度网盘
if(!StringUtils.isEmpty(ttProjectInfo.getProjectBaiduUrl())){
AppResourceList appResourceList = new AppResourceList();
appResourceList.setAppResourceId(resource.getId());
appResourceList.setListName("百度网盘");
appResourceList.setListUrl(ttProjectInfo.getProjectBaiduUrl());
resourceList.add(appResourceList);
}
//夸克网盘
if(!StringUtils.isEmpty(ttProjectInfo.getProjectUrl())){
AppResourceList appResourceList1 = new AppResourceList();
appResourceList1.setAppResourceId(resource.getId());
appResourceList1.setListName("夸克网盘");
appResourceList1.setListUrl(ttProjectInfo.getProjectUrl());
resourceList.add(appResourceList1);
}
if (resourceList.size() > 0)
{
appResourceMapper.batchAppResourceList(resourceList);
}
markCodePublished(ttCode);
return "源码转文章成功!";
}
/**
* 修复旧转换流程异常后已经生成文章、但尚未写入下载链接的半成品数据。
*/
private boolean repairIncompleteConversion(AppBlogArticle article, TtProjectInfo projectInfo,
String content, String firstImageUrl,
String projectSeries)
{
if (article.getAppResourceId() == null)
{
return false;
}
AppResource resource = appResourceMapper.selectAppResourceById(article.getAppResourceId());
if (resource == null || (resource.getAppResourceListList() != null
&& !resource.getAppResourceListList().isEmpty()))
{
return false;
}
List<AppResourceList> resourceList = buildResourceList(
projectInfo, resource.getId(), projectSeries);
if (resourceList.isEmpty())
{
return false;
}
resource.setExplain(content);
if (!StringUtils.isEmpty(firstImageUrl))
{
resource.setShowImg(firstImageUrl);
}
configureResource(resource, projectSeries);
resource.setAppResourceListList(resourceList);
appResourceService.updateAppResource(resource);
article.setContentInfo(content);
if (!StringUtils.isEmpty(firstImageUrl))
{
article.setShowImg(firstImageUrl);
}
if (isPremiumProject(projectSeries))
{
article.setArticleType(PREMIUM_SOURCE_ARTICLE_TYPE);
}
appBlogArticleMapper.updateAppBlogArticle(article);
return true;
}
private String getFirstImageUrl(List<TtFile> fileList)
{
if (fileList == null || fileList.isEmpty())
{
return null;
}
return fileList.get(0).getFileUrl();
}
private List<AppResourceList> buildResourceList(TtProjectInfo projectInfo, Long resourceId,
String projectSeries)
{
List<AppResourceList> resourceList = new ArrayList<AppResourceList>();
if ("K".equals(projectSeries))
{
addResourceList(resourceList, resourceId, BASIC_SPEC_NAME,
projectInfo.getProjectUrl(), 1900, 1);
addResourceList(resourceList, resourceId, DEPLOYMENT_SPEC_NAME,
DEPLOYMENT_CONTACT, 6600, 2);
return resourceList;
}
if ("S".equals(projectSeries))
{
addResourceList(resourceList, resourceId, BASIC_SPEC_NAME,
projectInfo.getProjectUrl(), 5900, 1);
addResourceList(resourceList, resourceId, DEPLOYMENT_SPEC_NAME,
DEPLOYMENT_CONTACT, 9900, 2);
return resourceList;
}
addResourceList(resourceList, resourceId, "百度网盘", projectInfo.getProjectBaiduUrl());
addResourceList(resourceList, resourceId, "夸克网盘", projectInfo.getProjectUrl());
return resourceList;
}
private void addResourceList(List<AppResourceList> resourceList, Long resourceId,
String name, String url)
{
if (StringUtils.isEmpty(url))
{
return;
}
AppResourceList item = new AppResourceList();
item.setAppResourceId(resourceId);
item.setListName(name);
item.setListUrl(url);
item.setPassword("");
item.setPriceFen(0);
item.setStatus(1);
item.setSortOrder(resourceList.size());
resourceList.add(item);
}
private void addResourceList(List<AppResourceList> resourceList, Long resourceId,
String name, String url, int priceFen, int sortOrder)
{
AppResourceList item = new AppResourceList();
item.setAppResourceId(resourceId);
item.setListName(name);
item.setListUrl(url);
item.setPassword("");
item.setPriceFen(priceFen);
item.setStatus(1);
item.setSortOrder(sortOrder);
resourceList.add(item);
}
private void configureResource(AppResource resource, String projectSeries)
{
if (isPremiumProject(projectSeries))
{
resource.setResourceType(PREMIUM_PROJECT_RESOURCE_TYPE);
resource.setIsAd(PAID_ACCESS_TYPE);
resource.setAdNumber(0L);
resource.setPriceFen("K".equals(projectSeries) ? 1900 : 5900);
return;
}
resource.setResourceType(SOURCE_SHARE_RESOURCE_TYPE);
resource.setIsAd(POINTS_ACCESS_TYPE);
resource.setAdNumber(100L);
resource.setPriceFen(0);
}
private boolean isPremiumProject(String projectSeries)
{
return "S".equals(projectSeries) || "K".equals(projectSeries);
}
private String resolveProjectSeries(TtCode code, TtProjectInfo projectInfo)
{
String projectSeries = extractProjectSeries(projectInfo.getProjectNum());
return projectSeries == null ? extractProjectSeries(code.getCodeName()) : projectSeries;
}
private String extractProjectSeries(String value)
{
if (StringUtils.isEmpty(value))
{
return null;
}
Matcher matcher = PREMIUM_PROJECT_CODE_PATTERN.matcher(value);
return matcher.find() ? matcher.group(1).toUpperCase(Locale.ROOT) : null;
}
private void markCodePublished(TtCode ttCode)
{
ttCode.setPublishFlag("Y");
ttCodeMapper.updateTtCode(ttCode);
}
}

View File

@@ -0,0 +1,59 @@
package com.ruoyi.office.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.mapper.TtCopyTemplateMapper;
import com.ruoyi.office.service.ITtCopyTemplateService;
/**
* 文案模板Service业务层处理
*
* @author ruoyi
* @date 2026-04-10
*/
@Service
public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService {
@Autowired
private TtCopyTemplateMapper ttCopyTemplateMapper;
@Override
public TtCopyTemplate selectTtCopyTemplateByTemplateId(Long templateId) {
return ttCopyTemplateMapper.selectTtCopyTemplateByTemplateId(templateId);
}
@Override
public List<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate) {
return ttCopyTemplateMapper.selectTtCopyTemplateList(ttCopyTemplate);
}
@Override
public List<TtCopyTemplate> selectEnabledTemplates() {
return ttCopyTemplateMapper.selectEnabledTemplates();
}
@Override
public int insertTtCopyTemplate(TtCopyTemplate ttCopyTemplate) {
ttCopyTemplate.setCreateTime(DateUtils.getNowDate());
return ttCopyTemplateMapper.insertTtCopyTemplate(ttCopyTemplate);
}
@Override
public int updateTtCopyTemplate(TtCopyTemplate ttCopyTemplate) {
ttCopyTemplate.setUpdateTime(DateUtils.getNowDate());
return ttCopyTemplateMapper.updateTtCopyTemplate(ttCopyTemplate);
}
@Override
public int deleteTtCopyTemplateByTemplateIds(Long[] templateIds) {
return ttCopyTemplateMapper.deleteTtCopyTemplateByTemplateIds(templateIds);
}
@Override
public int deleteTtCopyTemplateByTemplateId(Long templateId) {
return ttCopyTemplateMapper.deleteTtCopyTemplateByTemplateId(templateId);
}
}

View File

@@ -1,13 +1,30 @@
package com.ruoyi.office.service.impl;
import java.util.Collections;
import java.net.URI;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
import com.ruoyi.office.service.importer.ProjectLinkImportParser;
import com.ruoyi.office.service.netdisk.NetDiskConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.service.ITtProjectInfoService;
@@ -21,10 +38,23 @@ import com.ruoyi.office.service.ITtProjectInfoService;
@Service
public class TtProjectInfoServiceImpl implements ITtProjectInfoService
{
private static final Pattern PROJECT_NUM_PATTERN =
Pattern.compile("\\s*([^】]+?)\\s*】");
private static final Pattern QUARK_URL_PATTERN =
Pattern.compile("https://pan\\.quark\\.cn/s/[A-Za-z0-9_-]+(?:\\?[^\\s\"'<>,。;]*)?");
private static final Pattern BAIDU_PWD_PATTERN =
Pattern.compile("(?:\\?|&)pwd=[^&]*", Pattern.CASE_INSENSITIVE);
@Autowired
private TtProjectInfoMapper ttProjectInfoMapper;
@Autowired
private TtCodeMapper ttCodeMapper;
@Autowired
private TtProjectLinkCheckMapper ttProjectLinkCheckMapper;
@Autowired
private ProjectLinkImportParser projectLinkImportParser;
/**
* 查询项目清单
@@ -81,8 +111,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
* @return 结果
*/
@Override
@Transactional
public int deleteTtProjectInfoByIds(Integer[] ids)
{
ttProjectLinkCheckMapper.deleteByProjectIds(ids);
return ttProjectInfoMapper.deleteTtProjectInfoByIds(ids);
}
@@ -93,8 +125,10 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
* @return 结果
*/
@Override
@Transactional
public int deleteTtProjectInfoById(Integer id)
{
ttProjectLinkCheckMapper.deleteByProjectId(id);
return ttProjectInfoMapper.deleteTtProjectInfoById(id);
}
@@ -121,4 +155,296 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
public TtProjectInfo selectTtProjectInfoByName(String codeName) {
return ttProjectInfoMapper.selectTtProjectInfoByName(codeName);
}
/**
* 按项目名称导入网盘链接。新项目同时写入项目名称和源码名称;
* 已存在项目只更新本次导入的网盘链接字段。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ProjectLinkImportResult importProjectLinks(MultipartFile file, String diskType)
{
String normalizedDiskType = normalizeDiskType(diskType);
List<ProjectLinkImportRow> sourceRows = projectLinkImportParser.parse(file, normalizedDiskType);
ProjectLinkImportResult result = new ProjectLinkImportResult();
result.setTotalCount(sourceRows.size());
List<ValidatedImportRow> validRows = validateRows(sourceRows, normalizedDiskType, result);
Set<String> duplicateNames = findDuplicateNames(validRows);
Map<String, List<TtProjectInfo>> projectsByName = loadProjectsByNormalizedName();
for (ValidatedImportRow row : validRows)
{
if (duplicateNames.contains(row.normalizedName))
{
result.addFailed(row.rowNumber, row.projectName, "导入文件中存在重复项目名称");
continue;
}
List<TtProjectInfo> matches = projectsByName.get(row.normalizedName);
if (matches != null && matches.size() > 1)
{
result.addFailed(row.rowNumber, row.projectName, "数据库中存在多个同名项目,无法确定更新目标");
continue;
}
if (matches == null || matches.isEmpty())
{
TtProjectInfo project = createProject(row, normalizedDiskType);
if (ttProjectInfoMapper.insertTtProjectInfo(project) != 1)
{
throw new ServiceException("新增项目失败:" + row.projectName);
}
List<TtProjectInfo> inserted = new ArrayList<>();
inserted.add(project);
projectsByName.put(row.normalizedName, inserted);
result.incrementAddedCount();
continue;
}
TtProjectInfo project = matches.get(0);
backfillProjectNumIfBlank(project, row.projectName);
String oldLink = NetDiskConstants.DISK_QUARK.equals(normalizedDiskType)
? project.getProjectUrl() : project.getProjectBaiduUrl();
if (StringUtils.equals(StringUtils.trimToEmpty(oldLink), row.linkUrl))
{
result.incrementUnchangedCount();
continue;
}
int updated;
if (NetDiskConstants.DISK_QUARK.equals(normalizedDiskType))
{
updated = ttProjectInfoMapper.updateProjectQuarkUrl(project.getId(), row.linkUrl);
project.setProjectUrl(row.linkUrl);
}
else
{
updated = ttProjectInfoMapper.updateProjectBaiduUrl(project.getId(), row.linkUrl);
project.setProjectBaiduUrl(row.linkUrl);
}
if (updated != 1)
{
throw new ServiceException("更新项目链接失败:" + row.projectName);
}
ttProjectLinkCheckMapper.deleteByProjectIdAndDiskType(project.getId(), normalizedDiskType);
result.incrementUpdatedCount();
}
return result;
}
private List<ValidatedImportRow> validateRows(List<ProjectLinkImportRow> sourceRows,
String diskType,
ProjectLinkImportResult result)
{
List<ValidatedImportRow> validRows = new ArrayList<>();
String successStatus = NetDiskConstants.DISK_QUARK.equals(diskType) ? "成功" : "生成成功";
for (ProjectLinkImportRow sourceRow : sourceRows)
{
String projectName = normalizeProjectName(sourceRow.getProjectName());
String status = StringUtils.trimToEmpty(sourceRow.getShareStatus());
if (!successStatus.equals(status))
{
result.addSkipped(sourceRow.getRowNumber(), projectName,
"分享状态不是" + successStatus + "" + StringUtils.defaultIfBlank(status, ""));
continue;
}
if (StringUtils.isBlank(projectName))
{
result.addFailed(sourceRow.getRowNumber(), "", "项目名称不能为空");
continue;
}
if (projectName.length() > 255)
{
result.addFailed(sourceRow.getRowNumber(), projectName, "项目名称超过 255 个字符");
continue;
}
try
{
String linkUrl = buildAndValidateLink(sourceRow, diskType);
validRows.add(new ValidatedImportRow(sourceRow.getRowNumber(), projectName,
normalizeProjectName(projectName), linkUrl));
}
catch (IllegalArgumentException e)
{
result.addFailed(sourceRow.getRowNumber(), projectName, e.getMessage());
}
}
return validRows;
}
private String buildAndValidateLink(ProjectLinkImportRow row, String diskType)
{
String linkUrl;
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
Matcher matcher = QUARK_URL_PATTERN.matcher(StringUtils.defaultString(row.getShareAddress()));
if (!matcher.find())
{
throw new IllegalArgumentException("未找到有效的夸克网盘链接");
}
linkUrl = matcher.group();
}
else
{
linkUrl = StringUtils.trimToEmpty(row.getShareAddress());
String extractCode = StringUtils.trimToEmpty(row.getExtractCode());
if (StringUtils.isNotBlank(extractCode) && !BAIDU_PWD_PATTERN.matcher(linkUrl).find())
{
try
{
linkUrl += linkUrl.contains("?") ? "&" : "?";
linkUrl += "pwd=" + URLEncoder.encode(extractCode, "UTF-8");
}
catch (Exception e)
{
throw new IllegalArgumentException("百度网盘提取码处理失败");
}
}
}
if (linkUrl.length() > 1000)
{
throw new IllegalArgumentException("网盘链接超过 1000 个字符");
}
validateLinkHostAndPath(linkUrl, diskType);
return linkUrl;
}
private void validateLinkHostAndPath(String linkUrl, String diskType)
{
try
{
URI uri = new URI(linkUrl);
String host = uri.getHost();
String path = uri.getPath();
boolean isHttps = "https".equalsIgnoreCase(uri.getScheme());
boolean isQuark = NetDiskConstants.DISK_QUARK.equals(diskType)
&& "pan.quark.cn".equalsIgnoreCase(host)
&& path != null && path.startsWith("/s/");
boolean isBaidu = NetDiskConstants.DISK_BAIDU.equals(diskType)
&& host != null
&& ("pan.baidu.com".equalsIgnoreCase(host) || "yun.baidu.com".equalsIgnoreCase(host))
&& path != null
&& (path.startsWith("/s/") || path.startsWith("/share/"));
if (!isHttps || uri.getUserInfo() != null || (uri.getPort() != -1 && uri.getPort() != 443)
|| (!isQuark && !isBaidu))
{
throw new IllegalArgumentException("网盘链接格式不正确");
}
}
catch (IllegalArgumentException e)
{
throw e;
}
catch (Exception e)
{
throw new IllegalArgumentException("网盘链接格式不正确");
}
}
private Map<String, List<TtProjectInfo>> loadProjectsByNormalizedName()
{
Map<String, List<TtProjectInfo>> projectsByName = new HashMap<>();
List<TtProjectInfo> projects = ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo());
for (TtProjectInfo project : projects)
{
String normalizedName = normalizeProjectName(project.getProjectName());
if (StringUtils.isBlank(normalizedName))
{
continue;
}
projectsByName.computeIfAbsent(normalizedName, key -> new ArrayList<>()).add(project);
}
return projectsByName;
}
private Set<String> findDuplicateNames(List<ValidatedImportRow> rows)
{
Set<String> names = new HashSet<>();
Set<String> duplicateNames = new HashSet<>();
for (ValidatedImportRow row : rows)
{
if (!names.add(row.normalizedName))
{
duplicateNames.add(row.normalizedName);
}
}
return duplicateNames;
}
private TtProjectInfo createProject(ValidatedImportRow row, String diskType)
{
TtProjectInfo project = new TtProjectInfo();
project.setProjectName(row.projectName);
project.setProjectName1(row.projectName);
project.setProjectNum(extractProjectNum(row.projectName));
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
project.setProjectUrl(row.linkUrl);
}
else
{
project.setProjectBaiduUrl(row.linkUrl);
}
return project;
}
private void backfillProjectNumIfBlank(TtProjectInfo project, String projectName)
{
if (StringUtils.isNotBlank(project.getProjectNum()))
{
return;
}
String projectNum = extractProjectNum(projectName);
if (StringUtils.isNotBlank(projectNum)
&& ttProjectInfoMapper.updateProjectNumIfBlank(project.getId(), projectNum) == 1)
{
project.setProjectNum(projectNum);
}
}
private String extractProjectNum(String projectName)
{
Matcher matcher = PROJECT_NUM_PATTERN.matcher(projectName);
return matcher.find() ? matcher.group(1).toUpperCase(Locale.ROOT) : null;
}
private String normalizeProjectName(String projectName)
{
return StringUtils.trimToEmpty(projectName)
.replace("\uFEFF", "")
.replace('\u00A0', ' ')
.trim();
}
private String normalizeDiskType(String diskType)
{
String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT);
if (!NetDiskConstants.DISK_QUARK.equals(value) && !NetDiskConstants.DISK_BAIDU.equals(value))
{
throw new ServiceException("不支持的网盘类型");
}
return value;
}
private static class ValidatedImportRow
{
private final int rowNumber;
private final String projectName;
private final String normalizedName;
private final String linkUrl;
private ValidatedImportRow(int rowNumber, String projectName, String normalizedName, String linkUrl)
{
this.rowNumber = rowNumber;
this.projectName = projectName;
this.normalizedName = normalizedName;
this.linkUrl = linkUrl;
}
}
}

View File

@@ -0,0 +1,287 @@
package com.ruoyi.office.service.importer;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import cn.hutool.core.text.csv.CsvData;
import cn.hutool.core.text.csv.CsvReadConfig;
import cn.hutool.core.text.csv.CsvRow;
import cn.hutool.core.text.csv.CsvUtil;
import cn.hutool.poi.excel.ExcelReader;
import cn.hutool.poi.excel.ExcelUtil;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import com.ruoyi.office.service.netdisk.NetDiskConstants;
import org.apache.commons.io.FilenameUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* 解析夸克、百度网盘客户端导出的 CSV 或 Excel 文件。
*/
@Component
public class ProjectLinkImportParser
{
private static final long MAX_FILE_SIZE = 5L * 1024L * 1024L;
private static final int MAX_ROW_COUNT = 2000;
private static final Charset GB18030 = Charset.forName("GB18030");
public List<ProjectLinkImportRow> parse(MultipartFile file, String diskType)
{
validateFile(file);
String normalizedDiskType = normalizeDiskType(diskType);
String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(Locale.ROOT);
try
{
byte[] bytes = file.getBytes();
List<SourceRow> sourceRows;
if ("csv".equals(extension))
{
sourceRows = readCsv(bytes);
}
else if ("xls".equals(extension) || "xlsx".equals(extension))
{
sourceRows = readExcel(bytes);
}
else
{
throw new ServiceException("仅支持 csv、xls、xlsx 格式文件");
}
if (sourceRows.size() > MAX_ROW_COUNT)
{
throw new ServiceException("单次最多导入 " + MAX_ROW_COUNT + " 条数据");
}
validateHeaders(sourceRows, normalizedDiskType);
return convertRows(sourceRows, normalizedDiskType);
}
catch (ServiceException e)
{
throw e;
}
catch (Exception e)
{
throw new ServiceException("文件解析失败,请确认文件格式与网盘类型是否正确");
}
}
private void validateFile(MultipartFile file)
{
if (file == null || file.isEmpty())
{
throw new ServiceException("请选择需要导入的文件");
}
if (file.getSize() > MAX_FILE_SIZE)
{
throw new ServiceException("导入文件不能超过 5MB");
}
if (StringUtils.isBlank(file.getOriginalFilename()))
{
throw new ServiceException("无法识别导入文件名");
}
}
private String normalizeDiskType(String diskType)
{
String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT);
if (!NetDiskConstants.DISK_QUARK.equals(value) && !NetDiskConstants.DISK_BAIDU.equals(value))
{
throw new ServiceException("不支持的网盘类型");
}
return value;
}
private List<SourceRow> readCsv(byte[] bytes)
{
String csvText = decodeCsv(bytes);
CsvReadConfig config = CsvReadConfig.defaultConfig()
.setContainsHeader(true)
.setSkipEmptyRows(true)
.setErrorOnDifferentFieldCount(true);
CsvData csvData = CsvUtil.getReader(new StringReader(csvText), config).read();
List<SourceRow> rows = new ArrayList<>();
for (CsvRow csvRow : csvData.getRows())
{
rows.add(new SourceRow((int) csvRow.getOriginalLineNumber() + 1,
normalizeFieldMap(csvRow.getFieldMap())));
}
if (rows.isEmpty())
{
rows.add(new SourceRow(1, headerOnlyMap(csvData.getHeader())));
}
return rows;
}
private List<SourceRow> readExcel(byte[] bytes)
{
List<SourceRow> rows = new ArrayList<>();
try (ExcelReader reader = ExcelUtil.getReader(new ByteArrayInputStream(bytes)))
{
List<Map<String, Object>> excelRows = reader.readAll();
int rowNumber = 2;
for (Map<String, Object> excelRow : excelRows)
{
Map<String, String> fields = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : excelRow.entrySet())
{
fields.put(normalizeHeader(entry.getKey()), objectToString(entry.getValue()));
}
rows.add(new SourceRow(rowNumber++, fields));
}
if (rows.isEmpty())
{
List<Object> headers = reader.readRow(0);
Map<String, String> headerMap = new LinkedHashMap<>();
for (Object header : headers)
{
headerMap.put(normalizeHeader(objectToString(header)), "");
}
rows.add(new SourceRow(1, headerMap));
}
}
return rows;
}
private String decodeCsv(byte[] bytes)
{
try
{
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
CharBuffer decoded = decoder.decode(ByteBuffer.wrap(bytes));
return decoded.toString();
}
catch (CharacterCodingException ignored)
{
return new String(bytes, GB18030);
}
}
private Map<String, String> normalizeFieldMap(Map<String, String> source)
{
Map<String, String> fields = new LinkedHashMap<>();
for (Map.Entry<String, String> entry : source.entrySet())
{
fields.put(normalizeHeader(entry.getKey()), entry.getValue());
}
return fields;
}
private Map<String, String> headerOnlyMap(List<String> headers)
{
Map<String, String> fields = new LinkedHashMap<>();
if (headers != null)
{
for (String header : headers)
{
fields.put(normalizeHeader(header), "");
}
}
return fields;
}
private void validateHeaders(List<SourceRow> rows, String diskType)
{
Map<String, String> fields = rows.get(0).fields;
List<String> requiredHeaders = new ArrayList<>();
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
requiredHeaders.add("创建分享状态");
requiredHeaders.add("分享名");
requiredHeaders.add("分享地址");
}
else
{
requiredHeaders.add("文件名");
requiredHeaders.add("链接");
requiredHeaders.add("分享状态");
}
for (String header : requiredHeaders)
{
if (!fields.containsKey(header))
{
throw new ServiceException("文件缺少必要列:" + header);
}
}
}
private List<ProjectLinkImportRow> convertRows(List<SourceRow> sourceRows, String diskType)
{
List<ProjectLinkImportRow> rows = new ArrayList<>();
for (SourceRow sourceRow : sourceRows)
{
if (sourceRow.rowNumber == 1 && allValuesBlank(sourceRow.fields))
{
continue;
}
ProjectLinkImportRow row = new ProjectLinkImportRow();
row.setRowNumber(sourceRow.rowNumber);
if (NetDiskConstants.DISK_QUARK.equals(diskType))
{
row.setProjectName(sourceRow.fields.get("分享名"));
row.setShareAddress(sourceRow.fields.get("分享地址"));
row.setExtractCode(sourceRow.fields.get("提取码"));
row.setShareStatus(sourceRow.fields.get("创建分享状态"));
}
else
{
row.setProjectName(sourceRow.fields.get("文件名"));
row.setShareAddress(sourceRow.fields.get("链接"));
row.setExtractCode(sourceRow.fields.get("提取码"));
row.setShareStatus(sourceRow.fields.get("分享状态"));
}
rows.add(row);
}
return rows;
}
private boolean allValuesBlank(Map<String, String> fields)
{
for (String value : fields.values())
{
if (StringUtils.isNotBlank(value))
{
return false;
}
}
return true;
}
private String normalizeHeader(String header)
{
return StringUtils.trimToEmpty(header).replace("\uFEFF", "");
}
private String objectToString(Object value)
{
return value == null ? "" : String.valueOf(value);
}
private static class SourceRow
{
private final int rowNumber;
private final Map<String, String> fields;
private SourceRow(int rowNumber, Map<String, String> fields)
{
this.rowNumber = rowNumber;
this.fields = fields;
}
}
}

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

@@ -10,6 +10,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="codeDesc" column="code_desc" />
<result property="codeEnvironment" column="code_environment" />
<result property="codeTechnology" column="code_technology" />
<result property="frontendTechnology" column="frontend_technology" />
<result property="backendTechnology" column="backend_technology" />
<result property="databaseTechnology" column="database_technology" />
<result property="codeSource" column="code_source" />
<result property="paymentType" column="payment_type" />
<result property="diskLink" column="disk_link" />
@@ -19,7 +22,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectTtCodeVo">
select code_id, code_name, code_desc, code_environment, code_technology, code_source, payment_type, disk_link, publish_flag, picture_file, video_file from tt_code
select code_id, code_name, code_desc, code_environment, code_technology,
frontend_technology, backend_technology, database_technology,
code_source, payment_type, disk_link, publish_flag, picture_file, video_file
from tt_code
</sql>
<select id="selectTtCodeList" parameterType="TtCode" resultMap="TtCodeResult">
@@ -29,6 +35,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null and codeDesc != ''"> and code_desc = #{codeDesc}</if>
<if test="codeEnvironment != null and codeEnvironment != ''"> and code_environment = #{codeEnvironment}</if>
<if test="codeTechnology != null and codeTechnology != ''"> and code_technology = #{codeTechnology}</if>
<if test="frontendTechnology != null and frontendTechnology != ''"> and frontend_technology = #{frontendTechnology}</if>
<if test="backendTechnology != null and backendTechnology != ''"> and backend_technology = #{backendTechnology}</if>
<if test="databaseTechnology != null and databaseTechnology != ''"> and database_technology = #{databaseTechnology}</if>
<if test="codeSource != null and codeSource != ''"> and code_source = #{codeSource}</if>
<if test="paymentType != null and paymentType != ''"> and payment_type = #{paymentType}</if>
<if test="diskLink != null and diskLink != ''"> and disk_link = #{diskLink}</if>
@@ -36,13 +45,18 @@ 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">
<include refid="selectTtCodeVo"/>
where code_id = #{codeId}
</select>
<select id="selectTtCodeByCodeName" parameterType="String" resultMap="TtCodeResult">
<include refid="selectTtCodeVo"/>
where code_name = #{codeName} or code_name like concat('%', #{codeName}, '%') limit 1
</select>
<insert id="insertTtCode" parameterType="TtCode">
insert into tt_code
@@ -52,6 +66,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null">code_desc,</if>
<if test="codeEnvironment != null">code_environment,</if>
<if test="codeTechnology != null">code_technology,</if>
<if test="frontendTechnology != null">frontend_technology,</if>
<if test="backendTechnology != null">backend_technology,</if>
<if test="databaseTechnology != null">database_technology,</if>
<if test="codeSource != null">code_source,</if>
<if test="paymentType != null">payment_type,</if>
<if test="diskLink != null">disk_link,</if>
@@ -65,6 +82,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null">#{codeDesc},</if>
<if test="codeEnvironment != null">#{codeEnvironment},</if>
<if test="codeTechnology != null">#{codeTechnology},</if>
<if test="frontendTechnology != null">#{frontendTechnology},</if>
<if test="backendTechnology != null">#{backendTechnology},</if>
<if test="databaseTechnology != null">#{databaseTechnology},</if>
<if test="codeSource != null">#{codeSource},</if>
<if test="paymentType != null">#{paymentType},</if>
<if test="diskLink != null">#{diskLink},</if>
@@ -81,6 +101,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="codeDesc != null">code_desc = #{codeDesc},</if>
<if test="codeEnvironment != null">code_environment = #{codeEnvironment},</if>
<if test="codeTechnology != null">code_technology = #{codeTechnology},</if>
<if test="frontendTechnology != null">frontend_technology = #{frontendTechnology},</if>
<if test="backendTechnology != null">backend_technology = #{backendTechnology},</if>
<if test="databaseTechnology != null">database_technology = #{databaseTechnology},</if>
<if test="codeSource != null">code_source = #{codeSource},</if>
<if test="paymentType != null">payment_type = #{paymentType},</if>
<if test="diskLink != null">disk_link = #{diskLink},</if>
@@ -101,4 +124,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{codeId}
</foreach>
</delete>
</mapper>
</mapper>

View File

@@ -0,0 +1,85 @@
<?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.TtCopyTemplateMapper">
<resultMap type="TtCopyTemplate" id="TtCopyTemplateResult">
<result property="templateId" column="template_id" />
<result property="templateName" column="template_name" />
<result property="templateBody" column="template_body" />
<result property="sortNum" column="sort_num" />
<result property="status" column="status" />
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectTtCopyTemplateVo">
select template_id, template_name, template_body, sort_num, status, create_time, update_time
from tt_copy_template
</sql>
<select id="selectTtCopyTemplateList" parameterType="TtCopyTemplate" resultMap="TtCopyTemplateResult">
<include refid="selectTtCopyTemplateVo"/>
<where>
<if test="templateName != null and templateName != ''"> and template_name like concat('%', #{templateName}, '%')</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by sort_num asc, create_time asc
</select>
<select id="selectEnabledTemplates" resultMap="TtCopyTemplateResult">
<include refid="selectTtCopyTemplateVo"/>
where status = '0'
order by sort_num asc, create_time asc
</select>
<select id="selectTtCopyTemplateByTemplateId" parameterType="Long" resultMap="TtCopyTemplateResult">
<include refid="selectTtCopyTemplateVo"/>
where template_id = #{templateId}
</select>
<insert id="insertTtCopyTemplate" parameterType="TtCopyTemplate" useGeneratedKeys="true" keyProperty="templateId">
insert into tt_copy_template
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="templateName != null and templateName != ''">template_name,</if>
<if test="templateBody != null">template_body,</if>
<if test="sortNum != null">sort_num,</if>
<if test="status != null and status != ''">status,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="templateName != null and templateName != ''">#{templateName},</if>
<if test="templateBody != null">#{templateBody},</if>
<if test="sortNum != null">#{sortNum},</if>
<if test="status != null and status != ''">#{status},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim>
</insert>
<update id="updateTtCopyTemplate" parameterType="TtCopyTemplate">
update tt_copy_template
<trim prefix="SET" suffixOverrides=",">
<if test="templateName != null and templateName != ''">template_name = #{templateName},</if>
<if test="templateBody != null">template_body = #{templateBody},</if>
<if test="sortNum != null">sort_num = #{sortNum},</if>
<if test="status != null and status != ''">status = #{status},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim>
where template_id = #{templateId}
</update>
<delete id="deleteTtCopyTemplateByTemplateId" parameterType="Long">
delete from tt_copy_template where template_id = #{templateId}
</delete>
<delete id="deleteTtCopyTemplateByTemplateIds" parameterType="Long">
delete from tt_copy_template where template_id in
<foreach item="templateId" collection="array" open="(" separator="," close=")">
#{templateId}
</foreach>
</delete>
</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">
@@ -83,6 +113,25 @@
where id = #{id}
</update>
<update id="updateProjectQuarkUrl">
update tt_project_info
set project_url = #{projectUrl}
where id = #{id}
</update>
<update id="updateProjectBaiduUrl">
update tt_project_info
set project_baidu_url = #{projectBaiduUrl}
where id = #{id}
</update>
<update id="updateProjectNumIfBlank">
update tt_project_info
set project_num = #{projectNum}
where id = #{id}
and (project_num is null or trim(project_num) = '')
</update>
<delete id="deleteTtProjectInfoById" parameterType="Integer">
delete from tt_project_info where id = #{id}
</delete>
@@ -96,8 +145,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,61 @@
<?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>
<delete id="deleteByProjectIdAndDiskType">
delete from tt_project_link_check
where project_id = #{projectId}
and disk_type = #{diskType}
</delete>
</mapper>

View File

@@ -0,0 +1,56 @@
package com.ruoyi.office.service;
import java.util.Arrays;
import org.junit.Assert;
import org.junit.Test;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
public class CopyTemplateRendererTest
{
private final CopyTemplateRenderer renderer = new CopyTemplateRenderer();
@Test
public void rendersCodeFieldsAndScreenshots()
{
TtCopyTemplate template = new TtCopyTemplate();
template.setTemplateBody("{codeName}|{projectCode}|{projectName}|{codeDesc}|"
+ "{codeEnvironment}|{frontendTechnology}|{backendTechnology}|"
+ "{databaseTechnology}|{codeTechnology}|{diskLink}|{screenshots}");
TtCode code = new TtCode();
code.setCodeName("【S008】基于SpringBoot实现的爱心众筹系统");
code.setCodeDesc("<p>第一段</p><p>第二段</p>");
code.setCodeEnvironment("JDK 17");
code.setFrontendTechnology("Vue3");
code.setBackendTechnology("SpringBoot");
code.setDatabaseTechnology("MySQL");
code.setCodeTechnology("ECharts");
code.setDiskLink("https://example.com/resource");
TtFile screenshot = new TtFile();
screenshot.setFileName("首页");
screenshot.setFileUrl("https://example.com/home.png");
code.setFileList(Arrays.asList(screenshot));
String content = renderer.render(template, code);
Assert.assertEquals("【S008】基于SpringBoot实现的爱心众筹系统|S008|爱心众筹系统|"
+ "第一段\n第二段|JDK 17|Vue3|SpringBoot|MySQL|ECharts|"
+ "https://example.com/resource|1.首页\n![](https://example.com/home.png)\n\n", content);
}
@Test
public void usesFallbackWhenThereAreNoScreenshots()
{
TtCopyTemplate template = new TtCopyTemplate();
template.setTemplateBody("{screenshots}");
String content = renderer.render(template, new TtCode());
Assert.assertEquals("请前往微信小程序:南音源码库。查看项目详情!\n\n", content);
}
}

View File

@@ -0,0 +1,244 @@
package com.ruoyi.office.service.impl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import com.ruoyi.app.domain.AppBlogArticle;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.domain.AppResourceList;
import com.ruoyi.app.mapper.AppBlogArticleMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.service.IAppResourceService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.office.domain.TtCode;
import com.ruoyi.office.domain.TtCopyTemplate;
import com.ruoyi.office.domain.TtFile;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtCopyTemplateMapper;
import com.ruoyi.office.mapper.TtFileMapper;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.service.CopyTemplateRenderer;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class TtCodeServiceImplTest
{
@InjectMocks
private TtCodeServiceImpl service;
@Mock
private TtCodeMapper codeMapper;
@Mock
private TtFileMapper fileMapper;
@Mock
private AppBlogArticleMapper articleMapper;
@Mock
private AppResourceMapper resourceMapper;
@Mock
private IAppResourceService resourceService;
@Mock
private TtProjectInfoMapper projectInfoMapper;
@Mock
private TtCopyTemplateMapper copyTemplateMapper;
@Mock
private CopyTemplateRenderer templateRenderer;
private TtCode code;
private TtProjectInfo projectInfo;
@Before
public void setUp()
{
TtCopyTemplate template = new TtCopyTemplate();
template.setTemplateId(1L);
template.setStatus("0");
code = new TtCode();
code.setCodeId(9L);
code.setCodeName("【S009】自习室预约选座系统");
projectInfo = new TtProjectInfo();
projectInfo.setProjectNum("S009");
projectInfo.setProjectBaiduUrl("https://pan.baidu.com/s/example");
projectInfo.setProjectUrl("https://pan.quark.cn/s/example");
TtFile firstImage = new TtFile();
firstImage.setFileName("第一张");
firstImage.setFileUrl("https://img.example.com/first.png");
TtFile secondImage = new TtFile();
secondImage.setFileName("第二张");
secondImage.setFileUrl("https://img.example.com/second.png");
when(copyTemplateMapper.selectTtCopyTemplateByTemplateId(1L)).thenReturn(template);
when(codeMapper.selectTtCodeByCodeId(9L)).thenReturn(code);
when(projectInfoMapper.selectTtProjectInfoByName(any())).thenReturn(projectInfo);
when(fileMapper.selectTtFileList(any())).thenReturn(Arrays.asList(firstImage, secondImage));
when(templateRenderer.render(template, code)).thenReturn("模板正文");
}
@Test
public void createsSPremiumProjectWithPaidSpecs()
{
projectInfo.setProjectNum(null);
when(articleMapper.selectAppBlogArticleByTitle(code.getCodeName())).thenReturn(null);
when(resourceService.insertAppResource(any())).thenAnswer(invocation -> {
AppResource resource = invocation.getArgument(0);
resource.setId(100L);
return 1;
});
service.transToArticle1(9L, 1L);
ArgumentCaptor<AppResource> resourceCaptor = ArgumentCaptor.forClass(AppResource.class);
verify(resourceService).insertAppResource(resourceCaptor.capture());
AppResource resource = resourceCaptor.getValue();
Assert.assertEquals(Long.valueOf(6L), resource.getResourceType());
Assert.assertEquals(Long.valueOf(3L), resource.getIsAd());
Assert.assertEquals(Integer.valueOf(5900), resource.getPriceFen());
Assert.assertEquals("https://img.example.com/first.png", resource.getShowImg());
Assert.assertEquals("https://pan.quark.cn/s/example", code.getDiskLink());
assertPremiumSpecs(resource.getAppResourceListList(), 5900, 9900);
ArgumentCaptor<AppBlogArticle> articleCaptor = ArgumentCaptor.forClass(AppBlogArticle.class);
verify(articleMapper).insertAppBlogArticle(articleCaptor.capture());
Assert.assertEquals("https://img.example.com/first.png", articleCaptor.getValue().getShowImg());
Assert.assertEquals(Long.valueOf(7L), articleCaptor.getValue().getArticleType());
}
@Test
public void createsKPremiumProjectWithPaidSpecs()
{
code.setCodeName("【K001】药店进销存系统");
projectInfo.setProjectNum(" k001 ");
when(resourceService.insertAppResource(any())).thenAnswer(invocation -> {
AppResource resource = invocation.getArgument(0);
resource.setId(101L);
return 1;
});
service.transToArticle1(9L, 1L);
ArgumentCaptor<AppResource> resourceCaptor = ArgumentCaptor.forClass(AppResource.class);
verify(resourceService).insertAppResource(resourceCaptor.capture());
AppResource resource = resourceCaptor.getValue();
Assert.assertEquals(Long.valueOf(6L), resource.getResourceType());
Assert.assertEquals(Long.valueOf(3L), resource.getIsAd());
Assert.assertEquals(Integer.valueOf(1900), resource.getPriceFen());
assertPremiumSpecs(resource.getAppResourceListList(), 1900, 6600);
ArgumentCaptor<AppBlogArticle> articleCaptor = ArgumentCaptor.forClass(AppBlogArticle.class);
verify(articleMapper).insertAppBlogArticle(articleCaptor.capture());
Assert.assertEquals(Long.valueOf(7L), articleCaptor.getValue().getArticleType());
}
@Test
public void repairsArticleLeftByPreviousFailedConversion()
{
AppBlogArticle existingArticle = new AppBlogArticle();
existingArticle.setId(200L);
existingArticle.setTitle(code.getCodeName());
existingArticle.setAppResourceId(100L);
AppResource existingResource = new AppResource();
existingResource.setId(100L);
existingResource.setAppResourceListList(new ArrayList<AppResourceList>());
when(articleMapper.selectAppBlogArticleByTitle(code.getCodeName())).thenReturn(existingArticle);
when(resourceMapper.selectAppResourceById(100L)).thenReturn(existingResource);
String message = service.transToArticle1(9L, 1L);
Assert.assertEquals("源码转文章成功,已补全资源链接!", message);
Assert.assertEquals("https://img.example.com/first.png", existingResource.getShowImg());
Assert.assertEquals("https://img.example.com/first.png", existingArticle.getShowImg());
Assert.assertEquals(Long.valueOf(6L), existingResource.getResourceType());
Assert.assertEquals(Long.valueOf(3L), existingResource.getIsAd());
Assert.assertEquals(Long.valueOf(7L), existingArticle.getArticleType());
assertPremiumSpecs(existingResource.getAppResourceListList(), 5900, 9900);
verify(resourceService).updateAppResource(existingResource);
verify(articleMapper).updateAppBlogArticle(existingArticle);
verify(resourceService, never()).insertAppResource(any());
verify(articleMapper, never()).insertAppBlogArticle(any());
}
@Test
public void keepsLegacyRulesForOtherProjectSeries()
{
code.setCodeName("【A001】普通源码项目");
projectInfo.setProjectNum("A001");
when(resourceService.insertAppResource(any())).thenAnswer(invocation -> {
AppResource resource = invocation.getArgument(0);
resource.setId(102L);
return 1;
});
service.transToArticle1(9L, 1L);
ArgumentCaptor<AppResource> resourceCaptor = ArgumentCaptor.forClass(AppResource.class);
verify(resourceService).insertAppResource(resourceCaptor.capture());
AppResource resource = resourceCaptor.getValue();
Assert.assertEquals(Long.valueOf(5L), resource.getResourceType());
Assert.assertEquals(Long.valueOf(2L), resource.getIsAd());
Assert.assertEquals(Integer.valueOf(0), resource.getPriceFen());
Assert.assertEquals("https://pan.baidu.com/s/example", code.getDiskLink());
Assert.assertEquals("百度网盘", resource.getAppResourceListList().get(0).getListName());
Assert.assertEquals("夸克网盘", resource.getAppResourceListList().get(1).getListName());
ArgumentCaptor<AppBlogArticle> articleCaptor = ArgumentCaptor.forClass(AppBlogArticle.class);
verify(articleMapper).insertAppBlogArticle(articleCaptor.capture());
Assert.assertEquals(Long.valueOf(4L), articleCaptor.getValue().getArticleType());
}
@Test
public void rejectsPremiumProjectWithoutQuarkLink()
{
projectInfo.setProjectUrl(null);
try
{
service.transToArticle1(9L, 1L);
Assert.fail("应拒绝未配置夸克链接的精品项目");
}
catch (ServiceException exception)
{
Assert.assertEquals("该项目未配置夸克网盘链接,无法生成付费规格", exception.getMessage());
}
verify(resourceService, never()).insertAppResource(any());
verify(articleMapper, never()).insertAppBlogArticle(any());
}
private void assertPremiumSpecs(List<AppResourceList> items, int firstPrice, int secondPrice)
{
Assert.assertEquals(2, items.size());
assertPremiumSpec(items.get(0), "源码 + 数据库 + 论文 + 答辩PPT",
"https://pan.quark.cn/s/example", firstPrice, 1);
assertPremiumSpec(items.get(1), "源码 + 数据库 + 论文 + 答辩PPT + 项目部署",
"调试部署加微信forfeastcoding", secondPrice, 2);
}
private void assertPremiumSpec(AppResourceList item, String name, String url,
int priceFen, int sortOrder)
{
Assert.assertEquals(name, item.getListName());
Assert.assertEquals(url, item.getListUrl());
Assert.assertEquals(Integer.valueOf(priceFen), item.getPriceFen());
Assert.assertEquals(Integer.valueOf(1), item.getStatus());
Assert.assertEquals(Integer.valueOf(sortOrder), item.getSortOrder());
Assert.assertEquals("", item.getPassword());
}
}

View File

@@ -0,0 +1,150 @@
package com.ruoyi.office.service.impl;
import java.util.Collections;
import com.ruoyi.office.domain.ProjectLinkImportResult;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import com.ruoyi.office.domain.TtProjectInfo;
import com.ruoyi.office.mapper.TtCodeMapper;
import com.ruoyi.office.mapper.TtProjectInfoMapper;
import com.ruoyi.office.mapper.TtProjectLinkCheckMapper;
import com.ruoyi.office.service.importer.ProjectLinkImportParser;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.web.multipart.MultipartFile;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(MockitoJUnitRunner.class)
public class TtProjectInfoServiceImplTest
{
@InjectMocks
private TtProjectInfoServiceImpl service;
@Mock
private TtProjectInfoMapper projectInfoMapper;
@Mock
private TtCodeMapper codeMapper;
@Mock
private TtProjectLinkCheckMapper linkCheckMapper;
@Mock
private ProjectLinkImportParser parser;
@Mock
private MultipartFile file;
private ProjectLinkImportRow quarkRow;
@Before
public void setUp()
{
quarkRow = new ProjectLinkImportRow();
quarkRow.setRowNumber(2);
quarkRow.setProjectName("【S031】婚纱摄影管理系统");
quarkRow.setShareAddress("分享内容\r\n链接https://pan.quark.cn/s/741bf63df1bb");
quarkRow.setShareStatus("成功");
}
@Test
public void createsMissingProjectWithBothNameFields()
{
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.emptyList());
when(projectInfoMapper.insertTtProjectInfo(any(TtProjectInfo.class))).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
ArgumentCaptor<TtProjectInfo> captor = ArgumentCaptor.forClass(TtProjectInfo.class);
verify(projectInfoMapper).insertTtProjectInfo(captor.capture());
TtProjectInfo inserted = captor.getValue();
Assert.assertEquals("【S031】婚纱摄影管理系统", inserted.getProjectName());
Assert.assertEquals("【S031】婚纱摄影管理系统", inserted.getProjectName1());
Assert.assertEquals("S031", inserted.getProjectNum());
Assert.assertEquals("https://pan.quark.cn/s/741bf63df1bb", inserted.getProjectUrl());
Assert.assertEquals(1, result.getAddedCount());
}
@Test
public void extractsAnyTextInsideFullWidthBracketsAsProjectNumber()
{
quarkRow.setProjectName("课程项目【 K001 】药店进销存系统");
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.emptyList());
when(projectInfoMapper.insertTtProjectInfo(any(TtProjectInfo.class))).thenReturn(1);
service.importProjectLinks(file, "QUARK");
ArgumentCaptor<TtProjectInfo> captor = ArgumentCaptor.forClass(TtProjectInfo.class);
verify(projectInfoMapper).insertTtProjectInfo(captor.capture());
Assert.assertEquals("K001", captor.getValue().getProjectNum());
}
@Test
public void backfillsProjectNumberForExistingProjectWhenBlank()
{
quarkRow.setProjectName("【K001】药店进销存系统");
TtProjectInfo existing = new TtProjectInfo();
existing.setId(1);
existing.setProjectName("【K001】药店进销存系统");
existing.setProjectUrl("https://pan.quark.cn/s/741bf63df1bb");
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.singletonList(existing));
when(projectInfoMapper.updateProjectNumIfBlank(1, "K001")).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
verify(projectInfoMapper).updateProjectNumIfBlank(1, "K001");
Assert.assertEquals("K001", existing.getProjectNum());
Assert.assertEquals(1, result.getUnchangedCount());
}
@Test
public void updatesOnlyBaiduLinkForExistingProjectAndAddsPasscode()
{
ProjectLinkImportRow baiduRow = new ProjectLinkImportRow();
baiduRow.setRowNumber(2);
baiduRow.setProjectName("【S031】婚纱摄影管理系统");
baiduRow.setShareAddress("https://pan.baidu.com/s/1abc");
baiduRow.setExtractCode("iuxg");
baiduRow.setShareStatus("生成成功");
TtProjectInfo existing = new TtProjectInfo();
existing.setId(31);
existing.setProjectNum("S031");
existing.setProjectName("【S031】婚纱摄影管理系统");
existing.setProjectName1("原源码名称");
existing.setProjectUrl("https://pan.quark.cn/s/old");
when(parser.parse(file, "BAIDU")).thenReturn(Collections.singletonList(baiduRow));
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
.thenReturn(Collections.singletonList(existing));
when(projectInfoMapper.updateProjectBaiduUrl(31,
"https://pan.baidu.com/s/1abc?pwd=iuxg")).thenReturn(1);
ProjectLinkImportResult result = service.importProjectLinks(file, "BAIDU");
verify(projectInfoMapper).updateProjectBaiduUrl(31,
"https://pan.baidu.com/s/1abc?pwd=iuxg");
verify(linkCheckMapper).deleteByProjectIdAndDiskType(31, "BAIDU");
verify(projectInfoMapper, never()).updateTtProjectInfo(any(TtProjectInfo.class));
verify(projectInfoMapper, never()).insertTtProjectInfo(any(TtProjectInfo.class));
Assert.assertEquals("原源码名称", existing.getProjectName1());
Assert.assertEquals(1, result.getUpdatedCount());
}
}

View File

@@ -0,0 +1,89 @@
package com.ruoyi.office.service.importer;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import com.ruoyi.office.domain.ProjectLinkImportRow;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.mock.web.MockMultipartFile;
public class ProjectLinkImportParserTest
{
private final ProjectLinkImportParser parser = new ProjectLinkImportParser();
@Test
public void parsesQuarkCsvWithMultilineShareAddress()
{
String csv = "创建分享状态,分享名,分享地址,提取码,分享时间\r\n"
+ "成功,【S031】婚纱摄影管理系统,\"我用夸克网盘分享了文件。\r\n"
+ "链接https://pan.quark.cn/s/741bf63df1bb\",,2026-07-29 12:25\r\n";
MockMultipartFile file = new MockMultipartFile("file", "quark.csv", "text/csv",
csv.getBytes(StandardCharsets.UTF_8));
List<ProjectLinkImportRow> rows = parser.parse(file, "QUARK");
Assert.assertEquals(1, rows.size());
Assert.assertEquals(2, rows.get(0).getRowNumber());
Assert.assertEquals("【S031】婚纱摄影管理系统", rows.get(0).getProjectName());
Assert.assertTrue(rows.get(0).getShareAddress().contains("https://pan.quark.cn/s/741bf63df1bb"));
Assert.assertEquals("成功", rows.get(0).getShareStatus());
}
@Test
public void parsesBaiduCsv()
{
String csv = "文件名,链接,提取码,分享时间,分享状态\r\n"
+ "【S001】家政服务人员技能评级系统,"
+ "https://pan.baidu.com/s/1EimsC2N8zzvrCP6mn3fNyQ,iuxg,"
+ "2026-07-29 12:35,生成成功\r\n";
MockMultipartFile file = new MockMultipartFile("file", "baidu.csv", "text/csv",
csv.getBytes(StandardCharsets.UTF_8));
List<ProjectLinkImportRow> rows = parser.parse(file, "BAIDU");
Assert.assertEquals(1, rows.size());
Assert.assertEquals(2, rows.get(0).getRowNumber());
Assert.assertEquals("【S001】家政服务人员技能评级系统", rows.get(0).getProjectName());
Assert.assertEquals("iuxg", rows.get(0).getExtractCode());
Assert.assertEquals("生成成功", rows.get(0).getShareStatus());
}
@Test
public void parsesBaiduExcel() throws Exception
{
byte[] excelBytes;
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream())
{
Sheet sheet = workbook.createSheet("分享记录");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("文件名");
header.createCell(1).setCellValue("链接");
header.createCell(2).setCellValue("提取码");
header.createCell(3).setCellValue("分享时间");
header.createCell(4).setCellValue("分享状态");
Row data = sheet.createRow(1);
data.createCell(0).setCellValue("【S001】家政服务人员技能评级系统");
data.createCell(1).setCellValue("https://pan.baidu.com/s/1abc");
data.createCell(2).setCellValue("iuxg");
data.createCell(3).setCellValue("2026-07-29 12:35");
data.createCell(4).setCellValue("生成成功");
workbook.write(output);
excelBytes = output.toByteArray();
}
MockMultipartFile file = new MockMultipartFile("file", "baidu.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", excelBytes);
List<ProjectLinkImportRow> rows = parser.parse(file, "BAIDU");
Assert.assertEquals(1, rows.size());
Assert.assertEquals("【S001】家政服务人员技能评级系统", rows.get(0).getProjectName());
Assert.assertEquals("生成成功", rows.get(0).getShareStatus());
}
}

View File

@@ -40,6 +40,17 @@ public class AppBlogArticle extends BaseEntity
@Excel(name = "封面图url")
private String showImg;
/** List-card thumbnail URL (not persisted). */
private String showImgThumb;
/** 视频号演示视频feedId */
@Excel(name = "视频号演示视频ID")
private String videoFeedId;
/** 第二个视频号演示视频feedId */
@Excel(name = "视频号演示视频ID 2")
private String videoFeedId2;
/** 关联已有资源关联app_resource id */
@Excel(name = "关联已有资源", readConverterExp = "关=联app_resource,i=d")
private Long appResourceId;
@@ -79,6 +90,17 @@ public class AppBlogArticle extends BaseEntity
private List<String> picList;
/** 关联的资源对象 */
private AppResource appResource;
public AppResource getAppResource() {
return appResource;
}
public void setAppResource(AppResource appResource) {
this.appResource = appResource;
}
public String getOrderType() {
return orderType;
}
@@ -148,6 +170,33 @@ public class AppBlogArticle extends BaseEntity
{
return showImg;
}
public void setShowImgThumb(String showImgThumb)
{
this.showImgThumb = showImgThumb;
}
public String getShowImgThumb()
{
return showImgThumb;
}
public void setVideoFeedId(String videoFeedId)
{
this.videoFeedId = videoFeedId;
}
public String getVideoFeedId()
{
return videoFeedId;
}
public void setVideoFeedId2(String videoFeedId2)
{
this.videoFeedId2 = videoFeedId2;
}
public String getVideoFeedId2()
{
return videoFeedId2;
}
public void setAppResourceId(Long appResourceId)
{
this.appResourceId = appResourceId;
@@ -237,6 +286,8 @@ public class AppBlogArticle extends BaseEntity
.append("contentInfo", getContentInfo())
.append("articleType", getArticleType())
.append("showImg", getShowImg())
.append("videoFeedId", getVideoFeedId())
.append("videoFeedId2", getVideoFeedId2())
.append("appResourceId", getAppResourceId())
.append("lookNumber", getLookNumber())
.append("loveNumber", getLoveNumber())

View File

@@ -0,0 +1,108 @@
package com.ruoyi.app.domain;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 首页业务指标汇总。
*/
public class AppDashboardSummary implements Serializable
{
private static final long serialVersionUID = 1L;
private Long resourceTotal;
private Long resourceToday;
private Long userTotal;
private Long userToday;
private Long orderTotal;
private Long orderToday;
private BigDecimal amountTotal;
private BigDecimal amountToday;
public Long getResourceTotal()
{
return resourceTotal;
}
public void setResourceTotal(Long resourceTotal)
{
this.resourceTotal = resourceTotal;
}
public Long getResourceToday()
{
return resourceToday;
}
public void setResourceToday(Long resourceToday)
{
this.resourceToday = resourceToday;
}
public Long getUserTotal()
{
return userTotal;
}
public void setUserTotal(Long userTotal)
{
this.userTotal = userTotal;
}
public Long getUserToday()
{
return userToday;
}
public void setUserToday(Long userToday)
{
this.userToday = userToday;
}
public Long getOrderTotal()
{
return orderTotal;
}
public void setOrderTotal(Long orderTotal)
{
this.orderTotal = orderTotal;
}
public Long getOrderToday()
{
return orderToday;
}
public void setOrderToday(Long orderToday)
{
this.orderToday = orderToday;
}
public BigDecimal getAmountTotal()
{
return amountTotal;
}
public void setAmountTotal(BigDecimal amountTotal)
{
this.amountTotal = amountTotal;
}
public BigDecimal getAmountToday()
{
return amountToday;
}
public void setAmountToday(BigDecimal amountToday)
{
this.amountToday = amountToday;
}
}

View File

@@ -32,6 +32,10 @@ public class AppPayOrder extends BaseEntity
@Excel(name = "用户ID")
private Long userId;
/** 资源ID */
@Excel(name = "资源ID")
private Long resourceId;
/** openid */
@Excel(name = "openid")
private String openId;
@@ -135,6 +139,14 @@ public class AppPayOrder extends BaseEntity
return payTime;
}
public Long getResourceId() {
return resourceId;
}
public void setResourceId(Long resourceId) {
this.resourceId = resourceId;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
@@ -142,6 +154,7 @@ public class AppPayOrder extends BaseEntity
.append("orderNo", getOrderNo())
.append("tradeNo", getTradeNo())
.append("userId", getUserId())
.append("resourceId", getResourceId())
.append("openId", getOpenId())
.append("amount", getAmount())
.append("points", getPoints())

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

@@ -30,6 +30,21 @@ public class AppResourceList extends BaseEntity
@Excel(name = "访问密码")
private String password;
/** 规格价格,单位分 */
@Excel(name = "规格价格(分)")
private Integer priceFen;
/** 状态1启用0停用 */
@Excel(name = "规格状态", readConverterExp = "0=停用,1=启用")
private Integer status;
/** 显示顺序 */
@Excel(name = "显示顺序")
private Integer sortOrder;
/** 当前用户是否已购买,仅用于小程序详情返回 */
private Boolean purchased;
/** 关联主表app_resource */
@Excel(name = "关联主表app_resource")
private Long appResourceId;
@@ -70,6 +85,47 @@ public class AppResourceList extends BaseEntity
{
return password;
}
public Integer getPriceFen()
{
return priceFen;
}
public void setPriceFen(Integer priceFen)
{
this.priceFen = priceFen;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
public Integer getSortOrder()
{
return sortOrder;
}
public void setSortOrder(Integer sortOrder)
{
this.sortOrder = sortOrder;
}
public Boolean getPurchased()
{
return purchased;
}
public void setPurchased(Boolean purchased)
{
this.purchased = purchased;
}
public void setAppResourceId(Long appResourceId)
{
this.appResourceId = appResourceId;
@@ -87,6 +143,10 @@ public class AppResourceList extends BaseEntity
.append("listName", getListName())
.append("listUrl", getListUrl())
.append("password", getPassword())
.append("priceFen", getPriceFen())
.append("status", getStatus())
.append("sortOrder", getSortOrder())
.append("purchased", getPurchased())
.append("appResourceId", getAppResourceId())
.toString();
}

View File

@@ -0,0 +1,265 @@
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 Long resourceListId;
@Excel(name = "购买规格")
private String specName;
@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 Long getResourceListId()
{
return resourceListId;
}
public void setResourceListId(Long resourceListId)
{
this.resourceListId = resourceListId;
}
public String getSpecName()
{
return specName;
}
public void setSpecName(String specName)
{
this.specName = specName;
}
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,131 @@
package com.ruoyi.app.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 小程序“我的订单”安全展示对象,不包含 OpenID 和微信交易号等敏感字段。
*/
public class AppVirtualOrderSummary implements Serializable
{
private static final long serialVersionUID = 1L;
private String orderNo;
private Long resourceId;
private String resourceTitle;
private Long resourceListId;
private String specName;
private Integer priceFen;
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date payTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date refundTime;
public String getOrderNo()
{
return orderNo;
}
public void setOrderNo(String orderNo)
{
this.orderNo = orderNo;
}
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 Long getResourceListId()
{
return resourceListId;
}
public void setResourceListId(Long resourceListId)
{
this.resourceListId = resourceListId;
}
public String getSpecName()
{
return specName;
}
public void setSpecName(String specName)
{
this.specName = specName;
}
public Integer getPriceFen()
{
return priceFen;
}
public void setPriceFen(Integer priceFen)
{
this.priceFen = priceFen;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
public Date getCreateTime()
{
return createTime;
}
public void setCreateTime(Date createTime)
{
this.createTime = createTime;
}
public Date getPayTime()
{
return payTime;
}
public void setPayTime(Date payTime)
{
this.payTime = payTime;
}
public Date getRefundTime()
{
return refundTime;
}
public void setRefundTime(Date refundTime)
{
this.refundTime = refundTime;
}
}

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,45 @@
package com.ruoyi.app.domain.request;
import javax.validation.constraints.NotBlank;
public class CreateVirtualOrderRequest
{
/** 旧版小程序兼容字段;新版按 resourceListId 下单。 */
private Long resourceId;
/** 资源下载项ID一条下载项即一个付费规格。 */
private Long resourceListId;
@NotBlank(message = "微信登录凭证不能为空")
private String code;
public Long getResourceId()
{
return resourceId;
}
public void setResourceId(Long resourceId)
{
this.resourceId = resourceId;
}
public Long getResourceListId()
{
return resourceListId;
}
public void setResourceListId(Long resourceListId)
{
this.resourceListId = resourceListId;
}
public String getCode()
{
return code;
}
public void setCode(String code)
{
this.code = code;
}
}

View File

@@ -3,6 +3,8 @@ package com.ruoyi.app.mapper;
import java.util.List;
import com.ruoyi.app.domain.AppBlogArticle;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
@@ -21,6 +23,14 @@ public interface AppBlogArticleMapper
*/
public AppBlogArticle selectAppBlogArticleById(Long id);
/**
* 根据完整标题精确查询文章。
*
* @param title 文章标题
* @return 文章
*/
public AppBlogArticle selectAppBlogArticleByTitle(String title);
/**
* 查询文章列表
*
@@ -29,6 +39,10 @@ public interface AppBlogArticleMapper
*/
public List<AppBlogArticle> selectAppBlogArticleList(AppBlogArticle appBlogArticle);
/** 查询指定类型的可见文章数量 */
@Select("select count(*) from app_blog_article where article_type = #{articleType} and is_show = #{isShow}")
public Long countByArticleType(@Param("articleType") Long articleType, @Param("isShow") Long isShow);
/**
* 新增文章
*

View File

@@ -0,0 +1,11 @@
package com.ruoyi.app.mapper;
import com.ruoyi.app.domain.AppDashboardSummary;
/**
* 首页业务指标数据访问层。
*/
public interface AppDashboardMapper
{
AppDashboardSummary selectDashboardSummary();
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.app.mapper;
import java.util.List;
import com.ruoyi.app.domain.AppIntegralRecord;
import org.apache.ibatis.annotations.Param;
/**
* 积分记录Mapper接口
@@ -27,6 +28,8 @@ public interface AppIntegralRecordMapper
*/
public List<AppIntegralRecord> selectAppIntegralRecordList(AppIntegralRecord appIntegralRecord);
public List<AppIntegralRecord> selectMyIntegralRecordList(@Param("userId") Long userId);
public int selectAppIntegralRecordCount(AppIntegralRecord appIntegralRecord);
/**

View File

@@ -4,6 +4,7 @@ import java.util.List;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.domain.AppResourceList;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
@@ -22,6 +23,20 @@ public interface AppResourceMapper
*/
public AppResource selectAppResourceById(Long id);
/**
* 根据下载项ID查询规格。
*
* @param id 下载项ID
* @return 资源规格
*/
public AppResourceList selectAppResourceListById(Long id);
/**
* 查询用户当前拥有的最高资源规格,用于计算版本升级差价。
*/
public AppResourceList selectHighestEntitledResourceList(@Param("userId") Long userId,
@Param("resourceId") Long resourceId);
/**
* 查询资源列表
*
@@ -30,6 +45,10 @@ public interface AppResourceMapper
*/
public List<AppResource> selectAppResourceList(AppResource appResource);
/** 查询可见资源数量 */
@Select("select count(*) from app_resource where is_show = #{isShow}")
public Long countByIsShow(@Param("isShow") Long isShow);
/**
* 新增资源
*
@@ -87,6 +106,17 @@ public interface AppResourceMapper
*/
public int deleteAppResourceListByAppResourceId(Long id);
/**
* 查询本次保存将删除且已经产生订单的规格数量。
*/
public int countOrderedRemovedResourceLists(@Param("resourceId") Long resourceId,
@Param("retainedIds") List<Long> retainedIds);
/**
* 查询资源是否已经产生虚拟支付订单。
*/
public int countVirtualOrdersByResourceId(Long resourceId);
/**
* 查询资源(根据用户判断是否需要广告)
*

View File

@@ -0,0 +1,48 @@
package com.ruoyi.app.mapper;
import com.ruoyi.app.domain.AppVirtualOrder;
import com.ruoyi.app.domain.AppVirtualOrderSummary;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
public interface AppVirtualOrderMapper
{
String lockOpenIdForOrder(@Param("userId") Long userId);
int insertAppVirtualOrder(AppVirtualOrder order);
AppVirtualOrder selectByOrderNo(String orderNo);
AppVirtualOrder selectPendingByPurchase(@Param("userId") Long userId,
@Param("resourceId") Long resourceId,
@Param("resourceListId") Long resourceListId);
AppVirtualOrder selectAppVirtualOrderById(Long id);
List<AppVirtualOrder> selectAppVirtualOrderList(AppVirtualOrder order);
List<AppVirtualOrderSummary> selectMyOrderList(@Param("userId") Long userId);
int countAnyEntitlement(@Param("userId") Long userId,
@Param("resourceId") Long resourceId,
@Param("resourceListId") Long resourceListId);
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 cancelPendingOrder(@Param("orderNo") String orderNo, @Param("userId") Long userId);
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,11 @@
package com.ruoyi.app.service;
import com.ruoyi.app.domain.AppDashboardSummary;
/**
* 首页业务指标服务。
*/
public interface IAppDashboardService
{
AppDashboardSummary selectDashboardSummary();
}

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,23 @@
package com.ruoyi.app.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.ruoyi.app.domain.AppVirtualOrderSummary;
import com.ruoyi.app.domain.request.CreateVirtualOrderRequest;
import java.util.List;
import java.util.Map;
public interface IAppVirtualPayService
{
Map<String, Object> createOrder(CreateVirtualOrderRequest request);
Map<String, Object> queryOrder(String orderNo, boolean sync);
List<AppVirtualOrderSummary> listMyOrders();
void cancelOrder(String orderNo);
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

@@ -0,0 +1,23 @@
package com.ruoyi.app.service.impl;
import com.ruoyi.app.domain.AppDashboardSummary;
import com.ruoyi.app.mapper.AppDashboardMapper;
import com.ruoyi.app.service.IAppDashboardService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 首页业务指标服务实现。
*/
@Service
public class AppDashboardServiceImpl implements IAppDashboardService
{
@Autowired
private AppDashboardMapper appDashboardMapper;
@Override
public AppDashboardSummary selectDashboardSummary()
{
return appDashboardMapper.selectDashboardSummary();
}
}

View File

@@ -4,6 +4,7 @@ import java.util.Date;
import java.util.List;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.mapper.SysUserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -99,7 +100,9 @@ public class AppIntegralRecordServiceImpl implements IAppIntegralRecordService
@Override
public int czAppIntegra(AppIntegralRecord appIntegralRecord) {
appIntegralRecord.setSource("充值积分");
if (StringUtils.isBlank(appIntegralRecord.getSource())) {
appIntegralRecord.setSource("充值积分");
}
appIntegralRecord.setIsAdd(0L);
appIntegralRecord.setIntegralTime(new Date());
int count = appIntegralRecordMapper.insertAppIntegralRecord(appIntegralRecord);

View File

@@ -1,16 +1,21 @@
package com.ruoyi.app.service.impl;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import com.ruoyi.common.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.transaction.annotation.Transactional;
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 +29,9 @@ public class AppResourceServiceImpl implements IAppResourceService
@Autowired
private AppResourceMapper appResourceMapper;
@Autowired
private AppVirtualProductMapper virtualProductMapper;
/**
* 查询资源
*
@@ -58,6 +66,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 +83,8 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override
public int updateAppResource(AppResource appResource)
{
configureVirtualProduct(appResource);
ensureNoOrderedSpecRemoved(appResource);
appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId());
insertAppResourceList(appResource);
return appResourceMapper.updateAppResource(appResource);
@@ -89,6 +100,13 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override
public int deleteAppResourceByIds(Long[] ids)
{
for (Long id : ids)
{
if (appResourceMapper.countVirtualOrdersByResourceId(id) > 0)
{
throw new ServiceException("资源已产生支付订单,不能删除;可以将资源隐藏");
}
}
appResourceMapper.deleteAppResourceListByAppResourceIds(ids);
return appResourceMapper.deleteAppResourceByIds(ids);
}
@@ -103,6 +121,10 @@ public class AppResourceServiceImpl implements IAppResourceService
@Override
public int deleteAppResourceById(Long id)
{
if (appResourceMapper.countVirtualOrdersByResourceId(id) > 0)
{
throw new ServiceException("资源已产生支付订单,不能删除;可以将资源隐藏");
}
appResourceMapper.deleteAppResourceListByAppResourceId(id);
return appResourceMapper.deleteAppResourceById(id);
}
@@ -119,9 +141,18 @@ public class AppResourceServiceImpl implements IAppResourceService
if (StringUtils.isNotNull(appResourceListList))
{
List<AppResourceList> list = new ArrayList<AppResourceList>();
for (AppResourceList appResourceList : appResourceListList)
for (int index = 0; index < appResourceListList.size(); index++)
{
AppResourceList appResourceList = appResourceListList.get(index);
appResourceList.setAppResourceId(id);
if (appResourceList.getStatus() == null)
{
appResourceList.setStatus(1);
}
if (appResourceList.getSortOrder() == null)
{
appResourceList.setSortOrder(index);
}
list.add(appResourceList);
}
if (list.size() > 0)
@@ -130,4 +161,176 @@ public class AppResourceServiceImpl implements IAppResourceService
}
}
}
/**
* 付费资源按价格档位自动匹配已配置的微信道具。
*/
private void configureVirtualProduct(AppResource resource)
{
List<AppResourceList> resourceSpecs = resource.getAppResourceListList();
validateResourceAccessPasswords(resourceSpecs);
if (resource.getIsAd() == null || resource.getIsAd() != 3L)
{
resource.setPriceFen(0);
if (resourceSpecs != null)
{
for (int index = 0; index < resourceSpecs.size(); index++)
{
AppResourceList resourceSpec = resourceSpecs.get(index);
resourceSpec.setPriceFen(0);
if (resourceSpec.getStatus() == null)
{
resourceSpec.setStatus(1);
}
if (resourceSpec.getSortOrder() == null)
{
resourceSpec.setSortOrder(index);
}
}
}
return;
}
if (resourceSpecs == null || resourceSpecs.isEmpty())
{
throw new ServiceException("付费资源至少需要配置一个购买规格");
}
Integer minimumActivePrice = null;
String minimumPriceProductId = null;
for (int index = 0; index < resourceSpecs.size(); index++)
{
AppResourceList resourceSpec = resourceSpecs.get(index);
if (StringUtils.isBlank(resourceSpec.getListName()))
{
throw new ServiceException("" + (index + 1) + "个规格名称不能为空");
}
if (StringUtils.isBlank(resourceSpec.getListUrl()))
{
throw new ServiceException("规格“" + resourceSpec.getListName() + "”的资源地址不能为空");
}
if (resourceSpec.getPriceFen() == null || resourceSpec.getPriceFen() <= 0)
{
throw new ServiceException("规格“" + resourceSpec.getListName() + "”的价格必须大于0分");
}
if (resourceSpec.getStatus() == null)
{
resourceSpec.setStatus(1);
}
if (resourceSpec.getSortOrder() == null)
{
resourceSpec.setSortOrder(index);
}
if (resourceSpec.getStatus() != 1)
{
continue;
}
AppVirtualProduct priceProduct = virtualProductMapper.selectActiveByPrice(resourceSpec.getPriceFen());
if (priceProduct == null || StringUtils.isBlank(priceProduct.getProductId()))
{
throw new ServiceException("规格“" + resourceSpec.getListName()
+ "”的价格档位未配置微信道具");
}
if (minimumActivePrice == null || resourceSpec.getPriceFen() < minimumActivePrice)
{
minimumActivePrice = resourceSpec.getPriceFen();
minimumPriceProductId = priceProduct.getProductId();
}
}
if (minimumActivePrice == null)
{
throw new ServiceException("付费资源至少需要启用一个购买规格");
}
List<AppResourceList> orderedSpecs = new ArrayList<AppResourceList>(resourceSpecs);
Collections.sort(orderedSpecs, new Comparator<AppResourceList>()
{
@Override
public int compare(AppResourceList left, AppResourceList right)
{
return left.getSortOrder().compareTo(right.getSortOrder());
}
});
for (int index = 0; index < orderedSpecs.size(); index++)
{
AppResourceList current = orderedSpecs.get(index);
if (index > 0)
{
AppResourceList previous = orderedSpecs.get(index - 1);
if (current.getSortOrder().equals(previous.getSortOrder()))
{
throw new ServiceException("版本排序不能重复,请按版本等级从低到高填写");
}
if (current.getPriceFen() <= previous.getPriceFen())
{
throw new ServiceException("版本价格必须随排序等级递增");
}
}
if (current.getStatus() != 1)
{
continue;
}
for (int lowerIndex = 0; lowerIndex < index; lowerIndex++)
{
AppResourceList lower = orderedSpecs.get(lowerIndex);
int upgradePrice = current.getPriceFen() - lower.getPriceFen();
AppVirtualProduct upgradeProduct = virtualProductMapper.selectActiveByPrice(upgradePrice);
if (upgradeProduct == null || StringUtils.isBlank(upgradeProduct.getProductId()))
{
throw new ServiceException("从“" + lower.getListName() + "”升级到“"
+ current.getListName() + "”的差价" + upgradePrice + "分未配置微信道具");
}
}
}
// 主表价格仅用于列表展示和兼容旧版小程序,实际下单始终读取规格价格。
resource.setPriceFen(minimumActivePrice);
resource.setVirtualProductId(minimumPriceProductId);
}
/**
* 防止浏览器自动填充把资源链接误写到访问密码字段。
*/
private void validateResourceAccessPasswords(List<AppResourceList> resourceSpecs)
{
if (resourceSpecs == null)
{
return;
}
for (AppResourceList resourceSpec : resourceSpecs)
{
String password = resourceSpec.getPassword();
if (StringUtils.isNotBlank(password) && password.trim().matches("(?i)^https?://.*"))
{
throw new ServiceException("规格“" + resourceSpec.getListName()
+ "”的访问密码不能填写网盘链接,请填写到资源地址");
}
}
}
/**
* 已产生订单的规格必须保留原ID管理员可以停用但不能删除。
*/
private void ensureNoOrderedSpecRemoved(AppResource resource)
{
if (resource.getId() == null)
{
return;
}
List<Long> retainedIds = new ArrayList<Long>();
if (resource.getAppResourceListList() != null)
{
for (AppResourceList resourceSpec : resource.getAppResourceListList())
{
if (resourceSpec.getId() != null)
{
retainedIds.add(resourceSpec.getId());
}
}
}
if (appResourceMapper.countOrderedRemovedResourceLists(resource.getId(), retainedIds) > 0)
{
throw new ServiceException("已产生订单的规格不能删除,请将规格状态改为停用");
}
}
}

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,657 @@
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.AppResource;
import com.ruoyi.app.domain.AppResourceList;
import com.ruoyi.app.domain.AppVirtualOrder;
import com.ruoyi.app.domain.AppVirtualOrderSummary;
import com.ruoyi.app.domain.AppVirtualProduct;
import com.ruoyi.app.domain.request.CreateVirtualOrderRequest;
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.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 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.dao.DuplicateKeyException;
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.List;
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 AppVirtualProductMapper virtualProductMapper;
private final AppVirtualOrderMapper virtualOrderMapper;
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,
AppVirtualProductMapper virtualProductMapper,
AppVirtualOrderMapper virtualOrderMapper,
ObjectMapper objectMapper)
{
this.virtualPayConfig = virtualPayConfig;
this.wxPayConfig = wxPayConfig;
this.wxCodeSessionService = wxCodeSessionService;
this.appResourceMapper = appResourceMapper;
this.virtualProductMapper = virtualProductMapper;
this.virtualOrderMapper = virtualOrderMapper;
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();
WxCodeSession codeSession = wxCodeSessionService.exchange(request.getCode());
// 微信换码不占用数据库锁;换码完成后再锁定用户行,将同一用户的下单请求串行化。
String lockedOpenId = virtualOrderMapper.lockOpenIdForOrder(userId);
if (StringUtils.isBlank(lockedOpenId))
{
throw new ServiceException("当前账号未绑定微信");
}
if (!MessageDigest.isEqual(lockedOpenId.getBytes(StandardCharsets.UTF_8),
codeSession.getOpenId().getBytes(StandardCharsets.UTF_8)))
{
throw new ServiceException("微信身份与当前登录账号不一致");
}
AppResourceList resourceSpec = null;
AppResource resource;
if (request.getResourceListId() != null)
{
resourceSpec = appResourceMapper.selectAppResourceListById(request.getResourceListId());
if (resourceSpec == null)
{
throw new ServiceException("购买规格不存在");
}
resource = appResourceMapper.selectAppResourceById(resourceSpec.getAppResourceId());
}
else
{
// 兼容已发布的旧版小程序。新版小程序必须按 resourceListId 购买具体规格。
if (request.getResourceId() == null)
{
throw new ServiceException("购买规格不能为空");
}
resource = appResourceMapper.selectAppResourceById(request.getResourceId());
}
if (resource == null || resource.getIsAd() == null || resource.getIsAd() != 3L)
{
throw new ServiceException("该资源不支持虚拟支付");
}
if (resourceSpec == null)
{
int activeSpecCount = 0;
int specCount = resource.getAppResourceListList() == null
? 0 : resource.getAppResourceListList().size();
if (resource.getAppResourceListList() != null)
{
for (AppResourceList item : resource.getAppResourceListList())
{
if (item.getStatus() != null && item.getStatus() == 1)
{
activeSpecCount++;
resourceSpec = item;
}
}
}
if (specCount != 1 || activeSpecCount != 1)
{
throw new ServiceException("该资源包含多个购买规格,请升级小程序后选择版本");
}
}
if (resourceSpec.getStatus() == null || resourceSpec.getStatus() != 1)
{
throw new ServiceException("该购买规格已停用");
}
Integer orderPrice = resourceSpec.getPriceFen();
if (orderPrice == null || orderPrice <= 0)
{
throw new ServiceException("购买规格价格未配置");
}
Long resourceListId = resourceSpec.getId();
// 旧版整项权益和积分兑换都代表已经拥有完整资源,不能再购买任一版本。
if (virtualOrderMapper.countAnyEntitlement(userId, resource.getId(), null) > 0)
{
throw new ServiceException("该资源已经解锁");
}
AppResourceList highestOwnedSpec = appResourceMapper.selectHighestEntitledResourceList(
userId, resource.getId());
if (highestOwnedSpec != null)
{
if (resourceSpec.getSortOrder() == null || highestOwnedSpec.getSortOrder() == null)
{
throw new ServiceException("资源版本等级未配置");
}
if (highestOwnedSpec.getSortOrder() >= resourceSpec.getSortOrder())
{
throw new ServiceException("该版本已购买或已包含在更高版本中");
}
if (highestOwnedSpec.getPriceFen() == null || highestOwnedSpec.getPriceFen() <= 0)
{
throw new ServiceException("已购版本价格未配置");
}
orderPrice = resourceSpec.getPriceFen() - highestOwnedSpec.getPriceFen();
if (orderPrice <= 0)
{
throw new ServiceException("版本价格必须随等级递增");
}
}
AppVirtualOrder pendingOrder = virtualOrderMapper.selectPendingByPurchase(
userId, resource.getId(), resourceListId);
if (pendingOrder != null)
{
if (!orderPrice.equals(pendingOrder.getPriceFen()))
{
throw new ServiceException("升级价格已变化,请先在我的订单中取消原待支付订单");
}
if (!MessageDigest.isEqual(lockedOpenId.getBytes(StandardCharsets.UTF_8),
pendingOrder.getOpenId().getBytes(StandardCharsets.UTF_8)))
{
throw new ServiceException("待支付订单与当前微信账号不一致");
}
return buildPaymentParams(pendingOrder, codeSession.getSessionKey(), true);
}
AppVirtualProduct product = virtualProductMapper.selectActiveByPrice(orderPrice);
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.setResourceListId(resourceListId);
order.setSpecName(resourceSpec.getListName());
order.setProductId(product.getProductId());
order.setPriceFen(orderPrice);
order.setOpenId(lockedOpenId);
order.setStatus(0);
order.setCreateTime(DateUtils.getNowDate());
try
{
virtualOrderMapper.insertAppVirtualOrder(order);
}
catch (DuplicateKeyException e)
{
throw new ServiceException("该规格已有支付中或已支付订单,请刷新后重试");
}
return buildPaymentParams(order, codeSession.getSessionKey(), false);
}
private Map<String, Object> buildPaymentParams(AppVirtualOrder order, String sessionKey, boolean reused)
{
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", order.getProductId());
signDataMap.put("goodsPrice", order.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("reused", reused);
result.put("priceFen", order.getPriceFen());
result.put("signData", signData);
result.put("paySig", hmacSha256(virtualPayConfig.getAppKey(),
"requestVirtualPayment&" + signData));
result.put("signature", hmacSha256(sessionKey, 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("resourceListId", order.getResourceListId());
result.put("specName", order.getSpecName());
result.put("status", order.getStatus());
result.put("unlocked", order.getStatus() == 1);
return result;
}
@Override
public List<AppVirtualOrderSummary> listMyOrders()
{
return virtualOrderMapper.selectMyOrderList(SecurityUtils.getUserId());
}
@Override
@Transactional
public void cancelOrder(String orderNo)
{
if (StringUtils.isBlank(orderNo))
{
throw new ServiceException("订单号不能为空");
}
Long userId = SecurityUtils.getUserId();
AppVirtualOrder order = virtualOrderMapper.selectByOrderNo(orderNo);
if (order == null || !userId.equals(order.getUserId()))
{
throw new ServiceException("订单不存在");
}
if (order.getStatus() != null && order.getStatus() == 3)
{
return;
}
if (order.getStatus() != null && order.getStatus() == 0)
{
// 先向微信核对一次,避免支付成功但回调尚未落库时误关单。
syncOrderFromWechat(order);
order = virtualOrderMapper.selectByOrderNo(orderNo);
}
if (order != null && order.getStatus() != null && order.getStatus() == 3)
{
return;
}
if (order.getStatus() == null || order.getStatus() != 0)
{
throw new ServiceException("仅待支付订单可以取消");
}
if (virtualOrderMapper.cancelPendingOrder(orderNo, userId) != 1)
{
throw new ServiceException("订单状态已更新,请刷新后重试");
}
}
@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);
}
}
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

@@ -4,8 +4,10 @@ import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.ruoyi.app.domain.AppIntegralRecord;
import com.ruoyi.app.domain.AppPayOrder;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.mapper.AppIntegralRecordMapper;
import com.ruoyi.app.mapper.AppPayOrderMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.OrderNoGenerator;
import com.ruoyi.common.wx.*;
@@ -59,6 +61,8 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
@Autowired
private SysUserMapper sysUserMapper;
@Autowired
private AppResourceMapper appResourceMapper;
@Autowired
private AppPayOrderMapper appPayOrderMapper;
@Autowired
private AppIntegralRecordMapper appIntegralRecordMapper;
@@ -72,13 +76,21 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
@Override
public Response createOrder(CreateOrderReq req) {
SysUser sysUser = sysUserMapper.selectUserById(req.getUserId());
AppResource appResource = null;
if (!Objects.isNull(req.getResourceId())) {
appResource = appResourceMapper.selectAppResourceById(req.getResourceId());
}
String openId = sysUser.getUserName();
String orderNo = OrderNoGenerator.generatePayOrderNo();
// 创建本地订单
// 这里做本地业务相关的处理,包括生成一个订单号传递给微信,等于通过这个值来形成两边的数据对应。后续微信那边会返回他们的订单编号,也建议存在自己这边的数据库里。
PayOrderInfo order = new PayOrderInfo();
order.setOutTradeNo(orderNo);
order.setDescription("积分充值");
if (Objects.isNull(appResource)) {
order.setDescription("积分充值");
} else {
order.setDescription("购买资源:" + appResource.getResourceTitle());
}
order.setAmount(new BigDecimal(req.getAmount()));
// 请求微信支付相关配置
@@ -127,19 +139,20 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
}
// 保存订单信息
saveOrder(orderNo, req.getAmount(), req.getPoints(), req.getUserId(), openId);
saveOrder(orderNo, req.getAmount(), req.getPoints(), req.getUserId(), req.getResourceId(), openId);
return Response.success(response);
}
private void saveOrder(String orderNo, Long amount, Long points, Long userId, String openId) {
private void saveOrder(String orderNo, Long amount, Long points, Long userId, Long resourceId, String openId) {
AppPayOrder order = appPayOrderMapper.selectAppPayOrderByOrderNo(orderNo);
if(Objects.isNull(order)){
if (Objects.isNull(order)) {
order = new AppPayOrder();
}
order.setOrderNo(orderNo);
order.setAmount(amount);
order.setPoints(points);
order.setUserId(userId);
order.setResourceId(resourceId);
order.setOpenId(openId);
order.setStatus(0);
order.setCreateTime(new Date());
@@ -221,7 +234,9 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
order.setPayTime(new Date());
appPayOrderMapper.updateAppPayOrder(order);
// 更新充值积分
updateUserPoints(order.getUserId(), order.getPoints());
if (order.getPoints() != 0) {
updateUserPoints(order.getUserId(), order.getPoints());
}
returnMap.put("code", "SUCCESS");
returnMap.put("message", "成功");
@@ -234,6 +249,7 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
if (sysUser == null) {
throw new RuntimeException("用户不存在");
}
AppIntegralRecord appIntegralRecord = new AppIntegralRecord();
appIntegralRecord.setUserId(userId);
appIntegralRecord.setIntegralNumber(points);
@@ -241,6 +257,7 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
appIntegralRecord.setIsAdd(0L);
appIntegralRecord.setIntegralTime(new Date());
appIntegralRecordMapper.insertAppIntegralRecord(appIntegralRecord);
// 更新用户积分
sysUser.setIntegral(sysUser.getIntegral() + points.intValue());
sysUserMapper.updateUser(sysUser);
}
@@ -279,7 +296,7 @@ public class WxMiniappPayServiceImpl implements WxMiniappPayService {
// TODO 修改订单信息
return Response.success(result.getTradeStateDesc());
} catch (ServiceException e) {
} catch (ServiceException e) {
log.error("根据支付订单号查询订单订单查询失败发送HTTP请求成功返回异常返回码{},返回信息:", e.getErrorCode(), e);
return Response.error("订单查询失败");
} catch (MalformedMessageException e) {

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

@@ -11,6 +11,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="articleType" column="article_type" />
<result property="keyword" column="keyword" />
<result property="showImg" column="show_img" />
<result property="videoFeedId" column="video_feed_id" />
<result property="videoFeedId2" column="video_feed_id_2" />
<result property="appResourceId" column="app_resource_id" />
<result property="lookNumber" column="look_number" />
<result property="loveNumber" column="love_number" />
@@ -25,25 +27,66 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="weight" column="weight" />
</resultMap>
<resultMap type="AppBlogArticle" id="AppBlogArticleWithResourceResult" extends="AppBlogArticleResult">
<association property="appResource" javaType="AppResource">
<id property="id" column="resource_id" />
<result property="resourceTitle" column="resource_title" />
<result property="showImg" column="resource_show_img" />
<result property="explain" column="resource_explain" />
<result property="resourceType" column="resource_type" />
<result property="keyword" column="resource_keyword" />
<result property="isShow" column="resource_is_show" />
<result property="isAd" column="resource_is_ad" />
<result property="adNumber" column="resource_ad_number" />
<result property="downNum" column="resource_down_num" />
<result property="weight" column="resource_weight" />
<result property="delFlag" column="resource_del_flag" />
<result property="createBy" column="resource_create_by" />
<result property="createTime" column="resource_create_time" />
<result property="remark" column="resource_remark" />
</association>
</resultMap>
<sql id="selectAppBlogArticleVo">
select id, title, content_info, article_type, keyword, show_img, app_resource_id, look_number, weight, love_number, is_recommendation, is_show, is_ad, ad_number, del_flag, create_by, create_time, remark from app_blog_article
select id, title, content_info, article_type, keyword, show_img, video_feed_id, video_feed_id_2, app_resource_id, look_number, weight, love_number, is_recommendation, is_show, is_ad, ad_number, del_flag, create_by, create_time, remark from app_blog_article
</sql>
<select id="selectAppBlogArticleList" parameterType="AppBlogArticle" resultMap="AppBlogArticleResult">
<include refid="selectAppBlogArticleVo"/>
<select id="selectAppBlogArticleList" parameterType="AppBlogArticle" resultMap="AppBlogArticleWithResourceResult">
select
a.id, a.title, a.content_info, a.article_type, a.keyword, a.show_img, a.video_feed_id, a.video_feed_id_2, a.app_resource_id,
a.look_number, a.weight, a.love_number, a.is_recommendation, a.is_show, a.is_ad,
a.ad_number, a.del_flag, a.create_by, a.create_time, a.remark,
r.id as resource_id,
r.resource_title,
r.show_img as resource_show_img,
r.explain as resource_explain,
r.resource_type,
r.keyword as resource_keyword,
r.is_show as resource_is_show,
r.is_ad as resource_is_ad,
r.ad_number as resource_ad_number,
r.down_num as resource_down_num,
r.weight as resource_weight,
r.del_flag as resource_del_flag,
r.create_by as resource_create_by,
r.create_time as resource_create_time,
r.remark as resource_remark
from app_blog_article a
left join app_resource r on a.app_resource_id = r.id
<where>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="contentInfo != null and contentInfo != ''"> and content_info like concat('%', #{contentInfo}, '%')</if>
<if test="articleType != null "> and article_type = #{articleType}</if>
<if test="appResourceId != null "> and app_resource_id = #{appResourceId}</if>
<if test="isRecommendation != null "> and is_recommendation = #{isRecommendation}</if>
<if test="isShow != null "> and is_show = #{isShow}</if>
<if test="weight != null "> and weight = #{weight}</if>
<if test="keyword != null and keyword != ''"> and (keyword like concat('%', #{keyword}, '%') or title like concat('%', #{keyword}, '%'))</if>
<if test="title != null and title != ''"> and a.title like concat('%', #{title}, '%')</if>
<if test="contentInfo != null and contentInfo != ''"> and a.content_info like concat('%', #{contentInfo}, '%')</if>
<if test="articleType != null "> and a.article_type = #{articleType}</if>
<if test="appResourceId != null "> and a.app_resource_id = #{appResourceId}</if>
<if test="isRecommendation != null "> and a.is_recommendation = #{isRecommendation}</if>
<if test="isShow != null "> and a.is_show = #{isShow}</if>
<if test="weight != null "> and a.weight = #{weight}</if>
<if test="keyword != null and keyword != ''"> and (a.keyword like concat('%', #{keyword}, '%') or a.title like concat('%', #{keyword}, '%'))</if>
</where>
<choose>
<when test="orderType == 'look_number'"> ORDER BY look_number desc, create_time desc</when >
<otherwise> ORDER BY weight desc, create_time desc</otherwise >
<when test="orderType == 'look_number'"> ORDER BY a.look_number desc, a.create_time desc</when >
<when test="orderType == 'create_time'"> ORDER BY a.create_time desc, a.id desc</when>
<otherwise> ORDER BY a.weight desc, a.create_time desc</otherwise >
</choose>
</select>
@@ -51,6 +94,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectAppBlogArticleVo"/>
where id = #{id}
</select>
<select id="selectAppBlogArticleByTitle" parameterType="String" resultMap="AppBlogArticleResult">
<include refid="selectAppBlogArticleVo"/>
where title = #{title}
limit 1
</select>
<insert id="insertAppBlogArticle" parameterType="AppBlogArticle" useGeneratedKeys="true" keyProperty="id">
insert into app_blog_article
@@ -60,6 +109,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="articleType != null">article_type,</if>
<if test="keyword != null and keyword != ''">keyword,</if>
<if test="showImg != null">show_img,</if>
<if test="videoFeedId != null">video_feed_id,</if>
<if test="videoFeedId2 != null">video_feed_id_2,</if>
<if test="appResourceId != null">app_resource_id,</if>
<if test="lookNumber != null">look_number,</if>
<if test="loveNumber != null">love_number,</if>
@@ -79,6 +130,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="articleType != null">#{articleType},</if>
<if test="keyword != null and keyword != ''">#{keyword},</if>
<if test="showImg != null">#{showImg},</if>
<if test="videoFeedId != null">#{videoFeedId},</if>
<if test="videoFeedId2 != null">#{videoFeedId2},</if>
<if test="appResourceId != null">#{appResourceId},</if>
<if test="lookNumber != null">#{lookNumber},</if>
<if test="loveNumber != null">#{loveNumber},</if>
@@ -102,6 +155,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="articleType != null">article_type = #{articleType},</if>
<if test="keyword != null and keyword != ''">keyword = #{keyword},</if>
<if test="showImg != null">show_img = #{showImg},</if>
<if test="videoFeedId != null">video_feed_id = #{videoFeedId},</if>
<if test="videoFeedId2 != null">video_feed_id_2 = #{videoFeedId2},</if>
<if test="appResourceId != null">app_resource_id = #{appResourceId},</if>
<if test="lookNumber != null">look_number = #{lookNumber},</if>
<if test="loveNumber != null">love_number = #{loveNumber},</if>
@@ -128,4 +183,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id}
</foreach>
</delete>
</mapper>
</mapper>

View File

@@ -0,0 +1,78 @@
<?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.AppDashboardMapper">
<resultMap id="AppDashboardSummaryResult" type="AppDashboardSummary">
<result property="resourceTotal" column="resource_total"/>
<result property="resourceToday" column="resource_today"/>
<result property="userTotal" column="user_total"/>
<result property="userToday" column="user_today"/>
<result property="orderTotal" column="order_total"/>
<result property="orderToday" column="order_today"/>
<result property="amountTotal" column="amount_total"/>
<result property="amountToday" column="amount_today"/>
</resultMap>
<select id="selectDashboardSummary" resultMap="AppDashboardSummaryResult">
SELECT
(SELECT COUNT(1)
FROM app_resource
WHERE del_flag = 0) AS resource_total,
(SELECT COUNT(1)
FROM app_resource
WHERE del_flag = 0
AND create_time &gt;= CURDATE()
AND create_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY)) AS resource_today,
(SELECT COUNT(1)
FROM sys_user
WHERE del_flag = '0') AS user_total,
(SELECT COUNT(1)
FROM sys_user
WHERE del_flag = '0'
AND create_time &gt;= CURDATE()
AND create_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY)) AS user_today,
((SELECT COUNT(1)
FROM app_pay_order
WHERE status = 1)
+
(SELECT COUNT(1)
FROM app_virtual_order
WHERE status = 1)) AS order_total,
((SELECT COUNT(1)
FROM app_pay_order
WHERE status = 1
AND pay_time &gt;= CURDATE()
AND pay_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY))
+
(SELECT COUNT(1)
FROM app_virtual_order
WHERE status = 1
AND pay_time &gt;= CURDATE()
AND pay_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY))) AS order_today,
CAST(
COALESCE((SELECT SUM(amount)
FROM app_pay_order
WHERE status = 1), 0)
+
COALESCE((SELECT SUM(price_fen)
FROM app_virtual_order
WHERE status = 1), 0) / 100
AS DECIMAL(20, 2)
) AS amount_total,
CAST(
COALESCE((SELECT SUM(amount)
FROM app_pay_order
WHERE status = 1
AND pay_time &gt;= CURDATE()
AND pay_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY)), 0)
+
COALESCE((SELECT SUM(price_fen)
FROM app_virtual_order
WHERE status = 1
AND pay_time &gt;= CURDATE()
AND pay_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY)), 0) / 100
AS DECIMAL(20, 2)
) AS amount_today
</select>
</mapper>

View File

@@ -36,6 +36,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</where> ORDER BY a.integral_time desc
</select>
<select id="selectMyIntegralRecordList" resultMap="AppIntegralRecordResult">
<include refid="selectAppIntegralRecordVo"/>
where a.user_id = #{userId}
and a.is_add in (0, 1)
order by a.integral_time desc, a.id desc
</select>
<select id="selectAppIntegralRecordCount" parameterType="AppIntegralRecord" resultType="int">
SELECT COUNT(0) FROM app_integral_record a
<where>
@@ -96,4 +103,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id}
</foreach>
</delete>
</mapper>
</mapper>

View File

@@ -9,6 +9,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="orderNo" column="order_no" />
<result property="tradeNo" column="trade_no" />
<result property="userId" column="user_id" />
<result property="resourceId" column="resource_id" />
<result property="openId" column="open_id" />
<result property="amount" column="amount" />
<result property="points" column="points" />
@@ -18,7 +19,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectAppPayOrderVo">
select id, order_no, trade_no, user_id, open_id, amount, points, status, create_time, pay_time from app_pay_order
select id, order_no, trade_no, user_id, resource_id, open_id, amount, points, status, create_time, pay_time from app_pay_order
</sql>
<select id="selectAppPayOrderList" parameterType="AppPayOrder" resultMap="AppPayOrderResult">
@@ -49,6 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="orderNo != null and orderNo != ''">order_no,</if>
<if test="tradeNo != null and tradeNo != ''">trade_no,</if>
<if test="userId != null">user_id,</if>
<if test="resourceId != null">resource_id,</if>
<if test="openId != null and openId != ''">open_id,</if>
<if test="amount != null">amount,</if>
<if test="points != null">points,</if>
@@ -60,6 +62,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="orderNo != null and orderNo != ''">#{orderNo},</if>
<if test="tradeNo != null and tradeNo != ''">#{tradeNo},</if>
<if test="userId != null">#{userId},</if>
<if test="resourceId != null">#{resourceId},</if>
<if test="openId != null and openId != ''">#{openId},</if>
<if test="amount != null">#{amount},</if>
<if test="points != null">#{points},</if>
@@ -75,6 +78,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="orderNo != null and orderNo != ''">order_no = #{orderNo},</if>
<if test="tradeNo != null and tradeNo != ''">trade_no = #{tradeNo},</if>
<if test="userId != null">user_id = #{userId},</if>
<if test="resourceId != null">resource_id = #{resourceId},</if>
<if test="openId != null and openId != ''">open_id = #{openId},</if>
<if test="amount != null">amount = #{amount},</if>
<if test="points != null">points = #{points},</if>
@@ -115,13 +119,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
LEFT JOIN (
SELECT
DATE_FORMAT(pay_time, '%Y-%m-%d') AS date,
amount
CAST(amount AS DECIMAL(18, 2)) AS amount
FROM app_pay_order
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY)
AND status = 1
UNION ALL
SELECT
DATE_FORMAT(pay_time, '%Y-%m-%d') AS date,
CAST(price_fen AS DECIMAL(18, 2)) / 100 AS amount
FROM app_virtual_order
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY)
AND status = 1
) AS pay ON dates.date = pay.date
WHERE dates.date >= DATE_SUB(CURDATE(), INTERVAL #{days} DAY)
GROUP BY dates.date
ORDER BY dates.date
</select>
</mapper>
</mapper>

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" />
@@ -31,35 +33,72 @@
<result property="listName" column="sub_list_name" />
<result property="listUrl" column="sub_list_url" />
<result property="password" column="sub_password" />
<result property="priceFen" column="sub_price_fen" />
<result property="status" column="sub_status" />
<result property="sortOrder" column="sub_sort_order" />
<result property="purchased" column="sub_purchased" />
<result property="appResourceId" column="sub_app_resource_id" />
</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,
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
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.price_fen as sub_price_fen,
b.status as sub_status, b.sort_order as sub_sort_order,
0 as sub_purchased, 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
where a.id = #{id}
order by b.sort_order asc, b.id asc
</select>
<select id="selectAppResourceListById" parameterType="Long" resultType="AppResourceList">
select id, list_name as listName, list_url as listUrl, password,
price_fen as priceFen, status, sort_order as sortOrder,
app_resource_id as appResourceId
from app_resource_list
where id = #{id}
limit 1
</select>
<select id="selectHighestEntitledResourceList" resultType="AppResourceList">
select rl.id, rl.list_name as listName, rl.list_url as listUrl, rl.password,
rl.price_fen as priceFen, rl.status, rl.sort_order as sortOrder,
rl.app_resource_id as appResourceId
from app_resource_entitlement re
inner join app_resource_list rl on rl.id = re.resource_list_id
where re.user_id = #{userId}
and re.resource_id = #{resourceId}
and re.status = 1
order by rl.sort_order desc, rl.id desc
limit 1
</select>
<insert id="insertAppResource" parameterType="AppResource" useGeneratedKeys="true" keyProperty="id">
@@ -73,6 +112,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 +129,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 +150,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>
@@ -142,23 +184,129 @@
</delete>
<insert id="batchAppResourceList">
insert into app_resource_list( id, list_name, list_url, password, app_resource_id) values
insert into app_resource_list
(id, list_name, list_url, password, price_fen, status, sort_order, app_resource_id) values
<foreach item="item" index="index" collection="list" separator=",">
( #{item.id}, #{item.listName}, #{item.listUrl}, #{item.password}, #{item.appResourceId})
(#{item.id}, #{item.listName}, #{item.listUrl}, #{item.password},
#{item.priceFen}, #{item.status}, #{item.sortOrder}, #{item.appResourceId})
</foreach>
</insert>
<select id="countOrderedRemovedResourceLists" resultType="int">
select count(1)
from app_virtual_order vo
inner join app_resource_list rl on rl.id = vo.resource_list_id
where rl.app_resource_id = #{resourceId}
<if test="retainedIds != null and retainedIds.size() > 0">
and rl.id not in
<foreach item="id" collection="retainedIds" open="(" separator="," close=")">
#{id}
</foreach>
</if>
</select>
<select id="countVirtualOrdersByResourceId" parameterType="Long" resultType="int">
select count(1) from app_virtual_order where resource_id = #{resourceId}
</select>
<select id="selectAppResourceByIdAndUserId" resultMap="AppResourceAppResourceListResult">
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
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,
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
select a.id, a.resource_title, a.show_img, a.`explain`, a.`keyword`, a.resource_type, a.is_show,
case
when #{userId} is not null
and (
(a.is_ad in (2, 3)
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
(a.is_ad = 3
and exists(select 1 from app_resource_entitlement re
where re.resource_id = a.id and re.user_id = #{userId}
and re.status = 1 and re.resource_list_id is null))
)
then 0
else a.is_ad
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,
case when
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
left join app_resource_list owned_rl
on owned_rl.id = re.resource_list_id
where re.resource_id = a.id and re.user_id = #{userId}
and re.status = 1
and (re.resource_list_id is null
or (owned_rl.app_resource_id = a.id
and owned_rl.sort_order &gt;= b.sort_order)))
)
)
then b.list_url else null end as sub_list_url,
case when
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
left join app_resource_list owned_rl
on owned_rl.id = re.resource_list_id
where re.resource_id = a.id and re.user_id = #{userId}
and re.status = 1
and (re.resource_list_id is null
or (owned_rl.app_resource_id = a.id
and owned_rl.sort_order &gt;= b.sort_order)))
)
)
then b.password else null end as sub_password,
b.price_fen as sub_price_fen, b.status as sub_status,
b.sort_order as sub_sort_order,
case when
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
left join app_resource_list owned_rl
on owned_rl.id = re.resource_list_id
where re.resource_id = a.id and re.user_id = #{userId}
and re.status = 1
and (re.resource_list_id is null
or (owned_rl.app_resource_id = a.id
and owned_rl.sort_order &gt;= b.sort_order)))
)
)
then 1 else 0 end as sub_purchased,
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 != 3
or b.status = 1
or (
#{userId} is not null
and exists(select 1 from app_resource_entitlement re
left join app_resource_list owned_rl
on owned_rl.id = re.resource_list_id
where re.resource_id = a.id and re.user_id = #{userId}
and re.status = 1
and (re.resource_list_id is null
or (owned_rl.app_resource_id = a.id
and owned_rl.sort_order &gt;= b.sort_order)))
)
)
where a.id = #{id}
order by b.sort_order asc, b.id asc
</select>
</mapper>
</mapper>

View File

@@ -0,0 +1,227 @@
<?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="resourceListId" column="resource_list_id"/>
<result property="specName" column="spec_name"/>
<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>
<resultMap id="AppVirtualOrderSummaryResult" type="com.ruoyi.app.domain.AppVirtualOrderSummary">
<result property="orderNo" column="order_no"/>
<result property="resourceId" column="resource_id"/>
<result property="resourceTitle" column="resource_title"/>
<result property="resourceListId" column="resource_list_id"/>
<result property="specName" column="spec_name"/>
<result property="priceFen" column="price_fen"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
<result property="payTime" column="pay_time"/>
<result property="refundTime" column="refund_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.resource_list_id,
coalesce(vo.spec_name_snapshot, rl.list_name, '旧版整项资源') as spec_name,
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
left join app_resource_list rl on rl.id = vo.resource_list_id
</sql>
<select id="lockOpenIdForOrder" parameterType="Long" resultType="java.lang.String">
select open_id
from sys_user
where user_id = #{userId} and del_flag = '0'
for update
</select>
<insert id="insertAppVirtualOrder" parameterType="AppVirtualOrder" useGeneratedKeys="true" keyProperty="id">
insert into app_virtual_order
(order_no, user_id, resource_id, resource_list_id, spec_name_snapshot,
product_id, price_fen, open_id, status, create_time)
values
(#{orderNo}, #{userId}, #{resourceId}, #{resourceListId}, #{specName},
#{productId}, #{priceFen}, #{openId}, #{status}, #{createTime})
</insert>
<select id="selectByOrderNo" resultMap="AppVirtualOrderResult">
select id, order_no, user_id, resource_id, resource_list_id,
spec_name_snapshot as spec_name, 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="selectPendingByPurchase" resultMap="AppVirtualOrderResult">
select id, order_no, user_id, resource_id, resource_list_id,
spec_name_snapshot as spec_name, 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 user_id = #{userId}
and resource_id = #{resourceId}
and status = 0
<choose>
<when test="resourceListId != null">
and (resource_list_id = #{resourceListId} or resource_list_id is null)
</when>
<otherwise>
and resource_list_id is null
</otherwise>
</choose>
order by id desc
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="resourceListId != null">and vo.resource_list_id = #{resourceListId}</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="selectMyOrderList" resultMap="AppVirtualOrderSummaryResult">
select vo.order_no, vo.resource_id, coalesce(r.resource_title, '资源已删除') as resource_title,
vo.resource_list_id,
coalesce(vo.spec_name_snapshot, rl.list_name, '旧版整项资源') as spec_name,
vo.price_fen, vo.status, vo.create_time, vo.pay_time, vo.refund_time
from app_virtual_order vo
left join app_resource r on r.id = vo.resource_id
left join app_resource_list rl on rl.id = vo.resource_list_id
where vo.user_id = #{userId}
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
<choose>
<when test="resourceListId != null">
and (resource_list_id is null or resource_list_id = #{resourceListId})
</when>
<otherwise>
and resource_list_id is null
</otherwise>
</choose>)
+
(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 in (0, 3)
</update>
<insert id="insertEntitlement" parameterType="AppVirtualOrder">
insert into app_resource_entitlement
(user_id, resource_id, resource_list_id, order_no, status, granted_time)
values
(#{userId}, #{resourceId}, #{resourceListId}, #{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="cancelPendingOrder">
update app_virtual_order
set status = 3
where order_no = #{orderNo}
and user_id = #{userId}
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,9 @@
import request from '@/utils/request'
// 查询首页业务指标
export function getDashboardSummary() {
return request({
url: '/app/dashboard/summary',
method: 'get'
})
}

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,20 +43,12 @@ export function delCode(codeId) {
})
}
export function transToArticle(codeId) {
return request({
url: '/office/code/transToArticle/' + codeId,
method: 'get'
})
}
export function transToArticle1(codeId, coverUrl) {
export function transToArticle1(codeId, templateId) {
return request({
url: '/office/code/transToArticle1/' + codeId,
method: 'get',
params: {
coverUrl: coverUrl
templateId: templateId
}
})
}

View File

@@ -0,0 +1,52 @@
import request from '@/utils/request'
// 查询文案模板列表
export function listCopyTemplate(query) {
return request({
url: '/office/copyTemplate/list',
method: 'get',
params: query
})
}
// 查询所有启用的文案模板(源码明细页按钮用)
export function listEnabledTemplates() {
return request({
url: '/office/copyTemplate/enabled',
method: 'get'
})
}
// 查询文案模板详细
export function getCopyTemplate(templateId) {
return request({
url: '/office/copyTemplate/' + templateId,
method: 'get'
})
}
// 新增文案模板
export function addCopyTemplate(data) {
return request({
url: '/office/copyTemplate',
method: 'post',
data: data
})
}
// 修改文案模板
export function updateCopyTemplate(data) {
return request({
url: '/office/copyTemplate',
method: 'put',
data: data
})
}
// 删除文案模板
export function delCopyTemplate(templateId) {
return request({
url: '/office/copyTemplate/' + templateId,
method: 'delete'
})
}

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

@@ -9,20 +9,20 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="是否增加" prop="isAdd">
<el-select v-model="queryParams.isAdd" placeholder="请选择是否增加" clearable>
<el-form-item label="记录类型" prop="isAdd">
<el-select v-model="queryParams.isAdd" placeholder="请选择记录类型" clearable>
<el-option
v-for="dict in dict.type.sys_yes_no2"
v-for="dict in dict.type.sys_record_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="增减分数" prop="integralNumber">
<el-form-item label="增减分数/金额" prop="integralNumber">
<el-input
v-model="queryParams.integralNumber"
placeholder="请输入增减分数"
placeholder="请输入增减分数/金额"
clearable
@keyup.enter.native="handleQuery"
/>
@@ -109,14 +109,16 @@
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="id" align="center" prop="id" />
<el-table-column label="来源" align="center" prop="source" />
<el-table-column label="是否增加" align="center" prop="isAdd">
<el-table-column label="记录类型" align="center" prop="isAdd">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_yes_no2" :value="scope.row.isAdd"/>
<el-tag v-if="scope.row.isAdd === 0" type="success">积分增加</el-tag>
<el-tag v-else-if="scope.row.isAdd === 1" type="danger">积分减少</el-tag>
<el-tag v-else-if="scope.row.isAdd === 3" type="warning">付费购买</el-tag>
</template>
</el-table-column>
<el-table-column label="增减分数" align="center" prop="integralNumber" />
<el-table-column label="增减分数/金额" align="center" prop="integralNumber" />
<el-table-column label="记录用户" align="center" prop="userId" />
<el-table-column label="兑换内容" align="center" prop="resourceTitle" />
<el-table-column label="兑换/付费内容" align="center" prop="resourceTitle" />
<el-table-column label="记录时间" align="center" prop="integralTime" width="180"/>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
@@ -152,17 +154,17 @@
<el-form-item label="来源" prop="source">
<el-input v-model="form.source" placeholder="请输入来源" />
</el-form-item>
<el-form-item label="是否增加" prop="isAdd">
<el-form-item label="记录类型" prop="isAdd">
<el-radio-group v-model="form.isAdd">
<el-radio
v-for="dict in dict.type.sys_yes_no2"
v-for="dict in dict.type.sys_record_type"
:key="dict.value"
:label="parseInt(dict.value)"
>{{dict.label}}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="增减分数" prop="integralNumber">
<el-input v-model="form.integralNumber" placeholder="请输入增减分数" />
<el-form-item label="增减分数/金额" prop="integralNumber">
<el-input v-model="form.integralNumber" placeholder="请输入增减分数/金额" />
</el-form-item>
<el-form-item label="记录用户" prop="userId">
<el-input v-model="form.userId" placeholder="请输入记录用户" />
@@ -185,6 +187,9 @@
<!-- 充值积分对话框 -->
<el-dialog :title="title" :visible.sync="openCzjf" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="充值来源" prop="source">
<el-input v-model="form.source" placeholder="请输入充值来源" />
</el-form-item>
<el-form-item label="充值分数" prop="integralNumber">
<el-input v-model="form.integralNumber" placeholder="请输入充值分数" />
</el-form-item>
@@ -205,7 +210,7 @@ import { listAppIntegra, getAppIntegra, delAppIntegra, addAppIntegra, updateAppI
export default {
name: "AppIntegra",
dicts: ['sys_yes_no2'],
dicts: ['sys_record_type'],
data() {
return {
// 遮罩层
@@ -300,6 +305,7 @@ export default {
/** 充值积分按钮操作 */
handleCzjf() {
this.reset();
this.form.source = "充值积分";
this.openCzjf = true;
this.title = "充值积分";
},

View File

@@ -9,9 +9,6 @@
<el-option v-for="dict in dict.type.article_type" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="关键字" prop="keyword">
<el-input v-model="queryParams.keyword" placeholder="请输入搜素关键字" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="推荐文章" prop="isRecommendation">
<el-select v-model="queryParams.isRecommendation" placeholder="请选择是否为推荐文章" clearable>
<el-option v-for="dict in dict.type.sys_yes_no2" :key="dict.value" :label="dict.label" :value="dict.value" />
@@ -58,8 +55,6 @@
<dict-tag :options="dict.type.article_type" :value="scope.row.articleType" />
</template>
</el-table-column>
<el-table-column label="搜素关键字" align="center" prop="keyword" />
<el-table-column label="游览次数" align="center" prop="lookNumber" />
<el-table-column label="点赞次数" align="center" prop="loveNumber" />
<el-table-column label="权重" align="center" prop="weight" />
@@ -80,8 +75,16 @@
</el-table-column>
<!-- <el-table-column label="需要观看几次广告解锁" align="center" prop="adNumber" /> -->
<el-table-column label="关联已有资源" align="center" prop="appResourceId" />
<el-table-column label="演示视频" align="center" prop="videoFeedId" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.videoFeedId || scope.row.videoFeedId2 ? 'success' : 'info'" size="mini">
{{ scope.row.videoFeedId && scope.row.videoFeedId2
? '2个视频'
: (scope.row.videoFeedId || scope.row.videoFeedId2 ? '1个视频' : '未配置') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="160px" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
@@ -119,6 +122,20 @@
<el-input v-model="form.showImg" placeholder="请输入封面图url" />
<image-upload style="margin-top: 10px;" v-model="form.showImg"/>
</el-form-item>
<el-form-item label="演示视频ID" prop="videoFeedId">
<el-input
v-model.trim="form.videoFeedId"
placeholder="请输入 export/ 开头的视频 feedId留空表示暂无演示视频"
clearable
/>
</el-form-item>
<el-form-item label="演示视频ID 2" prop="videoFeedId2">
<el-input
v-model.trim="form.videoFeedId2"
placeholder="请输入第二个视频的 feedId留空表示只有一个演示视频"
clearable
/>
</el-form-item>
<el-form-item label="权重" prop="weight">
<el-input v-model="form.weight" placeholder="请输入权重 999 表示为置顶" />
</el-form-item>
@@ -283,6 +300,8 @@
contentInfo: null,
articleType: null,
showImg: null,
videoFeedId: null,
videoFeedId2: null,
appResourceId: null,
lookNumber: null,
loveNumber: null,

Some files were not shown because too many files have changed in this diff Show More