diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java new file mode 100644 index 0000000..434c421 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java @@ -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> page(@Validated AppVersionQueryReqVO req) { + return Result.ok(appVersionService.page(req)); + } + + @Operation(summary = "版本详情") + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('app-version:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(appVersionService.detail(id)); + } + + @Operation(summary = "新建版本(默认发布)") + @PostMapping + @PreAuthorize("@ss.hasPermission('app-version:create')") + @OperationLog(module = "版本管理", action = "新建版本") + public Result 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 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 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 delete(@PathVariable Long id) { + appVersionService.delete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AppVersion.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AppVersion.java new file mode 100644 index 0000000..3688cac --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AppVersion.java @@ -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)。 + * + *

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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AppVersionMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AppVersionMapper.java new file mode 100644 index 0000000..4d8be49 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AppVersionMapper.java @@ -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 { +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java new file mode 100644 index 0000000..1f4172a --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java @@ -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 + 按平台查询最新发布版。 + * + *

同一平台下 {@code version_code} 唯一(按 (platform, version_code) 校验重复)。 + * 发布(status=1)即对 C 端可见,下架后 C 端 latest 查询不再返回。 + */ +@Service +@RequiredArgsConstructor +public class AppVersionService { + + private static final List PLATFORMS = List.of("ANDROID", "IOS"); + + private final AppVersionMapper versionMapper; + + public PageResult page(AppVersionQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + String platform = normalizedPlatform(req.getPlatform()); + IPage result = versionMapper.selectPage(page, Wrappers.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.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.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; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionQueryReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionQueryReqVO.java new file mode 100644 index 0000000..615f3ba --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionQueryReqVO.java @@ -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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionRespVO.java new file mode 100644 index 0000000..99a5854 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionRespVO.java @@ -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; + }; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionSaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionSaveReqVO.java new file mode 100644 index 0000000..c710bb7 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AppVersionSaveReqVO.java @@ -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; +} diff --git a/sql/patch-2026-10-23-app-version.sql b/sql/patch-2026-10-23-app-version.sql new file mode 100644 index 0000000..bc665db --- /dev/null +++ b/sql/patch-2026-10-23-app-version.sql @@ -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;