feat(hjc): 已支付订单下发标书附件,并堵住项目接口的标书地址泄露
「购买后才能下载标书」此前在服务端从未成立:`GET /api/hjc/bid-project/{id}` 与 `/list`
匿名即可拿到 `tenderFile`,订单详情也会回带 `project.tenderFile`;而订单列表接口
(`/hjc/order/my`)根本不带附件,PC 端连下载入口都没有。
- 新增 `HjcTenderFileUtil` + `HjcTenderFileVo`:把 `tender_file`(一站式推的 `files`:
JSON 字符串数组 / 对象数组 / 逗号分隔)解析成 `{name,url}`;坏数据只丢弃、不抛异常
- `HjcOrder` 加非表字段 `tenderFiles`;`/hjc/order/my` 与 `/hjc/order/{id}` **只有
`payStatus == 1`** 才附带(列表整批一次 `listByIds`,不 N+1;其余订单保持 null)
- 订单详情的 `project.tenderFile` 恒置 null:订单路径不留第二条地址来源
- `/hjc/bid-project/list` 一律不下发 `tenderFile`;`/hjc/bid-project/{id}` 仅 hjc 管理员
(后台编辑表单要回填)或已为本项目付过款的买家可见,其余置 null,且**不返回 401**
(详情对未登录是正常可用页面)
- 单测:`HjcTenderFileUtilTest`(12)、`HjcOrderControllerTenderFilesTest`(8)、
`HjcBidProjectControllerDetailTest` 扩展 5 条(含既有 favorited/buyerCount 不回归)
→ `com.gxwebsoft.hjc.**` 共 147 条全通过
规格与验收:`.scratch/hjc-tender-download/`;两端前端同批改动见 hjc-web 2e847b5、hjc-h5 5412485
This commit is contained in:
@@ -3,6 +3,7 @@ package com.gxwebsoft.hjc.controller;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.hjc.auth.HjcAdminGuard;
|
||||
import com.gxwebsoft.hjc.entity.HjcBidProject;
|
||||
import com.gxwebsoft.hjc.entity.HjcEnterprise;
|
||||
import com.gxwebsoft.hjc.param.HjcBidProjectParam;
|
||||
@@ -16,9 +17,16 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 汇吉采标书项目(C端浏览 + 管理后台维护)
|
||||
*
|
||||
* <p><b>C 端接口不下发标书附件的下载地址</b>({@code tenderFile}):它是付费内容,
|
||||
* 只在订单接口上、且订单已支付时才给(见 {@code HjcOrderController#attachTenderFiles})。
|
||||
* 本类里 {@code list} 恒置 null、{@code detail} 只对「管理员」或「已为本项目付过款的买家」放行。
|
||||
* 不做这一步的话,UI 上加多少门槛都只是装饰——匿名 curl 一次就绕过了。</p>
|
||||
*/
|
||||
@Tag(name = "汇吉采-标书项目")
|
||||
@RestController
|
||||
@@ -33,11 +41,14 @@ public class HjcBidProjectController extends BaseController {
|
||||
private HjcEnterpriseService hjcEnterpriseService;
|
||||
@Resource
|
||||
private HjcProjectFavoriteService hjcProjectFavoriteService;
|
||||
@Resource
|
||||
private HjcAdminGuard hjcGuard;
|
||||
|
||||
@Operation(summary = "分页查询(后台)")
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<PageResult<HjcBidProject>> page(HjcBidProjectParam param) {
|
||||
// 后台**保留** tenderFile:hjc-vue 的编辑表单要靠它回填
|
||||
return success(hjcBidProjectService.pageRel(param));
|
||||
}
|
||||
|
||||
@@ -45,7 +56,17 @@ public class HjcBidProjectController extends BaseController {
|
||||
@GetMapping("/list")
|
||||
public ApiResult<PageResult<HjcBidProject>> list(HjcBidProjectParam param) {
|
||||
param.setStatus(1);
|
||||
return success(hjcBidProjectService.pageRel(param));
|
||||
PageResult<HjcBidProject> page = hjcBidProjectService.pageRel(param);
|
||||
// C 端列表从不渲染附件,而这里是**匿名可读**的:一律不下发地址(后台走 /page,不受影响)
|
||||
List<HjcBidProject> list = page.getList();
|
||||
if (list != null) {
|
||||
for (HjcBidProject project : list) {
|
||||
if (project != null) {
|
||||
project.setTenderFile(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return success(page);
|
||||
}
|
||||
|
||||
@Operation(summary = "详情")
|
||||
@@ -63,16 +84,39 @@ public class HjcBidProjectController extends BaseController {
|
||||
// 未登录 / 未注册企业一律 false —— 详情是公开接口,**不能**在这里返回 401,
|
||||
// 否则会把「收藏态查不到」升级成「整个详情页看不了」。
|
||||
project.setFavorited(false);
|
||||
// 登录态只解析一次企业,收藏态与「是否已购买」共用(别查两遍)
|
||||
HjcEnterprise enterprise = null;
|
||||
Integer userId = getLoginUserId();
|
||||
if (userId != null) {
|
||||
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId);
|
||||
enterprise = hjcEnterpriseService.getByUserId(userId);
|
||||
if (enterprise != null) {
|
||||
project.setFavorited(hjcProjectFavoriteService.exists(enterprise.getId(), project.getId()));
|
||||
}
|
||||
}
|
||||
// 附件下载地址是付费内容:管理员(后台编辑表单要回填)或已为本项目付过款的买家才拿到,
|
||||
// 其余(含匿名)一律抹掉。判定口径复用订单侧既有的 pay_status = 1,不新造一套。
|
||||
if (!isTenderFileVisible(project.getId(), enterprise)) {
|
||||
project.setTenderFile(null);
|
||||
}
|
||||
return success(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次请求是否有权看到该项目的标书附件地址。
|
||||
*
|
||||
* <p>两条放行路径:① hjc 管理员;② 登录买家且其企业在本项目上有已付款订单。</p>
|
||||
*/
|
||||
private boolean isTenderFileVisible(Integer projectId, HjcEnterprise enterprise) {
|
||||
if (hjcGuard.isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if (enterprise == null) {
|
||||
return false;
|
||||
}
|
||||
return !hjcOrderService.listPaidProjectIds(enterprise.getId(), Collections.singletonList(projectId))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Operation(summary = "新增(后台)")
|
||||
@PostMapping()
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.gxwebsoft.hjc.service.HjcBizService;
|
||||
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
|
||||
import com.gxwebsoft.hjc.service.HjcOrderService;
|
||||
import com.gxwebsoft.hjc.util.HjcOrderCancelUtil;
|
||||
import com.gxwebsoft.hjc.util.HjcTenderFileUtil;
|
||||
import com.gxwebsoft.payment.dto.PaymentRequest;
|
||||
import com.gxwebsoft.payment.dto.PaymentResponse;
|
||||
import com.gxwebsoft.payment.enums.PaymentStatus;
|
||||
@@ -34,6 +35,7 @@ import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -278,6 +280,8 @@ public class HjcOrderController extends BaseController {
|
||||
List<HjcOrder> list = hjcOrderService.list(new LambdaQueryWrapper<HjcOrder>()
|
||||
.eq(HjcOrder::getEnterpriseId, enterprise.getId())
|
||||
.orderByDesc(HjcOrder::getId));
|
||||
// 已支付订单带上标书附件(其余订单 tenderFiles 保持 null)。整批一次查项目,不要逐单查。
|
||||
attachTenderFiles(list);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
@@ -300,7 +304,14 @@ public class HjcOrderController extends BaseController {
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
order.setProject(hjcBidProjectService.getById(order.getProjectId()));
|
||||
HjcBidProject project = hjcBidProjectService.getById(order.getProjectId());
|
||||
order.setProject(project);
|
||||
// 顺序不能倒:先从 project.tenderFile 解析出附件(只有已支付订单会拿到),
|
||||
// 再把项目上的原始字段抹掉——订单路径不提供第二条地址来源(见 HjcOrder#tenderFiles)。
|
||||
attachTenderFiles(order);
|
||||
if (project != null) {
|
||||
project.setTenderFile(null);
|
||||
}
|
||||
return success(order);
|
||||
}
|
||||
|
||||
@@ -340,6 +351,71 @@ public class HjcOrderController extends BaseController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给**批量**订单附带标书附件(「我的订单」列表用)。
|
||||
*
|
||||
* <p>只有 {@code payStatus == 1}(已支付)的订单会拿到;其余订单的 {@code tenderFiles} 保持
|
||||
* {@code null}——「没付费」与「付费了但项目没附件」(空列表)是两件事,前端据此区分
|
||||
* 「不给下载」与「暂无附件」。</p>
|
||||
*
|
||||
* <p>一次性 {@code listByIds},不要逐单查:列表接口无分页,逐单查会变成 N+1。</p>
|
||||
*/
|
||||
private void attachTenderFiles(List<HjcOrder> orders) {
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<HjcOrder> paidOrders = new ArrayList<>();
|
||||
List<Integer> projectIds = new ArrayList<>();
|
||||
for (HjcOrder order : orders) {
|
||||
if (!isPaid(order) || order.getProjectId() == null) {
|
||||
continue;
|
||||
}
|
||||
paidOrders.add(order);
|
||||
if (!projectIds.contains(order.getProjectId())) {
|
||||
projectIds.add(order.getProjectId());
|
||||
}
|
||||
}
|
||||
if (paidOrders.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Integer, String> tenderFileOfProject = new HashMap<>(projectIds.size());
|
||||
for (HjcBidProject project : hjcBidProjectService.listByIds(projectIds)) {
|
||||
if (project != null) {
|
||||
tenderFileOfProject.put(project.getId(), project.getTenderFile());
|
||||
}
|
||||
}
|
||||
for (HjcOrder order : paidOrders) {
|
||||
order.setTenderFiles(HjcTenderFileUtil.parse(tenderFileOfProject.get(order.getProjectId())));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 给**单张**订单附带标书附件(订单详情用;项目对象已在手上,不再查库)。
|
||||
*
|
||||
* <p>调用方若还要把 {@code project.tenderFile} 抹掉,必须**先调本方法**再抹。</p>
|
||||
*/
|
||||
private void attachTenderFiles(HjcOrder order) {
|
||||
if (order == null || !isPaid(order)) {
|
||||
return;
|
||||
}
|
||||
HjcBidProject project = order.getProject();
|
||||
if (project == null && order.getProjectId() != null) {
|
||||
project = hjcBidProjectService.getById(order.getProjectId());
|
||||
}
|
||||
order.setTenderFiles(HjcTenderFileUtil.parse(project == null ? null : project.getTenderFile()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 这张订单是否**已付款**(下载权限的唯一依据)。
|
||||
*
|
||||
* <p>刻意**不**复用「已完成」的展示口径({@code payStatus == 1 || orderStatus == 1}):
|
||||
* 展示口径带兜底分支,而「能不能下载」必须绑在付款事实上。退款({@code payStatus == 3})
|
||||
* 与待支付都不算。</p>
|
||||
*/
|
||||
private boolean isPaid(HjcOrder order) {
|
||||
return order != null && order.getPayStatus() != null && order.getPayStatus() == 1;
|
||||
}
|
||||
|
||||
@Operation(summary = "后台-订单分页")
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.gxwebsoft.hjc.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 标书附件(下载用)。
|
||||
*
|
||||
* <p>来源是 {@code hjc_bid_project.tender_file}(一站式推送的 {@code files} 字段,
|
||||
* 形态见 {@link com.gxwebsoft.hjc.util.HjcTenderFileUtil}),由**后端**解析成结构化列表后
|
||||
* 随订单响应下发——两种运行端 + PC 端一共三处渲染,解析放前端必然分叉。</p>
|
||||
*
|
||||
* <p><b>只在 {@code payStatus == 1}(已支付)的订单上出现</b>:它是付费内容,
|
||||
* 见 {@code HjcOrderController#attachTenderFiles}。</p>
|
||||
*/
|
||||
@Data
|
||||
@Schema(name = "HjcTenderFileVo对象", description = "标书附件(下载用)")
|
||||
public class HjcTenderFileVo {
|
||||
|
||||
@Schema(description = "展示名(原始数据没有名称时取 URL 末段)")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "可下载地址")
|
||||
private String url;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.gxwebsoft.hjc.dto.HjcTenderFileVo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -12,6 +13,7 @@ import lombok.EqualsAndHashCode;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 汇吉采标书订单
|
||||
@@ -105,4 +107,19 @@ public class HjcOrder implements Serializable {
|
||||
@Schema(description = "标书项目")
|
||||
@TableField(exist = false)
|
||||
private HjcBidProject project;
|
||||
|
||||
/**
|
||||
* 标书附件(**非表字段**,仅在 {@code payStatus == 1} 的订单上由接口填充)。
|
||||
*
|
||||
* <p>它是**付费内容**:待支付 / 支付失败 / 已退款的订单一律为 {@code null},
|
||||
* 门槛的落点是 {@code HjcOrderController#attachTenderFiles}(只有一处)。
|
||||
* 附件来源是项目的 {@code tender_file},由 {@link com.gxwebsoft.hjc.util.HjcTenderFileUtil}
|
||||
* 解析成结构化列表后再下发。</p>
|
||||
*
|
||||
* <p>另外:订单详情响应里 {@code project.tenderFile} 会被**恒置 null**,
|
||||
* 免得订单路径上留一条能绕过付费判定的旁路。</p>
|
||||
*/
|
||||
@Schema(description = "标书附件(仅已支付订单返回,非表字段)")
|
||||
@TableField(exist = false)
|
||||
private List<HjcTenderFileVo> tenderFiles;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.gxwebsoft.hjc.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.gxwebsoft.hjc.dto.HjcTenderFileVo;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 标书附件({@code hjc_bid_project.tender_file})的解析(纯函数,不依赖 Spring)。
|
||||
*
|
||||
* <p><b>为什么要解析</b>:这个字段是从一站式推送里原样落库的字符串,契约是
|
||||
* 「数组JSON或逗号分隔」({@code HjcOneStopProjectPush#files}),也就是至少三种形态:
|
||||
* 字符串数组、对象数组(带文件名)、逗号分隔。前端三处(h5 两个运行端 + PC)都要渲染它,
|
||||
* 解析放前端必然分叉;放在这里还能用单测把「坏数据」这一支钉住。</p>
|
||||
*
|
||||
* <p><b>绝不抛异常</b>:一个坏附件不能让整张订单列表打不开(与 OCR「识别失败不阻断」同一取舍)。
|
||||
* 无法识别的条目就地丢弃,能认多少给多少。</p>
|
||||
*/
|
||||
public final class HjcTenderFileUtil {
|
||||
|
||||
private HjcTenderFileUtil() {
|
||||
}
|
||||
|
||||
/** 取不出文件名时的兜底展示名 */
|
||||
public static final String DEFAULT_NAME = "标书附件";
|
||||
|
||||
/** 非 JSON 形态的分隔符:英文/中文逗号、分号、换行 */
|
||||
private static final String DELIMITERS = "[,,;;\\r\\n]";
|
||||
|
||||
/**
|
||||
* 把库里的原始字符串解析成附件列表。
|
||||
*
|
||||
* <p>规则:</p>
|
||||
* <ul>
|
||||
* <li>空白 → 空列表</li>
|
||||
* <li>以 {@code [} / {@code {} 开头 → 按 JSON 解析(数组元素可为字符串或对象);
|
||||
* <b>JSON 坏了就返回空列表,不回退到按逗号切分</b>——形如 JSON 的坏数据切出来的
|
||||
* 碎片不是地址,宁可没有</li>
|
||||
* <li>其余 → 按 {@link #DELIMITERS} 切分</li>
|
||||
* <li>地址为空白的条目丢弃;同名地址按 {@code url} 去重(保持首次出现的顺序)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param raw 库里的 {@code tender_file},可为 null
|
||||
* @return 非 null 的列表(可能为空)
|
||||
*/
|
||||
public static List<HjcTenderFileVo> parse(String raw) {
|
||||
List<HjcTenderFileVo> files = new ArrayList<>();
|
||||
if (StrUtil.isBlank(raw)) {
|
||||
return files;
|
||||
}
|
||||
String text = raw.trim();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
if (text.startsWith("[")) {
|
||||
JSONArray array;
|
||||
try {
|
||||
array = JSON.parseArray(text);
|
||||
} catch (Exception e) {
|
||||
return files;
|
||||
}
|
||||
if (array == null) {
|
||||
return files;
|
||||
}
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
addFromJsonElement(array.get(i), seen, files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
if (text.startsWith("{")) {
|
||||
JSONObject object;
|
||||
try {
|
||||
object = JSON.parseObject(text);
|
||||
} catch (Exception e) {
|
||||
return files;
|
||||
}
|
||||
if (object != null) {
|
||||
addFromJsonObject(object, seen, files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
for (String piece : text.split(DELIMITERS)) {
|
||||
addFile(piece, null, seen, files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** 数组的一个元素:字符串直接当地址;对象取 url/name;其余类型(数字/布尔/嵌套)丢弃 */
|
||||
private static void addFromJsonElement(Object element, Set<String> seen, List<HjcTenderFileVo> files) {
|
||||
if (element == null) {
|
||||
return;
|
||||
}
|
||||
if (element instanceof JSONObject) {
|
||||
addFromJsonObject((JSONObject) element, seen, files);
|
||||
return;
|
||||
}
|
||||
if (element instanceof CharSequence) {
|
||||
addFile(element.toString(), null, seen, files);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addFromJsonObject(JSONObject object, Set<String> seen, List<HjcTenderFileVo> files) {
|
||||
String url = firstNotBlank(object, "url", "fileUrl", "fileURL", "path", "downloadUrl");
|
||||
String name = firstNotBlank(object, "name", "fileName", "filename", "title");
|
||||
addFile(url, name, seen, files);
|
||||
}
|
||||
|
||||
private static void addFile(String url, String name, Set<String> seen, List<HjcTenderFileVo> files) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return;
|
||||
}
|
||||
String trimmedUrl = url.trim();
|
||||
if (!seen.add(trimmedUrl)) {
|
||||
return;
|
||||
}
|
||||
HjcTenderFileVo vo = new HjcTenderFileVo();
|
||||
vo.setUrl(trimmedUrl);
|
||||
vo.setName(StrUtil.isBlank(name) ? nameFromUrl(trimmedUrl) : name.trim());
|
||||
files.add(vo);
|
||||
}
|
||||
|
||||
private static String firstNotBlank(JSONObject object, String... keys) {
|
||||
for (String key : keys) {
|
||||
String value = object.getString(key);
|
||||
if (StrUtil.isNotBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从地址里取一个能看的展示名:去掉 query/fragment 后取最后一段;取不到就用默认名。
|
||||
*
|
||||
* <p>末段若带百分号编码(中文文件名常见)就试着解码——解不开(不是合法编码)时原样返回,
|
||||
* 不因为一个名字让整条记录丢掉。</p>
|
||||
*/
|
||||
private static String nameFromUrl(String url) {
|
||||
String path = url;
|
||||
int cut = indexOfAny(path, '?', '#');
|
||||
if (cut >= 0) {
|
||||
path = path.substring(0, cut);
|
||||
}
|
||||
int slash = path.lastIndexOf('/');
|
||||
String name = slash >= 0 ? path.substring(slash + 1) : path;
|
||||
name = name.trim();
|
||||
if (name.isEmpty()) {
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
if (name.indexOf('%') >= 0) {
|
||||
try {
|
||||
name = URLDecoder.decode(name, "UTF-8").trim();
|
||||
} catch (Exception ignored) {
|
||||
// 不是合法的百分号编码:保留原样,好过丢一条附件
|
||||
}
|
||||
}
|
||||
return name.isEmpty() ? DEFAULT_NAME : name;
|
||||
}
|
||||
|
||||
private static int indexOfAny(String text, char a, char b) {
|
||||
int i = text.indexOf(a);
|
||||
int j = text.indexOf(b);
|
||||
if (i < 0) {
|
||||
return j;
|
||||
}
|
||||
if (j < 0) {
|
||||
return i;
|
||||
}
|
||||
return Math.min(i, j);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user