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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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_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。
|
||||
*
|
||||
* <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)
|
||||
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")
|
||||
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 org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
|
||||
@@ -40,4 +41,19 @@ public interface HjcOrderMapper extends BaseMapper<HjcOrder> {
|
||||
* 退款单(pay_status=3)与已取消单(pay_status 仍为 0)都不计。</p>
|
||||
*/
|
||||
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
|
||||
</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>
|
||||
|
||||
@@ -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.param.HjcOrderParam;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface HjcOrderService extends IService<HjcOrder> {
|
||||
@@ -26,4 +27,13 @@ public interface HjcOrderService extends IService<HjcOrder> {
|
||||
* 统计某标书项目**已付款**的订单数(标书详情的「购买人数」)。
|
||||
*/
|
||||
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 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<HjcOrderMapper, HjcOrder> i
|
||||
}
|
||||
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>已下架 > 已结束 > 在售</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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user