feat(device): 应用图标改存云存储并修复版本号唯一键冲突
This commit is contained in:
1 parent
72770b226a
commit
cbf4f37b94
15 files changed
+526
-133
No files matched your search
@@ -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
@@ -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管理端上传)',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
-31
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
# 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
|
||||
|
||||
@@ -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:
|
||||
# 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)
|
||||
|
||||
Reference in new issue
Block a user