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:
2026-09-17 18:09:50 +08:00
parent 6d8185cc70
commit 0bc66b5808
8 changed files with 784 additions and 3 deletions
@@ -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) {
// 后台**保留** tenderFilehjc-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);
}
}