feat(app): 新增后台 App 版本管理接口

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-23 15:27:35 +08:00
1 parent 34c775620d
commit 62792413df
8 files changed
+472

No files matched your search

@@ -0,0 +1,57 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* App 版本(app_version)。
*
* <p>iOS / Android 共用一张表,由 {@code platform} 区分;同一平台下 {@code version_code}
* 作为整数比较用(C 端用它判断是否需要升级),{@code version_name} 是展示用字符串。
*/
@Data
@TableName("app_version")
public class AppVersion {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** ANDROID / IOS。 */
private String platform;
/** 整数版本号(比较用)。 */
private Integer versionCode;
/** 展示版本号,如 1.2.0。 */
private String versionName;
/** 下载链接(Android apk 或 iOS App Store)。 */
private String downloadUrl;
/** 更新说明。 */
private String updateContent;
/** 1=强制升级,0=可选升级。 */
private Integer forceUpdate;
/** 低于此版本强制升级(兜底)。 */
private Integer minSupportedCode;
/** 1=已发布,0=已下架。 */
private Integer status;
private LocalDateTime releasedAt;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.system.dal.entity.AppVersion;
/** AppVersion Mapper。 */
public interface AppVersionMapper extends BaseMapper<AppVersion> {
}
@@ -0,0 +1,154 @@
// 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.AppVersion;
import com.fruitshorts.system.dal.mapper.AppVersionMapper;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
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;
/**
* App 版本管理:后台 CRUD + 按平台查询最新发布版。
*
* <p>同一平台下 {@code version_code} 唯一(按 (platform, version_code) 校验重复)。
* 发布(status=1)即对 C 端可见,下架后 C 端 latest 查询不再返回。
*/
@Service
@RequiredArgsConstructor
public class AppVersionService {
private static final List<String> PLATFORMS = List.of("ANDROID", "IOS");
private final AppVersionMapper versionMapper;
public PageResult<AppVersionRespVO> page(AppVersionQueryReqVO req) {
Page<AppVersion> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
String platform = normalizedPlatform(req.getPlatform());
IPage<AppVersion> result = versionMapper.selectPage(page, Wrappers.<AppVersion>lambdaQuery()
.eq(req.getStatus() != null, AppVersion::getStatus, req.getStatus())
.eq(platform != null, AppVersion::getPlatform, platform)
.like(StringUtils.hasText(req.getKeyword()), AppVersion::getVersionName, req.getKeyword())
.orderByDesc(AppVersion::getVersionCode)
.orderByDesc(AppVersion::getId));
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(AppVersionRespVO::of).toList());
}
public AppVersionRespVO detail(Long id) {
return AppVersionRespVO.of(getExisting(id));
}
@Transactional(rollbackFor = Exception.class)
public Long create(AppVersionSaveReqVO req) {
String platform = normalizePlatform(req.getPlatform());
checkDuplicate(platform, req.getVersionCode(), null);
AppVersion v = new AppVersion();
applyFields(v, req, platform);
v.setStatus(1);
v.setReleasedAt(LocalDateTime.now());
versionMapper.insert(v);
return v.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, AppVersionSaveReqVO req) {
String platform = normalizePlatform(req.getPlatform());
AppVersion existing = getExisting(id);
checkDuplicate(platform, req.getVersionCode(), id);
applyFields(existing, req, platform);
versionMapper.updateById(existing);
}
/** 发布(1)/ 下架(0)。 */
@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(发布)");
}
AppVersion v = getExisting(id);
v.setStatus(status);
if (status == 1 && v.getReleasedAt() == null) {
v.setReleasedAt(LocalDateTime.now());
}
versionMapper.updateById(v);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
versionMapper.deleteById(id);
}
/** C 端用:某平台最新已发布版本(版本码最大者)。 */
public AppVersion latestFor(String platform) {
String normalized = normalizePlatform(platform);
return versionMapper.selectOne(Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, normalized)
.eq(AppVersion::getStatus, 1)
.orderByDesc(AppVersion::getVersionCode)
.last("LIMIT 1"));
}
// ─────────── 内部 ───────────
private void applyFields(AppVersion v, AppVersionSaveReqVO req, String platform) {
v.setPlatform(platform);
v.setVersionCode(req.getVersionCode());
v.setVersionName(req.getVersionName().trim());
v.setDownloadUrl(req.getDownloadUrl().trim());
v.setUpdateContent(StringUtils.hasText(req.getUpdateContent()) ? req.getUpdateContent().trim() : "");
v.setForceUpdate(req.getForceUpdate() == null ? 0 : req.getForceUpdate());
v.setMinSupportedCode(req.getMinSupportedCode() == null ? 0 : req.getMinSupportedCode());
}
private String normalizePlatform(String platform) {
if (!StringUtils.hasText(platform)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择平台");
}
String normalized = platform.trim().toUpperCase();
if (!PLATFORMS.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"平台取值不合法:" + platform + ",可选 ANDROID / IOS");
}
return normalized;
}
private String normalizedPlatform(String platform) {
if (!StringUtils.hasText(platform)) return null;
return normalizePlatform(platform);
}
/** 同平台下 version_code 不可重复(update 时排除自身 id)。 */
private void checkDuplicate(String platform, Integer versionCode, Long excludeId) {
Long count = versionMapper.selectCount(Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, platform)
.eq(AppVersion::getVersionCode, versionCode)
.ne(excludeId != null, AppVersion::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"该平台下 versionCode=" + versionCode + " 已存在");
}
}
private AppVersion getExisting(Long id) {
AppVersion v = id == null ? null : versionMapper.selectById(id);
if (v == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "版本不存在");
}
return v;
}
}
@@ -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;
/** App 版本后台分页查询。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class AppVersionQueryReqVO extends PageParam {
/** ANDROID / IOS,空=全部。 */
private String platform;
/** 1=已发布 / 0=已下架,空=全部。 */
private Integer status;
/** 关键字(匹配 version_name)。 */
private String keyword;
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fruitshorts.system.dal.entity.AppVersion;
import lombok.Data;
import java.time.LocalDateTime;
/** App 版本后台响应。 */
@Data
public class AppVersionRespVO {
private Long id;
private String platform;
private String platformText;
private Integer versionCode;
private String versionName;
private String downloadUrl;
private String updateContent;
private Integer forceUpdate;
private Integer minSupportedCode;
private Integer status;
private String statusText;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
public static AppVersionRespVO of(AppVersion v) {
AppVersionRespVO vo = new AppVersionRespVO();
vo.setId(v.getId());
vo.setPlatform(v.getPlatform());
vo.setPlatformText(platformText(v.getPlatform()));
vo.setVersionCode(v.getVersionCode());
vo.setVersionName(v.getVersionName());
vo.setDownloadUrl(v.getDownloadUrl());
vo.setUpdateContent(v.getUpdateContent());
vo.setForceUpdate(v.getForceUpdate());
vo.setMinSupportedCode(v.getMinSupportedCode());
vo.setStatus(v.getStatus());
vo.setStatusText(v.getStatus() != null && v.getStatus() == 1 ? "已发布" : "已下架");
vo.setReleasedAt(v.getReleasedAt());
vo.setCreatedAt(v.getCreatedAt());
return vo;
}
private static String platformText(String p) {
return switch (p == null ? "" : p.toUpperCase()) {
case "ANDROID" -> "Android";
case "IOS" -> "iOS";
default -> p == null ? "-" : p;
};
}
}
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** App 版本新增/编辑。 */
@Data
public class AppVersionSaveReqVO {
/** ANDROID / IOS。 */
@NotBlank(message = "请选择平台")
private String platform;
@NotNull(message = "请填写整数版本号")
@Min(value = 1, message = "versionCode 必须大于 0")
@Max(value = 999999, message = "versionCode 过大")
private Integer versionCode;
@NotBlank(message = "请填写展示版本号")
@Size(max = 32, message = "versionName 过长")
private String versionName;
@NotBlank(message = "请填写下载地址")
@Size(max = 512, message = "下载地址过长")
private String downloadUrl;
@Size(max = 2000, message = "更新说明过长")
private String updateContent;
/** 0=可选(默认),1=强制升级。 */
private Integer forceUpdate;
/** 低于此版本强制升级(兜底,默认 0 不开启)。 */
private Integer minSupportedCode;
}