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:
1 parent
7513ac06bc
commit
8d19a0cc0a
24 files changed
+1761
-48
No files matched your search
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user