chore(merge): 合并 App 版本管理后端

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-23 15:27:35 +08:00
commit 47f19a98eb
8 files changed
+472

No files matched your search

@@ -0,0 +1,85 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.service.AppVersionService;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台 - App 版本管理。 */
@Tag(name = "后台-App 版本")
@RestController
@RequestMapping("/admin/app-versions")
@RequiredArgsConstructor
public class AppVersionAdminController {
private final AppVersionService appVersionService;
@Operation(summary = "版本分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<PageResult<AppVersionRespVO>> page(@Validated AppVersionQueryReqVO req) {
return Result.ok(appVersionService.page(req));
}
@Operation(summary = "版本详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<AppVersionRespVO> detail(@PathVariable Long id) {
return Result.ok(appVersionService.detail(id));
}
@Operation(summary = "新建版本(默认发布)")
@PostMapping
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "新建版本")
public Result<Long> create(@Valid @RequestBody AppVersionSaveReqVO req) {
return Result.ok(appVersionService.create(req));
}
@Operation(summary = "编辑版本")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "编辑版本")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AppVersionSaveReqVO req) {
appVersionService.update(id, req);
return Result.ok();
}
@Operation(summary = "发布 / 下架")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "版本上下架")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
appVersionService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "删除版本")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:delete')")
@OperationLog(module = "版本管理", action = "删除版本")
public Result<Void> delete(@PathVariable Long id) {
appVersionService.delete(id);
return Result.ok();
}
}
@@ -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;
}
+50
View File
@@ -0,0 +1,50 @@
-- Author: fruit.com.cn
-- App 版本管理:表 + 后台菜单 + 角色菜单授权
CREATE TABLE IF NOT EXISTS app_version (
id BIGINT PRIMARY KEY,
platform VARCHAR(16) NOT NULL, -- ANDROID / IOS
version_code INTEGER NOT NULL, -- 整数版本号(比较用)
version_name VARCHAR(32) NOT NULL, -- 展示版本号,如 1.2.0
download_url VARCHAR(512) NOT NULL,
update_content TEXT, -- 更新说明
force_update SMALLINT NOT NULL DEFAULT 0, -- 1=强更 0=可选
min_supported_code INTEGER NOT NULL DEFAULT 0, -- 低于此版本强制升级
status SMALLINT NOT NULL DEFAULT 1, -- 1=已发布 0=已下架
released_at TIMESTAMP(3),
created_by BIGINT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_app_version_platform ON app_version(platform, version_code DESC);
CREATE INDEX IF NOT EXISTS idx_app_version_status ON app_version(status, platform);
DROP TRIGGER IF EXISTS trg_app_version_updated_at ON app_version;
CREATE TRIGGER trg_app_version_updated_at
BEFORE UPDATE ON app_version
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- 菜单:后台管理下新增「版本管理」(id=39)
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, permission_code, type, always_show, affix, sort, visible, status, created_at, updated_at, super_admin_visible)
VALUES (39, 6, '版本管理', 'app-versions', 'app-versions', 'views/systemManagement/AppVersionView', '', 'ep:download', 'app-version:list', 'MENU', 0, 0, 9, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT (id) DO NOTHING;
-- 权限按钮
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, permission_code, type, sort, visible, status, created_at, updated_at, super_admin_visible) VALUES
(40, 39, '查看', '', '', '', 'app-version:list', 'BUTTON', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(41, 39, '新增', '', '', '', 'app-version:create', 'BUTTON', 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(42, 39, '编辑', '', '', '', 'app-version:update', 'BUTTON', 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(43, 39, '删除', '', '', '', 'app-version:delete', 'BUTTON', 4, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(44, 39, '上下架', '', '', '', 'app-version:update', 'BUTTON', 5, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT (id) DO NOTHING;
-- 超管角色授权(sys_role_menu 需要手动 id,按现有 MAX(id) 顺延)
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT 5000031, 1, 39 UNION ALL
SELECT 5000032, 1, 40 UNION ALL
SELECT 5000033, 1, 41 UNION ALL
SELECT 5000034, 1, 42 UNION ALL
SELECT 5000035, 1, 43 UNION ALL
SELECT 5000036, 1, 44
ON CONFLICT DO NOTHING;