feat: add dashboard and virtual pay enhancements

This commit is contained in:
王鹏
2026-07-29 14:34:54 +08:00
parent 2250f744d1
commit 349f1c0374
55 changed files with 3085 additions and 213 deletions

View File

@@ -4,13 +4,24 @@
## 1. 执行数据库迁移
执行 `sql/virtual_pay_resource.sql`。脚本会
首次部署依次执行:
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. 配置虚拟支付环境变量
@@ -38,11 +49,16 @@ AppKey 和小程序 Secret 不应提交到 Git生产环境应由部署平台
然后在若依后台编辑付费资源:
- 获取方式选择“付费”;
- 价格填写分,例如 5 元填写 `500`
- 同一价格的资源填写同一个已发布 `productId`
- 在“资源规格”中按版本从低到高填写排序,例如源码版 `1`、文档版 `2`、部署版 `3`
- 高排序版本自动包含所有低排序版本,且版本价格必须随排序递增;
- 为每个版本填写价格,例如 5 元填写 `500`
- 规格价格必须存在于 `app_virtual_product` 的已启用价格档位中,系统会自动匹配对应的 `productId`
- 所有可能产生的升级差价也必须配置价格档位。例如版本价格分别为 `9900``19900``29900` 分,还需配置 `10000``20000` 分两个差价档位。
后台会自动维护“价格 -> productId”唯一映射一个 productId 不能绑定多个价格。
用户升级时,服务端按“目标版本当前价格 - 已拥有最高版本当前价格”计算实付金额。前端展示金额仅供参考,签名和订单金额始终由服务端重新计算。高版本退款后,只撤销该高版本订单对应的权益,用户更早单独购买的低版本权益仍然保留。
## 4. 配置消息推送
在小程序后台配置:

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

@@ -15,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;
@@ -46,6 +47,8 @@ public class AppIntegralRecordController extends BaseController
private SysUserMapper sysUserMapper;
@Resource
private AppResourceMapper appResourceMapper;
@Resource
private PermissionService permissionService;
@Autowired
private IAppLotteryGoodsService appLotteryGoodsService;
@Autowired
@@ -54,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()));
}
/**
* 导出积分记录列表
*/

View File

@@ -6,11 +6,13 @@ 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;
@@ -44,6 +46,14 @@ public class AppVirtualPayController extends BaseController
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,
@@ -52,6 +62,14 @@ public class AppVirtualPayController extends BaseController
return success(virtualPayService.queryOrder(orderNo, sync));
}
@PreAuthorize("isAuthenticated()")
@DeleteMapping("/resource-orders/{orderNo}")
public AjaxResult cancelOrder(@PathVariable String orderNo)
{
virtualPayService.cancelOrder(orderNo);
return success("订单已取消");
}
/**
* 在小程序后台保存消息推送配置时使用。
*/

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密钥

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

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

@@ -174,10 +174,13 @@ public class TtCopyTemplateController extends BaseController {
content = content.replace("{projectName}", projectName);
content = content.replace("{codeDesc}", plainDesc);
content = content.replace("{codeEnvironment}", code.getCodeEnvironment() != null ? code.getCodeEnvironment() : "");
content = content.replace("{frontendTechnology}", code.getFrontendTechnology() != null ? code.getFrontendTechnology() : "");
content = content.replace("{backendTechnology}", code.getBackendTechnology() != null ? code.getBackendTechnology() : "");
content = content.replace("{databaseTechnology}", code.getDatabaseTechnology() != null ? code.getDatabaseTechnology() : "");
content = content.replace("{codeTechnology}", code.getCodeTechnology() != null ? code.getCodeTechnology() : "");
content = content.replace("{diskLink}", code.getDiskLink() != null ? code.getDiskLink() : "");
content = content.replace("{screenshots}", screenshots);
return success(content);
}
}
}

View File

@@ -8,6 +8,7 @@ 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;
@@ -20,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;
@@ -70,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);
}
/**
* 获取项目清单详细信息
*/

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

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

View File

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

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

@@ -149,7 +149,7 @@ public class TtCodeServiceImpl implements ITtCodeService {
content.append(ttCode.getCodeDesc());
content.append("<p><strong>### 运行环境</strong></p>");
content.append(ttCode.getCodeEnvironment());
content.append("<p><strong>### 项目技术</strong></p>");
content.append("<p><strong>### 其他技术</strong></p>");
content.append(ttCode.getCodeTechnology());
ttArticles.setContent(content.toString());
ttArticlesMapper.insertTtArticles(ttArticles);
@@ -168,7 +168,7 @@ public class TtCodeServiceImpl implements ITtCodeService {
content.append(ttCode.getCodeDesc());
content.append("<p><strong>### 运行环境</strong></p>");
content.append(ttCode.getCodeEnvironment());
content.append("<p><strong>### 项目技术</strong></p>");
content.append("<p><strong>### 其他技术</strong></p>");
content.append(ttCode.getCodeTechnology());
content.append("<p><strong>### 演示视频</strong></p>");
content.append("请移步首页-<strong>视频资源</strong>,搜索<strong>项目编号</strong>查看");

View File

@@ -1,15 +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;
@@ -23,12 +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\\d+)\\s*[】\\]]", Pattern.CASE_INSENSITIVE);
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;
/**
* 查询项目清单
@@ -129,4 +155,281 @@ 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);
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 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

@@ -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>
@@ -57,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>
@@ -70,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>
@@ -86,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>

View File

@@ -113,6 +113,18 @@
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>
<delete id="deleteTtProjectInfoById" parameterType="Integer">
delete from tt_project_info where id = #{id}
</delete>

View File

@@ -52,4 +52,10 @@
#{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,112 @@
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 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.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

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

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

@@ -33,6 +33,12 @@ public class AppVirtualOrder extends BaseEntity
@Excel(name = "资源标题")
private String resourceTitle;
@Excel(name = "规格ID")
private Long resourceListId;
@Excel(name = "购买规格")
private String specName;
@Excel(name = "微信道具ID")
private String productId;
@@ -137,6 +143,26 @@ public class AppVirtualOrder extends BaseEntity
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;

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

@@ -1,13 +1,15 @@
package com.ruoyi.app.domain.request;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class CreateVirtualOrderRequest
{
@NotNull(message = "资源ID不能为空")
/** 旧版小程序兼容字段;新版按 resourceListId 下单。 */
private Long resourceId;
/** 资源下载项ID一条下载项即一个付费规格。 */
private Long resourceListId;
@NotBlank(message = "微信登录凭证不能为空")
private String code;
@@ -21,6 +23,16 @@ public class CreateVirtualOrderRequest
this.resourceId = resourceId;
}
public Long getResourceListId()
{
return resourceListId;
}
public void setResourceListId(Long resourceListId)
{
this.resourceListId = resourceListId;
}
public String getCode()
{
return code;

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

@@ -22,6 +22,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);
/**
* 查询资源列表
*
@@ -87,6 +101,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

@@ -1,6 +1,7 @@
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;
@@ -8,15 +9,25 @@ 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);
int countAnyEntitlement(@Param("userId") Long userId, @Param("resourceId") Long resourceId);
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,
@@ -31,5 +42,7 @@ public interface AppVirtualOrderMapper
int markClosed(String orderNo);
int cancelPendingOrder(@Param("orderNo") String orderNo, @Param("userId") Long userId);
int markQuerying(String orderNo);
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.app.service;
import com.ruoyi.app.domain.AppDashboardSummary;
/**
* 首页业务指标服务。
*/
public interface IAppDashboardService
{
AppDashboardSummary selectDashboardSummary();
}

View File

@@ -1,8 +1,10 @@
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
@@ -11,6 +13,10 @@ public interface IAppVirtualPayService
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,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

@@ -1,10 +1,12 @@
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;
@@ -82,6 +84,7 @@ public class AppResourceServiceImpl implements IAppResourceService
public int updateAppResource(AppResource appResource)
{
configureVirtualProduct(appResource);
ensureNoOrderedSpecRemoved(appResource);
appResourceMapper.deleteAppResourceListByAppResourceId(appResource.getId());
insertAppResourceList(appResource);
return appResourceMapper.updateAppResource(appResource);
@@ -97,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);
}
@@ -111,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);
}
@@ -127,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)
@@ -144,20 +167,170 @@ 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 (resource.getPriceFen() == null || resource.getPriceFen() <= 0)
if (resourceSpecs == null || resourceSpecs.isEmpty())
{
throw new ServiceException("付费资源价格必须大于0分");
throw new ServiceException("付费资源至少需要配置一个购买规格");
}
AppVirtualProduct priceProduct = virtualProductMapper.selectActiveByPrice(resource.getPriceFen());
if (priceProduct == null || StringUtils.isBlank(priceProduct.getProductId()))
Integer minimumActivePrice = null;
String minimumPriceProductId = null;
for (int index = 0; index < resourceSpecs.size(); index++)
{
throw new ServiceException("当前价格档位未配置微信道具,请先配置价格与道具映射");
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("已产生订单的规格不能删除,请将规格状态改为停用");
}
resource.setVirtualProductId(priceProduct.getProductId());
}
}

View File

@@ -3,17 +3,16 @@ package com.ruoyi.app.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.app.domain.AppIntegralRecord;
import com.ruoyi.app.domain.AppResource;
import com.ruoyi.app.domain.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.AppIntegralRecordMapper;
import com.ruoyi.app.mapper.AppResourceMapper;
import com.ruoyi.app.mapper.AppVirtualOrderMapper;
import com.ruoyi.app.mapper.AppVirtualProductMapper;
import com.ruoyi.app.service.IAppVirtualPayService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
@@ -21,7 +20,6 @@ import com.ruoyi.common.wx.VirtualPayConfig;
import com.ruoyi.common.wx.WxCodeSession;
import com.ruoyi.common.wx.WxCodeSessionService;
import com.ruoyi.common.wx.WxPayConfig;
import com.ruoyi.system.mapper.SysUserMapper;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -31,6 +29,7 @@ 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;
@@ -43,6 +42,7 @@ 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;
@@ -62,10 +62,8 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
private final WxPayConfig wxPayConfig;
private final WxCodeSessionService wxCodeSessionService;
private final AppResourceMapper appResourceMapper;
private final AppIntegralRecordMapper integralRecordMapper;
private final AppVirtualProductMapper virtualProductMapper;
private final AppVirtualOrderMapper virtualOrderMapper;
private final SysUserMapper sysUserMapper;
private final ObjectMapper objectMapper;
private final OkHttpClient httpClient;
@@ -76,20 +74,16 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
WxPayConfig wxPayConfig,
WxCodeSessionService wxCodeSessionService,
AppResourceMapper appResourceMapper,
AppIntegralRecordMapper integralRecordMapper,
AppVirtualProductMapper virtualProductMapper,
AppVirtualOrderMapper virtualOrderMapper,
SysUserMapper sysUserMapper,
ObjectMapper objectMapper)
{
this.virtualPayConfig = virtualPayConfig;
this.wxPayConfig = wxPayConfig;
this.wxCodeSessionService = wxCodeSessionService;
this.appResourceMapper = appResourceMapper;
this.integralRecordMapper = integralRecordMapper;
this.virtualProductMapper = virtualProductMapper;
this.virtualOrderMapper = virtualOrderMapper;
this.sysUserMapper = sysUserMapper;
this.objectMapper = objectMapper;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
@@ -103,34 +97,120 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
{
checkEnabled();
Long userId = SecurityUtils.getUserId();
SysUser user = sysUserMapper.selectUserById(userId);
if (user == null || StringUtils.isBlank(user.getOpenId()))
WxCodeSession codeSession = wxCodeSessionService.exchange(request.getCode());
// 微信换码不占用数据库锁;换码完成后再锁定用户行,将同一用户的下单请求串行化。
String lockedOpenId = virtualOrderMapper.lockOpenIdForOrder(userId);
if (StringUtils.isBlank(lockedOpenId))
{
throw new ServiceException("当前账号未绑定微信");
}
WxCodeSession codeSession = wxCodeSessionService.exchange(request.getCode());
if (!MessageDigest.isEqual(user.getOpenId().getBytes(StandardCharsets.UTF_8),
if (!MessageDigest.isEqual(lockedOpenId.getBytes(StandardCharsets.UTF_8),
codeSession.getOpenId().getBytes(StandardCharsets.UTF_8)))
{
throw new ServiceException("微信身份与当前登录账号不一致");
}
AppResource resource = appResourceMapper.selectAppResourceById(request.getResourceId());
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 (resource.getPriceFen() == null || resource.getPriceFen() <= 0)
if (resourceSpec == null)
{
throw new ServiceException("资源价格未配置");
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 (virtualOrderMapper.countAnyEntitlement(userId, resource.getId()) > 0)
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("该资源已经解锁");
}
AppVirtualProduct product = virtualProductMapper.selectActiveByPrice(resource.getPriceFen());
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("当前价格档位尚未配置微信道具");
@@ -140,20 +220,33 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
order.setOrderNo(generateOrderNo());
order.setUserId(userId);
order.setResourceId(resource.getId());
order.setResourceListId(resourceListId);
order.setSpecName(resourceSpec.getListName());
order.setProductId(product.getProductId());
order.setPriceFen(resource.getPriceFen());
order.setOpenId(codeSession.getOpenId());
order.setPriceFen(orderPrice);
order.setOpenId(lockedOpenId);
order.setStatus(0);
order.setCreateTime(DateUtils.getNowDate());
virtualOrderMapper.insertAppVirtualOrder(order);
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", product.getProductId());
signDataMap.put("goodsPrice", resource.getPriceFen());
signDataMap.put("productId", order.getProductId());
signDataMap.put("goodsPrice", order.getPriceFen());
signDataMap.put("outTradeNo", order.getOrderNo());
signDataMap.put("attach", order.getOrderNo());
@@ -162,10 +255,12 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
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(codeSession.getSessionKey(), signData));
result.put("signature", hmacSha256(sessionKey, signData));
result.put("mode", "short_series_goods");
return result;
}
@@ -195,11 +290,57 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
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)
{
@@ -386,19 +527,6 @@ public class AppVirtualPayServiceImpl implements IAppVirtualPayService
{
order.setStatus(1);
virtualOrderMapper.insertEntitlement(order);
AppIntegralRecord purchaseRecord = new AppIntegralRecord();
purchaseRecord.setSource("资源购买");
purchaseRecord.setIsAdd(3L);
// 现金消费使用分作为最小单位,避免小数金额精度丢失。
purchaseRecord.setIntegralNumber(order.getPriceFen().longValue());
purchaseRecord.setUserId(order.getUserId());
purchaseRecord.setResourceId(order.getResourceId());
purchaseRecord.setIntegralTime(DateUtils.getNowDate());
if (integralRecordMapper.insertAppIntegralRecord(purchaseRecord) != 1)
{
throw new ServiceException("生成资源购买记录失败");
}
}
}

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

@@ -119,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

@@ -33,6 +33,10 @@
<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>
@@ -65,10 +69,36 @@
select a.id, a.resource_title, a.show_img, a.`explain`,a.`keyword`, a.resource_type, a.is_show, a.is_ad, a.ad_number, a.price_fen,
(select vp.product_id from app_virtual_product vp where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id,
a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url, b.password as sub_password, b.app_resource_id as sub_app_resource_id
b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url,
b.password as sub_password, b.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">
@@ -154,42 +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 #{userId} is not null and (
(SELECT COUNT(1) FROM app_integral_record
WHERE resource_id = #{id} AND user_id = #{userId}
AND source = '资源兑换' AND is_add = 1) > 0
OR
(SELECT COUNT(1) FROM app_resource_entitlement WHERE resource_id = #{id} AND user_id = #{userId} AND status = 1) > 0
) THEN 0
ELSE a.is_ad
END as is_ad,
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,
(select vp.product_id from app_virtual_product vp
where vp.price_fen = a.price_fen and vp.status = 1 limit 1) as virtual_product_id,
a.down_num, a.weight, a.del_flag, a.create_by, a.create_time, a.remark,
b.id as sub_id, b.list_name as sub_list_name, b.list_url as sub_list_url,
b.password as sub_password, b.app_resource_id as sub_app_resource_id
b.id as sub_id, b.list_name as sub_list_name,
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 not in (2, 3)
or (
#{userId} is not null and (
exists(select 1 from app_integral_record ir
where ir.resource_id = a.id and ir.user_id = #{userId}
and ir.source = '资源兑换' and ir.is_add = 1)
or exists(select 1 from app_resource_entitlement re where re.resource_id = a.id and re.user_id = #{userId} and re.status = 1)
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>

View File

@@ -11,6 +11,8 @@
<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"/>
@@ -24,31 +26,77 @@
<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.product_id, vo.price_fen,
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, product_id, price_fen, open_id, status, create_time)
(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}, #{productId}, #{priceFen}, #{openId}, #{status}, #{createTime})
(#{orderNo}, #{userId}, #{resourceId}, #{resourceListId}, #{specName},
#{productId}, #{priceFen}, #{openId}, #{status}, #{createTime})
</insert>
<select id="selectByOrderNo" resultMap="AppVirtualOrderResult">
select id, order_no, user_id, resource_id, product_id, price_fen, open_id, status,
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}
@@ -72,6 +120,7 @@
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>
@@ -89,10 +138,30 @@
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)
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}
@@ -108,14 +177,14 @@
transaction_id = coalesce(#{transactionId}, transaction_id),
pay_time = coalesce(#{payTime}, pay_time),
provide_time = now()
where order_no = #{orderNo} and status = 0
where order_no = #{orderNo} and status in (0, 3)
</update>
<insert id="insertEntitlement" parameterType="AppVirtualOrder">
insert into app_resource_entitlement
(user_id, resource_id, order_no, status, granted_time)
(user_id, resource_id, resource_list_id, order_no, status, granted_time)
values
(#{userId}, #{resourceId}, #{orderNo}, 1, now())
(#{userId}, #{resourceId}, #{resourceListId}, #{orderNo}, 1, now())
on duplicate key update
order_no = values(order_no),
status = 1,
@@ -140,6 +209,14 @@
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()

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
// 查询首页业务指标
export function getDashboardSummary() {
return request({
url: '/app/dashboard/summary',
method: 'get'
})
}

View File

@@ -76,9 +76,9 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="付费价" align="center" prop="priceFen">
<el-table-column label="付费价" align="center" prop="priceFen">
<template slot-scope="scope">
<span v-if="scope.row.isAd == 3">¥{{ (scope.row.priceFen / 100).toFixed(2) }}</span>
<span v-if="scope.row.isAd == 3">¥{{ formatPrice(scope.row.priceFen) }} </span>
<span v-else>-</span>
</template>
</el-table-column>
@@ -100,7 +100,7 @@
@pagination="getList" />
<!-- 添加或修改资源对话框 -->
<el-dialog :title="title" :visible.sync="open" width="50%" append-to-body>
<el-dialog :title="title" :visible.sync="open" width="72%" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="资源标题" prop="resourceTitle">
<el-input v-model="form.resourceTitle" placeholder="请输入资源标题" />
@@ -140,10 +140,6 @@
<el-form-item v-if="form.isAd == 2" label="兑换积分" prop="adNumber">
<el-input v-model="form.adNumber" placeholder="请输入需要兑换多少积分解锁" />
</el-form-item>
<el-form-item v-if="form.isAd == 3" label="价格(分)" prop="priceFen">
<el-input-number v-model="form.priceFen" :min="1" :step="100" controls-position="right" />
<span class="form-tip">系统将按价格自动匹配已配置的微信道具</span>
</el-form-item>
<el-form-item label="权重" prop="weight">
<el-input v-model="form.weight" placeholder="请输入权重" />
</el-form-item>
@@ -151,7 +147,15 @@
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
</el-form-item>
<el-divider content-position="center">资源列信息</el-divider>
<el-divider content-position="center">{{ form.isAd == 3 ? '资源规格' : '资源列信息' }}</el-divider>
<el-alert
v-if="form.isAd == 3"
title="排序代表版本等级:高排序包含低排序,价格必须递增;每个原价和可能产生的升级差价都要配置微信道具档位。"
type="info"
:closable="false"
show-icon
class="mb8"
/>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" icon="el-icon-plus" size="mini" @click="handleAddAppResourceList">添加</el-button>
@@ -165,19 +169,50 @@
@selection-change="handleAppResourceListSelectionChange" ref="appResourceList">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="序号" align="center" prop="index" width="50" />
<el-table-column label="资源名称" prop="listName">
<el-table-column :label="form.isAd == 3 ? '规格名称' : '资源名称'" prop="listName" min-width="150">
<template slot-scope="scope">
<el-input v-model="scope.row.listName" placeholder="请输入资源名称" />
<el-input v-model="scope.row.listName" :placeholder="form.isAd == 3 ? '如:源码版' : '请输入资源名称'" />
</template>
</el-table-column>
<el-table-column label="资源地址" prop="listUrl">
<el-table-column v-if="form.isAd == 3" label="价格(分)" prop="priceFen" width="150">
<template slot-scope="scope">
<el-input v-model="scope.row.listUrl" placeholder="请输入资源地址" />
<el-input-number
v-model="scope.row.priceFen"
:min="1"
:step="100"
controls-position="right"
style="width: 130px"
/>
</template>
</el-table-column>
<el-table-column label="访问密码" prop="password">
<el-table-column label="资源地址" prop="listUrl" min-width="220">
<template slot-scope="scope">
<el-input v-model="scope.row.password" placeholder="请输入访问密码" />
<el-input
v-model="scope.row.listUrl"
:name="`resource-url-${scope.$index}`"
autocomplete="off"
placeholder="请输入资源地址"
/>
</template>
</el-table-column>
<el-table-column label="访问密码" prop="password" width="130">
<template slot-scope="scope">
<el-input
v-model="scope.row.password"
:name="`resource-access-code-${scope.$index}`"
autocomplete="off"
placeholder="请输入访问密码"
/>
</template>
</el-table-column>
<el-table-column v-if="form.isAd == 3" label="排序" prop="sortOrder" width="100">
<template slot-scope="scope">
<el-input-number v-model="scope.row.sortOrder" :min="0" controls-position="right" style="width: 80px" />
</template>
</el-table-column>
<el-table-column v-if="form.isAd == 3" label="状态" prop="status" width="90">
<template slot-scope="scope">
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
</el-table>
@@ -267,11 +302,6 @@
message: "需要广告不能为空",
trigger: "change"
}],
priceFen: [{
required: true,
message: "付费价格不能为空",
trigger: "blur"
}],
},
directory:"appimg/fengmian/"
};
@@ -353,7 +383,12 @@
const id = row.id || this.ids
getResource(id).then(response => {
this.form = response.data;
this.appResourceListList = response.data.appResourceListList;
this.appResourceListList = (response.data.appResourceListList || []).map((item, index) => ({
...item,
priceFen: item.priceFen || response.data.priceFen || null,
status: item.status === 0 ? 0 : 1,
sortOrder: item.sortOrder === null || item.sortOrder === undefined ? index : item.sortOrder
}));
this.open = true;
this.title = "修改资源";
});
@@ -362,6 +397,9 @@
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (!this.validateResourceSpecs()) {
return;
}
this.form.appResourceListList = this.appResourceListList;
if (this.form.id != null) {
updateResource(this.form).then(response => {
@@ -402,6 +440,9 @@
obj.listName = "";
obj.listUrl = "";
obj.password = "";
obj.priceFen = this.form.isAd == 3 ? (this.form.priceFen || null) : 0;
obj.status = 1;
obj.sortOrder = this.appResourceListList.length;
this.appResourceListList.push(obj);
},
/** 资源列删除按钮操作 */
@@ -420,6 +461,60 @@
handleAppResourceListSelectionChange(selection) {
this.checkedAppResourceList = selection.map(item => item.index)
},
validateResourceSpecs() {
const invalidPasswordItem = this.appResourceListList.find(item => {
return item.password && /^https?:\/\//i.test(item.password.trim())
})
if (invalidPasswordItem) {
this.$modal.msgError(`${invalidPasswordItem.listName || '未命名资源'}”的访问密码不能填写网盘链接`)
return false
}
if (this.form.isAd != 3) {
return true
}
if (!this.appResourceListList.length) {
this.$modal.msgError("付费资源至少需要配置一个规格")
return false
}
for (let index = 0; index < this.appResourceListList.length; index++) {
const item = this.appResourceListList[index]
if (!item.listName || !item.listName.trim()) {
this.$modal.msgError(`${index + 1}个规格名称不能为空`)
return false
}
if (!item.listUrl || !item.listUrl.trim()) {
this.$modal.msgError(`规格“${item.listName}”的资源地址不能为空`)
return false
}
if (!item.priceFen || item.priceFen <= 0) {
this.$modal.msgError(`规格“${item.listName}”的价格必须大于0分`)
return false
}
}
const orderedSpecs = [...this.appResourceListList].sort((left, right) => {
return Number(left.sortOrder || 0) - Number(right.sortOrder || 0)
})
for (let index = 1; index < orderedSpecs.length; index++) {
const previous = orderedSpecs[index - 1]
const current = orderedSpecs[index]
if (Number(current.sortOrder || 0) === Number(previous.sortOrder || 0)) {
this.$modal.msgError("版本排序不能重复,请按等级从低到高填写")
return false
}
if (Number(current.priceFen) <= Number(previous.priceFen)) {
this.$modal.msgError("版本价格必须随排序等级递增")
return false
}
}
if (!this.appResourceListList.some(item => item.status === 1)) {
this.$modal.msgError("至少需要启用一个购买规格")
return false
}
return true
},
formatPrice(priceFen) {
return (Number(priceFen || 0) / 100).toFixed(2)
},
/** 导出按钮操作 */
handleExport() {
this.download('app/resource/export', {

View File

@@ -94,6 +94,7 @@
<template slot-scope="scope">
<div>{{ scope.row.resourceTitle || '-' }}</div>
<div class="secondary-text">ID: {{ scope.row.resourceId }}</div>
<div class="secondary-text">规格{{ scope.row.specName || '旧版整项资源' }}</div>
</template>
</el-table-column>
<el-table-column label="金额" align="right" width="100">
@@ -144,6 +145,8 @@
<el-descriptions-item label="OpenID">{{ detail.openId || '-' }}</el-descriptions-item>
<el-descriptions-item label="资源ID">{{ detail.resourceId }}</el-descriptions-item>
<el-descriptions-item label="资源标题">{{ detail.resourceTitle || '-' }}</el-descriptions-item>
<el-descriptions-item label="规格ID">{{ detail.resourceListId || '旧版整项' }}</el-descriptions-item>
<el-descriptions-item label="购买规格">{{ detail.specName || '旧版整项资源' }}</el-descriptions-item>
<el-descriptions-item label="微信道具ID">{{ detail.productId || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信内部订单号">{{ detail.wxOrderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="微信交易号" :span="2">{{ detail.transactionId || '-' }}</el-descriptions-item>

View File

@@ -1,7 +1,38 @@
<template>
<div class="app-container home">
<el-row :gutter="20" class="dashboard-stats" v-loading="summaryLoading">
<el-col
v-for="item in metricCards"
:key="item.key"
:xs="24"
:sm="12"
:lg="6"
class="dashboard-stat-col"
>
<el-card shadow="hover" class="stat-card">
<div class="stat-card__content">
<div class="stat-card__icon" :class="`stat-card__icon--${item.theme}`">
<i :class="item.icon"></i>
</div>
<div class="stat-card__body">
<div class="stat-card__title">{{ item.title }}</div>
<div class="stat-card__value">
<span v-if="item.money">¥</span>{{ formatMetric(item.value, item.money) }}
</div>
<div class="stat-card__today">
<span>今日新增</span>
<span class="stat-card__today-value">
+<span v-if="item.money">¥</span>{{ formatMetric(item.today, item.money) }}
</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :sm="24" :lg="24">
<el-col :xs="24" :sm="24" :lg="12">
<!-- 每日新增用户折线图 -->
<el-card style="margin-bottom: 20px;">
<div slot="header" class="clearfix">
@@ -9,7 +40,9 @@
</div>
<div ref="userChart" style="height: 400px;"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :lg="12">
<!-- 每日新增支付金额折线图 -->
<el-card style="margin-bottom: 20px;">
<div slot="header" class="clearfix">
@@ -958,6 +991,7 @@
<script>
import { getUserCountByDay } from '@/api/system/user';
import { getDailyPayAmount } from '@/api/app/payStatistics';
import { getDashboardSummary } from '@/api/app/dashboard';
import * as echarts from 'echarts';
export default {
@@ -975,14 +1009,85 @@ export default {
payStats: {
dates: [],
amounts: []
},
summaryLoading: false,
dashboardSummary: {
resourceTotal: 0,
resourceToday: 0,
userTotal: 0,
userToday: 0,
orderTotal: 0,
orderToday: 0,
amountTotal: 0,
amountToday: 0
}
};
},
computed: {
metricCards() {
return [
{
key: 'resource',
title: '资源总数',
value: this.dashboardSummary.resourceTotal,
today: this.dashboardSummary.resourceToday,
icon: 'el-icon-folder-opened',
theme: 'blue',
money: false
},
{
key: 'user',
title: '用户总数',
value: this.dashboardSummary.userTotal,
today: this.dashboardSummary.userToday,
icon: 'el-icon-user',
theme: 'green',
money: false
},
{
key: 'order',
title: '订单总数',
value: this.dashboardSummary.orderTotal,
today: this.dashboardSummary.orderToday,
icon: 'el-icon-shopping-cart-full',
theme: 'orange',
money: false
},
{
key: 'amount',
title: '订单总金额',
value: this.dashboardSummary.amountTotal,
today: this.dashboardSummary.amountToday,
icon: 'el-icon-money',
theme: 'purple',
money: true
}
];
}
},
mounted() {
this.loadDashboardSummary();
this.initUserChart();
this.initPayChart();
},
methods: {
loadDashboardSummary() {
this.summaryLoading = true;
getDashboardSummary().then(response => {
this.dashboardSummary = Object.assign({}, this.dashboardSummary, response.data || {});
}).finally(() => {
this.summaryLoading = false;
});
},
formatMetric(value, money) {
const number = Number(value || 0);
return number.toLocaleString('zh-CN', money ? {
minimumFractionDigits: 2,
maximumFractionDigits: 2
} : {
maximumFractionDigits: 0
});
},
goTarget(href) {
window.open(href, "_blank");
},
@@ -1082,6 +1187,109 @@ export default {
</script>
<style scoped lang="scss">
.dashboard-stats {
margin-bottom: 0;
}
.dashboard-stat-col {
margin-bottom: 20px;
}
.stat-card {
border: 0;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.06);
&__content {
display: flex;
align-items: center;
min-height: 116px;
}
&__icon {
display: flex;
flex: 0 0 64px;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-right: 18px;
border-radius: 16px;
font-size: 30px;
&--blue {
color: #409eff;
background: rgba(64, 158, 255, 0.12);
}
&--green {
color: #36b37e;
background: rgba(54, 179, 126, 0.12);
}
&--orange {
color: #e6a23c;
background: rgba(230, 162, 60, 0.14);
}
&--purple {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
}
}
&__body {
min-width: 0;
flex: 1;
}
&__title {
color: #909399;
font-size: 14px;
}
&__value {
overflow: hidden;
margin-top: 8px;
color: #303133;
font-size: 27px;
font-weight: 600;
line-height: 34px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__today {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 9px;
color: #909399;
font-size: 13px;
}
&__today-value {
color: #36b37e;
font-weight: 500;
}
}
@media (max-width: 1440px) {
.stat-card {
&__icon {
flex-basis: 54px;
width: 54px;
height: 54px;
margin-right: 12px;
font-size: 26px;
}
&__value {
font-size: 23px;
}
}
}
.home {
blockquote {
padding: 10px 20px;

View File

@@ -17,34 +17,14 @@
<!-- @keyup.enter.native="handleQuery"-->
<!-- />-->
<!-- </el-form-item>-->
<el-form-item label="项目技术" prop="codeTechnology">
<el-form-item label="其他技术" prop="codeTechnology">
<el-input
v-model="queryParams.codeTechnology"
placeholder="请输入项目技术"
placeholder="请输入其他技术"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="项目来源" prop="codeSource">
<el-select v-model="queryParams.codeSource" placeholder="请选择项目来源" clearable>
<el-option
v-for="dict in dict.type.code_source"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="付费方式" prop="paymentType">
<el-select v-model="queryParams.paymentType" placeholder="请选择付费方式" clearable>
<el-option
v-for="dict in dict.type.payment_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<!-- <el-form-item label="网盘地址" prop="diskLink">-->
<!-- <el-input-->
<!-- v-model="queryParams.diskLink"-->
@@ -124,17 +104,7 @@
<el-table-column label="项目名称" align="center" prop="codeName"/>
<!-- <el-table-column label="项目描述" align="center" prop="codeDesc" />-->
<el-table-column label="运行环境" align="center" prop="codeEnvironment"/>
<el-table-column label="项目技术" align="center" prop="codeTechnology"/>
<el-table-column label="项目来源" align="center" prop="codeSource">
<template slot-scope="scope">
<dict-tag :options="dict.type.code_source" :value="scope.row.codeSource"/>
</template>
</el-table-column>
<el-table-column label="付费方式" align="center" prop="paymentType">
<template slot-scope="scope">
<dict-tag :options="dict.type.payment_type" :value="scope.row.paymentType"/>
</template>
</el-table-column>
<el-table-column label="其他技术" align="center" prop="codeTechnology"/>
<!-- <el-table-column label="网盘地址" align="center" prop="diskLink" />-->
<el-table-column label="是否发布" align="center" prop="publishFlag">
<template slot-scope="scope">
@@ -218,31 +188,17 @@
<el-form-item label="运行环境" prop="codeEnvironment">
<el-input v-model="form.codeEnvironment" placeholder="请输入运行环境"/>
</el-form-item>
<el-form-item label="项目技术" prop="codeTechnology">
<el-input v-model="form.codeTechnology" placeholder="请输入项目技术"/>
<el-form-item label="前端" prop="frontendTechnology">
<el-input v-model="form.frontendTechnology" placeholder="请输入前端技术"/>
</el-form-item>
<el-form-item label="项目来源" prop="codeSource">
<el-select v-model="form.codeSource" placeholder="请选择项目来源">
<el-option
v-for="dict in dict.type.code_source"
:key="dict.value"
:label="dict.label"
:value="dict.value"
></el-option>
</el-select>
<el-form-item label="后端" prop="backendTechnology">
<el-input v-model="form.backendTechnology" placeholder="请输入后端技术"/>
</el-form-item>
<el-form-item label="付费方式" prop="paymentType">
<el-radio-group v-model="form.paymentType">
<el-radio
v-for="dict in dict.type.payment_type"
:key="dict.value"
:label="dict.value"
>{{dict.label}}
</el-radio>
</el-radio-group>
<el-form-item label="数据库" prop="databaseTechnology">
<el-input v-model="form.databaseTechnology" placeholder="请输入数据库技术"/>
</el-form-item>
<el-form-item label="网盘地址" prop="diskLink">
<el-input v-model="form.diskLink" placeholder="请输入网盘地址"/>
<el-form-item label="其他技术" prop="codeTechnology">
<el-input v-model="form.codeTechnology" placeholder="请输入其他技术"/>
</el-form-item>
<el-form-item label="是否发布" prop="publishFlag">
<el-radio-group v-model="form.publishFlag">
@@ -254,12 +210,6 @@
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="截图文件" prop="pictureFile">
<file-upload v-model="form.pictureFile"/>
</el-form-item>
<el-form-item label="视频文件" prop="videoFile">
<file-upload v-model="form.videoFile"/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
@@ -275,27 +225,19 @@
<span v-html="form.codeDesc"/>
</el-descriptions-item>
<el-descriptions-item label="运行环境" :span="4">{{ form.codeEnvironment }}</el-descriptions-item>
<el-descriptions-item label="项目技术" :span="4">{{ form.codeTechnology }}</el-descriptions-item>
<el-descriptions-item label="前端" :span="4">{{ form.frontendTechnology }}</el-descriptions-item>
<el-descriptions-item label="后端" :span="4">{{ form.backendTechnology }}</el-descriptions-item>
<el-descriptions-item label="数据库" :span="4">{{ form.databaseTechnology }}</el-descriptions-item>
<el-descriptions-item label="其他技术" :span="4">{{ form.codeTechnology }}</el-descriptions-item>
<!-- <el-descriptions-item label="项目来源">-->
<!-- <dict-tag :options="dict.type.code_source" :value="form.codeSource"/>-->
<!-- </el-descriptions-item>-->
<!-- <el-descriptions-item label="付费方式">-->
<!-- <dict-tag :options="dict.type.payment_type" :value="form.paymentType"/>-->
<!-- </el-descriptions-item>-->
<el-descriptions-item label="网盘地址" :span="2">{{ form.diskLink }}</el-descriptions-item>
<el-descriptions-item label="是否发布" :span="2">
<el-descriptions-item label="是否发布" :span="4">
<dict-tag :options="dict.type.sys_yes_no" :value="form.publishFlag"/>
</el-descriptions-item>
<el-descriptions-item label="截图文件" :span="4">
<el-link v-if="form.pictureFile" :href="`${baseUrl}${form.pictureFile}`" :underline="false" target="_blank">
<span class="el-icon-document"> 下载 </span>
</el-link>
</el-descriptions-item>
<el-descriptions-item label="视频文件" :span="4">
<el-link v-if="form.videoFile" :href="`${baseUrl}${form.videoFile}`" :underline="false" target="_blank">
<span class="el-icon-document"> 下载 </span>
</el-link>
</el-descriptions-item>
<el-descriptions-item label="截图展示" :span="4">
<el-image
style="width: 100px; height: 80px; padding: 3px;"
@@ -373,6 +315,9 @@
codeDesc: null,
codeEnvironment: null,
codeTechnology: null,
frontendTechnology: null,
backendTechnology: null,
databaseTechnology: null,
codeSource: null,
paymentType: null,
diskLink: null,
@@ -430,6 +375,9 @@
codeDesc: null,
codeEnvironment: 'jdk1.8 + mysql5.7以上 + idea + vscode',
codeTechnology: 'springboot + vue2 + elementui + nodejs14',
frontendTechnology: null,
backendTechnology: null,
databaseTechnology: null,
codeSource: null,
paymentType: null,
diskLink: null,
@@ -484,6 +432,9 @@
content = content.replace(/{projectName}/g, projectName);
content = content.replace(/{codeDesc}/g, plainDesc);
content = content.replace(/{codeEnvironment}/g, this.form.codeEnvironment || '');
content = content.replace(/{frontendTechnology}/g, this.form.frontendTechnology || '');
content = content.replace(/{backendTechnology}/g, this.form.backendTechnology || '');
content = content.replace(/{databaseTechnology}/g, this.form.databaseTechnology || '');
content = content.replace(/{codeTechnology}/g, this.form.codeTechnology || '');
content = content.replace(/{diskLink}/g, this.form.diskLink || '');
content = content.replace(/{screenshots}/g, screenshots);

View File

@@ -89,7 +89,10 @@
{projectName} 纯项目名(去掉编号和基于...实现的前缀)
{codeDesc} 项目描述(纯文本)
{codeEnvironment} 运行环境
{codeTechnology} 项目技术
{frontendTechnology} 前端
{backendTechnology} 后端
{databaseTechnology} 数据库
{codeTechnology} 其他技术
{diskLink} 网盘地址
{screenshots} 截图列表(自动生成 markdown 格式)"
/>
@@ -102,7 +105,10 @@
<code>{projectName}</code> 纯项目名 &nbsp;
<code>{codeDesc}</code> 描述(纯文本) &nbsp;
<code>{codeEnvironment}</code> 运行环境 &nbsp;
<code>{codeTechnology}</code> 项目技术 &nbsp;
<code>{frontendTechnology}</code> 前端 &nbsp;
<code>{backendTechnology}</code> 后端 &nbsp;
<code>{databaseTechnology}</code> 数据库 &nbsp;
<code>{codeTechnology}</code> 其他技术 &nbsp;
<code>{diskLink}</code> 网盘地址 &nbsp;
<code>{screenshots}</code> 截图列表
</template>
@@ -229,4 +235,4 @@ export default {
}
}
};
</script>
</script>

View File

@@ -82,6 +82,21 @@
v-hasPermi="['office:project:export']"
>导出</el-button>
</el-col>
<el-col :span="1.5">
<el-dropdown
trigger="click"
@command="handleLinkImport"
v-hasPermi="['office:project:edit']"
>
<el-button type="primary" plain icon="el-icon-upload2" size="mini">
导入链接<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="QUARK">导入夸克链接</el-dropdown-item>
<el-dropdown-item command="BAIDU">导入百度链接</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
@@ -97,10 +112,22 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="projectList" @cell-dblclick="copyText" @selection-change="handleSelectionChange">
<el-table
v-loading="loading"
:data="projectList"
@cell-dblclick="copyText"
@selection-change="handleSelectionChange"
@sort-change="handleSortChange"
>
<el-table-column type="selection" width="55" align="center" />
<!-- <el-table-column label="${comment}" align="center" prop="id" />-->
<el-table-column label="项目编号" align="center" prop="projectNum" />
<el-table-column
label="项目编号"
align="center"
prop="projectNum"
sortable="custom"
:sort-orders="['ascending', 'descending']"
/>
<el-table-column label="项目名称" align="center" prop="projectName" />
<el-table-column label="源码名称" align="center" prop="projectName1" />
<el-table-column label="项目技术" align="center" prop="projectNum1" />
@@ -241,6 +268,94 @@
</div>
</el-dialog>
<!-- 网盘链接导入对话框 -->
<el-dialog
:title="linkUpload.title"
:visible.sync="linkUpload.open"
width="460px"
append-to-body
@closed="resetLinkUpload"
>
<el-upload
ref="linkUpload"
:limit="1"
accept=".csv,.xls,.xlsx"
:headers="linkUpload.headers"
:action="linkUpload.url + '?diskType=' + linkUpload.diskType"
:disabled="linkUpload.isUploading"
:before-upload="beforeLinkFileUpload"
:on-progress="handleLinkFileUploadProgress"
:on-success="handleLinkFileSuccess"
:on-error="handleLinkFileError"
:auto-upload="false"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">
支持网盘客户端导出的 csvxlsxlsx 文件单个文件不超过 5MB
<div class="import-rule-tip">
按完整项目名称精确匹配不存在时自动新增存在时仅更新对应网盘链接
</div>
</div>
</el-upload>
<div slot="footer" class="dialog-footer">
<el-button
type="primary"
:loading="linkUpload.isUploading"
@click="submitLinkFileForm"
> </el-button>
<el-button @click="linkUpload.open = false"> </el-button>
</div>
</el-dialog>
<!-- 网盘链接导入结果 -->
<el-dialog
title="导入结果"
:visible.sync="importResult.open"
width="760px"
append-to-body
>
<div v-if="importResult.data" class="import-result">
<el-alert
:title="importResult.message"
:type="importResult.data.failedCount || importResult.data.skippedCount ? 'warning' : 'success'"
:closable="false"
show-icon
/>
<div class="import-result-summary">
<span>总计{{ importResult.data.totalCount || 0 }}</span>
<span class="result-added">新增{{ importResult.data.addedCount || 0 }}</span>
<span class="result-updated">更新{{ importResult.data.updatedCount || 0 }}</span>
<span>未变化{{ importResult.data.unchangedCount || 0 }}</span>
<span class="result-skipped">跳过{{ importResult.data.skippedCount || 0 }}</span>
<span class="result-failed">失败{{ importResult.data.failedCount || 0 }}</span>
</div>
<el-table
v-if="importResult.data.details && importResult.data.details.length"
:data="importResult.data.details"
max-height="360"
border
size="small"
>
<el-table-column label="行号" prop="rowNumber" width="70" align="center" />
<el-table-column label="项目名称" prop="projectName" min-width="210" show-overflow-tooltip />
<el-table-column label="结果" width="90" align="center">
<template slot-scope="scope">
<el-tag
size="mini"
:type="scope.row.resultType === 'FAILED' ? 'danger' : 'warning'"
>{{ scope.row.resultType === 'FAILED' ? '失败' : '跳过' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="原因" prop="reason" min-width="260" show-overflow-tooltip />
</el-table>
</div>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="importResult.open = false"> </el-button>
</div>
</el-dialog>
<!-- 封面预览对话框 -->
<el-dialog title="封面预览" :visible.sync="previewVisible" width="40%" append-to-body>
<div class="preview-container" style="text-align: center;">
@@ -297,6 +412,8 @@ export default {
queryParams: {
pageNum: 1,
pageSize: 10,
orderByColumn: null,
isAsc: null,
projectNum: null,
projectNum1: null,
projectName: null,
@@ -310,6 +427,21 @@ export default {
form: {},
// 表单校验
rules: {},
// 网盘链接导入参数
linkUpload: {
open: false,
title: '',
diskType: '',
isUploading: false,
headers: { Authorization: 'Bearer ' + getToken() },
url: process.env.VUE_APP_BASE_API + '/office/project/import-links'
},
// 网盘链接导入结果
importResult: {
open: false,
message: '',
data: null
},
previewVisible: false,
previewUrl: '',
previewBlob: null,
@@ -365,6 +497,13 @@ export default {
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 项目编号排序 */
handleSortChange({ prop, order }) {
this.queryParams.orderByColumn = prop
this.queryParams.isAsc = order
this.queryParams.pageNum = 1
this.getList()
},
/** 检测单个项目的网盘链接 */
handleCheckRow(row) {
if (this.isChecking(row.id)) {
@@ -516,6 +655,73 @@ export default {
...this.queryParams
}, `project_${new Date().getTime()}.xlsx`)
},
/** 打开网盘链接导入对话框 */
handleLinkImport(diskType) {
this.linkUpload.diskType = diskType
this.linkUpload.title = diskType === 'QUARK' ? '导入夸克网盘链接' : '导入百度网盘链接'
this.linkUpload.open = true
this.$nextTick(() => {
if (this.$refs.linkUpload) {
this.$refs.linkUpload.clearFiles()
}
})
},
/** 导入文件校验 */
beforeLinkFileUpload(file) {
const extension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
const isSupported = ['.csv', '.xls', '.xlsx'].indexOf(extension) !== -1
const isWithinLimit = file.size / 1024 / 1024 <= 5
if (!isSupported) {
this.$modal.msgError('仅支持 csv、xls、xlsx 格式文件')
return false
}
if (!isWithinLimit) {
this.$modal.msgError('导入文件不能超过 5MB')
return false
}
return true
},
/** 网盘链接文件上传中 */
handleLinkFileUploadProgress() {
this.linkUpload.isUploading = true
},
/** 网盘链接文件上传成功 */
handleLinkFileSuccess(response) {
this.linkUpload.isUploading = false
if (!response || response.code !== 200) {
this.$modal.msgError(response && response.msg ? response.msg : '导入失败')
return
}
this.linkUpload.open = false
this.importResult.message = response.msg || '导入完成'
this.importResult.data = response.data || {}
this.importResult.open = true
this.getList()
},
/** 网盘链接文件上传失败 */
handleLinkFileError(error) {
this.linkUpload.isUploading = false
let message = '导入失败'
if (error && error.message) {
message = error.message
}
this.$modal.msgError(message)
},
/** 提交网盘链接导入文件 */
submitLinkFileForm() {
if (!this.$refs.linkUpload || !this.$refs.linkUpload.uploadFiles.length) {
this.$modal.msgWarning('请选择需要导入的文件')
return
}
this.$refs.linkUpload.submit()
},
/** 重置网盘链接上传状态 */
resetLinkUpload() {
this.linkUpload.isUploading = false
if (this.$refs.linkUpload) {
this.$refs.linkUpload.clearFiles()
}
},
copyText(row, column, cell, event) {
if (column.label !== '操作') {
// 双击复制
@@ -534,14 +740,14 @@ export default {
let content = ''
if (row.projectUrl) {
content += '夸克网盘\n'
content += '链接:'+ row.projectUrl
content += '链接:' + row.projectUrl
}
if (content.length > 0) {
content += '\n\n'
}
if (row.projectBaiduUrl) {
content += '百度网盘\n'
content += '链接:'+ row.projectBaiduUrl
content += '链接:' + row.projectBaiduUrl
}
navigator.clipboard.writeText(content).then(() => {
this.$message({
@@ -559,14 +765,14 @@ export default {
let content = ''
if (row.projectUrl) {
content += '夸克网盘\n'
content += '链接:'+ row.projectUrl
content += '链接:' + row.projectUrl
}
if (content.length > 0) {
content += '\n\n'
}
if (row.projectBaiduUrl) {
content += '百度网盘\n'
content += '链接:'+ row.projectBaiduUrl
content += '链接:' + row.projectBaiduUrl
}
content += '\n\n项目部署文档\nhttps://www.yuque.com/feastcoding/cg5w9h/rbvp8w80h8tultgy 密码el4q'
navigator.clipboard.writeText(content).then(() => {
@@ -655,4 +861,31 @@ export default {
color: #606266;
text-align: right;
}
.import-rule-tip {
margin-top: 6px;
color: #909399;
line-height: 20px;
}
.import-result-summary {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin: 18px 0;
color: #606266;
}
.result-added,
.result-updated {
color: #67c23a;
}
.result-skipped {
color: #e6a23c;
}
.result-failed {
color: #f56c6c;
}
</style>

View File

@@ -29,7 +29,7 @@ INSERT INTO tt_copy_template (template_name, template_body, sort_num, status, cr
#### 运行环境
{codeEnvironment}
#### 项目技术
#### 其他技术
{codeTechnology}
#### 项目截图
@@ -48,7 +48,7 @@ INSERT INTO tt_copy_template (template_name, template_body, sort_num, status, cr
### 运行环境
{codeEnvironment}
### 项目技术
### 其他技术
{codeTechnology}
标价包含项目源码+数据库脚本+文档,没有调试解答,由于此商品的可复制性,发货后,不退不换,介意勿拍', 2, '0', NOW(), 'admin'),
@@ -74,7 +74,7 @@ INSERT INTO tt_copy_template (template_name, template_body, sort_num, status, cr
### 运行环境
{codeEnvironment}
### 项目技术
### 其他技术
{codeTechnology}
获取项目源码资源,\\/X小CX南音源码库', 4, '0', NOW(), 'admin'),
@@ -255,4 +255,4 @@ INSERT INTO tt_copy_template (template_name, template_body, sort_num, status, cr
<img src="https://img.yidaima.cn/qrcode.jpg" style="width: 100px;">
<br>
<span>长按小程序码,打开小程序搜索 "<Strong style="color:var(--md-primary-color);">{projectCode}</Strong>" 即可获取资源</span>
</center>', 6, '0', NOW(), 'admin');
</center>', 6, '0', NOW(), 'admin');

View File

@@ -0,0 +1,46 @@
-- 首页统计查询索引(可重复执行)
SET @dashboard_index_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE app_virtual_order ADD INDEX idx_virtual_order_create_time (create_time)',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'app_virtual_order'
AND index_name = 'idx_virtual_order_create_time'
);
PREPARE dashboard_index_stmt FROM @dashboard_index_sql;
EXECUTE dashboard_index_stmt;
DEALLOCATE PREPARE dashboard_index_stmt;
SET @dashboard_index_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE app_pay_order ADD INDEX idx_pay_order_status_pay_time (status, pay_time)',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'app_pay_order'
AND index_name = 'idx_pay_order_status_pay_time'
);
PREPARE dashboard_index_stmt FROM @dashboard_index_sql;
EXECUTE dashboard_index_stmt;
DEALLOCATE PREPARE dashboard_index_stmt;
SET @dashboard_index_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE app_virtual_order ADD INDEX idx_virtual_order_status_pay_time (status, pay_time)',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'app_virtual_order'
AND index_name = 'idx_virtual_order_status_pay_time'
);
PREPARE dashboard_index_stmt FROM @dashboard_index_sql;
EXECUTE dashboard_index_stmt;
DEALLOCATE PREPARE dashboard_index_stmt;

View File

@@ -0,0 +1,7 @@
-- 源码信息新增前端、后端、数据库技术字段。
-- 执行前请先备份数据库。适用于当前项目的 MySQL 数据库。
ALTER TABLE `tt_code`
ADD COLUMN `frontend_technology` VARCHAR(255) NULL COMMENT '前端' AFTER `code_technology`,
ADD COLUMN `backend_technology` VARCHAR(255) NULL COMMENT '后端' AFTER `frontend_technology`,
ADD COLUMN `database_technology` VARCHAR(255) NULL COMMENT '数据库' AFTER `backend_technology`;

View File

@@ -0,0 +1,30 @@
-- 资源虚拟支付:同一用户、同一规格有效订单防重
-- 前置条件:已经执行 sql/virtual_pay_resource_specs.sql。
-- 执行前请先备份数据库。
-- 下面的查询必须返回空结果。
-- 如果存在结果,说明上线防重前已经产生重复待支付/已支付订单,
-- 需要先结合微信订单状态人工核对,不能直接批量关闭或删除。
SELECT user_id,
resource_id,
COALESCE(resource_list_id, 0) AS resource_list_key,
COUNT(*) AS active_order_count,
GROUP_CONCAT(order_no ORDER BY id) AS order_nos
FROM app_virtual_order
WHERE status IN (0, 1)
GROUP BY user_id, resource_id, COALESCE(resource_list_id, 0)
HAVING COUNT(*) > 1;
-- status=0待支付和 status=1已支付共用一个唯一购买键。
-- 退款或关闭后键值自动变为 NULL允许用户重新购买。
ALTER TABLE app_virtual_order
ADD COLUMN active_purchase_key VARCHAR(80)
GENERATED ALWAYS AS (
CASE
WHEN status IN (0, 1)
THEN CONCAT(user_id, ':', resource_id, ':', COALESCE(resource_list_id, 0))
ELSE NULL
END
) STORED COMMENT '待支付/已支付订单防重键' AFTER last_query_time,
ADD UNIQUE KEY uk_virtual_order_active_purchase (active_purchase_key);

View File

@@ -0,0 +1,29 @@
-- 资源虚拟支付:简版多规格迁移
-- 前置条件:已经执行 sql/virtual_pay_resource.sql。
-- 约定app_resource_list 的每一行就是一个可单独购买的规格。
-- 执行前请备份数据库。
ALTER TABLE app_resource_list
ADD COLUMN price_fen INT NOT NULL DEFAULT 0 COMMENT '规格价格,单位分' AFTER password,
ADD COLUMN status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用' AFTER price_fen,
ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '显示顺序' AFTER status;
-- 现有付费资源的下载项继承原资源价格非付费资源价格保持0。
UPDATE app_resource_list rl
INNER JOIN app_resource r ON r.id = rl.app_resource_id
SET rl.price_fen = r.price_fen
WHERE r.is_ad = 3 AND rl.price_fen = 0;
ALTER TABLE app_virtual_order
ADD COLUMN resource_list_id BIGINT NULL COMMENT '购买的资源规格IDNULL表示旧版整项购买' AFTER resource_id,
ADD COLUMN spec_name_snapshot VARCHAR(255) NULL COMMENT '下单时的规格名称快照' AFTER resource_list_id,
ADD KEY idx_virtual_order_resource_list (resource_list_id);
ALTER TABLE app_resource_entitlement
ADD COLUMN resource_list_id BIGINT NULL COMMENT '解锁的资源规格IDNULL表示旧版整项权益' AFTER resource_id,
DROP INDEX uk_resource_entitlement_user_resource,
ADD UNIQUE KEY uk_entitlement_user_resource_spec (user_id, resource_id, resource_list_id),
ADD KEY idx_entitlement_resource_list (resource_list_id);
-- 旧订单和旧权益保留 resource_list_id=NULL继续拥有整项资源访问权限。
-- 新订单必须写入具体 resource_list_id只解锁所购买的规格。