From 0bc66b580887d16f588400ab97df9dd0f0cc097d Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 17 Sep 2026 18:09:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(hjc):=20=E5=B7=B2=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E4=B8=8B=E5=8F=91=E6=A0=87=E4=B9=A6=E9=99=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E5=B9=B6=E5=A0=B5=E4=BD=8F=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9A=84=E6=A0=87=E4=B9=A6=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「购买后才能下载标书」此前在服务端从未成立:`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 --- .../controller/HjcBidProjectController.java | 48 +++- .../hjc/controller/HjcOrderController.java | 78 +++++- .../gxwebsoft/hjc/dto/HjcTenderFileVo.java | 25 ++ .../com/gxwebsoft/hjc/entity/HjcOrder.java | 17 ++ .../gxwebsoft/hjc/util/HjcTenderFileUtil.java | 176 +++++++++++++ .../HjcBidProjectControllerDetailTest.java | 71 +++++ .../HjcOrderControllerTenderFilesTest.java | 249 ++++++++++++++++++ .../hjc/util/HjcTenderFileUtilTest.java | 123 +++++++++ 8 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/hjc/dto/HjcTenderFileVo.java create mode 100644 src/main/java/com/gxwebsoft/hjc/util/HjcTenderFileUtil.java create mode 100644 src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerTenderFilesTest.java create mode 100644 src/test/java/com/gxwebsoft/hjc/util/HjcTenderFileUtilTest.java diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java index 21873d1..b29aeb6 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java @@ -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端浏览 + 管理后台维护) + * + *

C 端接口不下发标书附件的下载地址({@code tenderFile}):它是付费内容, + * 只在订单接口上、且订单已支付时才给(见 {@code HjcOrderController#attachTenderFiles})。 + * 本类里 {@code list} 恒置 null、{@code detail} 只对「管理员」或「已为本项目付过款的买家」放行。 + * 不做这一步的话,UI 上加多少门槛都只是装饰——匿名 curl 一次就绕过了。

*/ @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> page(HjcBidProjectParam param) { + // 后台**保留** tenderFile:hjc-vue 的编辑表单要靠它回填 return success(hjcBidProjectService.pageRel(param)); } @@ -45,7 +56,17 @@ public class HjcBidProjectController extends BaseController { @GetMapping("/list") public ApiResult> list(HjcBidProjectParam param) { param.setStatus(1); - return success(hjcBidProjectService.pageRel(param)); + PageResult page = hjcBidProjectService.pageRel(param); + // C 端列表从不渲染附件,而这里是**匿名可读**的:一律不下发地址(后台走 /page,不受影响) + List 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); } + /** + * 本次请求是否有权看到该项目的标书附件地址。 + * + *

两条放行路径:① hjc 管理员;② 登录买家且其企业在本项目上有已付款订单。

+ */ + 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()") diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java index 8dc55c6..2b4aaea 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java @@ -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 list = hjcOrderService.list(new LambdaQueryWrapper() .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; } + /** + * 给**批量**订单附带标书附件(「我的订单」列表用)。 + * + *

只有 {@code payStatus == 1}(已支付)的订单会拿到;其余订单的 {@code tenderFiles} 保持 + * {@code null}——「没付费」与「付费了但项目没附件」(空列表)是两件事,前端据此区分 + * 「不给下载」与「暂无附件」。

+ * + *

一次性 {@code listByIds},不要逐单查:列表接口无分页,逐单查会变成 N+1。

+ */ + private void attachTenderFiles(List orders) { + if (orders == null || orders.isEmpty()) { + return; + } + List paidOrders = new ArrayList<>(); + List 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 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()))); + } + } + + /** + * 给**单张**订单附带标书附件(订单详情用;项目对象已在手上,不再查库)。 + * + *

调用方若还要把 {@code project.tenderFile} 抹掉,必须**先调本方法**再抹。

+ */ + 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())); + } + + /** + * 这张订单是否**已付款**(下载权限的唯一依据)。 + * + *

刻意**不**复用「已完成」的展示口径({@code payStatus == 1 || orderStatus == 1}): + * 展示口径带兜底分支,而「能不能下载」必须绑在付款事实上。退款({@code payStatus == 3}) + * 与待支付都不算。

+ */ + private boolean isPaid(HjcOrder order) { + return order != null && order.getPayStatus() != null && order.getPayStatus() == 1; + } + @Operation(summary = "后台-订单分页") @GetMapping("/page") @PreAuthorize("@hjcGuard.isAdmin()") diff --git a/src/main/java/com/gxwebsoft/hjc/dto/HjcTenderFileVo.java b/src/main/java/com/gxwebsoft/hjc/dto/HjcTenderFileVo.java new file mode 100644 index 0000000..d9a6ef4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/dto/HjcTenderFileVo.java @@ -0,0 +1,25 @@ +package com.gxwebsoft.hjc.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 标书附件(下载用)。 + * + *

来源是 {@code hjc_bid_project.tender_file}(一站式推送的 {@code files} 字段, + * 形态见 {@link com.gxwebsoft.hjc.util.HjcTenderFileUtil}),由**后端**解析成结构化列表后 + * 随订单响应下发——两种运行端 + PC 端一共三处渲染,解析放前端必然分叉。

+ * + *

只在 {@code payStatus == 1}(已支付)的订单上出现:它是付费内容, + * 见 {@code HjcOrderController#attachTenderFiles}。

+ */ +@Data +@Schema(name = "HjcTenderFileVo对象", description = "标书附件(下载用)") +public class HjcTenderFileVo { + + @Schema(description = "展示名(原始数据没有名称时取 URL 末段)") + private String name; + + @Schema(description = "可下载地址") + private String url; +} diff --git a/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java b/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java index b0005e2..7e2f329 100644 --- a/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java +++ b/src/main/java/com/gxwebsoft/hjc/entity/HjcOrder.java @@ -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} 的订单上由接口填充)。 + * + *

它是**付费内容**:待支付 / 支付失败 / 已退款的订单一律为 {@code null}, + * 门槛的落点是 {@code HjcOrderController#attachTenderFiles}(只有一处)。 + * 附件来源是项目的 {@code tender_file},由 {@link com.gxwebsoft.hjc.util.HjcTenderFileUtil} + * 解析成结构化列表后再下发。

+ * + *

另外:订单详情响应里 {@code project.tenderFile} 会被**恒置 null**, + * 免得订单路径上留一条能绕过付费判定的旁路。

+ */ + @Schema(description = "标书附件(仅已支付订单返回,非表字段)") + @TableField(exist = false) + private List tenderFiles; } diff --git a/src/main/java/com/gxwebsoft/hjc/util/HjcTenderFileUtil.java b/src/main/java/com/gxwebsoft/hjc/util/HjcTenderFileUtil.java new file mode 100644 index 0000000..fa9930d --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/util/HjcTenderFileUtil.java @@ -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)。 + * + *

为什么要解析:这个字段是从一站式推送里原样落库的字符串,契约是 + * 「数组JSON或逗号分隔」({@code HjcOneStopProjectPush#files}),也就是至少三种形态: + * 字符串数组、对象数组(带文件名)、逗号分隔。前端三处(h5 两个运行端 + PC)都要渲染它, + * 解析放前端必然分叉;放在这里还能用单测把「坏数据」这一支钉住。

+ * + *

绝不抛异常:一个坏附件不能让整张订单列表打不开(与 OCR「识别失败不阻断」同一取舍)。 + * 无法识别的条目就地丢弃,能认多少给多少。

+ */ +public final class HjcTenderFileUtil { + + private HjcTenderFileUtil() { + } + + /** 取不出文件名时的兜底展示名 */ + public static final String DEFAULT_NAME = "标书附件"; + + /** 非 JSON 形态的分隔符:英文/中文逗号、分号、换行 */ + private static final String DELIMITERS = "[,,;;\\r\\n]"; + + /** + * 把库里的原始字符串解析成附件列表。 + * + *

规则:

+ *
    + *
  • 空白 → 空列表
  • + *
  • 以 {@code [} / {@code {} 开头 → 按 JSON 解析(数组元素可为字符串或对象); + * JSON 坏了就返回空列表,不回退到按逗号切分——形如 JSON 的坏数据切出来的 + * 碎片不是地址,宁可没有
  • + *
  • 其余 → 按 {@link #DELIMITERS} 切分
  • + *
  • 地址为空白的条目丢弃;同名地址按 {@code url} 去重(保持首次出现的顺序)
  • + *
+ * + * @param raw 库里的 {@code tender_file},可为 null + * @return 非 null 的列表(可能为空) + */ + public static List parse(String raw) { + List files = new ArrayList<>(); + if (StrUtil.isBlank(raw)) { + return files; + } + String text = raw.trim(); + Set 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 seen, List 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 seen, List 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 seen, List 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 后取最后一段;取不到就用默认名。 + * + *

末段若带百分号编码(中文文件名常见)就试着解码——解不开(不是合法编码)时原样返回, + * 不因为一个名字让整条记录丢掉。

+ */ + 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); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java b/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java index 1ca5d87..408f609 100644 --- a/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java +++ b/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java @@ -2,6 +2,7 @@ package com.gxwebsoft.hjc.controller; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.hjc.auth.HjcAdminGuard; import com.gxwebsoft.hjc.entity.HjcBidProject; import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.service.HjcBidProjectService; @@ -18,7 +19,9 @@ import java.lang.reflect.Field; import java.util.Collections; 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.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -30,18 +33,23 @@ import static org.mockito.Mockito.when; *

最要紧的一条:未登录时 {@code favorited} 必须是 {@code false},而不是 401。 * 详情页对未登录用户是正常可用的页面(页面上本就有未登录分支), * 在这里返回 401 会把「收藏态查不到」升级成「整个详情页看不了」。

+ * + *

本轮追加的护栏:{@code tenderFile}(标书附件地址)是付费内容——只有 hjc 管理员 + * (后台编辑表单要回填)或已为本项目付过款的买家才拿得到,其余一律 {@code null}。

*/ class HjcBidProjectControllerDetailTest { private static final int PROJECT_ID = 123; private static final int BUYER_USER_ID = 9001; private static final int BUYER_ENTERPRISE_ID = 7001; + private static final String TENDER_FILE = "[\"https://oss.example.com/a/招标文件.pdf\"]"; private HjcBidProjectController controller; private HjcBidProjectService hjcBidProjectService; private HjcOrderService hjcOrderService; private HjcEnterpriseService hjcEnterpriseService; private HjcProjectFavoriteService hjcProjectFavoriteService; + private HjcAdminGuard hjcGuard; @BeforeEach void setUp() throws Exception { @@ -50,15 +58,18 @@ class HjcBidProjectControllerDetailTest { hjcOrderService = mock(HjcOrderService.class); hjcEnterpriseService = mock(HjcEnterpriseService.class); hjcProjectFavoriteService = mock(HjcProjectFavoriteService.class); + hjcGuard = mock(HjcAdminGuard.class); inject("hjcBidProjectService", hjcBidProjectService); inject("hjcOrderService", hjcOrderService); inject("hjcEnterpriseService", hjcEnterpriseService); inject("hjcProjectFavoriteService", hjcProjectFavoriteService); + inject("hjcGuard", hjcGuard); HjcBidProject project = new HjcBidProject(); project.setId(PROJECT_ID); project.setProjectName("某测试项目"); + project.setTenderFile(TENDER_FILE); when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project); when(hjcOrderService.countPaidOrders(PROJECT_ID)).thenReturn(3); } @@ -148,4 +159,64 @@ class HjcBidProjectControllerDetailTest { 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()); + } } diff --git a/src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerTenderFilesTest.java b/src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerTenderFilesTest.java new file mode 100644 index 0000000..9716bd5 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/controller/HjcOrderControllerTenderFilesTest.java @@ -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} 两个出口)。 + * + *

为什么必须用 mock 测:这条门槛最要紧的几支在 dev 里造不出来——要一张真实已付款订单、 + * 要一张已退款订单,还要有附件数据的项目。而它一旦失效(未支付也拿到地址), + * 就是把付费内容白送出去,属于「宁可多测一遍」的那类改动。

+ */ +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 ordersOf(ApiResult res) { + return (List) 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 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 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()); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/util/HjcTenderFileUtilTest.java b/src/test/java/com/gxwebsoft/hjc/util/HjcTenderFileUtilTest.java new file mode 100644 index 0000000..4539a13 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/util/HjcTenderFileUtilTest.java @@ -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; + +/** + * 标书附件解析的护栏。 + * + *

这里的每一种形态都来自真实数据契约(一站式推送的 {@code files}「数组JSON或逗号分隔」), + * 而**坏数据这一支在端到端环境里造不出来**(要往库里塞脏字符串),只能靠单测钉住。

+ */ +class HjcTenderFileUtilTest { + + @Test + void blankShouldGiveEmptyList() { + assertTrue(HjcTenderFileUtil.parse(null).isEmpty()); + assertTrue(HjcTenderFileUtil.parse("").isEmpty()); + assertTrue(HjcTenderFileUtil.parse(" ").isEmpty()); + } + + @Test + void stringArrayShouldBeParsedWithUrlTailAsName() { + List 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 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 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 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.pdf,https://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 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 files = HjcTenderFileUtil.parse( + "[\"https://oss.example.com/a.pdf\",\"https://oss.example.com/a.pdf\"]"); + + assertEquals(1, files.size()); + } + + @Test + void queryStringShouldNotPolluteTheName() { + List files = HjcTenderFileUtil.parse( + "https://oss.example.com/a.pdf?sign=abc&expires=1"); + + assertEquals("a.pdf", files.get(0).getName()); + } + + @Test + void encodedNameShouldBeDecoded() { + List 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 files = HjcTenderFileUtil.parse("https://oss.example.com/"); + + assertEquals(1, files.size()); + assertEquals(HjcTenderFileUtil.DEFAULT_NAME, files.get(0).getName()); + } +}