feat(device): 应用图标改存云存储并修复版本号唯一键冲突

This commit is contained in:
tongtong committed 2026-10-19 14:35:27 +08:00
1 parent 72770b226a
commit cbf4f37b94
15 files changed
+526 -133

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
# AGENTS.md
本仓库是 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) 的 TTSTD 定制分支,用于**老年平板设备管理**:设备端(sn)、家属端(client)、运营后台(device / app)。
## 1. 与 Gitee 上游的关系(最高优先级)
远程仓库:
| remote | 地址 | 用途 |
| --- | --- | --- |
| `gitee` | `git@gitee.com:youlaiorg/youlai-boot.git` | **上游只读**(push 已禁用),只用于比对/同步 |
| `origin` | `ssh://git@gitea.ttstd.com:2222/tt/youlai-boot.git` | 我方仓库 |
**只有以下目录是我方新增的业务代码,可自由新增/修改:**
| 目录 | 职责 |
| --- | --- |
| `src/main/java/com/youlai/boot/app/` | 客户端应用管理:应用分类、应用包(APK/XAPK/APKM 解析、上传、Mongo 存储) |
| `src/main/java/com/youlai/boot/client/` | 家属端:App 用户、通讯录、设备操作、文件 |
| `src/main/java/com/youlai/boot/device/` | 设备管理:sn 设备、截图、壁纸、策略、告警、APK 安装、硬件/网络/安全/系统信息 |
| `src/main/java/com/youlai/boot/open/` | 家属端开放认证:短信登录、密码登录、注册、重置密码 |
| `src/main/java/com/youlai/boot/common/config/` | `FilePath`(本地存储路径)、`WebMvcConfig`(静态资源映射) |
**其余所有目录(`auth/`、`system/`、`framework/`、`support/`、`config/`、`module/`、`codegen/` 等)都来自上游,尽量保持不动。**
### 修改前必做检查
```bash
git fetch gitee
git ls-tree -r --name-only gitee/master -- src/main/java/com/youlai/boot/xxx # 上游是否存在该文件
git log --oneline gitee/master -- src/main/java/com/youlai/boot/xxx # 上游是否有近期改动
```
- **上游存在该文件 → 尽量不改**。优先在五个自有目录内新增类/扩展点(继承、实现接口、新增 Controller)来满足需求。
- **确实必须改上游文件时**:只做最小必要改动,不做格式化/重命名/顺带重构,提交信息注明原因,例如
`chore(upstream): 安全配置放行 /api/v1/xxx 以支持新模块`。
- **上游不存在该文件 → 可新增**,但应落在上述五个目录内。
### 已登记的例外清单
以下改动位于上游目录,属结构性必要或历史遗留。**请勿删除、重构或格式化**,修改前先确认调用方。
#### A. 上游目录中新增的文件(上游不存在,因框架结构必须留在原包)
| 位置 | 说明 |
| --- | --- |
| `common/constant/ClientTypeConstants.java` | 端标识(admin / client) |
| `common/util/{HashUtils, FileUtils, RedisSafeOps, CodeGeneratorUtil}.java` | 校验值、文件、Redis 安全操作、编码生成工具 |
| `config/{DataSourceConfig, RedissonConfig, MqttConfig, RedisHealthConfig}.java`、`config/property/MqttProperties.java` | 多数据源、分布式锁、MQTT、Redis 健康检查 |
| `framework/security/filter/{MobileApiSignatureFilter, CachedBodyRequestWrapper}.java` | 设备端 ECDSA 验签过滤器与请求体缓存 |
| `framework/security/signature/DeviceSignatureVerifier.java` | 设备签名校验 |
| `framework/security/authorization/ClientAccessAuthorizationManager.java` | 家属端令牌隔离 |
| `support/mqtt/{MqttController, MqttMessageHandler, MqttProducer}.java` | MQTT 上报/推送 |
| `support/webrtc/**` | WebRTC 信令(`/ws/signal`) |
| `support/sms/SmsResult.java` | 短信发送结果 |
| `resources/mapper/system/DeviceMapper.xml` | 设备分页查询 |
| `test/java/com/youlai/boot/app/packages/component/ApkMetaParserTest.java` | 安装包解析测试 |
#### B. 被修改的上游文件(只允许最小增补,不做重构/格式化)
| 文件 | 允许的改动范围 |
| --- | --- |
| `config/SecurityConfig.java` | 放行 `/api/v1/sn/**`、`/api/v1/open/**`、`/ws/signal`;`/api/v1/client/**` 走 `ClientAccessAuthorizationManager` |
| `config/JacksonConfig.java` | **保持上游原样**(不得添加未使用的 import / 注释代码) |
| `config/RedisConfig.java` | 仅 `@Lazy` 延迟初始化;序列化方式必须与上游一致 |
| `framework/security/config/SecurityProperties.java` | client 端令牌 TTL 两个字段 |
| `framework/security/model/{SecurityUser, SecurityUserDetails}.java` | `clientType` 字段 |
| `framework/security/token/JwtTokenManager.java` | client 端 TTL 解析、`clientType` claim、校验失败日志 |
| `common/constant/JwtClaimConstants.java`、`common/constant/RedisConstants.java` | `clientType` claim、设备在线 key、短信验证码 key |
| `common/enums/{ActionTypeEnum, LogModuleEnum}.java`、`common/result/ResultCode.java` | 仅追加新枚举值/错误码 |
| `framework/web/advice/GlobalExceptionHandler.java` | Redis 相关异常、SSE 超时、静态资源 404 |
| `framework/web/log/OperationLogAspect.java` | 设备无登录会话时的操作人兜底;`error_msg` 上限 2000 |
| `system/service/impl/ConfigServiceImpl.java` | 缓存预热失败不阻断启动 |
| `support/file/model/FileInfo.java`、`support/file/service/impl/LocalFileServiceImpl.java` | md5/sha1/sha256 校验值 |
| `support/sms/{SmsTypeEnum, service/SmsService, service/impl/AliyunSmsServiceImpl}.java` | `sendWithResult`,按响应码判定成败 |
| `support/sse/{SseService, SseSessionManager, constant/SseEventConstants}.java` | 设备事件主题 |
| `pom.xml` | 新增依赖(websocket、mongodb、mqtt、apk-parser、apkanalyzer、redisson 等) |
| `.gitignore` | 目前仅新增 `/uploadFile/` |
| `resources/application-dev.yml`、`resources/application-prod.yml` | 数据源(mysql/tdengine)、redis、mongodb、oss、sms、mqtt、安全白名单、令牌 TTL |
| `sql/youlai-admin.sql` | `sys_log.error_msg` 由 1000 调整为 2000 |
#### C. 数据与运维脚本(非 Java 代码,允许新增)
`sql/*.sql`、`docs/sql/*.sql`、`docker/*`。
#### D. 禁止事项
- 上游文件里做格式化、重命名、import 重排、注释掉代码等无关改动。
- 配置文件提交真实凭证:密码/AccessKey 一律用环境变量或本地 profile 覆盖,不写入版本库。
- 在上游目录新增 Java 类(应放到自有目录,例如 `ByteArrayMultipartFile` 放在 `app/packages/component/`)。
## 2. 技术栈
Java 17 · Spring Boot 4.0.5 · Spring Security · MyBatis-Plus 3.5.15 · MySQL · Redis + Redisson · MongoDB(`spring-boot-starter-data-mongodb`) · WebSocket · knife4j/springdoc · MapStruct + Lombok · Hutool · apk-parser / apkanalyzer
## 3. 构建与运行
```bash
mvn -DskipTests clean package # 打包,产物 target/youlai-boot-4.12.0.jar
mvn test # 测试
java -jar target/youlai-boot-4.12.0.jar --spring.profiles.active=dev
```
- 默认 profile 为 `dev`(`application.yml`),配置见 `application-dev.yml` / `application-prod.yml`;代码生成配置在 `codegen.yml`。
- 数据库连接请使用本地 `/ 环境变量` 覆盖,**不要提交真实账号密码**。
- Windows 上 shell 为 PowerShell,勿使用 `head`/`tail`/`cd /d` 等 cmd/Unix 语法。
## 4. 代码约定
包结构(自有模块统一遵循):
```
xxx/
├── controller/ # @RestController,路径 /api/v1/...
├── service/ # 接口
│ └── impl/ # 实现,继承 MyBatis-Plus ServiceImpl<Mapper, Entity>
├── converter/ # MapStruct 转换器
├── mapper/ # MyBatis-Plus Mapper(自有模块一般不写 XML)
├── repository/ # Mongo 仓储(接口 + impl,用 MongoTemplate)
└── model/
├── entity/ # MySQL 实体(设备相关以 Sn 前缀命名,如 SnDeviceInfo)
├── document/ # Mongo 文档(如 AppPackageDocument)
├── form/ req/ query/ # 入参
└── vo/ dto/ # 出参
```
- 统一返回 `Result<T>` / `PageResult<T>`,分页参数用 `PageQuery`,分页结果用 `IPage`。
- 构造器注入:类加 `@RequiredArgsConstructor`,字段 `private final`。
- 接口文档:`@Tag(name = "序号.名称")` + `@Operation(summary = ...)`。
- 操作日志:`@Log(module = LogModuleEnum.X, value = ActionTypeEnum.Y)`。
- 鉴权分区:
- `/api/v1/sn/**`:设备端,`MobileApiSignatureFilter` 做 ECDSA 设备签名校验(非 JWT)
- `/api/v1/client/**`:家属端,仅接受 `clientType=client` 的令牌
- `/api/v1/open/**`:免登录(登录/注册/找回密码)
- 后台接口(`/api/v1/device`、`/api/v1/app/**`、`/api/v1/developers` 等):走 Spring Security,按需 `@PreAuthorize`
- 文件落盘统一走 `FilePath`(根目录为 `user.dir` 下的 `uploadFile/tablet/{screenshot,packages,category_icon,wallpaper}`),HTTP 访问走 `/static/{子目录}/**`,由 `WebMvcConfig` 映射;截图/壁纸用 `noCache`,安装包/分类图标用长缓存。
- 新增后台菜单/表结构请补充 `sql/` 脚本,保持与代码同步。
## 5. 提交约定
采用 Conventional Commits(`feat(device): ...`、`fix(client): ...`、`refactor(config): ...`、`chore: ...`)。涉及上游文件的改动需在正文说明原因。
+9 -4
View File
@@ -11,11 +11,16 @@
-- 约定:
-- - 同一 package_name 下 status=1(当前生效)的记录有且仅有一条
-- - 历史版本(status=0)仅用于展示与选取锁定,不物理删除
-- - 图标文件按 {md5}.{ext} 内容寻址存储,访问路径由后端拼接为 /static/app_icon/{md5}.{ext}
-- (该前缀与 WebMvcConfig 注册的静态资源映射一致;内容变更必然导致文件名变更,故可长缓存)
-- - 版本号生成规则(必须严格遵守):
-- version = 该 package_name 下所有记录的 MAX(version) + 1
-- 统计范围必须包含 is_deleted=1 的逻辑删除行,版本号只增不减、永不复用。
-- 原因:uk_package_version 不包含 is_deleted,若用「当前生效版本+1」或从 1 重新开始,
-- 当前生效记录被删除/缺失时会与残留版本号撞唯一键(Duplicate entry '包名-1')。
-- - 图标文件统一存入 storage.type 配置的存储(当前为阿里云 OSS),icon_url 为可直接访问的完整 URL
--
-- 部署规范:不直连库,由 DBA 执行;先 DROP 再 CREATE 确保结构与实体完全一致
-- (DROP 会清空历史图标数据,可由设备端重新上报补齐)。
-- 存量库修复:不重建表的场景,先执行 sql/app_icon_version_repair.sql 清理脏数据。
-- =============================================================================
DROP TABLE IF EXISTS `app_icon`;
@@ -25,11 +30,11 @@ CREATE TABLE `app_icon`
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`package_name` VARCHAR(255) NOT NULL COMMENT '应用包名(全局唯一维度,同包名多版本共存)',
`app_name` VARCHAR(128) DEFAULT NULL COMMENT '应用名称(上报时的展示名,可为空;新版本上报时刷新)',
`icon_url` VARCHAR(500) NOT NULL COMMENT '图标访问地址(相对路径,如 /static/app_icon/{md5}.png)',
`icon_url` VARCHAR(500) NOT NULL COMMENT '图标访问地址(存储服务返回的完整 URL)',
`icon_md5` VARCHAR(64) DEFAULT NULL COMMENT '图标文件MD5(内容指纹,用于去重与换图标检测)',
`icon_sha1` VARCHAR(64) DEFAULT NULL COMMENT '图标文件SHA1',
`icon_sha256` VARCHAR(64) DEFAULT NULL COMMENT '图标文件SHA256',
`version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同包名内自增,从1开始)',
`version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同包名内自增,取所有记录含逻辑删除的 MAX(version)+1,不复用)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1当前生效 0历史版本)',
`locked` TINYINT NOT NULL DEFAULT 0 COMMENT '是否锁定(0未锁定 1已锁定,锁定后不可上传覆盖)',
`source` VARCHAR(20) NOT NULL DEFAULT 'device' COMMENT '图标来源(device设备端上报 admin管理端上传)',
+92
View File
@@ -0,0 +1,92 @@
-- =============================================================================
-- app_icon 版本号 / 生效状态 存量数据修复脚本
--
-- 背景:
-- 旧逻辑用「当前生效版本 + 1」生成版本号,无当前生效记录时固定用 1;
-- 而删除是逻辑删除(is_deleted=1),且唯一索引 uk_package_version(package_name, version)
-- 不包含 is_deleted。当前生效记录被删除或缺失时,再次上传就会撞唯一键:
-- Duplicate entry 'com_alibaba_android_rimet-1' for key 'app_icon.uk_package_version'
--
-- 新逻辑:
-- version = 该 package_name 下所有记录(含 is_deleted=1)的 MAX(version) + 1,
-- 版本号只增不减、永不复用;上传 / 锁定已按包名加锁并置于同一事务内。
--
-- 使用说明:
-- 1. 本脚本仅用于「已在运行、不重建表」的库;全新部署执行 app_icon.sql 即可。
-- 2. 由 DBA 在业务低峰期单独执行,执行前请备份 app_icon 表。
-- 3. 诊断语句(第 1 ~ 3 段)先执行确认现状,再执行修复语句(第 4 ~ 5 段)。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. 诊断:同一包名存在多条 status=1(会导致 one() 抛 TooManyResultsException)
-- -----------------------------------------------------------------------------
SELECT package_name, COUNT(*) AS active_cnt
FROM app_icon
WHERE is_deleted = 0
AND status = 1
GROUP BY package_name
HAVING COUNT(*) > 1;
-- -----------------------------------------------------------------------------
-- 2. 诊断:同一包名有未删除记录,但没有任何 status=1(上传会走「无当前版本」分支)
-- -----------------------------------------------------------------------------
SELECT package_name, COUNT(*) AS alive_cnt, MAX(version) AS max_version
FROM app_icon
WHERE is_deleted = 0
GROUP BY package_name
HAVING SUM(status = 1) = 0;
-- -----------------------------------------------------------------------------
-- 3. 诊断:查看具体包名的版本与删除残留
-- -----------------------------------------------------------------------------
SELECT id, package_name, version, status, locked, is_deleted, update_time
FROM app_icon
WHERE package_name = 'com_alibaba_android_rimet'
ORDER BY is_deleted, version;
-- -----------------------------------------------------------------------------
-- 4. 修复多条 status=1:保留 version 最大的一条为当前生效,其余置为历史
-- -----------------------------------------------------------------------------
UPDATE app_icon a
JOIN (SELECT package_name, MAX(version) AS max_version
FROM app_icon
WHERE is_deleted = 0
AND status = 1
GROUP BY package_name) t
ON a.package_name = t.package_name
SET a.status = 0,
a.update_time = NOW()
WHERE a.is_deleted = 0
AND a.status = 1
AND a.version < t.max_version;
-- -----------------------------------------------------------------------------
-- 5. 修复无 status=1:把未删除记录里版本号最大的一条置为当前生效
-- -----------------------------------------------------------------------------
UPDATE app_icon a
JOIN (SELECT package_name, MAX(version) AS max_version
FROM app_icon
WHERE is_deleted = 0
GROUP BY package_name
HAVING SUM(status = 1) = 0) t
ON a.package_name = t.package_name
AND a.version = t.max_version
SET a.status = 1,
a.update_time = NOW()
WHERE a.is_deleted = 0;
-- -----------------------------------------------------------------------------
-- 6. 复核:两条诊断语句均应返回空结果集
-- -----------------------------------------------------------------------------
SELECT package_name, COUNT(*) AS active_cnt
FROM app_icon
WHERE is_deleted = 0
AND status = 1
GROUP BY package_name
HAVING COUNT(*) > 1;
SELECT package_name, COUNT(*) AS alive_cnt
FROM app_icon
WHERE is_deleted = 0
GROUP BY package_name
HAVING SUM(status = 1) = 0;
@@ -0,0 +1,73 @@
package com.youlai.boot.app.packages.component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
/**
* 基于内存字节数组的 {@link MultipartFile} 实现
* <p>
* 用于把解析得到的内存数据(如 APK 内提取的图标)交给
* {@link com.youlai.boot.support.file.service.FileService} 上传,
* 避免为字节流场景单独复制一套存储实现。
*
* @author TTSTD
*/
public class ByteArrayMultipartFile implements MultipartFile {
private final String name;
private final String originalFilename;
private final String contentType;
private final byte[] content;
public ByteArrayMultipartFile(String name, String originalFilename, String contentType, byte[] content) {
this.name = name;
this.originalFilename = originalFilename;
this.contentType = contentType;
this.content = content == null ? new byte[0] : content;
}
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return originalFilename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(File dest) throws IOException {
Files.write(dest.toPath(), content);
}
}
@@ -5,12 +5,15 @@ import cn.hutool.core.util.IdUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.youlai.boot.app.packages.component.ApkMetaParser;
import com.youlai.boot.app.packages.component.ApkMetaResult;
import com.youlai.boot.app.packages.component.ByteArrayMultipartFile;
import com.youlai.boot.app.packages.model.document.AppPackageDocument;
import com.youlai.boot.app.packages.model.query.AppPackagePageQuery;
import com.youlai.boot.app.packages.model.vo.AppPackageVO;
import com.youlai.boot.app.packages.repository.AppPackageRepository;
import com.youlai.boot.app.packages.service.AppPackageService;
import com.youlai.boot.common.config.FilePath;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -22,7 +25,6 @@ import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.List;
@@ -36,6 +38,9 @@ public class AppPackageServiceImpl implements AppPackageService {
private final AppPackageRepository appPackageRepository;
/** 文件存储服务:由 storage.type 决定激活 s3 / aliyun / local 实现 */
private final FileService fileService;
private static final List<String> ALLOWED_EXT = List.of("apk", "apks", "aab", "apkm", "xapk");
@Override
@@ -94,19 +99,7 @@ public class AppPackageServiceImpl implements AppPackageService {
.compileSdk(meta.getCompileSdk())
.abiList(meta.getAbiList())
.permissions(meta.getPermissions());
if (meta.getIconPath() != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) {
String iconMd5 = DigestUtil.md5Hex(meta.getIconBytes());
String iconName = iconMd5 + ".png";
File iconDir = new File(FilePath.getApkIconPath());
if (!iconDir.exists()) {
iconDir.mkdirs();
}
File iconTarget = new File(iconDir, iconName);
if (!iconTarget.exists()) {
Files.write(iconTarget.toPath(), meta.getIconBytes());
}
builder.iconUrl("/static/app_icon/" + iconName);
}
applyIcon(builder, meta);
} catch (Exception e) {
log.warn("APK 元数据解析失败,使用表单补充信息:{}", e.getMessage());
fillFallback(builder, packageName, versionName, versionCode);
@@ -124,19 +117,7 @@ public class AppPackageServiceImpl implements AppPackageService {
.compileSdk(meta.getCompileSdk())
.abiList(meta.getAbiList())
.permissions(meta.getPermissions());
if (meta.getIconPath() != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) {
String iconMd5 = DigestUtil.md5Hex(meta.getIconBytes());
String iconName = iconMd5 + ".png";
File iconDir = new File(FilePath.getApkIconPath());
if (!iconDir.exists()) {
iconDir.mkdirs();
}
File iconTarget = new File(iconDir, iconName);
if (!iconTarget.exists()) {
Files.write(iconTarget.toPath(), meta.getIconBytes());
}
builder.iconUrl("/static/app_icon/" + iconName);
}
applyIcon(builder, meta);
} catch (Exception e) {
log.warn("{} 元数据解析失败,使用表单补充信息:{}", ext, e.getMessage());
fillFallback(builder, packageName, versionName, versionCode);
@@ -150,6 +131,19 @@ public class AppPackageServiceImpl implements AppPackageService {
return toVO(saved);
}
/**
* 将 APK 内解析出的图标写入配置的存储(storage.type:s3 / aliyun / local),并回填访问地址
*/
private void applyIcon(AppPackageDocument.AppPackageDocumentBuilder builder, ApkMetaResult meta) {
if (meta.getIconPath() == null || meta.getIconBytes() == null || meta.getIconBytes().length == 0) {
return;
}
String iconName = DigestUtil.md5Hex(meta.getIconBytes()) + ".png";
FileInfo iconInfo = fileService.uploadFile(
new ByteArrayMultipartFile("file", iconName, "image/png", meta.getIconBytes()));
builder.iconUrl(iconInfo.getUrl());
}
/**
* 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储)
*/
@@ -212,10 +206,13 @@ public class AppPackageServiceImpl implements AppPackageService {
if (doc.getFileName() != null) {
FileUtil.del(FilePath.getPackagePath() + doc.getFileName());
}
// 删除本地图标文件
if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("/static/app_icon/")) {
String iconName = doc.getIconUrl().substring("/static/app_icon/".length());
FileUtil.del(FilePath.getApkIconPath() + iconName);
// 删除云端图标对象(历史 /static 本地图标已下线索引,不再处理)
if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("http")) {
try {
fileService.deleteFile(doc.getIconUrl());
} catch (Exception e) {
log.warn("删除云端图标失败:{}", doc.getIconUrl(), e);
}
}
// 删除本地截图文件(独立 screenshot 目录)
if (doc.getScreenshots() != null) {
@@ -21,7 +21,6 @@ public class FilePath {
public static final String TABLET_PATH = "tablet";
public static final String AVATAR_PATH = "avatar";
public static final String APK_ICON_PATH = "apkIcon";
public static final String SCREENSHOT_PATH = "screenshot";
public static final String WALLPAPER_PATH = "wallpaper";
public static final String PACKAGE_PATH = "packages";
@@ -43,10 +42,6 @@ public class FilePath {
return getRootPath() + File.separator + TABLET_PATH + File.separator + AVATAR_PATH + File.separator;
}
public static String getApkIconPath() {
return getRootPath() + File.separator + TABLET_PATH + File.separator + APK_ICON_PATH + File.separator;
}
public static String getScreenshotPath() {
return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH + File.separator;
}
@@ -17,23 +17,10 @@ import java.util.concurrent.TimeUnit;
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 应用图标静态资源映射:
* /static/app_icon/{md5}.{ext} -> 磁盘 apkIcon 目录
* <p>
* 使用独立的 /static 前缀而非 /api/v1/device/**,避免与业务 Controller
* (DeviceController、DeviceOpsController)的 @RequestMapping 命名空间重叠:
* Controller 映射优先级高于静态资源,一旦后续新增同名路径接口会静默劫持图标请求。
* <p>
* 图标文件名为内容 MD5(内容寻址),内容变更必然导致 URL 变更,
* 因此可安全使用 immutable 长缓存,浏览器无需回源校验。
* 静态资源映射(应用图标已改为云端存储,不再本地映射)
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String location = "file:" + FilePath.getApkIconPath().replace("\\", "/");
registry.addResourceHandler("/static/app_icon/**")
.addResourceLocations(location)
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable());
// 设备截图静态资源映射:/static/screenshot/{fileName} -> 磁盘 tablet/screenshot 目录
// 截图内容会变化(删除/重截),且文件名 md5 可能因画面相同而重复,
// 故不使用长缓存,避免浏览器复用旧图(表现为 304 + 图片不更新)。
@@ -2,7 +2,6 @@ package com.youlai.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;
@@ -34,7 +33,6 @@ public class JacksonConfig {
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(BigInteger.class, ToStringSerializer.instance)
)
// .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.build();
}
@@ -1,34 +1,29 @@
package com.youlai.boot.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* Redis 配置
* <p>
* 使用 Lazy 初始化,避免 Redis 不可用时阻塞应用启动。
*
* @author Ray.Hao
* @since 2023/5/15
*/
@Slf4j
@Configuration
@ConditionalOnClass(RedisConnectionFactory.class)
public class RedisConfig {
/**
* 自定义 RedisTemplate
* <p>
* 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer。
* 延迟初始化:即使 Redis 不可用,应用也能正常启动。
* 相对上游仅增加 {@link Lazy}:Redis 不可用时延迟初始化,不阻塞应用启动;
* 序列化方式与上游保持一致,避免缓存数据读写格式不兼容。
*
* @param redisConnectionFactory {@link RedisConnectionFactory}
* @return {@link RedisTemplate}
@@ -36,7 +31,6 @@ public class RedisConfig {
@Bean
@Lazy
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
log.info("初始化 RedisTemplate...");
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
@@ -45,14 +39,15 @@ public class RedisConfig {
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
GenericJacksonJsonRedisSerializer jsonSerializer = new GenericJacksonJsonRedisSerializer(new ObjectMapper());
JsonMapper jsonMapper = JsonMapper.builder()
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
JacksonJsonRedisSerializer<Object> jsonSerializer = new JacksonJsonRedisSerializer<>(jsonMapper, Object.class);
redisTemplate.setValueSerializer(jsonSerializer);
redisTemplate.setHashValueSerializer(jsonSerializer);
redisTemplate.afterPropertiesSet();
log.info("RedisTemplate 初始化完成");
return redisTemplate;
}
@@ -3,10 +3,25 @@ package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.device.model.entity.AppIcon;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 应用图标库Mapper
*/
@Mapper
public interface AppIconMapper extends BaseMapper<AppIcon> {
/**
* 查询指定包名下所有记录的最大版本号(包含已逻辑删除的行)
* <p>
* 自定义 SQL 不会经过 MyBatis-Plus 的 @TableLogic 自动拼条件,
* 因此 is_deleted=1 的历史行同样参与计算,保证版本号不被复用,
* 避免与唯一索引 uk_package_version(package_name, version) 冲突。
*
* @param packageName 应用包名
* @return 最大版本号,无记录时返回 0
*/
@Select("SELECT COALESCE(MAX(`version`), 0) FROM `app_icon` WHERE `package_name` = #{packageName}")
Integer selectMaxVersion(@Param("packageName") String packageName);
}
@@ -31,7 +31,7 @@ public class AppIcon extends BaseEntity {
private String appName;
/**
* 图标访问地址(相对路径,如 /static/app_icon/{md5}.png)
* 图标访问地址(由 storage.type 决定:云存储为完整 URL,本地存储为相对路径)
*/
private String iconUrl;
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.common.config.FilePath;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.HashUtils;
@@ -13,25 +12,32 @@ import com.youlai.boot.device.model.entity.AppIcon;
import com.youlai.boot.device.model.vo.AppIconVO;
import com.youlai.boot.device.service.AppIconService;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* 应用图标库服务实现类
@@ -59,11 +65,23 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
/** 应用名称最大长度,需与 app_icon.app_name 字段长度一致 */
private static final int APP_NAME_MAX_LENGTH = 128;
/**
* 图标访问地址前缀,需与 {@link com.youlai.boot.common.config.WebMvcConfig} 中
* 注册的静态资源路径保持一致,修改时必须同步
*/
private static final String ICON_URL_PREFIX = "/static/app_icon/";
/** 图标上传分布式锁 key 前缀(按包名维度串行化,避免并发取到相同版本号) */
private static final String LOCK_KEY_PREFIX = "lock:app_icon:upload:";
/** 本地锁分段数:Redis 不可用时兜底为进程内串行 */
private static final int LOCAL_LOCK_STRIPES = 64;
/** 获取锁的最长等待秒数 */
private static final long LOCK_WAIT_SECONDS = 10;
/** 文件存储服务:由 storage.type 决定激活 s3 / aliyun / local 实现 */
private final FileService fileService;
/** Redisson 客户端(Redis 未配置时为空,自动降级为本地锁) */
private final ObjectProvider<RedissonClient> redissonClientProvider;
/** 本地分段锁,key 相同的请求在单机内互斥 */
private final ReentrantLock[] localLocks = createLocalLocks();
@Override
public AppIconVO getCurrentIcon(String packageName) {
@@ -84,10 +102,14 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean lockIcon(String packageName, Long iconId) {
Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法");
Assert.notNull(iconId, "图标记录ID不能为空");
return withPackageLock(packageName, () -> doLockIcon(packageName, iconId));
}
private boolean doLockIcon(String packageName, Long iconId) {
AppIcon target = this.getById(iconId);
Assert.notNull(target, "图标记录不存在");
Assert.isTrue(packageName.equals(target.getPackageName()), "图标记录不属于该应用");
@@ -130,6 +152,7 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
}
@Override
@Transactional(rollbackFor = Exception.class)
public String uploadIcon(String packageName, String appName, MultipartFile file, String source) {
Assert.isTrue(file != null && !file.isEmpty(), "上传文件不能为空");
Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法");
@@ -157,6 +180,15 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
throw new RuntimeException("计算图标文件哈希失败", e);
}
// 同一包名的「读当前版本 → 计算新版本号 → 落库」必须串行,
// 否则并发上传会取到相同版本号,触发唯一键 uk_package_version 冲突
String finalAppName = safeAppName;
return withPackageLock(packageName,
() -> doUploadIcon(packageName, finalAppName, file, source, md5, sha1, sha256));
}
private String doUploadIcon(String packageName, String safeAppName, MultipartFile file,
String source, String md5, String sha1, String sha256) {
AppIcon current = getActiveEntity(packageName);
// 内容未变化:不产生新版本,但应用名可能已变更(如应用改名),需同步刷新;
@@ -186,9 +218,13 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
throw new BusinessException(ResultCode.APP_ICON_LOCKED);
}
// 保存文件(内容寻址 {md5}.{ext},文件已存在则复用)
String iconUrl = saveIconFile(md5, extension.toLowerCase(), file);
// 保存文件:走 storage.type 配置的存储实现(s3 / aliyun / local)
String iconUrl = saveIconFile(file);
// 版本号取该包名下所有记录(含已逻辑删除行)的最大值 +1:
// 唯一索引 uk_package_version 不区分 is_deleted,若仍用「当前生效版本 +1」或固定 1,
// 一旦当前生效记录被删除/缺失,就会与残留版本号撞唯一键(Duplicate entry 'xxx-1')
int nextVersion = getNextVersion(packageName);
Long userId = SecurityUtils.getUserId();
if (current != null) {
// 原当前版本转为历史
@@ -204,7 +240,7 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
next.setIconMd5(md5);
next.setIconSha1(sha1);
next.setIconSha256(sha256);
next.setVersion(current.getVersion() + 1);
next.setVersion(nextVersion);
next.setStatus(STATUS_ACTIVE);
next.setLocked(UNLOCKED);
next.setSource(source);
@@ -219,7 +255,7 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
icon.setIconMd5(md5);
icon.setIconSha1(sha1);
icon.setIconSha256(sha256);
icon.setVersion(1);
icon.setVersion(nextVersion);
icon.setStatus(STATUS_ACTIVE);
icon.setLocked(UNLOCKED);
icon.setSource(source);
@@ -228,12 +264,14 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
this.save(icon);
}
log.info("应用图标上传成功, packageName: {}, md5: {}, iconUrl: {}", packageName, md5, iconUrl);
log.info("应用图标上传成功, packageName: {}, version: {}, md5: {}, iconUrl: {}",
packageName, nextVersion, md5, iconUrl);
return iconUrl;
}
@Override
public Map<String, String> getActiveIconUrls(Collection<String> packageNames) { if (packageNames == null || packageNames.isEmpty()) {
public Map<String, String> getActiveIconUrls(Collection<String> packageNames) {
if (packageNames == null || packageNames.isEmpty()) {
return Map.of();
}
List<AppIcon> activeIcons = this.lambdaQuery()
@@ -249,37 +287,42 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
/**
* 查询当前生效的图标记录
* <p>
* 用 orderBy + LIMIT 1 兜底:历史脏数据可能残留多条 status=1,
* {@code one()} 会抛 TooManyResultsException 导致该包图标上传永久失败。
*/
private AppIcon getActiveEntity(String packageName) {
return this.lambdaQuery()
.eq(AppIcon::getPackageName, packageName)
.eq(AppIcon::getStatus, STATUS_ACTIVE)
.orderByDesc(AppIcon::getVersion)
.last("LIMIT 1")
.one();
}
/**
* 保存图标文件(内容寻址 {md5}.{ext},文件已存在则复用)
* 计算下一个版本号:该包名下所有记录(含已逻辑删除)的 MAX(version) + 1
* <p>
* 走自定义 SQL,绕过 MyBatis-Plus 的 @TableLogic 自动追加条件,
* 保证已逻辑删除的版本号不会被复用,避免唯一键冲突。
*/
private String saveIconFile(String md5, String extension, MultipartFile file) {
String iconDirPath = FilePath.getApkIconPath();
File iconDir = new File(iconDirPath);
if (!iconDir.exists() && !iconDir.mkdirs()) {
throw new RuntimeException("创建图标目录失败: " + iconDirPath);
}
private int getNextVersion(String packageName) {
Integer maxVersion = baseMapper.selectMaxVersion(packageName);
return (maxVersion == null ? 0 : maxVersion) + 1;
}
String fileName = md5 + "." + extension;
File destFile = new File(iconDir, fileName);
if (destFile.exists()) {
return ICON_URL_PREFIX + fileName;
/**
* 保存图标文件:交由 {@link FileService} 处理,具体落盘/上云由 storage.type 决定
* <p>
* 返回的地址由存储实现决定:aliyun/s3 为完整 http(s) URL,
* local 为相对存储根目录的路径,前端拼访问前缀。
*/
private String saveIconFile(MultipartFile file) {
FileInfo fileInfo = fileService.uploadFile(file);
if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) {
throw new BusinessException("保存应用图标失败");
}
try {
file.transferTo(destFile);
} catch (IOException e) {
log.error("保存图标文件失败, fileName: {}", fileName, e);
throw new RuntimeException("保存应用图标失败");
}
return ICON_URL_PREFIX + fileName;
return fileInfo.getUrl();
}
@Override
@@ -319,4 +362,65 @@ public class AppIconServiceImpl extends ServiceImpl<AppIconMapper, AppIcon> impl
BeanUtils.copyProperties(entity, vo);
return vo;
}
}
// ==================== 按包名串行化 ====================
private static ReentrantLock[] createLocalLocks() {
ReentrantLock[] locks = new ReentrantLock[LOCAL_LOCK_STRIPES];
for (int i = 0; i < locks.length; i++) {
locks[i] = new ReentrantLock();
}
return locks;
}
/**
* 以包名为维度串行执行:优先 Redis 分布式锁(多实例),失败时降级为本地分段锁(单机)
*/
private <T> T withPackageLock(String packageName, Supplier<T> action) {
ReentrantLock localLock = localLocks[Math.floorMod(packageName.hashCode(), LOCAL_LOCK_STRIPES)];
localLock.lock();
RLock distributedLock = tryLockDistributed(packageName);
try {
return action.get();
} finally {
unlockQuietly(distributedLock);
localLock.unlock();
}
}
/**
* 尝试获取分布式锁,Redis 不可用或等待超时返回 null(不阻断业务)
*/
private RLock tryLockDistributed(String packageName) {
try {
RedissonClient client = redissonClientProvider.getIfAvailable();
if (client == null) {
return null;
}
RLock lock = client.getLock(LOCK_KEY_PREFIX + packageName);
if (lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) {
return lock;
}
log.warn("等待图标上传分布式锁超时,转由本地锁串行, packageName: {}", packageName);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("获取图标上传分布式锁被中断,转由本地锁串行, packageName: {}", packageName);
} catch (Exception e) {
log.warn("获取图标上传分布式锁异常,转由本地锁串行, packageName: {}", packageName, e);
}
return null;
}
private void unlockQuietly(RLock lock) {
if (lock == null) {
return;
}
try {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (Exception e) {
log.warn("释放图标上传分布式锁异常, key: {}", lock.getName(), e);
}
}
}
@@ -10,7 +10,6 @@ import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.util.HashUtils;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import jakarta.annotation.PostConstruct;
@@ -63,17 +62,6 @@ public class AliyunFileServiceImpl implements FileService {
String suffix = FileUtil.getSuffix(originalFilename);
String uuid = IdUtil.simpleUUID();
String fileName = DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uuid + "." + suffix;
// 计算文件哈希(md5/sha1/sha256)
String md5;
String sha1;
String sha256;
try {
md5 = HashUtils.calculateMultipartFileMd5(file);
sha1 = HashUtils.calculateMultipartFileSha1(file);
sha256 = HashUtils.calculateMultipartFileSha256(file);
} catch (Exception e) {
throw new RuntimeException("文件上传失败");
}
try (InputStream inputStream = file.getInputStream()) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
@@ -87,9 +75,6 @@ public class AliyunFileServiceImpl implements FileService {
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
fileInfo.setMd5(md5);
fileInfo.setSha1(sha1);
fileInfo.setSha256(sha256);
return fileInfo;
}
+9 -6
View File
@@ -169,7 +169,7 @@ security:
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
- /api/v1/open/** # C端客户端(open)接口(注册/登录/短信/刷新令牌等,落地 app_user)
- /static/** # 静态资源(应用图标等,img 标签无法携带 token;文件名为内容MD5,不可枚举)
- /static/** # 静态资源(截图/安装包/分类图标/壁纸等,img 标签无法携带 token)
- /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流)
- /api/v1/health # 健康检查接口(供探针与限流测试)
- ${springdoc.swagger-ui.path} # Swagger UI 路径
@@ -183,7 +183,7 @@ security:
# 文件存储
storage:
# 存储类型:s3 | aliyun | local
type: s3
type: aliyun
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
@@ -204,10 +204,13 @@ storage:
domain:
# 阿里云 OSS
aliyun:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
bucket: default
endpoint: oss-cn-shenzhen.aliyuncs.com
access-key-id: LTAI5t8meTkKjPrPCW9cLtjo
access-key-secret: 6XRt2RAUO7Eiv0zrKqR8ov31B3GzH1
bucket: ttstd
# 上传对象的 ACL:留空跟随桶权限(推荐,公开读由桶/CDN 控制)
# 配 public-read 需先关闭桶的「阻止公共访问」,否则上传报 Put public object acl is not allowed
# object-acl: public-read
# 本地存储
local:
# 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
+9 -6
View File
@@ -164,7 +164,7 @@ security:
- /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌)
- /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
- /api/v1/open/** # C端客户端(open)接口(注册/登录/短信/刷新令牌等,落地 app_user)
- /static/** # 静态资源(应用图标等,img 标签无法携带 token;文件名为内容MD5,不可枚举)
- /static/** # 静态资源(截图/安装包/分类图标/壁纸等,img 标签无法携带 token)
- /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流)
- /api/v1/health # 健康检查接口(供探针与限流测试)
- ${springdoc.swagger-ui.path} # Swagger UI 路径
@@ -178,7 +178,7 @@ security:
# 文件存储
storage:
# 存储类型:s3 | aliyun | local
type: s3
type: aliyun
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
@@ -199,10 +199,13 @@ storage:
domain:
# 阿里云 OSS
aliyun:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
bucket: default
endpoint: oss-cn-shenzhen.aliyuncs.com
access-key-id: LTAI5t8meTkKjPrPCW9cLtjo
access-key-secret: 6XRt2RAUO7Eiv0zrKqR8ov31B3GzH1
bucket: ttstd
# 上传对象的 ACL:留空跟随桶权限(推荐,公开读由桶/CDN 控制)
# 配 public-read 需先关闭桶的「阻止公共访问」,否则上传报 Put public object acl is not allowed
# object-acl: public-read
# 本地存储
local:
# 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)