feat(admin): 商品配置与系统公告管理接口,id 以字符串下发

- /admin/products 六个接口 + 校验(code 唯一、store_product_id 同平台唯一、
  COIN_PACK 需 coin_amount>0、VIP 需 vip_days>0、VIP 只建不启)
- /admin/notices 六个接口,与 C 端 /app/notices 共用 OpsNoticeService.pageForApp 一套筛选口径
- 权限点 product:list/product:update/notice:list/notice:update + 菜单 31/32/33(sql/data-phase1.sql 幂等)
- 雪花 id 超过 JS 安全整数范围:id 以字符串下发,避免浏览器四舍五入导致回查 200002
- C 端商品查询收敛到 ProductService.findForStore,OrderService 不再自带一份
- 测试:module-commerce 33 / admin-api 16 / app-api 49 / worker 12,共 110 条全过
This commit is contained in:
jianghaifeng committed 2026-10-16 16:33:15 +08:00
1 parent 7513ac06bc
commit 8d19a0cc0a
24 files changed
+1761 -48

No files matched your search

@@ -0,0 +1,159 @@
// Author: fruit.com.cn
package com.fruitshorts.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.dal.entity.OpsNotice;
import com.fruitshorts.system.dal.mapper.OpsNoticeMapper;
import com.fruitshorts.system.vo.NoticeQueryReqVO;
import com.fruitshorts.system.vo.NoticeRespVO;
import com.fruitshorts.system.vo.NoticeSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* 系统公告:后台写入 + C 端读取(**同一个筛选实现**)。
*
* <p>把 C 端的筛选条件收敛到 {@link #pageForApp(int, int)} 一处,后台与 App 共用 ——
* 否则"后台发布后 C 端能否看到"这种问题会变成两处条件对不对得上的猜谜。
*
* <p>C 端口径(契约 §1.9.2):`status=1` 且 `target ∈ {ALL, USER}`、`publish_at` 为空或 `<= now`、
* `expire_at` 为空或未过期,按 `publish_at desc, id desc`。
*/
@Service
@RequiredArgsConstructor
public class OpsNoticeService {
private static final String TARGET_ALL = "ALL";
private static final String TARGET_USER = "USER";
private static final List<String> TARGETS = List.of(TARGET_ALL, TARGET_USER, "CREATOR");
private static final List<String> TYPES = List.of("SYSTEM", "AUDIT", "PAYMENT", "ACTIVITY");
private static final int MAX_PAGE_SIZE = 50;
private final OpsNoticeMapper opsNoticeMapper;
// ─────────── 后台 ───────────
public PageResult<NoticeRespVO> pageForAdmin(NoticeQueryReqVO req) {
Page<OpsNotice> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<OpsNotice> result = opsNoticeMapper.selectPage(page, Wrappers.<OpsNotice>lambdaQuery()
.eq(req.getStatus() != null, OpsNotice::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getTarget()), OpsNotice::getTarget, req.getTarget())
.eq(StringUtils.hasText(req.getType()), OpsNotice::getType, req.getType())
.like(StringUtils.hasText(req.getKeyword()), OpsNotice::getTitle, req.getKeyword())
.orderByDesc(OpsNotice::getPublishAt)
.orderByDesc(OpsNotice::getId));
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(NoticeRespVO::of).toList());
}
public NoticeRespVO detail(Long id) {
return NoticeRespVO.of(getExisting(id));
}
/** 新建即发布(`status=1`);不传 `publish_at` 视为立即生效。 */
@Transactional(rollbackFor = Exception.class)
public Long create(NoticeSaveReqVO req) {
validate(req);
OpsNotice notice = new OpsNotice();
applyFields(notice, req);
notice.setStatus(1);
if (notice.getPublishAt() == null) {
notice.setPublishAt(LocalDateTime.now());
}
opsNoticeMapper.insert(notice);
return notice.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, NoticeSaveReqVO req) {
validate(req);
OpsNotice notice = getExisting(id);
applyFields(notice, req);
opsNoticeMapper.updateById(notice);
}
/** 发布(1)/ 下架(0):下架后 C 端立即不可见(C 端条件里要求 status=1)。 */
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(发布)");
}
OpsNotice notice = getExisting(id);
notice.setStatus(status);
if (status == 1 && notice.getPublishAt() == null) {
// 下架再发布时补上发布时间,否则 C 端排序里它会跑到最后
notice.setPublishAt(LocalDateTime.now());
}
opsNoticeMapper.updateById(notice);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
opsNoticeMapper.deleteById(id);
}
// ─────────── C 端(与后台共用同一套筛选定义) ───────────
/** `GET /app/notices` 的数据源:只返回"对普通用户可见且在有效期内"的公告。 */
public PageResult<OpsNotice> pageForApp(int pageIndex, int pageSize) {
LocalDateTime now = LocalDateTime.now();
Page<OpsNotice> page = new Page<>(pageIndex < 1 ? 1 : pageIndex,
pageSize < 1 ? 20 : Math.min(pageSize, MAX_PAGE_SIZE));
IPage<OpsNotice> result = opsNoticeMapper.selectPage(page, Wrappers.<OpsNotice>lambdaQuery()
.eq(OpsNotice::getStatus, 1)
.in(OpsNotice::getTarget, List.of(TARGET_ALL, TARGET_USER))
.and(w -> w.isNull(OpsNotice::getPublishAt).or().le(OpsNotice::getPublishAt, now))
.and(w -> w.isNull(OpsNotice::getExpireAt).or().gt(OpsNotice::getExpireAt, now))
.orderByDesc(OpsNotice::getPublishAt)
.orderByDesc(OpsNotice::getId));
return new PageResult<>(result.getTotal(), result.getRecords());
}
private void applyFields(OpsNotice notice, NoticeSaveReqVO req) {
notice.setTitle(req.getTitle().trim());
notice.setContent(req.getContent() == null ? "" : req.getContent());
notice.setTarget(normalize(req.getTarget(), TARGETS, TARGET_ALL));
notice.setType(normalize(req.getType(), TYPES, "SYSTEM"));
notice.setPublishAt(req.getPublishAt());
notice.setExpireAt(req.getExpireAt());
}
private void validate(NoticeSaveReqVO req) {
if (req.getExpireAt() != null && req.getPublishAt() != null
&& !req.getExpireAt().isAfter(req.getPublishAt())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "失效时间必须晚于发布时间");
}
}
private String normalize(String value, List<String> allowed, String fallback) {
if (!StringUtils.hasText(value)) {
return fallback;
}
String normalized = value.trim().toUpperCase();
if (!allowed.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"取值不合法:" + value + ",可选 " + String.join("/", allowed));
}
return normalized;
}
private OpsNotice getExisting(Long id) {
OpsNotice notice = id == null ? null : opsNoticeMapper.selectById(id);
if (notice == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "公告不存在");
}
return notice;
}
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 后台公告检索条件。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class NoticeQueryReqVO extends PageParam {
/** 1 已发布 / 0 已下架。 */
private Integer status;
/** ALL / USER / CREATOR。 */
private String target;
/** SYSTEM / AUDIT / PAYMENT / ACTIVITY。 */
private String type;
/** 模糊匹配标题。 */
private String keyword;
}
@@ -0,0 +1,76 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fruitshorts.system.dal.entity.OpsNotice;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台公告响应:实体字段 + 中文文案(前端不写死中文)。 */
@Data
public class NoticeRespVO {
/** 同样是雪花 id 的精度问题:id 以字符串下发,避免浏览器把 id 四舍五入(见 api-phase1.md §2.12)。 */
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
private String title;
private String content;
private String target;
private String targetText;
private String type;
private String typeText;
private Integer status;
private String statusText;
private LocalDateTime publishAt;
private LocalDateTime expireAt;
private LocalDateTime createdAt;
public static NoticeRespVO of(OpsNotice notice) {
NoticeRespVO vo = new NoticeRespVO();
vo.setId(notice.getId());
vo.setTitle(notice.getTitle());
vo.setContent(notice.getContent());
vo.setTarget(notice.getTarget());
vo.setTargetText(targetText(notice.getTarget()));
vo.setType(notice.getType());
vo.setTypeText(typeText(notice.getType()));
vo.setStatus(notice.getStatus());
vo.setStatusText(statusText(notice.getStatus()));
vo.setPublishAt(notice.getPublishAt());
vo.setExpireAt(notice.getExpireAt());
vo.setCreatedAt(notice.getCreatedAt());
return vo;
}
private static String statusText(Integer status) {
return status != null && status == 1 ? "已发布" : "已下架";
}
private static String targetText(String target) {
if (target == null) {
return "-";
}
return switch (target) {
case "ALL" -> "全部用户";
case "USER" -> "普通用户";
case "CREATOR" -> "创作者";
default -> target;
};
}
private static String typeText(String type) {
if (type == null) {
return "-";
}
return switch (type) {
case "SYSTEM" -> "系统";
case "AUDIT" -> "审核";
case "PAYMENT" -> "支付";
case "ACTIVITY" -> "活动";
default -> type;
};
}
}
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台公告新增/编辑(字段与 `ops_notice` 表一致,不改表)。 */
@Data
public class NoticeSaveReqVO {
@NotBlank(message = "请填写公告标题")
@Size(max = 128, message = "公告标题过长")
private String title;
@Size(max = 2000, message = "公告正文过长")
private String content;
/** ALL(默认)/ USER / CREATOR。 */
private String target;
/** SYSTEM(默认)/ AUDIT / PAYMENT / ACTIVITY。 */
private String type;
/** 不传按"立即发布"处理(C 端条件里 publish_at 为空视为立即生效)。 */
private LocalDateTime publishAt;
private LocalDateTime expireAt;
}