diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java index cb3ead2..21873d1 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java @@ -4,9 +4,12 @@ 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.entity.HjcBidProject; +import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.param.HjcBidProjectParam; import com.gxwebsoft.hjc.service.HjcBidProjectService; +import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcOrderService; +import com.gxwebsoft.hjc.service.HjcProjectFavoriteService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; @@ -26,6 +29,10 @@ public class HjcBidProjectController extends BaseController { private HjcBidProjectService hjcBidProjectService; @Resource private HjcOrderService hjcOrderService; + @Resource + private HjcEnterpriseService hjcEnterpriseService; + @Resource + private HjcProjectFavoriteService hjcProjectFavoriteService; @Operation(summary = "分页查询(后台)") @GetMapping("/page") @@ -51,6 +58,18 @@ public class HjcBidProjectController extends BaseController { // 「购买人数」= 实时统计的**已付款订单数**(不是 saleCount:那个字段下单即加、从不回退, // 含未付款与已取消的单,拿它当购买人数一直是假数)。详见 HjcBidProject#buyerCount。 project.setBuyerCount(hjcOrderService.countPaidOrders(project.getId())); + // 收藏态(非表字段,见 HjcBidProject#favorited):详情**内联**返回它,让前端一次请求 + // 就能画出收藏按钮的正确状态,避免「页面已渲染、心形图标稍后跳一下」。 + // 未登录 / 未注册企业一律 false —— 详情是公开接口,**不能**在这里返回 401, + // 否则会把「收藏态查不到」升级成「整个详情页看不了」。 + project.setFavorited(false); + Integer userId = getLoginUserId(); + if (userId != null) { + HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId); + if (enterprise != null) { + project.setFavorited(hjcProjectFavoriteService.exists(enterprise.getId(), project.getId())); + } + } return success(project); } diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcProjectFavoriteController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcProjectFavoriteController.java new file mode 100644 index 0000000..f4670b1 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcProjectFavoriteController.java @@ -0,0 +1,124 @@ +package com.gxwebsoft.hjc.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.hjc.auth.HjcAuthResponses; +import com.gxwebsoft.hjc.dto.HjcFavoriteRequest; +import com.gxwebsoft.hjc.entity.HjcBidProject; +import com.gxwebsoft.hjc.entity.HjcEnterprise; +import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam; +import com.gxwebsoft.hjc.service.HjcBidProjectService; +import com.gxwebsoft.hjc.service.HjcEnterpriseService; +import com.gxwebsoft.hjc.service.HjcProjectFavoriteService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.Map; + +/** + * 汇吉采项目收藏(买家「我的收藏」) + * + *
为什么不校验企业资质:收藏是零成本留资动作,资质只卡「下单购买」。 + * 未认证 / 待审核 / 已驳回的企业都能收藏(spec 决策 3)。 + * 不要在这里顺手加 {@code authStatus == 1} 的判定。
+ * + *归属:收藏挂在企业账号上({@code hjc_enterprise.id}),不挂自然人, + * 全企业共享一份(ADR 0011)。所以每个接口都要先 + * {@code getLoginUserId() → hjcEnterpriseService.getByUserId(...)}。
+ * + *为什么每个方法都自己判登录:共享 {@code SecurityConfig} 放行了 {@code GET /**}, + * 未登录的 GET 会直接进到 controller(见 {@code HjcAuthResponses} 的类注释)。
+ * + * @author WebSoft + */ +@Tag(name = "汇吉采-项目收藏") +@Slf4j +@RestController +@RequestMapping("/api/hjc/project-favorite") +public class HjcProjectFavoriteController extends BaseController { + + @Resource + private HjcProjectFavoriteService hjcProjectFavoriteService; + @Resource + private HjcBidProjectService hjcBidProjectService; + @Resource + private HjcEnterpriseService hjcEnterpriseService; + + @Operation(summary = "收藏项目(幂等)") + @PostMapping() + public ApiResult> add(@RequestBody HjcFavoriteRequest request) { + Integer userId = getLoginUserId(); + if (userId == null) { + return HjcAuthResponses.unauthorized(); + } + if (request == null || request.getProjectId() == null) { + return fail("标书项目ID不能为空"); + } + HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId); + if (enterprise == null) { + return fail("请先完善企业信息"); + } + Integer projectId = request.getProjectId(); + // 只判项目存在:**不判 status、不判 needSell** —— 已下架的项目也可以先收藏着 + // (收藏不是购买,不该复刻购买的门槛) + HjcBidProject project = hjcBidProjectService.getById(projectId); + if (project == null) { + return fail("标书项目不存在"); + } + // 幂等:已收藏时 service 内部直接返回,不改 create_time(spec 决策 16) + hjcProjectFavoriteService.add(enterprise.getId(), projectId, enterprise.getTenantId()); + return success(favoriteOutcome(projectId, true)); + } + + @Operation(summary = "取消收藏(幂等)") + @DeleteMapping("/{projectId}") + public ApiResult> remove(@PathVariable("projectId") Integer projectId) { + Integer userId = getLoginUserId(); + if (userId == null) { + return HjcAuthResponses.unauthorized(); + } + HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId); + if (enterprise == null) { + return fail("请先完善企业信息"); + } + if (projectId == null) { + return fail("标书项目ID不能为空"); + } + // 忽略影响行数:本来就没收藏也算成功(幂等) + hjcProjectFavoriteService.remove(enterprise.getId(), projectId); + return success(favoriteOutcome(projectId, false)); + } + + @Operation(summary = "我的收藏") + @GetMapping("/page") + public ApiResult> page(HjcProjectFavoriteParam param) { + Integer userId = getLoginUserId(); + if (userId == null) { + return HjcAuthResponses.unauthorized(); + } + HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(userId); + if (enterprise == null) { + return fail("请先完善企业信息"); + } + // **强制覆盖**:enterpriseId 可以被 query string 绑定,不覆盖就是一个「读别家收藏」的入口。 + // 同时收藏列表不支持客户端排序,这也是 PageParam 里要把 orders 清掉的原因。 + param.setEnterpriseId(enterprise.getId()); + return success(hjcProjectFavoriteService.pageRel(param)); + } + + /** + * 写接口的结构化返回体:前端按字段判定,不解析 message。 + * + *恒 {@code code = 0}(幂等契约,spec 决策 16):重复收藏、重复取消都是成功。
+ */ + private Map项目字段来自 {@code LEFT JOIN hjc_bid_project}。项目被物理删除时它们全为 {@code null}, + * 此时 {@link #joinedProjectId} 为 {@code null}——那是「项目还在不在」唯一可靠的判据, + * 不要用 {@code projectName} 是否为 {@code null} 去猜。
+ * + *三个判定用的原始字段({@code joinedProjectId} / {@code projectStatus} / + * {@code projectDeleted})标了 {@code @JsonIgnore}: + * 展示态由 {@link #saleState} 与 {@link #purchased} 表达,原始字段**不属于接口契约**, + * 泄漏出去就会有人拿它当契约用。
+ * + * @author WebSoft + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "HjcFavoriteVo对象", description = "我的收藏单条") +public class HjcFavoriteVo implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "收藏行ID") + private Integer id; + + @Schema(description = "标书项目ID") + private Integer projectId; + + @Schema(description = "收藏时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + // ---------- LEFT JOIN hjc_bid_project 得到的项目当前值 ---------- + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "项目编号") + private String projectNo; + + @Schema(description = "项目分类:服务类/工程类/货物类") + private String category; + + @Schema(description = "标书价格/信息服务费") + private BigDecimal tenderPrice; + + @Schema(description = "投标截止时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime deadlineTime; + + @Schema(description = "停售时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime offsaleTime; + + // ---------- 判定用的原始字段:不进响应 ---------- + + /** 项目主键({@code b.id AS joined_project_id});为 null 即项目已被物理删除。 */ + @JsonIgnore + private Integer joinedProjectId; + + /** 项目状态:1 上架 / 0 下架({@code b.status AS project_status})。 */ + @JsonIgnore + private Integer projectStatus; + + /** 项目是否已逻辑删除({@code b.deleted AS project_deleted})。 */ + @JsonIgnore + private Integer projectDeleted; + + // ---------- 计算出来的展示态 ---------- + + @Schema(description = "售卖状态:onsale 在售 / ended 已结束 / removed 已下架") + private String saleState; + + @Schema(description = "本企业是否已购买该项目(口径:该企业对该项目存在已付款订单)") + private Boolean purchased; +} diff --git a/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java b/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java index b12dce6..32c0c37 100644 --- a/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java +++ b/src/main/java/com/gxwebsoft/hjc/entity/HjcBidProject.java @@ -106,6 +106,20 @@ public class HjcBidProject implements Serializable { @TableField(exist = false) private Integer buyerCount; + /** + * 是否已收藏(**非表字段**):当前登录企业是否收藏了本项目。 + * + *未登录恒为 false——详情是公开接口,不能因为「收藏态查不到」就把整个详情页 + * 打回登录页(页面上本来就有未登录分支)。
+ * + *口径:{@code hjc_project_favorite} 里存在 + * {@code (enterprise_id = 当前企业, project_id = 本项目)} 一行。 + * 收藏归属企业账号、全企业共享一份,见 docs/adr/0011。
+ */ + @Schema(description = "是否已收藏(未登录恒 false,非表字段)") + @TableField(exist = false) + private Boolean favorited; + @Schema(description = "租户ID") private Integer tenantId; diff --git a/src/main/java/com/gxwebsoft/hjc/entity/HjcProjectFavorite.java b/src/main/java/com/gxwebsoft/hjc/entity/HjcProjectFavorite.java new file mode 100644 index 0000000..cdc66cf --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/entity/HjcProjectFavorite.java @@ -0,0 +1,55 @@ +package com.gxwebsoft.hjc.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 汇吉采标书项目收藏(买家「我的收藏」) + * + *不加 {@code @TableName}:与 hjc 既有实体一致,靠驼峰转下划线映射到 + * {@code hjc_project_favorite}(同 {@code HjcPasswordApply} 的注释)。
+ * + *没有 {@code deleted} 字段,也没有 {@code @TableLogic}:取消收藏就是物理删除。 + * 理由与先例见 docs/adr/0012-收藏关系不做逻辑删除.md —— + * 「逻辑删除 + {@code (enterprise_id, project_id)} 唯一键」是互斥的,且收藏关系没有审计价值。 + * 不要顺手把 {@code deleted} 补上。
+ * + *归属键是 {@code enterpriseId}({@code hjc_enterprise.id}),不是核心实例的 {@code userId}: + * 收藏是企业账号级的、全企业共享一份,见 docs/adr/0011-收藏归属企业账号而非操作人.md。
+ * + * @author WebSoft + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Schema(name = "HjcProjectFavorite对象", description = "汇吉采标书项目收藏") +public class HjcProjectFavorite implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "ID") + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @Schema(description = "企业ID(hjc_enterprise.id)") + private Integer enterpriseId; + + @Schema(description = "标书项目ID(hjc_bid_project.id)") + private Integer projectId; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "收藏时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "修改时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java b/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java index 23ab276..4ee32b8 100644 --- a/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java +++ b/src/main/java/com/gxwebsoft/hjc/mapper/HjcOrderMapper.java @@ -7,6 +7,7 @@ import com.gxwebsoft.hjc.entity.HjcOrder; import com.gxwebsoft.hjc.param.HjcOrderParam; import org.apache.ibatis.annotations.Param; +import java.util.Collection; import java.util.List; public interface HjcOrderMapper extends BaseMapper口径与 {@link #countPaidOrders(Integer)} 完全一致:只看 {@code pay_status = 1}, + * 退款单({@code pay_status = 3})与未付款单都不算。 + * **同一个概念不许有第二套口径。**
+ * + *放在订单 mapper 上而不是收藏 mapper 上:它查的是 {@code hjc_order}, + * 表归属该由它的 mapper 持有。
+ * + * @param projectIds 调用方需保证**非空**;空集合会生成 {@code IN ()} 的语法错误 + */ + List只保留一条自定义查询(连表分页);「是否已收藏」「收藏」「取消收藏」都由 + * {@code BaseMapper} 的 wrapper 完成——租户条件由租户拦截器自动附加, + * 且本实体**没有** {@code @TableLogic},所以 {@code delete} 就是物理删除(ADR 0012)。
+ */ +public interface HjcProjectFavoriteMapper extends BaseMapper排序**不在 XML 里**:由 service 设进 {@code PageParam}。若这里手写 ORDER BY, + * MyBatis-Plus 分页插件还会再追加一段,直接语法错误。
+ */ + List{@code enterpriseId} 必须由 controller 在入口处强制覆盖:它可以被请求参数绑定, + * 若原样透传到查询里,就是一个「读别家企业收藏」的越权入口。
+ * + * @author WebSoft + */ +@Data +@EqualsAndHashCode(callSuper = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "HjcProjectFavoriteParam对象", description = "汇吉采标书项目收藏查询参数") +public class HjcProjectFavoriteParam extends BaseParam { + + @QueryField(type = QueryType.EQ) + private Integer enterpriseId; + + @QueryField(type = QueryType.EQ) + private Integer projectId; +} diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java index ec8d5b0..7ad8605 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcOrderService.java @@ -5,6 +5,7 @@ import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.hjc.entity.HjcOrder; import com.gxwebsoft.hjc.param.HjcOrderParam; +import java.util.Collection; import java.util.List; public interface HjcOrderService extends IService口径与 {@link #countPaidOrders(Integer)} 一致:只看 {@code pay_status = 1}。
+ * + * @param projectIds 非空集合 + */ + List并发下两个请求同时收藏会撞唯一键,内部按幂等成功处理,不抛异常。
+ */ + void add(Integer enterpriseId, Integer projectId, Integer tenantId); + + /** + * 取消收藏(**幂等**)。 + * + * @return 实际影响行数(0 或 1);为 0 也表示成功 + */ + int remove(Integer enterpriseId, Integer projectId); +} diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java index 3a412ad..37f9649 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcOrderServiceImpl.java @@ -9,6 +9,8 @@ import com.gxwebsoft.hjc.param.HjcOrderParam; import com.gxwebsoft.hjc.service.HjcOrderService; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; @Service @@ -44,4 +46,13 @@ public class HjcOrderServiceImpl extends ServiceImpl为什么不用 JOIN:一个项目可能有多张订单,{@code LEFT JOIN hjc_order} 会把收藏行 + * 复制成多行,分页的行数与总数会一起算错。所以分页查完之后,用当页的 projectId 再发**一条** + * {@code IN} 查询,在内存里打标。
+ * + *口径复用详情页「购买人数」那一条({@code HjcOrderService#countPaidProjectIds} 的实现): + * 已付款,退款单与未付款单都不算。
+ */ + private void fillPurchased(List为什么单独抽出来:这一支有三个输入(项目是否还在 / 项目状态 / 停售时间), + * 而「项目已被物理删除、但收藏行还在」这种数据端到端造起来很麻烦,只能靠单测钉住判定本身。
+ * + *为什么判定在后端做:口径只写一份。仓库里 {@code orderStatus} 的口径在 + * {@code hjc-h5} 与 {@code hjc-web} 各写了一份,改动时必然分叉——收藏不再重演这件事。
+ */ +public final class HjcFavoriteStateUtil { + + private HjcFavoriteStateUtil() { + } + + /** 在售:可进详情。 */ + public static final String ONSALE = "onsale"; + + /** 已结束:项目还在架,但停售时间已过(自然到期)。 */ + public static final String ENDED = "ended"; + + /** 已下架:项目被后台下架,或已被删除(平台行为)。 */ + public static final String REMOVED = "removed"; + + /** {@code hjc_bid_project.status}:0 = 下架/停售。 */ + private static final int PROJECT_STATUS_OFF = 0; + + /** {@code hjc_bid_project.deleted}:1 = 已删除。 */ + private static final int DELETED_YES = 1; + + /** + * 判定一条收藏的展示态。 + * + *优先级:已下架 > 已结束 > 在售。项目已被删除时,即便它的 {@code status} + * 仍是 1,也必须是「已下架」——「项目还在不在」比它的状态字段更根本。
+ * + * @param vo 收藏条目,读 {@code joinedProjectId} / {@code projectDeleted} / + * {@code projectStatus} / {@code offsaleTime} + * @param now 判定基准时间。**由调用方传入**而不是在方法内部取 + * {@code LocalDateTime.now()},否则边界用例没法测 + * @return {@link #ONSALE} / {@link #ENDED} / {@link #REMOVED} + */ + public static String saleStateOf(HjcFavoriteVo vo, LocalDateTime now) { + if (vo == null) { + return REMOVED; + } + // 项目已被物理删除(LEFT JOIN 没匹配上):收藏行还在,项目没了 + if (vo.getJoinedProjectId() == null) { + return REMOVED; + } + // 项目被逻辑删除,或被后台下架 + if (Objects.equals(vo.getProjectDeleted(), DELETED_YES) + || Objects.equals(vo.getProjectStatus(), PROJECT_STATUS_OFF)) { + return REMOVED; + } + // 项目在架,但停售时间已过 → 自然到期 + if (vo.getOffsaleTime() != null && now != null && !vo.getOffsaleTime().isAfter(now)) { + return ENDED; + } + return ONSALE; + } + + /** + * 该条目是否还能进入详情页。 + * + *与前端「失效项目不进详情、只保留取消收藏」一一对应(spec 决策 8)。
+ */ + public static boolean enterable(String saleState) { + return ONSALE.equals(saleState); + } +} diff --git a/src/main/resources/sql/hjc_project_favorite.sql b/src/main/resources/sql/hjc_project_favorite.sql new file mode 100644 index 0000000..c87e10a --- /dev/null +++ b/src/main/resources/sql/hjc_project_favorite.sql @@ -0,0 +1,31 @@ +-- 汇吉采:标书项目收藏表(买家「我的收藏」,见 .scratch/hjc-favorite/spec.md) +-- +-- 幂等:`CREATE TABLE IF NOT EXISTS` 本身幂等,重复执行不会报错,可安全地作为发布步骤对每个环境跑一遍。 +-- (对比 hjc_order_add_refund.sql:那个脚本要 ADD COLUMN,MySQL 8 无 `ADD COLUMN IF NOT EXISTS`, +-- 故必须按 information_schema 判断;建表不需要这一套。) +-- +-- 只增表,不删不改任何既有表,仅作用于 hjc 自己的新表,对共用本库的其他项目无影响。 +-- +-- 刻意**没有** `deleted` 列,实体上也没有 `@TableLogic`:收藏关系可反复增删、没有审计价值, +-- 而「逻辑删除 + (enterprise_id, project_id) 唯一键」是互斥的——取消收藏置 deleted=1 之后, +-- 再收藏同一个项目就会撞唯一键,只能靠手写 `ON DUPLICATE KEY UPDATE` 复活。 +-- 详见 docs/adr/0012-收藏关系不做逻辑删除.md。**不要顺手补上这一列。** +-- +-- 也刻意**没有**外键:项目被下架或删除后,收藏行要留着,好让「我的收藏」如实显示「已下架」。 +-- +-- 归属键是 `enterprise_id`(hjc_enterprise.id),不是核心实例的 userId: +-- 收藏是企业账号级的、全企业共享一份,见 docs/adr/0011-收藏归属企业账号而非操作人.md。 + +CREATE TABLE IF NOT EXISTS `hjc_project_favorite` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `enterprise_id` int(11) NOT NULL COMMENT '企业ID(hjc_enterprise.id),收藏归属企业账号(ADR-0011)', + `project_id` int(11) NOT NULL COMMENT '标书项目ID(hjc_bid_project.id)', + `tenant_id` int(11) DEFAULT NULL COMMENT '租户ID', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hjc_pf_ent_project` (`enterprise_id`, `project_id`), + KEY `idx_hjc_pf_ent` (`enterprise_id`), + KEY `idx_hjc_pf_project` (`project_id`), + KEY `idx_hjc_pf_tenant` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='汇吉采标书项目收藏'; diff --git a/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java b/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java new file mode 100644 index 0000000..1ca5d87 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/controller/HjcBidProjectControllerDetailTest.java @@ -0,0 +1,151 @@ +package com.gxwebsoft.hjc.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.hjc.entity.HjcBidProject; +import com.gxwebsoft.hjc.entity.HjcEnterprise; +import com.gxwebsoft.hjc.service.HjcBidProjectService; +import com.gxwebsoft.hjc.service.HjcEnterpriseService; +import com.gxwebsoft.hjc.service.HjcOrderService; +import com.gxwebsoft.hjc.service.HjcProjectFavoriteService; +import org.junit.jupiter.api.AfterEach; +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.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 标书详情接口**内联收藏态**的护栏。 + * + *最要紧的一条:未登录时 {@code favorited} 必须是 {@code false},而不是 401。 + * 详情页对未登录用户是正常可用的页面(页面上本就有未登录分支), + * 在这里返回 401 会把「收藏态查不到」升级成「整个详情页看不了」。
+ */ +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 HjcBidProjectController controller; + private HjcBidProjectService hjcBidProjectService; + private HjcOrderService hjcOrderService; + private HjcEnterpriseService hjcEnterpriseService; + private HjcProjectFavoriteService hjcProjectFavoriteService; + + @BeforeEach + void setUp() throws Exception { + controller = new HjcBidProjectController(); + hjcBidProjectService = mock(HjcBidProjectService.class); + hjcOrderService = mock(HjcOrderService.class); + hjcEnterpriseService = mock(HjcEnterpriseService.class); + hjcProjectFavoriteService = mock(HjcProjectFavoriteService.class); + + inject("hjcBidProjectService", hjcBidProjectService); + inject("hjcOrderService", hjcOrderService); + inject("hjcEnterpriseService", hjcEnterpriseService); + inject("hjcProjectFavoriteService", hjcProjectFavoriteService); + + HjcBidProject project = new HjcBidProject(); + project.setId(PROJECT_ID); + project.setProjectName("某测试项目"); + when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(project); + when(hjcOrderService.countPaidOrders(PROJECT_ID)).thenReturn(3); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void inject(String field, Object value) throws Exception { + Field f = HjcBidProjectController.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())); + } + + private HjcBidProject projectOf(ApiResult> res) { + return (HjcBidProject) res.getData(); + } + + @Test + void anonymousShouldGetFalseAndNot401() { + ApiResult> res = controller.detail(PROJECT_ID); + + assertEquals(0, res.getCode(), "详情是公开接口:未登录也不能返回 401"); + assertEquals(Boolean.FALSE, projectOf(res).getFavorited()); + // 未登录就别去问企业档案,更别查收藏 + verify(hjcEnterpriseService, never()).getByUserId(anyInt()); + verify(hjcProjectFavoriteService, never()).exists(anyInt(), anyInt()); + } + + @Test + void loggedInAndFavoritedShouldGetTrue() { + loginAsBuyer(); + HjcEnterprise e = new HjcEnterprise(); + e.setId(BUYER_ENTERPRISE_ID); + when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(e); + when(hjcProjectFavoriteService.exists(BUYER_ENTERPRISE_ID, PROJECT_ID)).thenReturn(true); + + ApiResult> res = controller.detail(PROJECT_ID); + + assertEquals(0, res.getCode()); + assertEquals(Boolean.TRUE, projectOf(res).getFavorited()); + } + + @Test + void loggedInButNotFavoritedShouldGetFalse() { + loginAsBuyer(); + HjcEnterprise e = new HjcEnterprise(); + e.setId(BUYER_ENTERPRISE_ID); + when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(e); + when(hjcProjectFavoriteService.exists(BUYER_ENTERPRISE_ID, PROJECT_ID)).thenReturn(false); + + ApiResult> res = controller.detail(PROJECT_ID); + + assertEquals(Boolean.FALSE, projectOf(res).getFavorited()); + } + + @Test + void loggedInWithoutEnterpriseShouldGetFalse() { + loginAsBuyer(); + when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(null); + + ApiResult> res = controller.detail(PROJECT_ID); + + assertEquals(0, res.getCode(), "没有企业档案也不该让详情页打不开"); + assertEquals(Boolean.FALSE, projectOf(res).getFavorited()); + verify(hjcProjectFavoriteService, never()).exists(anyInt(), anyInt()); + } + + @Test + void buyerCountShouldStillBeFilled() { + ApiResult> res = controller.detail(PROJECT_ID); + + assertEquals(3, projectOf(res).getBuyerCount(), "既有字段不能因为本轮改动而丢"); + } + + @Test + void missingProjectShouldStillFail() { + when(hjcBidProjectService.getById(999)).thenReturn(null); + + assertEquals(1, controller.detail(999).getCode()); + } +} diff --git a/src/test/java/com/gxwebsoft/hjc/controller/HjcProjectFavoriteControllerTest.java b/src/test/java/com/gxwebsoft/hjc/controller/HjcProjectFavoriteControllerTest.java new file mode 100644 index 0000000..3933148 --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/controller/HjcProjectFavoriteControllerTest.java @@ -0,0 +1,241 @@ +package com.gxwebsoft.hjc.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.hjc.dto.HjcFavoriteRequest; +import com.gxwebsoft.hjc.entity.HjcBidProject; +import com.gxwebsoft.hjc.entity.HjcEnterprise; +import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam; +import com.gxwebsoft.hjc.service.HjcBidProjectService; +import com.gxwebsoft.hjc.service.HjcEnterpriseService; +import com.gxwebsoft.hjc.service.HjcProjectFavoriteService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 收藏接口的**编排**测试(不是纯函数测试,展示态判定那些在 + * {@code HjcFavoriteStateUtilTest})。 + * + *为什么需要它:这个接口最要紧的几条分支在真实环境里验不了或在环境里代价太大—— + * 未登录态(我没有可用账号)、越权读别家收藏(dev 库只有一个企业)、 + * 以及「不能对不存在的项目落库」这条护栏。用 mock 把 service 换掉就能把编排钉住。
+ * + *{@code getLoginUserId()} 读的是 {@code SecurityContextHolder},所以登录态可以用 + * 真实的 {@link User} 放进 SecurityContext 来模拟,不必绕过鉴权 + * (照 {@code HjcOrderControllerCancelTest} 的做法)。
+ */ +class HjcProjectFavoriteControllerTest { + + private static final int BUYER_USER_ID = 9001; + private static final int BUYER_ENTERPRISE_ID = 7001; + private static final int OTHER_ENTERPRISE_ID = 8888; + private static final int PROJECT_ID = 123; + + private HjcProjectFavoriteController controller; + private HjcProjectFavoriteService hjcProjectFavoriteService; + private HjcBidProjectService hjcBidProjectService; + private HjcEnterpriseService hjcEnterpriseService; + + @BeforeEach + void setUp() throws Exception { + controller = new HjcProjectFavoriteController(); + hjcProjectFavoriteService = mock(HjcProjectFavoriteService.class); + hjcBidProjectService = mock(HjcBidProjectService.class); + hjcEnterpriseService = mock(HjcEnterpriseService.class); + + inject("hjcProjectFavoriteService", hjcProjectFavoriteService); + inject("hjcBidProjectService", hjcBidProjectService); + inject("hjcEnterpriseService", hjcEnterpriseService); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void inject(String field, Object value) throws Exception { + Field f = HjcProjectFavoriteController.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())); + } + + private void stubOwnEnterprise() { + HjcEnterprise e = new HjcEnterprise(); + e.setId(BUYER_ENTERPRISE_ID); + e.setTenantId(10626); + when(hjcEnterpriseService.getByUserId(BUYER_USER_ID)).thenReturn(e); + } + + private HjcFavoriteRequest addRequest(Integer projectId) { + HjcFavoriteRequest r = new HjcFavoriteRequest(); + r.setProjectId(projectId); + return r; + } + + @SuppressWarnings("unchecked") + private Map幂等本身由 service 实现(见 {@code HjcProjectFavoriteServiceImplTest}), + * controller 这一层只保证「不把重复当成业务失败」。
+ */ + @Test + void repeatedAddShouldStillSucceed() { + loginAsBuyer(); + stubOwnEnterprise(); + when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(new HjcBidProject()); + + assertEquals(0, controller.add(addRequest(PROJECT_ID)).getCode()); + ApiResult> second = controller.add(addRequest(PROJECT_ID)); + + assertEquals(0, second.getCode(), "重复收藏不能报错"); + assertEquals(Boolean.TRUE, dataOf(second).get("favorited")); + } + + @Test + void removeWithoutFavoriteShouldStillSucceed() { + loginAsBuyer(); + stubOwnEnterprise(); + + ApiResult> res = controller.remove(PROJECT_ID); + + assertEquals(0, res.getCode(), "取消一个本来就没收藏的项目也算成功"); + assertEquals(Boolean.FALSE, dataOf(res).get("favorited")); + verify(hjcProjectFavoriteService).remove(BUYER_ENTERPRISE_ID, PROJECT_ID); + } + + // ---------- 我的收藏 ---------- + + /** + * 越权护栏:{@code enterpriseId} 能被 query string 绑定,controller 必须**覆盖**它, + * 否则任何人都能读别家企业的收藏。 + */ + @Test + void pageMustIgnoreClientSuppliedEnterpriseId() { + loginAsBuyer(); + stubOwnEnterprise(); + HjcProjectFavoriteParam param = new HjcProjectFavoriteParam(); + param.setEnterpriseId(OTHER_ENTERPRISE_ID); + when(hjcProjectFavoriteService.pageRel(any())) + .thenReturn(new PageResult<>(Collections.emptyList(), 0L)); + + ApiResult> res = controller.page(param); + + assertEquals(0, res.getCode()); + ArgumentCaptor为什么必须单测:这里钉住的两件事在真实环境里都很难验——
+ *用 {@code tenantId} 当样本,是因为它正是**会真的炸**的那一类:{@code tenant_id} 与 + * {@code update_time} 两张表都有、又都没被选进选择列表,放行客户端排序就会得到 + * {@code Column 'tenant_id' in order clause is ambiguous} 的 500(已在 dev 库实测)。
+ * + *反例对照:{@code ?sort=id} **不会**报错({@code id} 在选择列表里,MySQL 按输出列名解析), + * 但它会把「收藏时间倒序」悄悄换成按 {@code a.id} 排——所以判据是「客户端排序一律丢弃」, + * 不是「只挡会报错的那几个」。
+ */ + @Test + void clientSortMustBeDiscardedAndReplacedByFavoriteTime() { + HjcProjectFavoriteParam param = new HjcProjectFavoriteParam(); + param.setPage(1L); + param.setLimit(10L); + param.setSort("tenantId"); + param.setOrder("desc"); + param.setEnterpriseId(ENTERPRISE_ID); + when(mapper.selectPageRel(any(IPage.class), any(HjcProjectFavoriteParam.class))) + .thenReturn(new ArrayList<>()); + + service.pageRel(param); + + ArgumentCaptor为什么必须单测:「项目已被物理删除、但收藏行还在」这种数据端到端造起来很麻烦 + * (要先收藏、再绕过逻辑删除把项目真删掉),而它恰好是最容易写错的一支; + * 「已下架」与「已结束」的优先级也只能在这里钉住。
+ */ +class HjcFavoriteStateUtilTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 17, 12, 0, 0); + + private static final int STATUS_ONSALE = 1; + private static final int STATUS_OFF = 0; + private static final int NOT_DELETED = 0; + private static final int DELETED = 1; + + private HjcFavoriteVo vo(Integer joinedProjectId, Integer status, Integer deleted, + LocalDateTime offsaleTime) { + HjcFavoriteVo v = new HjcFavoriteVo(); + v.setId(1); + v.setProjectId(100); + v.setJoinedProjectId(joinedProjectId); + v.setProjectStatus(status); + v.setProjectDeleted(deleted); + v.setOffsaleTime(offsaleTime); + return v; + } + + /** 项目已被物理删除:LEFT JOIN 没匹配上,joinedProjectId 为 null */ + @Test + void physicallyDeletedProjectShouldBeRemoved() { + assertEquals(HjcFavoriteStateUtil.REMOVED, + HjcFavoriteStateUtil.saleStateOf(vo(null, null, null, null), NOW)); + } + + /** 项目被逻辑删除:joinedProjectId 还在(LEFT JOIN 匹配得到),但 deleted = 1 */ + @Test + void logicallyDeletedProjectShouldBeRemoved() { + assertEquals(HjcFavoriteStateUtil.REMOVED, + HjcFavoriteStateUtil.saleStateOf(vo(100, STATUS_ONSALE, DELETED, null), NOW)); + } + + @Test + void offShelfProjectShouldBeRemoved() { + assertEquals(HjcFavoriteStateUtil.REMOVED, + HjcFavoriteStateUtil.saleStateOf(vo(100, STATUS_OFF, NOT_DELETED, null), NOW)); + } + + @Test + void offsaleTimePassedShouldBeEnded() { + assertEquals(HjcFavoriteStateUtil.ENDED, + HjcFavoriteStateUtil.saleStateOf( + vo(100, STATUS_ONSALE, NOT_DELETED, NOW.minusSeconds(1)), NOW)); + } + + @Test + void offsaleTimeNullShouldBeOnsale() { + assertEquals(HjcFavoriteStateUtil.ONSALE, + HjcFavoriteStateUtil.saleStateOf(vo(100, STATUS_ONSALE, NOT_DELETED, null), NOW)); + } + + @Test + void offsaleTimeInFutureShouldBeOnsale() { + assertEquals(HjcFavoriteStateUtil.ONSALE, + HjcFavoriteStateUtil.saleStateOf( + vo(100, STATUS_ONSALE, NOT_DELETED, NOW.plusDays(1)), NOW)); + } + + /** 已下架的优先级高于已结束:项目都删了,就不该只说「已结束」 */ + @Test + void removedShouldWinOverEnded() { + assertEquals(HjcFavoriteStateUtil.REMOVED, + HjcFavoriteStateUtil.saleStateOf( + vo(null, STATUS_OFF, DELETED, NOW.minusDays(1)), NOW)); + } + + /** 边界:停售时间**正好等于**基准时间,不算「还没过」,即已结束 */ + @Test + void offsaleTimeBoundaryIsInclusive() { + assertEquals(HjcFavoriteStateUtil.ENDED, + HjcFavoriteStateUtil.saleStateOf( + vo(100, STATUS_ONSALE, NOT_DELETED, NOW), NOW), + "停售时间正好到点就是已结束"); + assertEquals(HjcFavoriteStateUtil.ONSALE, + HjcFavoriteStateUtil.saleStateOf( + vo(100, STATUS_ONSALE, NOT_DELETED, NOW.plusSeconds(1)), NOW), + "还差一秒没到,仍是在售"); + } + + @Test + void nullVoShouldNotBlowUp() { + assertEquals(HjcFavoriteStateUtil.REMOVED, HjcFavoriteStateUtil.saleStateOf(null, NOW)); + } + + @Test + void onlyOnsaleIsEnterable() { + assertTrue(HjcFavoriteStateUtil.enterable(HjcFavoriteStateUtil.ONSALE)); + assertFalse(HjcFavoriteStateUtil.enterable(HjcFavoriteStateUtil.ENDED)); + assertFalse(HjcFavoriteStateUtil.enterable(HjcFavoriteStateUtil.REMOVED)); + } +}