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两条放行路径:① 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只有 {@code payStatus == 1}(已支付)的订单会拿到;其余订单的 {@code tenderFiles} 保持 + * {@code null}——「没付费」与「付费了但项目没附件」(空列表)是两件事,前端据此区分 + * 「不给下载」与「暂无附件」。
+ * + *一次性 {@code listByIds},不要逐单查:列表接口无分页,逐单查会变成 N+1。
+ */ + private void attachTenderFiles(List调用方若还要把 {@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为什么要解析:这个字段是从一站式推送里原样落库的字符串,契约是 + * 「数组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]"; + + /** + * 把库里的原始字符串解析成附件列表。 + * + *规则:
+ *末段若带百分号编码(中文文件名常见)就试着解码——解不开(不是合法编码)时原样返回, + * 不因为一个名字让整条记录丢掉。
+ */ + 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这里的每一种形态都来自真实数据契约(一站式推送的 {@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