diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
index b5b552d..bdb497a 100644
--- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
+++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
@@ -5,6 +5,7 @@ import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
+import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
@@ -146,4 +147,15 @@ public class AppController {
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
}
+
+ /**
+ * 全量应用矢量图标映射(AppIcon 资产库通道):[{id, iconKey, iconColor}],仅含已配置行。
+ * 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段;
+ * UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。
+ * 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。
+ */
+ @GetMapping("icon-map")
+ public List iconMap() {
+ return applicationInfoService.listIconMap();
+ }
}
diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java
index fd5ee3b..dbad556 100644
--- a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java
+++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java
@@ -104,6 +104,19 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "app_icon")
private String appIcon;
+ /**
+ * 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。
+ * 不复用 pc_icon/app_icon:那两列存完整 URL,被 oapi BoaApp 出参与 appIconPath() 按 URL 语义消费(方案 §5.2)。
+ */
+ @Column(name = "icon_key", length = 64)
+ private String iconKey;
+
+ /**
+ * 矢量图标着色(CSS 色值,如 #366EF4)。空 = 端侧默认(场景 token / 分组色)。人工配置值非派生值,落库安全(方案 §5.3)。
+ */
+ @Column(name = "icon_color", length = 16)
+ private String iconColor;
+
/**
* 公众号应用地址
*/
diff --git a/src/main/java/com/yunhai/userbase/model/application/AppIconMapVo.java b/src/main/java/com/yunhai/userbase/model/application/AppIconMapVo.java
new file mode 100644
index 0000000..dc7fe10
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/application/AppIconMapVo.java
@@ -0,0 +1,29 @@
+package com.yunhai.userbase.model.application;
+
+import lombok.Data;
+
+/**
+ * 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参)。
+ * 消费方:UserHome(PC GetUserApp 合并注入)与 MobilePortal(移动端桌面 AppItemVo 合并)。
+ * 只透出 id/iconKey/iconColor 三字段,不含名称/地址等其余应用信息。
+ *
+ * @author chenx
+ */
+@Data
+public class AppIconMapVo {
+
+ /**
+ * 应用主键 ID(与 BoaApp.id 同源,消费方按此合并)。
+ */
+ private Integer id;
+
+ /**
+ * 矢量图标键(AppIcon 资产库图标名);null 表示该行不携带,消费方跳过。
+ */
+ private String iconKey;
+
+ /**
+ * 矢量图标着色(CSS 色值);null 表示端侧默认。
+ */
+ private String iconColor;
+}
diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java
index 3e0167b..8991976 100644
--- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java
+++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoRequest.java
@@ -81,6 +81,16 @@ public class ApplicationInfoRequest {
*/
private String appIcon;
+ /**
+ * 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。
+ */
+ private String iconKey;
+
+ /**
+ * 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。
+ */
+ private String iconColor;
+
/**
* 公众号应用地址。
*/
diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java
index 719c3cd..23492e6 100644
--- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java
+++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java
@@ -82,6 +82,16 @@ public class ApplicationInfoVo {
*/
private String appIcon;
+ /**
+ * 矢量图标键(AppIcon 资产库图标名);空 = 未配置。
+ */
+ private String iconKey;
+
+ /**
+ * 矢量图标着色(CSS 色值);空 = 端侧默认。
+ */
+ private String iconColor;
+
/**
* 公众号应用地址。
*/
diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java
index 242896d..633e3b3 100644
--- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java
+++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java
@@ -1,6 +1,7 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.entity.ApplicationInfo;
+import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
@@ -133,4 +134,11 @@ public interface IApplicationInfoService {
* @return 推送设置 VO(仅含推送相关字段)
*/
PushSettingVo getPushSetting(Integer id);
+
+ /**
+ * 全量应用的矢量图标映射(id → iconKey/iconColor),{@code /oapi/app/icon-map} 数据面。
+ * 仅返回 iconKey 非空的行;供 UserHome / MobilePortal 在既有 BoaApp 列表之上合并注入
+ * (BoaApp 属 BasePlatform SDK 本期不动,矢量字段走本独立通道补拉)。
+ */
+ List listIconMap();
}
diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java
index c5fab57..f3d1857 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java
@@ -18,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
+import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
@@ -202,15 +203,24 @@ public class ApplicationInfoService implements IApplicationInfoService {
&& applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) {
throw new DException("技术标识已存在");
}
- // appCode/appNature 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties
- // 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新
- BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret", "appCode", "appNature");
+ // appCode/appNature/iconKey/iconColor 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties
+ // 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新。
+ // iconKey/iconColor 采用「未携带(null)=不改,携带空串=清除,携带值=更新」三分语义:
+ // 未启用矢量选择器的提交方不送该键即零影响;管理端全量表单显式清空即解除矢量渲染回到原回退链
+ BeanUtils.copyProperties(request, entity,
+ "id", "appKey", "appSecret", "appCode", "appNature", "iconKey", "iconColor");
if (StringUtils.isNotBlank(request.getAppCode())) {
entity.setAppCode(request.getAppCode());
}
if (StringUtils.isNotBlank(request.getAppNature())) {
entity.setAppNature(request.getAppNature());
}
+ if (request.getIconKey() != null) {
+ entity.setIconKey(StringUtils.trimToNull(request.getIconKey()));
+ }
+ if (request.getIconColor() != null) {
+ entity.setIconColor(StringUtils.trimToNull(request.getIconColor()));
+ }
ApplicationInfo saved = applicationInfoRepository.save(entity);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
}
@@ -407,4 +417,20 @@ public class ApplicationInfoService implements IApplicationInfoService {
vo.setPushSecret(entity.getPushSecret());
return vo;
}
+
+ @Override
+ public List listIconMap() {
+ List result = new ArrayList<>();
+ for (ApplicationInfo app : applicationInfoRepository.findAll()) {
+ if (StringUtils.isBlank(app.getIconKey())) {
+ continue; // 未配置矢量键的行不下发,消费方零合并成本
+ }
+ AppIconMapVo vo = new AppIconMapVo();
+ vo.setId(app.getId());
+ vo.setIconKey(app.getIconKey());
+ vo.setIconColor(app.getIconColor());
+ result.add(vo);
+ }
+ return result;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/com/yunhai/userbase/utils/FileUtil.java b/src/main/java/com/yunhai/userbase/utils/FileUtil.java
index 6a8cf09..4332591 100644
--- a/src/main/java/com/yunhai/userbase/utils/FileUtil.java
+++ b/src/main/java/com/yunhai/userbase/utils/FileUtil.java
@@ -379,6 +379,8 @@ public class FileUtil {
/**
* MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。
+ * svg 后缀走白名单消毒流程(AppIcon 方案 P4):≤64KB + viewBox ≤1024 + 元素/属性白名单消毒,
+ * 落盘的是消毒后内容——不把 svg 加进危险后缀黑名单(正常图标功能保留)。
*
* @param file 上传文件
* @param subDir 子目录
@@ -394,14 +396,30 @@ public class FileUtil {
ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase();
// 后缀黑名单校验
validateExt(ext);
+ // svg 消毒(含 64KB/viewBox 校验):落盘消毒后字节,而非原始上传内容
+ byte[] sanitized = null;
+ if (".svg".equals(ext)) {
+ try {
+ String raw = new String(file.getBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ sanitized = SvgSanitizer.sanitize(raw).getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ } catch (DException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new DException("SVG 文件处理失败");
+ }
+ }
ensureStoreBase();
try {
String dirPath = BASE_STORE_PATH + File.separator + subDir;
cn.hutool.core.io.FileUtil.mkdir(dirPath);
String fileName = IdUtil.fastSimpleUUID() + ext;
File targetFile = new File(dirPath, fileName);
- // 流式写入,不加载全文件到内存
- cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
+ if (sanitized != null) {
+ cn.hutool.core.io.FileUtil.writeBytes(sanitized, targetFile);
+ } else {
+ // 流式写入,不加载全文件到内存
+ cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
+ }
return buildUrl(subDir, fileName);
} catch (DException e) {
throw e;
diff --git a/src/main/java/com/yunhai/userbase/utils/SvgSanitizer.java b/src/main/java/com/yunhai/userbase/utils/SvgSanitizer.java
new file mode 100644
index 0000000..40e0d19
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/utils/SvgSanitizer.java
@@ -0,0 +1,243 @@
+package com.yunhai.userbase.utils;
+
+import com.yunhai.platform.utils.exception.DException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.io.ByteArrayInputStream;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * 上传 SVG 白名单式消毒器(AppIcon 方案 P4 安全收口)。
+ * 背景:上传框早已 accept=".svg" 且 /upload/icon/** 同源可访问,存储型 XSS(如
+ * {@code