feat(hjc): 标书项目收藏:详情页收藏态 + 我的收藏

收藏归属**企业账号**(hjc_enterprise.id,全企业共享一份,ADR 0011),
且**不需要资质**——未认证/待审核/已驳回的企业都能收藏,资质只卡购买。

- 新表 hjc_project_favorite(enterprise_id, project_id, tenant_id, create_time,
  update_time),唯一键 (enterprise_id, project_id) 保证幂等;
  **没有 deleted 列**:取消收藏即物理删除(ADR 0012,先例 project_collection)。
- 接口:POST /api/hjc/project-favorite(幂等,并发撞唯一键按成功处理)、
  DELETE /{projectId}(幂等)、GET /page(我的收藏,收藏时间倒序)。
- 我的收藏每条由后端算好 saleState(onsale / ended / removed)与 purchased,
  口径只写一份;项目被下架或删除后**条目仍在**(LEFT JOIN 上刻意不加 b.deleted = 0),
  以「已下架」呈现而不是让收藏凭空消失。
- 标书详情内联 favorited(非表字段,未登录恒 false,不返回 401);
  purchased 复用「购买人数」的已付款口径。
- 排序:客户端排序**一律丢弃**。已在 dev 库实测:放行 ?sort=tenantId / ?sort=updateTime
  会得到「Column 'tenant_id' in order clause is ambiguous」的 500;而放行 ?sort=id
  更隐蔽——它不报错,只是把收藏时间倒序悄悄换成按 a.id 排。
- 单测 35 项:展示态判定、详情内联(未登录不得 401)、幂等、排序覆盖、
  越权覆盖(param.enterpriseId 强制覆盖)。
This commit is contained in:
2026-09-17 04:03:30 +08:00
parent 13cb08621f
commit 6d8185cc70
21 changed files with 1490 additions and 0 deletions
@@ -4,9 +4,12 @@ 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.entity.HjcBidProject; import com.gxwebsoft.hjc.entity.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.param.HjcBidProjectParam; import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService; import com.gxwebsoft.hjc.service.HjcBidProjectService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.service.HjcOrderService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -26,6 +29,10 @@ public class HjcBidProjectController extends BaseController {
private HjcBidProjectService hjcBidProjectService; private HjcBidProjectService hjcBidProjectService;
@Resource @Resource
private HjcOrderService hjcOrderService; private HjcOrderService hjcOrderService;
@Resource
private HjcEnterpriseService hjcEnterpriseService;
@Resource
private HjcProjectFavoriteService hjcProjectFavoriteService;
@Operation(summary = "分页查询(后台)") @Operation(summary = "分页查询(后台)")
@GetMapping("/page") @GetMapping("/page")
@@ -51,6 +58,18 @@ public class HjcBidProjectController extends BaseController {
// 「购买人数」= 实时统计的**已付款订单数**(不是 saleCount:那个字段下单即加、从不回退, // 「购买人数」= 实时统计的**已付款订单数**(不是 saleCount:那个字段下单即加、从不回退,
// 含未付款与已取消的单,拿它当购买人数一直是假数)。详见 HjcBidProject#buyerCount。 // 含未付款与已取消的单,拿它当购买人数一直是假数)。详见 HjcBidProject#buyerCount。
project.setBuyerCount(hjcOrderService.countPaidOrders(project.getId())); 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); return success(project);
} }
@@ -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;
/**
* 汇吉采项目收藏(买家「我的收藏」)
*
* <p><b>为什么不校验企业资质</b>:收藏是零成本留资动作,资质只卡「下单购买」。
* 未认证 / 待审核 / 已驳回的企业都能收藏(spec 决策 3)。
* <b>不要</b>在这里顺手加 {@code authStatus == 1} 的判定。</p>
*
* <p><b>归属</b>:收藏挂在企业账号上({@code hjc_enterprise.id}),不挂自然人,
* 全企业共享一份(ADR 0011)。所以每个接口都要先
* {@code getLoginUserId() → hjcEnterpriseService.getByUserId(...)}。</p>
*
* <p><b>为什么每个方法都自己判登录</b>:共享 {@code SecurityConfig} 放行了 {@code GET /**}
* 未登录的 GET 会直接进到 controller(见 {@code HjcAuthResponses} 的类注释)。</p>
*
* @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_timespec 决策 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。
*
* <p>恒 {@code code = 0}(幂等契约,spec 决策 16):重复收藏、重复取消都是成功。</p>
*/
private Map<String, Object> favoriteOutcome(Integer projectId, boolean favorited) {
Map<String, Object> data = new HashMap<>(4);
data.put("projectId", projectId);
data.put("favorited", favorited);
return data;
}
}
@@ -0,0 +1,15 @@
package com.gxwebsoft.hjc.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 汇吉采收藏请求(只有一个字段:收藏哪个项目)
*/
@Data
@Schema(name = "HjcFavoriteRequest", description = "汇吉采收藏请求")
public class HjcFavoriteRequest {
@Schema(description = "标书项目ID", required = true)
private Integer projectId;
}
@@ -0,0 +1,86 @@
package com.gxwebsoft.hjc.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 「我的收藏」单条:收藏行 + 项目的**当前值**(实时,不做收藏时快照)。
*
* <p>项目字段来自 {@code LEFT JOIN hjc_bid_project}。项目被物理删除时它们全为 {@code null}
* 此时 {@link #joinedProjectId} 为 {@code null}——那是「项目还在不在」唯一可靠的判据,
* 不要用 {@code projectName} 是否为 {@code null} 去猜。</p>
*
* <p>三个判定用的原始字段({@code joinedProjectId} / {@code projectStatus} /
* {@code projectDeleted})标了 {@code @JsonIgnore}
* 展示态由 {@link #saleState} 与 {@link #purchased} 表达,原始字段**不属于接口契约**,
* 泄漏出去就会有人拿它当契约用。</p>
*
* @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;
}
@@ -106,6 +106,20 @@ public class HjcBidProject implements Serializable {
@TableField(exist = false) @TableField(exist = false)
private Integer buyerCount; private Integer buyerCount;
/**
* 是否已收藏(**非表字段**):当前登录企业是否收藏了本项目。
*
* <p><b>未登录恒为 false</b>——详情是公开接口,不能因为「收藏态查不到」就把整个详情页
* 打回登录页(页面上本来就有未登录分支)。</p>
*
* <p>口径:{@code hjc_project_favorite} 里存在
* {@code (enterprise_id = 当前企业, project_id = 本项目)} 一行。
* 收藏归属企业账号、全企业共享一份,见 docs/adr/0011。</p>
*/
@Schema(description = "是否已收藏(未登录恒 false,非表字段)")
@TableField(exist = false)
private Boolean favorited;
@Schema(description = "租户ID") @Schema(description = "租户ID")
private Integer tenantId; private Integer tenantId;
@@ -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;
/**
* 汇吉采标书项目收藏(买家「我的收藏」)
*
* <p>不加 {@code @TableName}:与 hjc 既有实体一致,靠驼峰转下划线映射到
* {@code hjc_project_favorite}(同 {@code HjcPasswordApply} 的注释)。</p>
*
* <p><b>没有 {@code deleted} 字段,也没有 {@code @TableLogic}</b>:取消收藏就是物理删除。
* 理由与先例见 docs/adr/0012-收藏关系不做逻辑删除.md ——
* 「逻辑删除 + {@code (enterprise_id, project_id)} 唯一键」是互斥的,且收藏关系没有审计价值。
* <b>不要顺手把 {@code deleted} 补上。</b></p>
*
* <p>归属键是 {@code enterpriseId}{@code hjc_enterprise.id}),不是核心实例的 {@code userId}
* 收藏是企业账号级的、全企业共享一份,见 docs/adr/0011-收藏归属企业账号而非操作人.md。</p>
*
* @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;
}
@@ -7,6 +7,7 @@ import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.param.HjcOrderParam; import com.gxwebsoft.hjc.param.HjcOrderParam;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.Collection;
import java.util.List; import java.util.List;
public interface HjcOrderMapper extends BaseMapper<HjcOrder> { public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
@@ -40,4 +41,19 @@ public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
* 退款单(pay_status=3)与已取消单(pay_status 仍为 0)都不计。</p> * 退款单(pay_status=3)与已取消单(pay_status 仍为 0)都不计。</p>
*/ */
int countPaidOrders(@Param("projectId") Integer projectId); int countPaidOrders(@Param("projectId") Integer projectId);
/**
* 取「该企业已付款过的项目 id 集合」(用于「我的收藏」里的「已购买」标记)。
*
* <p>口径与 {@link #countPaidOrders(Integer)} 完全一致:只看 {@code pay_status = 1}
* 退款单({@code pay_status = 3})与未付款单都不算。
* **同一个概念不许有第二套口径。**</p>
*
* <p>放在订单 mapper 上而不是收藏 mapper 上:它查的是 {@code hjc_order}
* 表归属该由它的 mapper 持有。</p>
*
* @param projectIds 调用方需保证**非空**;空集合会生成 {@code IN ()} 的语法错误
*/
List<Integer> listPaidProjectIds(@Param("enterpriseId") Integer enterpriseId,
@Param("projectIds") Collection<Integer> projectIds);
} }
@@ -0,0 +1,29 @@
package com.gxwebsoft.hjc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 汇吉采标书项目收藏
*
* <p>只保留一条自定义查询(连表分页);「是否已收藏」「收藏」「取消收藏」都由
* {@code BaseMapper} 的 wrapper 完成——租户条件由租户拦截器自动附加,
* 且本实体**没有** {@code @TableLogic},所以 {@code delete} 就是物理删除(ADR 0012)。</p>
*/
public interface HjcProjectFavoriteMapper extends BaseMapper<HjcProjectFavorite> {
/**
* 「我的收藏」分页:收藏行 + 项目当前值(实时)。
*
* <p>排序**不在 XML 里**:由 service 设进 {@code PageParam}。若这里手写 ORDER BY
* MyBatis-Plus 分页插件还会再追加一段,直接语法错误。</p>
*/
List<HjcFavoriteVo> selectPageRel(@Param("page") IPage<HjcFavoriteVo> page,
@Param("param") HjcProjectFavoriteParam param);
}
@@ -90,4 +90,24 @@
AND deleted = 0 AND deleted = 0
</select> </select>
<!--
「我的收藏」的「已购买」标记:该企业已付款过的项目 id 集合。
口径与 countPaidOrders 完全一致(只看 pay_status = 1),同一个概念不许有第二套口径。
用 DISTINCT:同一企业对同一项目可能有多张已付款订单,只要一条就够。
调用方保证 projectIds 非空(空集合会生成 `IN ()`)。
租户条件由租户拦截器自动附加,不手写。
-->
<select id="listPaidProjectIds" resultType="int">
SELECT DISTINCT project_id
FROM hjc_order
WHERE enterprise_id = #{enterpriseId}
AND pay_status = 1
AND deleted = 0
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" close=")" separator=",">
#{pid}
</foreach>
</select>
</mapper> </mapper>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcProjectFavoriteMapper">
<!--
「我的收藏」分页:收藏行 + 项目**当前值**(实时,不做收藏时的快照)。
两条与仓库里其他 JOIN 查询**相反**的约定,都是刻意的:
1. LEFT JOIN 上**没有** `b.deleted = 0`。hjc_bid_project 是逻辑删除表,但这里必须把
「已下架 / 已被删除」的项目**留在结果里**(前端显示为「已下架」),
而不是让一条收藏凭空消失。自定义 XML 不受 MyBatis-Plus 逻辑删除影响,
所以正确的写法就是「什么都不加」——仓库里其他 JOIN 都手写 deleted = 0
(如 HjcBannerMapper.xml),**唯独这张表不许照抄**。
见 .scratch/hjc-favorite/spec.md 决策 8。
2. 租户条件**不手写**:租户拦截器会自动给 JOIN 的两张表都附加 tenant_id
HjcBannerMapper.xml 的注释记录了这个行为)。
`b.id AS joined_project_id` 是「项目还在不在」唯一可靠的判据:它为 null 即项目已被物理删除,
不要用 project_name 是否为 null 去猜。
排序不在这里写(见 HjcProjectFavoriteMapper#selectPageRel 的注释)。
-->
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.dto.HjcFavoriteVo">
SELECT a.id,
a.project_id,
a.create_time,
b.id AS joined_project_id,
b.project_name,
b.project_no,
b.category,
b.tender_price,
b.deadline_time,
b.offsale_time,
b.status AS project_status,
b.deleted AS project_deleted
FROM hjc_project_favorite a
LEFT JOIN hjc_bid_project b ON b.id = a.project_id
WHERE a.enterprise_id = #{param.enterpriseId}
</select>
</mapper>
@@ -0,0 +1,30 @@
package com.gxwebsoft.hjc.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.gxwebsoft.common.core.annotation.QueryField;
import com.gxwebsoft.common.core.annotation.QueryType;
import com.gxwebsoft.common.core.web.BaseParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 汇吉采标书项目收藏查询参数
*
* <p><b>{@code enterpriseId} 必须由 controller 在入口处强制覆盖</b>:它可以被请求参数绑定,
* 若原样透传到查询里,就是一个「读别家企业收藏」的越权入口。</p>
*
* @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;
}
@@ -5,6 +5,7 @@ import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcOrder; import com.gxwebsoft.hjc.entity.HjcOrder;
import com.gxwebsoft.hjc.param.HjcOrderParam; import com.gxwebsoft.hjc.param.HjcOrderParam;
import java.util.Collection;
import java.util.List; import java.util.List;
public interface HjcOrderService extends IService<HjcOrder> { public interface HjcOrderService extends IService<HjcOrder> {
@@ -26,4 +27,13 @@ public interface HjcOrderService extends IService<HjcOrder> {
* 统计某标书项目**已付款**的订单数(标书详情的「购买人数」)。 * 统计某标书项目**已付款**的订单数(标书详情的「购买人数」)。
*/ */
int countPaidOrders(Integer projectId); int countPaidOrders(Integer projectId);
/**
* 取「该企业已付款过的项目 id 集合」(「我的收藏」的「已购买」标记)。
*
* <p>口径与 {@link #countPaidOrders(Integer)} 一致:只看 {@code pay_status = 1}。</p>
*
* @param projectIds 非空集合
*/
List<Integer> listPaidProjectIds(Integer enterpriseId, Collection<Integer> projectIds);
} }
@@ -0,0 +1,37 @@
package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
/**
* 汇吉采标书项目收藏
*/
public interface HjcProjectFavoriteService extends IService<HjcProjectFavorite> {
/**
* 「我的收藏」分页(收藏时间倒序 + 展示态)。
*
* @param param 必须已由调用方设置 {@code enterpriseId}(归属企业,见 ADR 0011
*/
PageResult<HjcFavoriteVo> pageRel(HjcProjectFavoriteParam param);
/** 该企业是否已收藏该项目。 */
boolean exists(Integer enterpriseId, Integer projectId);
/**
* 收藏(**幂等**):已收藏时直接返回,不改 {@code create_time}。
*
* <p>并发下两个请求同时收藏会撞唯一键,内部按幂等成功处理,不抛异常。</p>
*/
void add(Integer enterpriseId, Integer projectId, Integer tenantId);
/**
* 取消收藏(**幂等**)。
*
* @return 实际影响行数(0 或 1);为 0 也表示成功
*/
int remove(Integer enterpriseId, Integer projectId);
}
@@ -9,6 +9,8 @@ import com.gxwebsoft.hjc.param.HjcOrderParam;
import com.gxwebsoft.hjc.service.HjcOrderService; import com.gxwebsoft.hjc.service.HjcOrderService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List; import java.util.List;
@Service @Service
@@ -44,4 +46,13 @@ public class HjcOrderServiceImpl extends ServiceImpl<HjcOrderMapper, HjcOrder> i
} }
return baseMapper.countPaidOrders(projectId); return baseMapper.countPaidOrders(projectId);
} }
@Override
public List<Integer> listPaidProjectIds(Integer enterpriseId, Collection<Integer> projectIds) {
if (enterpriseId == null || projectIds == null || projectIds.isEmpty()) {
// 空集合交给 SQL 会生成 `IN ()`,那是语法错误;在这里就地短路
return new ArrayList<>();
}
return baseMapper.listPaidProjectIds(enterpriseId, projectIds);
}
} }
@@ -0,0 +1,141 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.mapper.HjcProjectFavoriteMapper;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.service.HjcProjectFavoriteService;
import com.gxwebsoft.hjc.util.HjcFavoriteStateUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 汇吉采标书项目收藏
*/
@Slf4j
@Service
public class HjcProjectFavoriteServiceImpl extends ServiceImpl<HjcProjectFavoriteMapper, HjcProjectFavorite>
implements HjcProjectFavoriteService {
@Resource
private HjcOrderService hjcOrderService;
@Override
public PageResult<HjcFavoriteVo> pageRel(HjcProjectFavoriteParam param) {
PageParam<HjcFavoriteVo, HjcProjectFavoriteParam> page = new PageParam<>(param);
// 收藏列表**不支持客户端排序**:一、这是产品口径(spec 决策 6);
// 二、放行客户端排序会真的出事——这个查询是连表,`tenant_id` 与 `update_time` 两张表都有、
// 又都没被选进选择列表,客户端传 `?sort=tenantId` / `?sort=updateTime` 会直接得到
// `Column 'tenant_id' in order clause is ambiguous` 的 500**已在 dev 库实测**
// 见 .scratch/hjc-favorite/issues/08-验收.md 的实测记录);
// 而传 `?sort=id` 更隐蔽:**它不报错**`id` 在选择列表里,MySQL 会按输出列名解析),
// 只是把「收藏时间倒序」悄悄换成按 a.id 排。
// 所以这里是**覆盖**(先把 orders 清空)而不是 setDefaultOrder ——
// 后者只在 orders 为空时才生效(PageParam#setDefaultOrder)。
page.setOrders(new ArrayList<>());
page.setDefaultOrder("a.create_time desc");
List<HjcFavoriteVo> list = baseMapper.selectPageRel(page, param);
fillSaleState(list);
fillPurchased(list, param == null ? null : param.getEnterpriseId());
return new PageResult<>(list, page.getTotal());
}
@Override
public boolean exists(Integer enterpriseId, Integer projectId) {
if (enterpriseId == null || projectId == null) {
return false;
}
// 不写 XML:wrapper 查询由租户拦截器自动附加 tenant_id,且本实体没有 @TableLogic
return baseMapper.selectCount(new LambdaQueryWrapper<HjcProjectFavorite>()
.eq(HjcProjectFavorite::getEnterpriseId, enterpriseId)
.eq(HjcProjectFavorite::getProjectId, projectId)) > 0;
}
@Override
public void add(Integer enterpriseId, Integer projectId, Integer tenantId) {
// 幂等:已收藏就直接成功,且**不改 create_time**——「按收藏时间倒序」的语义是
// 「最近一次收藏排最前」,重复点击不该把它顶到最上面(spec 决策 16)
if (exists(enterpriseId, projectId)) {
return;
}
HjcProjectFavorite favorite = new HjcProjectFavorite();
favorite.setEnterpriseId(enterpriseId);
favorite.setProjectId(projectId);
favorite.setTenantId(tenantId);
try {
baseMapper.insert(favorite);
} catch (DuplicateKeyException e) {
// 并发下两个请求同时收藏:唯一键 uk_hjc_pf_ent_project 挡掉第二个。
// 幂等是接口契约(spec 决策 16),所以这里**吞掉异常视为成功**,不要让它冒成 500。
log.info("HjcProjectFavorite: 重复收藏(并发命中唯一键),按幂等成功处理 enterpriseId={} projectId={}",
enterpriseId, projectId);
}
}
@Override
public int remove(Integer enterpriseId, Integer projectId) {
if (enterpriseId == null || projectId == null) {
return 0;
}
// 没有 @TableLogic,所以这是一条真 DELETE(ADR 0012);本来没收藏就影响 0 行,也算成功
return baseMapper.delete(new LambdaQueryWrapper<HjcProjectFavorite>()
.eq(HjcProjectFavorite::getEnterpriseId, enterpriseId)
.eq(HjcProjectFavorite::getProjectId, projectId));
}
/** 填充展示态。判定逻辑在 {@link HjcFavoriteStateUtil}(纯函数,可单测)。 */
private void fillSaleState(List<HjcFavoriteVo> list) {
if (list == null || list.isEmpty()) {
return;
}
// 一次分页共用一个基准时间,避免同一页里两条相差几毫秒、却落在停售时间两侧
LocalDateTime now = LocalDateTime.now();
for (HjcFavoriteVo vo : list) {
vo.setSaleState(HjcFavoriteStateUtil.saleStateOf(vo, now));
}
}
/**
* 填充「本企业是否已购买该项目」。
*
* <p><b>为什么不用 JOIN</b>:一个项目可能有多张订单,{@code LEFT JOIN hjc_order} 会把收藏行
* 复制成多行,分页的行数与总数会一起算错。所以分页查完之后,用当页的 projectId 再发**一条**
* {@code IN} 查询,在内存里打标。</p>
*
* <p>口径复用详情页「购买人数」那一条({@code HjcOrderService#countPaidProjectIds} 的实现):
* <b>已付款</b>,退款单与未付款单都不算。</p>
*/
private void fillPurchased(List<HjcFavoriteVo> list, Integer enterpriseId) {
if (list == null || list.isEmpty() || enterpriseId == null) {
return;
}
Set<Integer> projectIds = list.stream()
.map(HjcFavoriteVo::getProjectId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (projectIds.isEmpty()) {
return;
}
// 当页非空时 IN 一定有内容,不会出现 `IN ()` 的语法错误
Set<Integer> paidProjectIds = new HashSet<>(
hjcOrderService.listPaidProjectIds(enterpriseId, projectIds));
for (HjcFavoriteVo vo : list) {
vo.setPurchased(vo.getProjectId() != null && paidProjectIds.contains(vo.getProjectId()));
}
}
}
@@ -0,0 +1,77 @@
package com.gxwebsoft.hjc.util;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 「我的收藏」展示态的判定逻辑(纯函数,不依赖 Spring)。
*
* <p><b>为什么单独抽出来</b>:这一支有三个输入(项目是否还在 / 项目状态 / 停售时间),
* 而「项目已被物理删除、但收藏行还在」这种数据端到端造起来很麻烦,只能靠单测钉住判定本身。</p>
*
* <p><b>为什么判定在后端做</b>:口径只写一份。仓库里 {@code orderStatus} 的口径在
* {@code hjc-h5} 与 {@code hjc-web} 各写了一份,改动时必然分叉——收藏不再重演这件事。</p>
*/
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;
/**
* 判定一条收藏的展示态。
*
* <p>优先级:<b>已下架 &gt; 已结束 &gt; 在售</b>。项目已被删除时,即便它的 {@code status}
* 仍是 1,也必须是「已下架」——「项目还在不在」比它的状态字段更根本。</p>
*
* @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;
}
/**
* 该条目是否还能进入详情页。
*
* <p>与前端「失效项目不进详情、只保留取消收藏」一一对应(spec 决策 8)。</p>
*/
public static boolean enterable(String saleState) {
return ONSALE.equals(saleState);
}
}
@@ -0,0 +1,31 @@
-- 汇吉采:标书项目收藏表(买家「我的收藏」,见 .scratch/hjc-favorite/spec.md
--
-- 幂等:`CREATE TABLE IF NOT EXISTS` 本身幂等,重复执行不会报错,可安全地作为发布步骤对每个环境跑一遍。
-- (对比 hjc_order_add_refund.sql:那个脚本要 ADD COLUMNMySQL 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='汇吉采标书项目收藏';
@@ -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;
/**
* 标书详情接口**内联收藏态**的护栏。
*
* <p><b>最要紧的一条:未登录时 {@code favorited} 必须是 {@code false},而不是 401。</b>
* 详情页对未登录用户是正常可用的页面(页面上本就有未登录分支),
* 在这里返回 401 会把「收藏态查不到」升级成「整个详情页看不了」。</p>
*/
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());
}
}
@@ -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})。
*
* <p><b>为什么需要它</b>:这个接口最要紧的几条分支在真实环境里验不了或在环境里代价太大——
* 未登录态(我没有可用账号)、越权读别家收藏(dev 库只有一个企业)、
* 以及「不能对不存在的项目落库」这条护栏。用 mock 把 service 换掉就能把编排钉住。</p>
*
* <p>{@code getLoginUserId()} 读的是 {@code SecurityContextHolder},所以登录态可以用
* 真实的 {@link User} 放进 SecurityContext 来模拟,不必绕过鉴权
* (照 {@code HjcOrderControllerCancelTest} 的做法)。</p>
*/
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<String, Object> dataOf(ApiResult<?> res) {
return (Map<String, Object>) res.getData();
}
// ---------- 鉴权 ----------
@Test
void anonymousAddShouldGet401() {
ApiResult<?> res = controller.add(addRequest(PROJECT_ID));
assertEquals(401, res.getCode(), "未登录必须是 401(前端据此清凭据跳登录)");
verify(hjcProjectFavoriteService, never()).add(anyInt(), anyInt(), anyInt());
}
@Test
void anonymousRemoveShouldGet401() {
assertEquals(401, controller.remove(PROJECT_ID).getCode());
verify(hjcProjectFavoriteService, never()).remove(anyInt(), anyInt());
}
@Test
void anonymousPageShouldGet401() {
assertEquals(401, controller.page(new HjcProjectFavoriteParam()).getCode());
verify(hjcProjectFavoriteService, never()).pageRel(any());
}
// ---------- 收藏 ----------
@Test
void addWithoutProjectIdShouldFailAndNotTouchDatabase() {
loginAsBuyer();
assertEquals(1, controller.add(addRequest(null)).getCode());
verify(hjcProjectFavoriteService, never()).add(anyInt(), anyInt(), anyInt());
}
@Test
void addNonExistentProjectShouldFailAndNotTouchDatabase() {
loginAsBuyer();
stubOwnEnterprise();
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(null);
ApiResult<?> res = controller.add(addRequest(PROJECT_ID));
assertEquals(1, res.getCode());
assertEquals("标书项目不存在", res.getMessage());
// 这条护栏最要紧:不能对一个不存在的项目落一条收藏
verify(hjcProjectFavoriteService, never()).add(anyInt(), anyInt(), anyInt());
}
@Test
void addShouldReturnFavoritedTrueAndUseEnterpriseIdAsOwner() {
loginAsBuyer();
stubOwnEnterprise();
when(hjcBidProjectService.getById(PROJECT_ID)).thenReturn(new HjcBidProject());
ApiResult<?> res = controller.add(addRequest(PROJECT_ID));
assertEquals(0, res.getCode());
assertEquals(Boolean.TRUE, dataOf(res).get("favorited"));
assertEquals(PROJECT_ID, dataOf(res).get("projectId"));
// 归属键必须是 enterpriseIdADR 0011),不是核心实例的 userId
verify(hjcProjectFavoriteService).add(BUYER_ENTERPRISE_ID, PROJECT_ID, 10626);
verify(hjcProjectFavoriteService, never()).add(eq(BUYER_USER_ID), anyInt(), anyInt());
}
/**
* 幂等契约(spec 决策 16):重复收藏、重复取消都必须是 {@code code=0}。
*
* <p>幂等本身由 service 实现(见 {@code HjcProjectFavoriteServiceImplTest}),
* controller 这一层只保证「不把重复当成业务失败」。</p>
*/
@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<HjcProjectFavoriteParam> captor =
ArgumentCaptor.forClass(HjcProjectFavoriteParam.class);
verify(hjcProjectFavoriteService).pageRel(captor.capture());
assertEquals(BUYER_ENTERPRISE_ID, captor.getValue().getEnterpriseId(),
"必须覆盖成当前登录企业,否则就是一个「读别家收藏」的越权入口");
assertNotNull(res.getData());
}
@Test
void pageShouldPassThroughServiceResult() {
loginAsBuyer();
stubOwnEnterprise();
PageResult<com.gxwebsoft.hjc.dto.HjcFavoriteVo> serviceResult =
new PageResult<>(Collections.emptyList(), 7L);
when(hjcProjectFavoriteService.pageRel(any())).thenReturn(serviceResult);
ApiResult<?> res = controller.page(new HjcProjectFavoriteParam());
assertEquals(0, res.getCode());
assertEquals(serviceResult, res.getData());
verify(hjcProjectFavoriteService, times(1)).pageRel(any());
}
}
@@ -0,0 +1,228 @@
package com.gxwebsoft.hjc.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import com.gxwebsoft.hjc.entity.HjcProjectFavorite;
import com.gxwebsoft.hjc.mapper.HjcProjectFavoriteMapper;
import com.gxwebsoft.hjc.param.HjcProjectFavoriteParam;
import com.gxwebsoft.hjc.service.HjcOrderService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
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;
/**
* 收藏数据层的**幂等与排序**护栏。
*
* <p><b>为什么必须单测</b>:这里钉住的两件事在真实环境里都很难验——</p>
* <ul>
* <li><b>并发收藏撞唯一键</b>:要两个请求同时到达才复现,而它的正确行为是「吞掉异常、按成功算」
* (否则用户点两下收藏会看到 500)。</li>
* <li><b>客户端排序必须被丢弃</b>:收藏列表是 LEFT JOIN,两张表都有 {@code id} 与
* {@code create_time},客户端只要传一个 {@code ?sort=id}SQL 就会变成歧义列而报错。
* 这条只能靠断言「交给 SQL 的 orders 是什么」来钉住。</li>
* </ul>
*/
class HjcProjectFavoriteServiceImplTest {
private static final int ENTERPRISE_ID = 7001;
private static final int PROJECT_ID = 123;
private static final int TENANT_ID = 10626;
private HjcProjectFavoriteServiceImpl service;
private HjcProjectFavoriteMapper mapper;
private HjcOrderService hjcOrderService;
@BeforeEach
void setUp() throws Exception {
service = new HjcProjectFavoriteServiceImpl();
mapper = mock(HjcProjectFavoriteMapper.class);
hjcOrderService = mock(HjcOrderService.class);
injectBaseMapper(mapper);
Field f = HjcProjectFavoriteServiceImpl.class.getDeclaredField("hjcOrderService");
f.setAccessible(true);
f.set(service, hjcOrderService);
}
/**
* {@code baseMapper} 声明在 MyBatis-Plus 的 {@code ServiceImpl} 上(父类),
* 所以要从子类往上找。
*/
private void injectBaseMapper(Object value) throws Exception {
Class<?> c = HjcProjectFavoriteServiceImpl.class;
while (c != null) {
try {
Field f = c.getDeclaredField("baseMapper");
f.setAccessible(true);
f.set(service, value);
return;
} catch (NoSuchFieldException e) {
c = c.getSuperclass();
}
}
throw new IllegalStateException("没有在类层次里找到 baseMapper 字段");
}
private void stubCount(int count) {
when(mapper.selectCount(any(Wrapper.class))).thenReturn(count);
}
// ---------- 幂等 ----------
@Test
void addWhenAlreadyFavoritedShouldNotInsertAgain() {
stubCount(1);
service.add(ENTERPRISE_ID, PROJECT_ID, TENANT_ID);
// 已收藏就直接成功:不插、也不改 create_time(「最近一次收藏排最前」的语义)
verify(mapper, never()).insert(any(HjcProjectFavorite.class));
}
@Test
void addShouldInsertWithOwnerAndTenant() {
stubCount(0);
when(mapper.insert(any(HjcProjectFavorite.class))).thenReturn(1);
service.add(ENTERPRISE_ID, PROJECT_ID, TENANT_ID);
ArgumentCaptor<HjcProjectFavorite> captor =
ArgumentCaptor.forClass(HjcProjectFavorite.class);
verify(mapper, times(1)).insert(captor.capture());
HjcProjectFavorite saved = captor.getValue();
assertEquals(ENTERPRISE_ID, saved.getEnterpriseId(), "归属键必须是 enterpriseIdADR 0011");
assertEquals(PROJECT_ID, saved.getProjectId());
assertEquals(TENANT_ID, saved.getTenantId());
// 审计字段由 DB 默认值负责,代码里不许赋值
assertNull(saved.getCreateTime());
assertNull(saved.getUpdateTime());
}
/**
* 并发下两个请求同时收藏:唯一键挡掉第二个,接口必须按幂等成功处理。
* 这条一旦变红,用户连点两次收藏就会看到 500。
*/
@Test
void addShouldSwallowDuplicateKeyException() {
stubCount(0);
when(mapper.insert(any(HjcProjectFavorite.class)))
.thenThrow(new DuplicateKeyException("uk_hjc_pf_ent_project"));
assertDoesNotThrow(() -> service.add(ENTERPRISE_ID, PROJECT_ID, TENANT_ID));
}
@Test
void existsShouldShortCircuitOnNullArgs() {
assertFalse(service.exists(null, PROJECT_ID));
assertFalse(service.exists(ENTERPRISE_ID, null));
verify(mapper, never()).selectCount(any(Wrapper.class));
}
@Test
void removeShouldReturnAffectedRows() {
when(mapper.delete(any(Wrapper.class))).thenReturn(1);
assertEquals(1, service.remove(ENTERPRISE_ID, PROJECT_ID));
}
@Test
void removeWithoutFavoriteShouldBeHarmless() {
when(mapper.delete(any(Wrapper.class))).thenReturn(0);
assertEquals(0, service.remove(ENTERPRISE_ID, PROJECT_ID));
}
// ---------- 排序 ----------
/**
* 客户端排序必须被丢弃,且换成 {@code a.create_time desc}。
*
* <p>用 {@code tenantId} 当样本,是因为它正是**会真的炸**的那一类:{@code tenant_id} 与
* {@code update_time} 两张表都有、又都没被选进选择列表,放行客户端排序就会得到
* {@code Column 'tenant_id' in order clause is ambiguous} 的 500(已在 dev 库实测)。</p>
*
* <p>反例对照:{@code ?sort=id} **不会**报错({@code id} 在选择列表里,MySQL 按输出列名解析),
* 但它会把「收藏时间倒序」悄悄换成按 {@code a.id} 排——所以判据是「客户端排序一律丢弃」,
* 不是「只挡会报错的那几个」。</p>
*/
@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<IPage<HjcFavoriteVo>> captor = pageCaptor();
verify(mapper).selectPageRel(captor.capture(), any(HjcProjectFavoriteParam.class));
Page<HjcFavoriteVo> page = asPage(captor.getValue());
List<OrderItem> orders = page.orders();
assertEquals(1, orders.size(), "排序只能有一条,不能被客户端加进来");
assertEquals("a.create_time", orders.get(0).getColumn(), "必须覆盖成收藏时间倒序");
assertFalse(orders.get(0).isAsc(), "是倒序");
}
@Test
void noClientSortShouldStillDefaultToFavoriteTime() {
HjcProjectFavoriteParam param = new HjcProjectFavoriteParam();
param.setEnterpriseId(ENTERPRISE_ID);
when(mapper.selectPageRel(any(IPage.class), any(HjcProjectFavoriteParam.class)))
.thenReturn(new ArrayList<>());
service.pageRel(param);
ArgumentCaptor<IPage<HjcFavoriteVo>> captor = pageCaptor();
verify(mapper).selectPageRel(captor.capture(), any(HjcProjectFavoriteParam.class));
List<OrderItem> orders = asPage(captor.getValue()).orders();
assertEquals(1, orders.size());
assertEquals("a.create_time", orders.get(0).getColumn());
}
/** 空页时不得去查「已购买」——否则会生成 {@code IN ()} 的语法错误 */
@Test
void emptyPageShouldNotQueryPaidProjects() {
HjcProjectFavoriteParam param = new HjcProjectFavoriteParam();
param.setEnterpriseId(ENTERPRISE_ID);
when(mapper.selectPageRel(any(IPage.class), any(HjcProjectFavoriteParam.class)))
.thenReturn(new ArrayList<>());
service.pageRel(param);
verify(hjcOrderService, never()).listPaidProjectIds(anyInt(), any());
}
@SuppressWarnings({"unchecked", "rawtypes"})
private ArgumentCaptor<IPage<HjcFavoriteVo>> pageCaptor() {
return (ArgumentCaptor) ArgumentCaptor.forClass(IPage.class);
}
@SuppressWarnings("unchecked")
private Page<HjcFavoriteVo> asPage(IPage<HjcFavoriteVo> page) {
return (Page<HjcFavoriteVo>) page;
}
}
@@ -0,0 +1,112 @@
package com.gxwebsoft.hjc.util;
import com.gxwebsoft.hjc.dto.HjcFavoriteVo;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 「我的收藏」展示态的判定(纯函数)。
*
* <p><b>为什么必须单测</b>:「项目已被物理删除、但收藏行还在」这种数据端到端造起来很麻烦
* (要先收藏、再绕过逻辑删除把项目真删掉),而它恰好是最容易写错的一支;
* 「已下架」与「已结束」的优先级也只能在这里钉住。</p>
*/
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));
}
}