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.ApiResult;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.hjc.auth.HjcAdminGuard;
import com.gxwebsoft.hjc.entity.HjcBidProject; import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.param.HjcBidProjectParam; import com.gxwebsoft.hjc.param.HjcBidProjectParam;
@@ -16,9 +17,16 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
/** /**
* 汇吉采标书项目(C端浏览 + 管理后台维护) * 汇吉采标书项目(C端浏览 + 管理后台维护)
*
* <p><b>C 端接口不下发标书附件的下载地址</b>{@code tenderFile}):它是付费内容,
* 只在订单接口上、且订单已支付时才给(见 {@code HjcOrderController#attachTenderFiles})。
* 本类里 {@code list} 恒置 null、{@code detail} 只对「管理员」或「已为本项目付过款的买家」放行。
* 不做这一步的话,UI 上加多少门槛都只是装饰——匿名 curl 一次就绕过了。</p>
*/ */
@Tag(name = "汇吉采-标书项目") @Tag(name = "汇吉采-标书项目")
@RestController @RestController
@@ -33,11 +41,14 @@ public class HjcBidProjectController extends BaseController {
private HjcEnterpriseService hjcEnterpriseService; private HjcEnterpriseService hjcEnterpriseService;
@Resource @Resource
private HjcProjectFavoriteService hjcProjectFavoriteService; private HjcProjectFavoriteService hjcProjectFavoriteService;
@Resource
private HjcAdminGuard hjcGuard;
@Operation(summary = "分页查询(后台)") @Operation(summary = "分页查询(后台)")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()") @PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcBidProject>> page(HjcBidProjectParam param) { public ApiResult<PageResult<HjcBidProject>> page(HjcBidProjectParam param) {
// 后台**保留** tenderFilehjc-vue 的编辑表单要靠它回填
return success(hjcBidProjectService.pageRel(param)); return success(hjcBidProjectService.pageRel(param));
} }
@@ -45,7 +56,17 @@ public class HjcBidProjectController extends BaseController {
@GetMapping("/list") @GetMapping("/list")
public ApiResult<PageResult<HjcBidProject>> list(HjcBidProjectParam param) { public ApiResult<PageResult<HjcBidProject>> list(HjcBidProjectParam param) {
param.setStatus(1); 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 = "详情") @Operation(summary = "详情")
@@ -63,16 +84,39 @@ public class HjcBidProjectController extends BaseController {
// 未登录 / 未注册企业一律 false —— 详情是公开接口,**不能**在这里返回 401, // 未登录 / 未注册企业一律 false —— 详情是公开接口,**不能**在这里返回 401,
// 否则会把「收藏态查不到」升级成「整个详情页看不了」。 // 否则会把「收藏态查不到」升级成「整个详情页看不了」。
project.setFavorited(false); project.setFavorited(false);
// 登录态只解析一次企业,收藏态与「是否已购买」共用(别查两遍)
HjcEnterprise enterprise = null;
Integer userId = getLoginUserId(); Integer userId = getLoginUserId();
if (userId != null) { if (userId != null) {
HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId); enterprise = hjcEnterpriseService.getByUserId(userId);
if (enterprise != null) { if (enterprise != null) {
project.setFavorited(hjcProjectFavoriteService.exists(enterprise.getId(), project.getId())); project.setFavorited(hjcProjectFavoriteService.exists(enterprise.getId(), project.getId()));
} }
} }
// 附件下载地址是付费内容:管理员(后台编辑表单要回填)或已为本项目付过款的买家才拿到,
// 其余(含匿名)一律抹掉。判定口径复用订单侧既有的 pay_status = 1,不新造一套。
if (!isTenderFileVisible(project.getId(), enterprise)) {
project.setTenderFile(null);
}
return success(project); 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 = "新增(后台)") @Operation(summary = "新增(后台)")
@PostMapping() @PostMapping()
@PreAuthorize("@hjcGuard.isAdmin()") @PreAuthorize("@hjcGuard.isAdmin()")
@@ -18,6 +18,7 @@ import com.gxwebsoft.hjc.service.HjcBizService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService; import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.util.HjcOrderCancelUtil; import com.gxwebsoft.hjc.util.HjcOrderCancelUtil;
import com.gxwebsoft.hjc.util.HjcTenderFileUtil;
import com.gxwebsoft.payment.dto.PaymentRequest; import com.gxwebsoft.payment.dto.PaymentRequest;
import com.gxwebsoft.payment.dto.PaymentResponse; import com.gxwebsoft.payment.dto.PaymentResponse;
import com.gxwebsoft.payment.enums.PaymentStatus; import com.gxwebsoft.payment.enums.PaymentStatus;
@@ -34,6 +35,7 @@ import javax.annotation.Resource;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -278,6 +280,8 @@ public class HjcOrderController extends BaseController {
List<HjcOrder> list = hjcOrderService.list(new LambdaQueryWrapper<HjcOrder>() List<HjcOrder> list = hjcOrderService.list(new LambdaQueryWrapper<HjcOrder>()
.eq(HjcOrder::getEnterpriseId, enterprise.getId()) .eq(HjcOrder::getEnterpriseId, enterprise.getId())
.orderByDesc(HjcOrder::getId)); .orderByDesc(HjcOrder::getId));
// 已支付订单带上标书附件(其余订单 tenderFiles 保持 null)。整批一次查项目,不要逐单查。
attachTenderFiles(list);
return success(list); return success(list);
} }
@@ -300,7 +304,14 @@ public class HjcOrderController extends BaseController {
if (denied != null) { if (denied != null) {
return denied; 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); return success(order);
} }
@@ -340,6 +351,71 @@ public class HjcOrderController extends BaseController {
return null; 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 = "后台-订单分页") @Operation(summary = "后台-订单分页")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()") @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.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.gxwebsoft.hjc.dto.HjcTenderFileVo;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -12,6 +13,7 @@ import lombok.EqualsAndHashCode;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 汇吉采标书订单 * 汇吉采标书订单
@@ -105,4 +107,19 @@ public class HjcOrder implements Serializable {
@Schema(description = "标书项目") @Schema(description = "标书项目")
@TableField(exist = false) @TableField(exist = false)
private HjcBidProject project; 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);
}
}
@@ -2,6 +2,7 @@ package com.gxwebsoft.hjc.controller;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.hjc.auth.HjcAdminGuard;
import com.gxwebsoft.hjc.entity.HjcBidProject; import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.service.HjcBidProjectService; import com.gxwebsoft.hjc.service.HjcBidProjectService;
@@ -18,7 +19,9 @@ import java.lang.reflect.Field;
import java.util.Collections; import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -30,18 +33,23 @@ import static org.mockito.Mockito.when;
* <p><b>最要紧的一条:未登录时 {@code favorited} 必须是 {@code false},而不是 401。</b> * <p><b>最要紧的一条:未登录时 {@code favorited} 必须是 {@code false},而不是 401。</b>
* 详情页对未登录用户是正常可用的页面(页面上本就有未登录分支), * 详情页对未登录用户是正常可用的页面(页面上本就有未登录分支),
* 在这里返回 401 会把「收藏态查不到」升级成「整个详情页看不了」。</p> * 在这里返回 401 会把「收藏态查不到」升级成「整个详情页看不了」。</p>
*
* <p>本轮追加的护栏:{@code tenderFile}(标书附件地址)是付费内容——只有 hjc 管理员
* (后台编辑表单要回填)或已为本项目付过款的买家才拿得到,其余一律 {@code null}。</p>
*/ */
class HjcBidProjectControllerDetailTest { class HjcBidProjectControllerDetailTest {
private static final int PROJECT_ID = 123; private static final int PROJECT_ID = 123;
private static final int BUYER_USER_ID = 9001; private static final int BUYER_USER_ID = 9001;
private static final int BUYER_ENTERPRISE_ID = 7001; private static final int BUYER_ENTERPRISE_ID = 7001;
private static final String TENDER_FILE = "[\"https://oss.example.com/a/招标文件.pdf\"]";
private HjcBidProjectController controller; private HjcBidProjectController controller;
private HjcBidProjectService hjcBidProjectService; private HjcBidProjectService hjcBidProjectService;
private HjcOrderService hjcOrderService; private HjcOrderService hjcOrderService;
private HjcEnterpriseService hjcEnterpriseService; private HjcEnterpriseService hjcEnterpriseService;
private HjcProjectFavoriteService hjcProjectFavoriteService; private HjcProjectFavoriteService hjcProjectFavoriteService;
private HjcAdminGuard hjcGuard;
@BeforeEach @BeforeEach
void setUp() throws Exception { void setUp() throws Exception {
@@ -50,15 +58,18 @@ class HjcBidProjectControllerDetailTest {
hjcOrderService = mock(HjcOrderService.class); hjcOrderService = mock(HjcOrderService.class);
hjcEnterpriseService = mock(HjcEnterpriseService.class); hjcEnterpriseService = mock(HjcEnterpriseService.class);
hjcProjectFavoriteService = mock(HjcProjectFavoriteService.class); hjcProjectFavoriteService = mock(HjcProjectFavoriteService.class);
hjcGuard = mock(HjcAdminGuard.class);
inject("hjcBidProjectService", hjcBidProjectService); inject("hjcBidProjectService", hjcBidProjectService);
inject("hjcOrderService", hjcOrderService); inject("hjcOrderService", hjcOrderService);
inject("hjcEnterpriseService", hjcEnterpriseService); inject("hjcEnterpriseService", hjcEnterpriseService);
inject("hjcProjectFavoriteService", hjcProjectFavoriteService); inject("hjcProjectFavoriteService", hjcProjectFavoriteService);
inject("hjcGuard", hjcGuard);
HjcBidProject project = new HjcBidProject(); HjcBidProject project = new HjcBidProject();
project.setId(PROJECT_ID); project.setId(PROJECT_ID);
project.setProjectName("某测试项目"); project.setProjectName("某测试项目");
project.setTenderFile(TENDER_FILE);
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project); when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project);
when(hjcOrderService.countPaidOrders(PROJECT_ID)).thenReturn(3); when(hjcOrderService.countPaidOrders(PROJECT_ID)).thenReturn(3);
} }
@@ -148,4 +159,64 @@ class HjcBidProjectControllerDetailTest {
assertEquals(1, controller.detail(999).getCode()); assertEquals(1, controller.detail(999).getCode());
} }
// ---------- 标书附件地址:付费内容,未购买拿不到 ----------
@Test
void anonymousShouldNotGetTenderFile() {
ApiResult<?> res = controller.detail(PROJECT_ID);
assertEquals(0, res.getCode(), "抹掉附件地址不等于把详情打回 401");
assertNull(projectOf(res).getTenderFile(), "匿名不能拿到标书附件地址");
verify(hjcOrderService, never()).listPaidProjectIds(anyInt(), anyList());
}
@Test
void loggedInWithoutEnterpriseShouldNotGetTenderFile() {
loginAsBuyer();
when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(null);
ApiResult<?> res = controller.detail(PROJECT_ID);
assertNull(projectOf(res).getTenderFile());
verify(hjcOrderService, never()).listPaidProjectIds(anyInt(), anyList());
}
@Test
void loggedInNotPurchasedShouldNotGetTenderFile() {
loginAsBuyer();
HjcEnterprise e = new HjcEnterprise();
e.setId(BUYER_ENTERPRISE_ID);
when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(e);
// 默认 mock 返回空集合 = 没买过
ApiResult<?> res = controller.detail(PROJECT_ID);
assertNull(projectOf(res).getTenderFile(), "没付款就不能拿到标书附件地址");
}
@Test
void loggedInPurchasedShouldGetTenderFile() {
loginAsBuyer();
HjcEnterprise e = new HjcEnterprise();
e.setId(BUYER_ENTERPRISE_ID);
when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(e);
when(hjcOrderService.listPaidProjectIds(BUYER_ENTERPRISE_ID, Collections.singletonList(PROJECT_ID)))
.thenReturn(Collections.singletonList(PROJECT_ID));
ApiResult<?> res = controller.detail(PROJECT_ID);
assertEquals(TENDER_FILE, projectOf(res).getTenderFile(), "已付款买家应当能拿到附件地址");
}
@Test
void adminShouldGetTenderFileWithoutPurchaseCheck() {
loginAsBuyer();
when(hjcGuard.isAdmin()).thenReturn(true);
ApiResult<?> res = controller.detail(PROJECT_ID);
assertEquals(TENDER_FILE, projectOf(res).getTenderFile(), "后台编辑表单要靠它回填");
verify(hjcOrderService, never()).listPaidProjectIds(anyInt(), anyList());
}
} }
@@ -0,0 +1,249 @@
package com.gxwebsoft.hjc.controller;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.system.entity.User;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.gxwebsoft.hjc.auth.HjcAdminGuard;
import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcBizService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.payment.service.PaymentService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 「已支付订单才有标书附件」这条门槛的护栏({@code my} 与 {@code detail} 两个出口)。
*
* <p><b>为什么必须用 mock 测</b>:这条门槛最要紧的几支在 dev 里造不出来——要一张真实已付款订单、
* 要一张已退款订单,还要有附件数据的项目。而它一旦失效(未支付也拿到地址),
* 就是把付费内容白送出去,属于「宁可多测一遍」的那类改动。</p>
*/
class HjcOrderControllerTenderFilesTest {
private static final int ORDER_ID = 88;
private static final int PROJECT_ID = 66;
private static final int BUYER_USER_ID = 9001;
private static final int BUYER_ENTERPRISE_ID = 7001;
private static final String FILES = "[\"https://oss.example.com/a/招标文件.pdf\"]";
private HjcOrderController controller;
private HjcOrderService hjcOrderService;
private HjcBidProjectService hjcBidProjectService;
private HjcEnterpriseService hjcEnterpriseService;
private HjcAdminGuard hjcGuard;
/**
* {@code my()} 用 {@code LambdaQueryWrapper} 取列表,而 lambda 的列名解析依赖 MyBatis-Plus 的
* {@code TableInfo} 缓存;单元测试里没有 Spring/MyBatis 环境(不启动容器就没有 mapper 扫描),
* 不手动注册实体的话,构造 wrapper 时就会抛
* {@code can not find lambda cache for this entity}。
*/
@BeforeAll
static void initMybatisPlusTableInfo() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), HjcOrder.class);
}
@BeforeEach
void setUp() throws Exception {
controller = new HjcOrderController();
hjcOrderService = mock(HjcOrderService.class);
hjcBidProjectService = mock(HjcBidProjectService.class);
hjcEnterpriseService = mock(HjcEnterpriseService.class);
hjcGuard = mock(HjcAdminGuard.class);
inject("hjcOrderService", hjcOrderService);
inject("hjcBidProjectService", hjcBidProjectService);
inject("hjcEnterpriseService", hjcEnterpriseService);
inject("hjcGuard", hjcGuard);
inject("hjcBizService", mock(HjcBizService.class));
inject("paymentService", mock(PaymentService.class));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
private void inject(String field, Object value) throws Exception {
Field f = HjcOrderController.class.getDeclaredField(field);
f.setAccessible(true);
f.set(controller, value);
}
private void loginAsBuyer() {
User u = new User();
u.setUserId(BUYER_USER_ID);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(u, null, Collections.emptyList()));
when(hjcGuard.currentUser()).thenReturn(u);
when(hjcGuard.isAdmin()).thenReturn(false);
HjcEnterprise enterprise = new HjcEnterprise();
enterprise.setId(BUYER_ENTERPRISE_ID);
when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(enterprise);
}
private HjcOrder order(Integer payStatus) {
HjcOrder o = new HjcOrder();
o.setId(ORDER_ID);
o.setOrderNo("HJC202609170000000001");
o.setProjectId(PROJECT_ID);
o.setEnterpriseId(BUYER_ENTERPRISE_ID);
o.setPayStatus(payStatus);
o.setOrderStatus(payStatus != null && payStatus == 1 ? 1 : 0);
return o;
}
private HjcBidProject project() {
HjcBidProject p = new HjcBidProject();
p.setId(PROJECT_ID);
p.setProjectName("某测试项目");
p.setTenderFile(FILES);
return p;
}
@SuppressWarnings("unchecked")
private List<HjcOrder> ordersOf(ApiResult<?> res) {
return (List<HjcOrder>) res.getData();
}
// ---------- 我的订单 ----------
@Test
void myShouldAttachFilesOnlyForPaidOrders() {
loginAsBuyer();
HjcOrder paid = order(1);
HjcOrder pending = order(0);
HjcOrder refunded = order(3);
when(hjcOrderService.list(any())).thenReturn(Arrays.asList(paid, pending, refunded));
when(hjcBidProjectService.listByIds(any())).thenReturn(Collections.singletonList(project()));
ApiResult<?> res = controller.my();
assertEquals(0, res.getCode());
List<HjcOrder> list = ordersOf(res);
assertEquals(1, list.get(0).getTenderFiles().size(), "已支付必须拿到附件");
assertEquals("招标文件.pdf", list.get(0).getTenderFiles().get(0).getName());
assertNull(list.get(1).getTenderFiles(), "待支付不给附件(null 与空列表是两件事)");
assertNull(list.get(2).getTenderFiles(), "已退款不给附件");
}
@Test
void myShouldQueryProjectsOnceForAllPaidOrders() {
loginAsBuyer();
when(hjcOrderService.list(any())).thenReturn(Arrays.asList(order(1), order(1), order(0)));
when(hjcBidProjectService.listByIds(any())).thenReturn(Collections.singletonList(project()));
controller.my();
List<Integer> expectedIds = new ArrayList<>();
expectedIds.add(PROJECT_ID);
verify(hjcBidProjectService, times(1)).listByIds(expectedIds);
}
@Test
void myWithoutPaidOrdersShouldNotQueryProjects() {
loginAsBuyer();
when(hjcOrderService.list(any())).thenReturn(Collections.singletonList(order(0)));
controller.my();
verify(hjcBidProjectService, times(0)).listByIds(any());
}
// ---------- 订单详情 ----------
@Test
void detailOfPaidOrderShouldAttachFilesAndHideProjectTenderFile() {
loginAsBuyer();
HjcOrder paid = order(1);
when(hjcOrderService.getById(ORDER_ID)).thenReturn(paid);
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project());
ApiResult<?> res = controller.detail(ORDER_ID);
assertEquals(0, res.getCode());
HjcOrder detail = (HjcOrder) res.getData();
assertNotNull(detail.getTenderFiles());
assertEquals(1, detail.getTenderFiles().size());
assertNull(detail.getProject().getTenderFile(),
"订单路径不留第二条地址来源:一律抹掉 project.tenderFile");
}
@Test
void detailOfPendingOrderShouldGiveNoFiles() {
loginAsBuyer();
HjcOrder pending = order(0);
when(hjcOrderService.getById(ORDER_ID)).thenReturn(pending);
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project());
ApiResult<?> res = controller.detail(ORDER_ID);
assertEquals(0, res.getCode());
HjcOrder detail = (HjcOrder) res.getData();
assertNull(detail.getTenderFiles(), "未支付不能拿到附件地址");
assertNull(detail.getProject().getTenderFile(), "未支付也不能从 project 里绕到地址");
}
@Test
void detailOfRefundedOrderShouldGiveNoFiles() {
loginAsBuyer();
HjcOrder refunded = order(3);
when(hjcOrderService.getById(ORDER_ID)).thenReturn(refunded);
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project());
ApiResult<?> res = controller.detail(ORDER_ID);
HjcOrder detail = (HjcOrder) res.getData();
assertNull(detail.getTenderFiles(), "已退款视为不再提供下载");
}
@Test
void detailWithoutProjectShouldStillSucceed() {
loginAsBuyer();
when(hjcOrderService.getById(ORDER_ID)).thenReturn(order(1));
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(null);
ApiResult<?> res = controller.detail(ORDER_ID);
assertEquals(0, res.getCode(), "项目被删不该让订单详情打不开");
assertTrue(((HjcOrder) res.getData()).getTenderFiles().isEmpty());
}
@Test
void otherEnterpriseOrderShouldStillBeForbidden() {
loginAsBuyer();
HjcOrder other = order(1);
other.setEnterpriseId(9999);
when(hjcOrderService.getById(ORDER_ID)).thenReturn(other);
assertEquals(403, controller.detail(ORDER_ID).getCode());
verify(hjcBidProjectService, times(0)).getById(anyInt());
}
}
@@ -0,0 +1,123 @@
package com.gxwebsoft.hjc.util;
import com.gxwebsoft.hjc.dto.HjcTenderFileVo;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 标书附件解析的护栏。
*
* <p>这里的每一种形态都来自真实数据契约(一站式推送的 {@code files}「数组JSON或逗号分隔」),
* 而**坏数据这一支在端到端环境里造不出来**(要往库里塞脏字符串),只能靠单测钉住。</p>
*/
class HjcTenderFileUtilTest {
@Test
void blankShouldGiveEmptyList() {
assertTrue(HjcTenderFileUtil.parse(null).isEmpty());
assertTrue(HjcTenderFileUtil.parse("").isEmpty());
assertTrue(HjcTenderFileUtil.parse(" ").isEmpty());
}
@Test
void stringArrayShouldBeParsedWithUrlTailAsName() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"[\"https://oss.example.com/a/招标文件.pdf\",\"https://oss.example.com/b/清单.docx\"]");
assertEquals(2, files.size());
assertEquals("招标文件.pdf", files.get(0).getName());
assertEquals("https://oss.example.com/a/招标文件.pdf", files.get(0).getUrl());
assertEquals("清单.docx", files.get(1).getName());
}
@Test
void objectArrayShouldPreferExplicitName() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"[{\"url\":\"https://oss.example.com/1.pdf\",\"name\":\"招标文件(正式版).pdf\"}]");
assertEquals(1, files.size());
assertEquals("招标文件(正式版).pdf", files.get(0).getName());
assertEquals("https://oss.example.com/1.pdf", files.get(0).getUrl());
}
@Test
void objectArrayShouldAcceptAlternativeKeys() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"[{\"fileUrl\":\"https://oss.example.com/1.pdf\",\"fileName\":\"附件A.pdf\"},"
+ "{\"path\":\"https://oss.example.com/2.pdf\",\"filename\":\"附件B.pdf\"}]");
assertEquals(2, files.size());
assertEquals("附件A.pdf", files.get(0).getName());
assertEquals("附件B.pdf", files.get(1).getName());
}
@Test
void singleJsonObjectShouldBeTreatedAsOneFile() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"{\"url\":\"https://oss.example.com/only.pdf\"}");
assertEquals(1, files.size());
assertEquals("only.pdf", files.get(0).getName());
}
@Test
void commaAndNewlineSeparatedShouldBeParsed() {
assertEquals(2, HjcTenderFileUtil.parse(
"https://oss.example.com/a.pdf,https://oss.example.com/b.pdf").size());
assertEquals(2, HjcTenderFileUtil.parse(
"https://oss.example.com/a.pdfhttps://oss.example.com/b.pdf").size(), "中文逗号也要认");
assertEquals(2, HjcTenderFileUtil.parse(
"https://oss.example.com/a.pdf\nhttps://oss.example.com/b.pdf").size());
}
@Test
void brokenJsonShouldGiveEmptyListInsteadOfThrowing() {
assertTrue(HjcTenderFileUtil.parse("[坏 JSON").isEmpty(), "坏数据不能抛异常,也不能切成碎片当地址");
assertTrue(HjcTenderFileUtil.parse("{oops").isEmpty());
}
@Test
void unusableElementsShouldBeDropped() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"[\"https://oss.example.com/a.pdf\",null,\"\",123,true,{\"name\":\"没有地址\"}]");
assertEquals(1, files.size(), "只有带地址的字符串算一条附件");
assertEquals("https://oss.example.com/a.pdf", files.get(0).getUrl());
}
@Test
void duplicatedUrlShouldAppearOnce() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"[\"https://oss.example.com/a.pdf\",\"https://oss.example.com/a.pdf\"]");
assertEquals(1, files.size());
}
@Test
void queryStringShouldNotPolluteTheName() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"https://oss.example.com/a.pdf?sign=abc&expires=1");
assertEquals("a.pdf", files.get(0).getName());
}
@Test
void encodedNameShouldBeDecoded() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse(
"https://oss.example.com/%E6%8B%9B%E6%A0%87%E6%96%87%E4%BB%B6.pdf");
assertEquals("招标文件.pdf", files.get(0).getName());
}
@Test
void urlWithoutTailShouldFallBackToDefaultName() {
List<HjcTenderFileVo> files = HjcTenderFileUtil.parse("https://oss.example.com/");
assertEquals(1, files.size());
assertEquals(HjcTenderFileUtil.DEFAULT_NAME, files.get(0).getName());
}
}