Compare commits
58
Commits
4ed43dbc05
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f7a2eeea | ||
|
|
d7ea119996 | ||
|
|
d2cf718b51 | ||
|
|
920408dd0f | ||
|
|
1f31612609 | ||
|
|
ad28149731 | ||
|
|
71c571f884 | ||
|
|
0099e5df2b | ||
|
|
927d4b14f8 | ||
|
|
9370429455 | ||
|
|
175dacc9ad | ||
|
|
29d32eed6e | ||
|
|
47f9e3f751 | ||
|
|
977b5c90a4 | ||
|
|
570b593293 | ||
|
|
1f352214f7 | ||
|
|
f9282574e2 | ||
|
|
8b3f8afd77 | ||
|
|
0ac521f286 | ||
|
|
54d1b848c3 | ||
|
|
c6acd5d71a | ||
|
|
0e57022555 | ||
|
|
18e8e978a6 | ||
|
|
b621c959cf | ||
|
|
564403b5ca | ||
|
|
a3aa6d917e | ||
|
|
f2166c007b | ||
|
|
d71901db35 | ||
|
|
29f6c30309 | ||
|
|
9f10d8d4eb | ||
|
|
ae4bc5ecf6 | ||
|
|
847f13445d | ||
|
|
15be1ffabb | ||
|
|
7924b2d654 | ||
|
|
8492fba06b | ||
|
|
5f5ac3db71 | ||
|
|
ed8bbcdf45 | ||
|
|
563edb5232 | ||
|
|
6aa55bbc44 | ||
|
|
59e8197d22 | ||
|
|
ac00581417 | ||
|
|
65a17fb881 | ||
|
|
759b120f2a | ||
|
|
567c8b7786 | ||
|
|
e7ca6dd846 | ||
|
|
a3a2cd0cf0 | ||
|
|
ea3d5ed121 | ||
|
|
d9ec243926 | ||
|
|
4e6dd3e52a | ||
|
|
79bce1b4d2 | ||
|
|
5ab9e90a51 | ||
|
|
35e1dd2da5 | ||
|
|
ec93c5648d | ||
|
|
f20e204800 | ||
|
|
a6f99d02bd | ||
|
|
8b15dc6fdc | ||
|
|
5a7adb9ce3 | ||
|
|
a7539a4cf0 |
+38
-14
@@ -1,7 +1,7 @@
|
||||
name: build
|
||||
|
||||
# Spring Boot 应用 CI(仿 UserBase ci.yml build 阶段 + projman webhook 建档,不含部署)
|
||||
# 触发:push 到 main / 手动 Run workflow
|
||||
# 触发:push 到 main / 手动 Run workflow(可勾选缓存重建)
|
||||
# 链路:clone 源码 -> maven 容器构建 jar(复用宿主 m2 缓存)-> docker build -f docker/Dockerfile -> push Nexus -> 通知 projman 建档(部署由运维在 DeployTab 手动触发)
|
||||
# 依赖仓库 Secrets:NEXUS_USER / NEXUS_PASSWORD(docker-hosted 推送)/ DEPLOY_TOKEN(webhook 鉴权)
|
||||
# 依赖 docker/Dockerfile(由 projman 注入,开发者也可自备——已写则尊重不覆盖)
|
||||
@@ -11,47 +11,71 @@ on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cache_bust:
|
||||
description: '构建前清空 maven m2 缓存'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:24
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl(alpine)
|
||||
run: apk add --no-cache git curl
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
# GITHUB_TOKEN 由 Gitea Actions 自动下发,用于 clone 私有仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea:3000/${{ github.repository }}.git" /tmp/src
|
||||
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
|
||||
cp -a /tmp/src/. .
|
||||
rm -rf /tmp/src
|
||||
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
|
||||
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
|
||||
|
||||
# 可选:清空 m2 缓存(仅手动勾选 cache_bust 时)
|
||||
- name: 可选清空 m2 缓存
|
||||
if: ${{ inputs.cache_bust == 'true' }}
|
||||
run: |
|
||||
docker run --rm -v /opt/docker/projman-m2:/root/.m2 alpine sh -c 'rm -rf /root/.m2/*'
|
||||
echo "m2 cache busted"
|
||||
|
||||
# maven 构建:起独立 maven 容器(入 infra-infra-net 用容器名 nexus 直连 maven-public)
|
||||
# 通过 docker.sock 调宿主 dockerd,故 -v 路径按宿主解析;用 docker cp 进出源码/产物
|
||||
# m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖(-U 强制检查远端更新)
|
||||
# m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖
|
||||
# settings.xml 用 profile 方式追加 nexus maven-public 仓库(匿名可拉,不带凭据)
|
||||
# maven 实际产物名 = artifactId-version.jar(随 pom 变,-DfinalName 命令行 property 不可靠覆盖不了),
|
||||
# 故 mvn package 后在容器内 cp 成固定名 app.jar,使 docker cp 与 docker/Dockerfile 的 COPY target/app.jar 绑定
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" \
|
||||
--network infra-infra-net \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/build/
|
||||
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
docker exec mavenbuild sh -c 'cd /build && mvn -U -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
@@ -68,4 +92,4 @@ jobs:
|
||||
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
|
||||
-d "{\"app\":\"userhome\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\"}"
|
||||
-d "{\"app\":\"userhome\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}"
|
||||
|
||||
@@ -13,6 +13,11 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
COPY target/app.jar /app/app.jar
|
||||
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
|
||||
# 正常启动由 bootstrap.yml 从 nacos 拉取线上配置(dataId=userhome.yaml)后覆盖。
|
||||
# 配置由平台供给:SPRING_PROFILES_ACTIVE env + /app/config/ 挂载(应用档案 + 平台公共配置层)
|
||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
||||
RUN mkdir -p /data/upload
|
||||
|
||||
EXPOSE 10000
|
||||
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar"]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# UserHome 生产配置(线上以 nacos 的 userhome.yaml 为准,本文件是 nacos 不可达时的兜底)
|
||||
server:
|
||||
port: 10000
|
||||
servlet:
|
||||
# 对外经 caddy 暴露为 http://home.yunhaixingtong.com/userhome
|
||||
context-path: /userhome
|
||||
|
||||
spring:
|
||||
jackson:
|
||||
time-zone: GMT+8
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 200MB
|
||||
|
||||
jpa:
|
||||
database: MySQL
|
||||
show-sql: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
properties:
|
||||
hibernate:
|
||||
jdbc:
|
||||
time_zone: Asia/Shanghai
|
||||
open-in-view: false
|
||||
|
||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||
yh:
|
||||
auth:
|
||||
enabled: true
|
||||
sso:
|
||||
server-url-prefix: http://sso.yunhaixingtong.com/cas/
|
||||
client-host-url: http://home.yunhaixingtong.com/userhome
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
url-pattern: /api/*
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
app-key: 100001
|
||||
app-secret: ec4e860206374c80938719fff8e14a86
|
||||
open:
|
||||
# 四域服务目录零配置;身份密钥唯一来源 = yh.app-auth
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
base-path: /userbase
|
||||
|
||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
||||
FILE_PATH: /data/upload
|
||||
FILE_WEB_PATH: http://home.yunhaixingtong.com/userhome/upload/
|
||||
UPLOAD_WEB_PATH_HTTP: http://home.yunhaixingtong.com/userhome/upload/file/
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.yunhai.userhome: info
|
||||
file:
|
||||
path: /app/logs
|
||||
@@ -55,6 +55,20 @@
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点引入,但同时也是生产隐患修复:
|
||||
Dockerfile 一直在探 /actuator/health 而 pom 从未引 actuator(方案 §3.3、事实 #7),
|
||||
探活 404 隐患原本就该修。压测结束后建议保留本段 actuator(健康探活),
|
||||
仅按《埋点移除清单.md》评估是否移除 prometheus registry。 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<!-- [LOADTEST-METRICS-END] -->
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
@@ -78,22 +92,12 @@
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 基础平台架包:CAS 认证 + 应用间签名 + 统一响应(经 BOM 传递 yh-openapi-client 等) -->
|
||||
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端 +
|
||||
actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
|
||||
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-auth-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-openapi-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-api-std</artifactId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.yunhai.userhome.config;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Jackson 全局配置:统一 java.time.LocalDateTime 的反序列化格式。
|
||||
*
|
||||
* <p>背景:UserBase 跨仓接口(如 /oapi/app/list)返回的时间是 "yyyy-MM-dd HH:mm:ss"(空格分隔),
|
||||
* 而 Jackson 默认的 LocalDateTimeDeserializer 只接受 ISO 格式("yyyy-MM-dd'T'HH:mm:ss"),
|
||||
* 导致 OpenAPI 客户端反序列化 BoaApp.createTime 时抛 DateTimeParseException,
|
||||
* 消息中心(getMessageByUserId)等接口因此报"内部服务错误"。
|
||||
*
|
||||
* <p>yh-openapi 的装配(YschCallAutoConfiguration.yschOpenCall)经 ObjectProvider 取容器内
|
||||
* ObjectMapper,本工程定义该 bean 后即被 OpenAPI 客户端(core.YschOpenCall,2026-09-16 SDK
|
||||
* 重构后的唯一出站公共入口)采用;同时它也是 Spring MVC 使用的全局 mapper,因此同样保留
|
||||
* spring.jackson.* 配置(GMT+8 时区、java.util.Date 的 yyyy-MM-dd HH:mm:ss 格式)。
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
/** 兼容的 LocalDateTime 字符串格式,按优先级依次尝试:先 UserBase 的空格格式,再 ISO 格式。 */
|
||||
private static final List<DateTimeFormatter> LOCAL_DATE_TIME_FORMATTERS = Arrays.asList(
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
|
||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addDeserializer(LocalDateTime.class, new FlexibleLocalDateTimeDeserializer());
|
||||
return builder.modulesToInstall(module).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 "yyyy-MM-dd HH:mm:ss" 与 ISO 格式的 LocalDateTime 反序列化器;
|
||||
* 字符串以外的输入(如数字时间戳)沿用 Jackson 默认处理。
|
||||
*/
|
||||
private static class FlexibleLocalDateTimeDeserializer extends LocalDateTimeDeserializer {
|
||||
|
||||
private FlexibleLocalDateTimeDeserializer() {
|
||||
super(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
if (p.hasToken(JsonToken.VALUE_STRING)) {
|
||||
String text = p.getText();
|
||||
for (DateTimeFormatter formatter : LOCAL_DATE_TIME_FORMATTERS) {
|
||||
try {
|
||||
return LocalDateTime.parse(text, formatter);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// 继续尝试下一种格式
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.deserialize(p, ctxt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yunhai.userhome.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
|
||||
* UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
|
||||
* jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按应用注册表(UserBase by-code/by-key
|
||||
* 取 pc_url)拼绝对 jumpUrl;手工基址表 jump-bases 已退役(C2)。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "userhome.msgcenter")
|
||||
public class MsgCenterProperties {
|
||||
|
||||
/** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */
|
||||
private String usermsgBaseUrl = "";
|
||||
|
||||
/**
|
||||
* flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**);空 = 走服务目录
|
||||
* flow-center(PlatformService 枚举兜底,key 与部署名对齐)。不可达时直批降级提示,不阻塞消息卡片。
|
||||
*/
|
||||
private String flowCenterBaseUrl = "";
|
||||
|
||||
/**
|
||||
* 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址,
|
||||
* 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。
|
||||
* 生产经网关暴露,如 wss://msg.yunhaixingtong.com/usermsg/ws/msg。
|
||||
*/
|
||||
private String usermsgWsUrl = "";
|
||||
|
||||
/** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
|
||||
private Map<String, String> appNames = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userhome.config;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 强制开放接口必须带应用签名(对齐 UserMsg/UserBase 同名配置)。<br>
|
||||
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
|
||||
* 但 UserHome 的 /oapi/**(外观偏好开放接口)绝不允许匿名访问。本过滤器在 AppAuthFilter 之后运行,
|
||||
* 校验其写入的请求属性:已验签(属性存在)则放行,否则 401。<br>
|
||||
* 顺序须晚于 AppAuthFilter(HIGHEST_PRECEDENCE+10),故置为 +11。
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
|
||||
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new RequireAppSignFilter());
|
||||
bean.addUrlPatterns("/oapi/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
|
||||
return bean;
|
||||
}
|
||||
|
||||
public static class RequireAppSignFilter implements Filter {
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userhome.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Web MVC 配置:将上传文件目录映射为静态资源,使 /upload/** URL 可直接访问磁盘文件。
|
||||
*
|
||||
* <p>对齐 UserBase 的 MyWebAppConfigurer:按子目录分别映射,
|
||||
* UserHome 当前使用 icon(图标上传)和 wallpaper(壁纸上传)两个子目录。
|
||||
*
|
||||
* <p>背景:UserSettingsController 的 iconUpload / wallpaperUpload 将文件保存到
|
||||
* ${FILE_PATH}/${子目录}/,返回 URL 如 /upload/icon/xxx.svg,
|
||||
* 但 Spring Boot 默认不映射工作目录下的文件夹为静态资源,导致 404。
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${FILE_PATH:./upload/}")
|
||||
private String FILE_PATH;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// 转为绝对路径,避免相对路径在不同工作目录下解析不一致
|
||||
String basePath = new File(FILE_PATH).getAbsolutePath().replace("\\", "/");
|
||||
if (!basePath.endsWith("/")) {
|
||||
basePath += "/";
|
||||
}
|
||||
|
||||
// icon 子目录:/upload/icon/** → ${FILE_PATH}/icon/
|
||||
registry.addResourceHandler("/upload/icon/**")
|
||||
.addResourceLocations("file:" + basePath + "icon/");
|
||||
|
||||
// wallpaper 子目录:/upload/wallpaper/** → ${FILE_PATH}/wallpaper/
|
||||
registry.addResourceHandler("/upload/wallpaper/**")
|
||||
.addResourceLocations("file:" + basePath + "wallpaper/");
|
||||
|
||||
// logo 子目录:/upload/logo/** → ${FILE_PATH}/logo/
|
||||
registry.addResourceHandler("/upload/logo/**")
|
||||
.addResourceLocations("file:" + basePath + "logo/");
|
||||
|
||||
// tempUpload 子目录(应用模板):/upload/tempUpload/** → ${FILE_PATH}/tempUpload/
|
||||
registry.addResourceHandler("/upload/tempUpload/**")
|
||||
.addResourceLocations("file:" + basePath + "tempUpload/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.yunhai.userhome.config;
|
||||
|
||||
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||
import com.yunhai.userhome.repository.WidgetCatalogRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 小组件内置目录行启动 seeding(幂等)。
|
||||
*
|
||||
* <p>背景:种子 INSERT 原先只在 {@code resources/sql/widget_catalog.sql} 里,代码无 seeding,
|
||||
* 新环境首次启动时候选池恒为空。此处补一条启动期兜底:库为空才写入 3 条内置目录行。</p>
|
||||
*
|
||||
* <p>三条种子与 {@code resources/sql/widget_catalog.sql} 用同一份常量(code / name / comp_type / scope_type / enabled),
|
||||
* 仅补「目录行」,不补内容(内置组件内容在前端工程);{@code WidgetDataService} 白名单不动。</p>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026-10-11
|
||||
*/
|
||||
@Component
|
||||
public class WidgetCatalogSeeder implements CommandLineRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogSeeder.class);
|
||||
|
||||
/**
|
||||
* {code, name, sort, linkAppCode, linkPath}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应。
|
||||
* linkAppCode/linkPath 为外跳入口默认值(**只存子应用 code + 路由,不存绝对 URL**,域名由应用注册表解析);
|
||||
* 留空 = 该组件不出外跳入口,管理员可在管理台改。
|
||||
*/
|
||||
private static final String[][] SEEDS = {
|
||||
{"evaluate", "听评课", "1", null, null},
|
||||
{"syllabus", "课程表", "2", null, null},
|
||||
{"weekplan", "一周安排", "3", "work-week-plan", "/weekplan"},
|
||||
};
|
||||
|
||||
private static final int COMP_TYPE_BUILTIN = 1;
|
||||
|
||||
private static final int SCOPE_ALL = 0;
|
||||
|
||||
private static final int ENABLED = 1;
|
||||
|
||||
@Autowired
|
||||
private WidgetCatalogRepository widgetCatalogRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
try {
|
||||
// 幂等:库非空即整体跳过,避免管理员下线过的内置组件在重启后被重新种回
|
||||
if (widgetCatalogRepository.count() > 0) {
|
||||
log.info("[widget] widget_catalog 非空,跳过内置目录 seeding");
|
||||
return;
|
||||
}
|
||||
for (String[] seed : SEEDS) {
|
||||
String code = seed[0];
|
||||
if (widgetCatalogRepository.findByCode(code).isPresent()) {
|
||||
continue;
|
||||
}
|
||||
WidgetCatalog catalog = new WidgetCatalog();
|
||||
catalog.setCode(code);
|
||||
catalog.setName(seed[1]);
|
||||
catalog.setCompType(COMP_TYPE_BUILTIN);
|
||||
catalog.setScopeType(SCOPE_ALL);
|
||||
catalog.setSort(Integer.valueOf(seed[2]));
|
||||
catalog.setLinkAppCode(seed[3]);
|
||||
catalog.setLinkPath(seed[4]);
|
||||
catalog.setEnabled(ENABLED);
|
||||
catalog.setUpdateTime(new Date());
|
||||
widgetCatalogRepository.save(catalog);
|
||||
log.info("[widget] seeding 内置目录行:{}", code);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// seeding 失败不阻断应用启动(候选池另有管理端入口补录)
|
||||
log.error("[widget] 内置目录 seeding 失败,跳过", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.service.IAppearanceService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户外观偏好接口(CAS 登录态,路径 {@code /{platform}/},外观偏好服务端化 2026-10-08)。
|
||||
* <p>服务端真源 = user_appearance(per-user 一行)+ sch_settings + logo 表;
|
||||
* 消费方:UserHome/front 设置页双写(saveMyTheme/saveMyAvatarStyle)与各子应用
|
||||
* yh-theme-init.js 异步补齐段(GET appearance,挂载后 fetch、失败静默回落默认观感)。</p>
|
||||
* <p>三端点全部登录态、走本应用 /api/** 既有 CAS 链路,不进 oapi/enforce。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class AppearanceController {
|
||||
|
||||
@Autowired
|
||||
private IAppearanceService appearanceService;
|
||||
|
||||
/**
|
||||
* 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}。
|
||||
* myTheme/schTheme 为紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01};
|
||||
* avatarMy/avatarSch 为 {@code text|silhouette};logoUrl 为绝对 URL;
|
||||
* 查不到/脏值/依赖故障的字段一律 null(init 侧按"null=清键回落默认"收敛)。
|
||||
*/
|
||||
@GetMapping("appearance")
|
||||
public Map<String, Object> appearance(BoaUser authUserWithDept) {
|
||||
return appearanceService.getAppearance(authUserWithDept);
|
||||
}
|
||||
|
||||
/** 保存用户自选主题档:body {@code {"themeState":"ocean|1|0|1"}};null=跟随学校默认;非法值抛业务异常。 */
|
||||
@PostMapping("saveMyTheme")
|
||||
public void saveMyTheme(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
|
||||
appearanceService.saveMyTheme(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("themeState")));
|
||||
}
|
||||
|
||||
/** 保存用户自选头像样式:body {@code {"avatarStyle":"text"|"silhouette"}};null=跟随学校默认。 */
|
||||
@PostMapping("saveMyAvatarStyle")
|
||||
public void saveMyAvatarStyle(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
|
||||
appearanceService.saveMyAvatarStyle(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("avatarStyle")));
|
||||
}
|
||||
|
||||
private static String stringOrNull(Object v) {
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.MenuBar;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.service.IMenuBarService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 菜单栏控制器(对齐旧 UserHome contorller.api.MenuBarController) 。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH)
|
||||
public class MenuBarController {
|
||||
|
||||
@Autowired
|
||||
private IMenuBarService menuBarService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@GetMapping("getMenuBarSchList")
|
||||
public List<MenuBar> getMenuBarSchList(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
return menuBarService.getMenuBarList(userCurrentRole.getSchId());
|
||||
}
|
||||
|
||||
@GetMapping("getMenuBarList")
|
||||
public List<Map> getMenuBarList(BoaUser authUserWithDept) {
|
||||
List<Map> result = new ArrayList<>();
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return result;
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
Integer roleId = userCurrentRole.getRoleId();
|
||||
if (roleId == 7 || roleId == 2) roleId = 2;
|
||||
Boolean isSuperAdmin = authUserWithDept.getType() == 1;
|
||||
List<MenuBar> list = menuBarService.findMenuBarById(authUserWithDept.getId());
|
||||
for (MenuBar model : list) {
|
||||
Map map = new HashMap();
|
||||
if (!isSuperAdmin && !model.getRoleIds().contains(roleId)) continue;
|
||||
map.put("id", model.getId());
|
||||
map.put("name", model.getName());
|
||||
map.put("url", model.getUrl());
|
||||
map.put("icon", model.getIcon());
|
||||
map.put("isNative", model.getIsNative());
|
||||
map.put("isHeader", model.getIsHeader());
|
||||
map.put("isBottom", model.getIsBottom());
|
||||
map.put("orderNum", model.getOrderNum());
|
||||
map.put("openType", model.getLinkOpenType());
|
||||
result.add(map);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveMenuBar", produces = "application/json")
|
||||
public void saveMenuBar(BoaUser authUserWithDept, @RequestBody MenuBar menuBar) {
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(com.yunhai.platform.utils.exception.ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
menuBar.setCId(authUserWithDept.getId());
|
||||
menuBar.setSchId(userCurrentRole.getSchId());
|
||||
if (menuBar.getId() == null) menuBar.setCDate(new Date());
|
||||
menuBarService.saveMenuBar(menuBar);
|
||||
}
|
||||
|
||||
@PostMapping(value = "toggleMenuBarShow", produces = "application/json")
|
||||
public void toggleMenuBarShow(BoaUser authUserWithDept, Integer id) {
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(com.yunhai.platform.utils.exception.ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
menuBarService.toggleMenuBarShow(id, userCurrentRole.getSchId());
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveMenuBarOrderNum", produces = "application/json")
|
||||
public void saveMenuBarOrderNum(BoaUser authUserWithDept, @RequestBody Map<String, Integer> params) {
|
||||
Integer id = params.get("id"),
|
||||
orderNum = params.get("orderNum");
|
||||
menuBarService.saveMenuBarOrderNum(id, orderNum);
|
||||
}
|
||||
|
||||
@PostMapping(value = "deleteMenuBar", produces = "application/json")
|
||||
public void deleteMenuBar(BoaUser authUserWithDept, Integer id) {
|
||||
menuBarService.deleteMenuBar(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.model.MsgHandleRequest;
|
||||
import com.yunhai.userhome.service.MsgApprovalProxyService;
|
||||
import com.yunhai.userhome.service.MsgCenterProxyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 消息中心代理接口(§3.5 最小消息接入,BP-R3-1):前端铃铛/消息列表改经本控制器
|
||||
* 签名代理 UserMsg /oapi/msg/**(前端不直连 UserMsg)。旧 {@code /getMessageByUserId}
|
||||
* 等本地 user_message 表链路保留不动(后续消息中心专项清理)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
@RequiredArgsConstructor
|
||||
public class MessageCenterController {
|
||||
|
||||
private final MsgCenterProxyService proxy;
|
||||
private final MsgApprovalProxyService approvalProxy;
|
||||
|
||||
/**
|
||||
* 会话主学校(旧口径):<b>只</b>供待办角标(todo)与卡片直批(flow-center 办理人学校)使用——
|
||||
* 这两条链路口径不变(方案 §8)。<b>消息读取一律不得用它</b>(R2:跨校切换会错),
|
||||
* 必须用 {@link MsgCenterProxyService#resolveReadSchId}(当前激活身份学校)。
|
||||
*/
|
||||
private Integer resolveSchId(BoaUser user) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
}
|
||||
if (user.getBureau() != null) {
|
||||
return user.getBureau().getId();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 消息读取口径 schId:当前激活身份所属学校(0/平台/超管与身份链不可达 → null=看全部,D5) */
|
||||
private Integer msgSchId(BoaUser authUserWithDept) {
|
||||
return proxy.resolveReadSchId(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
|
||||
* 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
|
||||
@GetMapping("msgCenter/list")
|
||||
public Map<String, Object> list(BoaUser authUserWithDept,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "count", required = false) Integer count,
|
||||
@RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread,
|
||||
@RequestParam(value = "msgType", required = false) Integer msgType,
|
||||
@RequestParam(value = "appId", required = false) String appId,
|
||||
@RequestParam(value = "isRead", required = false) Integer isRead,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword,
|
||||
msgSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/** 铃铛摘要:未读数 + 各应用待办角标(未读/分类计数按当前身份学校口径;待办角标沿用主学校口径不变) */
|
||||
@GetMapping("msgCenter/summary")
|
||||
public Map<String, Object> summary(BoaUser authUserWithDept) {
|
||||
return proxy.summary(authUserWithDept.getId(), msgSchId(authUserWithDept), resolveSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/** 按来源应用消息计数(消息中心「按应用」清单角标):[{appId, appName, total, unread}],总数含已读 */
|
||||
@GetMapping("msgCenter/countByApp")
|
||||
public java.util.List<java.util.Map<String, Object>> countByApp(BoaUser authUserWithDept) {
|
||||
return proxy.countByApp(authUserWithDept.getId(), msgSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 实时推送握手地址(UserMsg 一次性票据 + 浏览器可达端点)。
|
||||
* 未配置 WS / UserMsg 不可达 → url=null,前端维持"手动刷新"兜底表现。
|
||||
*/
|
||||
@GetMapping("msgCenter/wsTicket")
|
||||
public Map<String, Object> wsTicket(BoaUser authUserWithDept) {
|
||||
Map<String, Object> out = new java.util.HashMap<>();
|
||||
out.put("url", proxy.createWsTicket(authUserWithDept.getId()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪;schId 只用于缓存失效键与 readState 口径) */
|
||||
@PostMapping("msgCenter/read")
|
||||
public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) {
|
||||
proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId), msgSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/** 全部已读(按当前身份学校限定:只标本校+平台未读,他校未读不受影响,D6) */
|
||||
@PostMapping("msgCenter/readAll")
|
||||
public void readAll(BoaUser authUserWithDept) {
|
||||
proxy.readAll(authUserWithDept.getId(), msgSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片直批(B3 §2):同意/拒绝消息对应的审批待办。
|
||||
* 红线:operatorId/schId/operatorName 一律取当前会话,绝不接受前端传参(防越权代批)。
|
||||
*
|
||||
* @return {ok, message}——ok=false 时 message 为可直接展示的文案(已处理/多节点/业务拒绝/不可达降级)
|
||||
*/
|
||||
@PostMapping("msgCenter/handle")
|
||||
public Map<String, Object> handle(BoaUser authUserWithDept, @RequestBody MsgHandleRequest request) {
|
||||
return approvalProxy.handle(authUserWithDept.getId(), resolveSchId(authUserWithDept),
|
||||
authUserWithDept.getName(), request.getMsgId(), request.getAction(), request.getOpinion());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.UserMessage;
|
||||
import com.yunhai.userhome.service.AppRemoteService;
|
||||
import com.yunhai.userhome.service.IUserMessageService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 消息中心接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome MessageController 契约。
|
||||
* <p>响应字段与旧版 {@code getMessageByUserId} 完全一致:{id,msg,date,name,avatar,appPath,msgType}。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class MessageController {
|
||||
|
||||
@Autowired
|
||||
private IUserMessageService userMessageService;
|
||||
|
||||
@Autowired
|
||||
private AppRemoteService appRemoteService;
|
||||
|
||||
/** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐旧版 school.id→bureau.id→0)。 */
|
||||
private Integer resolveSchId(BoaUser user) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
}
|
||||
if (user.getBureau() != null) {
|
||||
return user.getBureau().getId();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private List<BoaApp> flattenApps(BoaUser user) {
|
||||
List<BoaAppType> types = appRemoteService.getUserApps(user);
|
||||
if (types == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<BoaApp> apps = new ArrayList<>();
|
||||
for (BoaAppType t : types) {
|
||||
if (t.getApps() != null) {
|
||||
apps.addAll(t.getApps());
|
||||
}
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
// 获取个人最近消息(对齐旧 getMessageByUserId,响应 List<Map>,字段逐字核对)
|
||||
@GetMapping("getMessageByUserId")
|
||||
public List<Map<String, Object>> getMessageByUserId(BoaUser authUserWithDept, Integer page, Integer count) {
|
||||
Integer userId = authUserWithDept.getId();
|
||||
Integer schId = resolveSchId(authUserWithDept);
|
||||
|
||||
List<UserMessage> msgList = userMessageService.findUserMessageByUserId(userId);
|
||||
Collections.reverse(msgList);
|
||||
|
||||
List<BoaApp> appList = flattenApps(authUserWithDept);
|
||||
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (UserMessage msg : msgList) {
|
||||
Optional<BoaApp> boaApp = appList.stream().filter(x -> x.getId() != null && x.getId().equals(msg.getAppId())).findFirst();
|
||||
if (!boaApp.isPresent()) {
|
||||
continue;
|
||||
}
|
||||
BoaApp model = boaApp.get();
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", msg.getId());
|
||||
map.put("msg", msg.getContent());
|
||||
map.put("date", msg.getCDate());
|
||||
map.put("name", model.getName());
|
||||
map.put("avatar", model.getIcon());
|
||||
map.put("appPath", model.getWebAddr());
|
||||
map.put("msgType", model.getType());
|
||||
list.add(map);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// 已读消息(对齐旧 readMessage,POST,参数 id,返回 void)
|
||||
@PostMapping("readMessage")
|
||||
public void readMessage(BoaUser authUserWithDept, Integer id) {
|
||||
userMessageService.updateUserRead(id, authUserWithDept.getId());
|
||||
}
|
||||
|
||||
// 全部已读消息(对齐旧 readMessageAll,POST 无参,返回 void)
|
||||
@PostMapping("readMessageAll")
|
||||
public void readMessageAll(BoaUser authUserWithDept) {
|
||||
userMessageService.updateReadAll(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
// 是否有新消息(对齐旧 findUserMessageUnRead,GET,返回 Boolean)
|
||||
@GetMapping("findUserMessageUnRead")
|
||||
public Boolean findUserMessageUnRead(BoaUser authUserWithDept) {
|
||||
return userMessageService.findUserMessageUnRead(authUserWithDept.getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 承载旧后端 UserController 中挂在扁平路径 {@code /{platform}/} 下的端点。
|
||||
*
|
||||
* <p>新后端 UserController 已改为 {@code /{platform}/user/} 前缀,
|
||||
* 但以下两个端点的前端调用路径是扁平的(不含 /user/ 段),故单独抽出:
|
||||
* <ul>
|
||||
* <li>{@code POST /api/updateUserPassword} — 前端 BaseApi.updatePassword</li>
|
||||
* <li>{@code GET /api/RedirectTo/{hexUrl}} — 前端 bschSsoRedirect.replaceOriginUrl</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class MiscController {
|
||||
|
||||
@Autowired
|
||||
private OpenApiUserClient openApiUserClient;
|
||||
|
||||
/**
|
||||
* 修改用户密码(对齐旧 UserController.updateUserPassword)。
|
||||
* 前端调用:BaseApi.updatePassword → POST /api/updateUserPassword
|
||||
*/
|
||||
@PostMapping("updateUserPassword")
|
||||
public void updateUserPassword(BoaUser authUserWithDept, @RequestBody Map<String, String> params) {
|
||||
String password = params.get("password");
|
||||
String oldPassword = params.get("oldPassword");
|
||||
if (password == null || password.trim().isEmpty() || oldPassword == null || oldPassword.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "密码参数不能为空");
|
||||
}
|
||||
if (password.equals(oldPassword)) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "新密码不得与旧密码相同!");
|
||||
}
|
||||
try {
|
||||
openApiUserClient.updateUserPassword(authUserWithDept.getId(), oldPassword, password);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 十六进制 URL 重定向(对齐旧 UserController.RedirectTo)。
|
||||
* 前端调用:bschSsoRedirect.replaceOriginUrl → GET /api/RedirectTo/{hexUrl}
|
||||
*/
|
||||
@GetMapping("RedirectTo/{id}")
|
||||
public RedirectView redirectTo(@PathVariable(name = "id") String id) {
|
||||
String url = OtherUtil.hexToString(id);
|
||||
return new RedirectView(url, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
import com.yunhai.userhome.utils.ThemeCodec;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 学校设置接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome SchSettingsController。
|
||||
* <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig);
|
||||
* saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p>
|
||||
* <p>主题设置轮(2026-09-30)新增学校默认主题档三接口(契约见 YschTheme《主题设置与通道改动-执行方案》§3.5):
|
||||
* schTheme 读(已登录可读,当时"可空不兜底"——S7 起改为学校→平台级联,见文末说明);
|
||||
* saveSchThemeState 写(仅学校管理员/超管);clearSchTheme 清空(置空 theme 字段——sch_settings 行还承载
|
||||
* logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态(2026-10-09 JSON 化):
|
||||
* theme_name 列存规范化 ThemeState JSON 串(固定字段序;历史紧凑四段编码读侧兼容自动转 JSON;
|
||||
* 旧格式值/非法值读侧一律视同未配置)。</p>
|
||||
* <p>默认头像轮(2026-09-30)同构新增学校默认头像样式三接口:schAvatarStyle 读(可空不兜底);
|
||||
* saveSchAvatarStyle 写(仅学校管理员/超管,值只接受 text|silhouette);clearSchAvatarStyle 清空(置空
|
||||
* avatar_style 字段)。</p>
|
||||
* <p>外观偏好轮(2026-10-08):编解码下沉 {@link ThemeCodec}、schId 解析下沉
|
||||
* {@link ISchSettingsService#resolveThemeSchId}(行为等价重构,供 AppearanceController 共享)。</p>
|
||||
* <p>三级重构 S7(2026-10-08):{@code schTheme}/{@code schAvatarStyle} 读侧加"学校 → 平台默认(schId=0)"级联
|
||||
* (实现已随 oapi 代理轮下沉 {@link ISchSettingsService#readThemeWithFallback}/{@link ISchSettingsService#readAvatarWithFallback})
|
||||
* ——本校未配置或脏值时回落平台档,平台也无配置才返回 null。写侧与接口签名、响应体结构均未变;
|
||||
* {@code getSchSettings}/{@code findBySchId} 的"合成默认对象"兜底语义也未变。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class SchSettingsController {
|
||||
|
||||
@Autowired
|
||||
private ISchSettingsService schSettingsService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
/** 主题档写权限:超管或学校管理员(先例:UserSettingsServiceImpl.findSchWallpaperManageList) */
|
||||
private void requireThemeAdmin(BoaUser user) {
|
||||
boolean superAdmin = user.getType() != null && user.getType() == 1;
|
||||
if (!superAdmin) {
|
||||
Optional<UserCurrentRole> currentRole = userCurrentRoleService.findById(user.getId());
|
||||
if (!currentRole.isPresent() || !OtherUtil.isAdminById(currentRole.get().getRoleId())) {
|
||||
throw new DException("仅学校管理员可配置本校默认主题");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。
|
||||
* 未配置/脏值 → 回落平台默认层(级联见 {@link ISchSettingsService#readAvatarWithFallback});两级都无 →
|
||||
* avatarStyle=null(前端"不写键、不报错")。 */
|
||||
@GetMapping("schAvatarStyle")
|
||||
public Map<String, Object> schAvatarStyle(BoaUser authUserWithDept) {
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("avatarStyle", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存学校默认头像样式(仅学校管理员/超管):值只接受 "text"/"silhouette",非法抛 DException。 */
|
||||
@PostMapping("saveSchAvatarStyle")
|
||||
public void saveSchAvatarStyle(BoaUser authUserWithDept,
|
||||
@RequestParam("avatarStyle") String avatarStyle,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
if (!ThemeCodec.isAvatarStyle(avatarStyle)) {
|
||||
throw new DException("头像样式不合法: " + avatarStyle);
|
||||
}
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setAvatarStyle(avatarStyle);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
/** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行;置空后读侧回落平台层,平台也无则 null)。 */
|
||||
@PostMapping("clearSchAvatarStyle")
|
||||
public void clearSchAvatarStyle(BoaUser authUserWithDept,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
|
||||
if (!existing.isPresent()) return;
|
||||
SchSettings schSettings = existing.get();
|
||||
schSettings.setAvatarStyle(null);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
/** 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId};schId 为实际命中层
|
||||
* (见 {@link ISchSettingsService.LayerValue})。本校未配置/脏值 → 回落平台默认层
|
||||
* (级联见 {@link ISchSettingsService#readThemeWithFallback});两级都无 → themeState=null
|
||||
* (前端"不写键、不报错")。
|
||||
*/
|
||||
@GetMapping("schTheme")
|
||||
public Map<String, Object> schTheme(BoaUser authUserWithDept) {
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("themeState", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存学校默认主题档(仅学校管理员/超管):themeState 为整份 ThemeState JSON,规范化后原形态存 theme_name(2026-10-09 JSON 化)。 */
|
||||
@PostMapping("saveSchThemeState")
|
||||
public void saveSchThemeState(BoaUser authUserWithDept,
|
||||
@RequestParam("themeState") String themeState,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
String encoded = ThemeCodec.encodeThemeState(themeState);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setThemeName(encoded);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
/** 清空学校默认主题(仅管理员):置空 theme 字段(行还承载 logo 等配置不能删行;置空后读侧返回 null)。 */
|
||||
@PostMapping("clearSchTheme")
|
||||
public void clearSchTheme(BoaUser authUserWithDept,
|
||||
@RequestParam(name = "schId", required = false) Integer schId) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
|
||||
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
|
||||
if (!existing.isPresent()) return;
|
||||
SchSettings schSettings = existing.get();
|
||||
schSettings.setThemeName(null);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
// ===== LOGO 文字(平台/学校名称,LOGO 文字轮 2026-10-09):与主题三件套同模式 =====
|
||||
|
||||
/** LOGO 文字读(已登录可读):{logoText: string|null, schId=实际命中层}。本校空 → 平台层 → null。 */
|
||||
@GetMapping("schLogoText")
|
||||
public Map<String, Object> schLogoText(BoaUser authUserWithDept) {
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readLogoTextWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("logoText", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存 LOGO 文字(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校);空串存 null(=清空)。 */
|
||||
@PostMapping("saveSchLogoText")
|
||||
public void saveSchLogoText(BoaUser authUserWithDept,
|
||||
@RequestParam("logoText") String logoText) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
String text = logoText == null ? "" : logoText.trim();
|
||||
if (text.length() > 64) {
|
||||
throw new DException("LOGO 文字不能超过 64 个字符");
|
||||
}
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, null);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setLogoText(text.isEmpty() ? null : text);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
// ===== 默认壁纸(平台/学校两级,壁纸三级默认轮 2026-10-09):与 LOGO 文字同模式 =====
|
||||
|
||||
/** 默认壁纸读(已登录可读):{wallpaperUrl: string|null, schId=实际命中层}。本校空 → 平台层 → null。 */
|
||||
@GetMapping("schWallpaper")
|
||||
public Map<String, Object> schWallpaper(BoaUser authUserWithDept) {
|
||||
ISchSettingsService.LayerValue hit =
|
||||
schSettingsService.readWallpaperWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("wallpaperUrl", hit.getValue());
|
||||
out.put("schId", hit.getHitSchId());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保存默认壁纸(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校);空串存 null(=清空)。
|
||||
* URL 为管理池返回的绝对地址原样存(与 user_settings.wallpaper_url 同口径),≤255。 */
|
||||
@PostMapping("saveSchWallpaper")
|
||||
public void saveSchWallpaper(BoaUser authUserWithDept,
|
||||
@RequestParam("wallpaperUrl") String wallpaperUrl) {
|
||||
requireThemeAdmin(authUserWithDept);
|
||||
String url = wallpaperUrl == null ? "" : wallpaperUrl.trim();
|
||||
if (url.length() > 255) {
|
||||
throw new DException("壁纸地址不能超过 255 个字符");
|
||||
}
|
||||
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, null);
|
||||
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(targetSchId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setWallpaperUrl(url.isEmpty() ? null : url);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
@PostMapping("saveSchTheme")
|
||||
public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
|
||||
if (schId == null || schId == 0) {
|
||||
schId = schSettingsService.resolveSchId(authUserWithDept);
|
||||
}
|
||||
SchSettings schSettings = new SchSettings();
|
||||
Optional<SchSettings> model = schSettingsService.findBySchId(schId);
|
||||
if (model.isPresent()) {
|
||||
schSettings = model.get();
|
||||
}
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(schId);
|
||||
schSettings.setCId(authUserWithDept.getId());
|
||||
schSettings.setThemeName(themeName);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
@PostMapping("saveSchLogo")
|
||||
public void saveSchLogo(BoaUser authUserWithDept, String logoUrl, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
|
||||
if (schId == null || schId == 0) {
|
||||
schId = schSettingsService.resolveSchId(authUserWithDept);
|
||||
}
|
||||
SchSettings schSettings = newSchSettingsOrLoad(schId, authUserWithDept);
|
||||
schSettings.setLogoUrl(logoUrl);
|
||||
schSettingsService.save(schSettings);
|
||||
}
|
||||
|
||||
/** getSchSettings:schId=0 时按 主学校→主归属组→0 推断(对齐旧 SchSettingsController)。 */
|
||||
@GetMapping("getSchSettings")
|
||||
public Optional<SchSettings> getSchSettings(BoaUser authUserWithDept,
|
||||
@RequestParam(name = "schId", defaultValue = "0") Integer schId) {
|
||||
if (schId == null || schId == 0) {
|
||||
schId = schSettingsService.resolveSchId(authUserWithDept);
|
||||
}
|
||||
return schSettingsService.findBySchId(schId);
|
||||
}
|
||||
|
||||
private SchSettings newSchSettingsOrLoad(Integer schId, BoaUser user) {
|
||||
Optional<SchSettings> model = schSettingsService.findBySchId(schId);
|
||||
SchSettings schSettings = model.orElseGet(SchSettings::new);
|
||||
schSettings.setCDate(new Date());
|
||||
schSettings.setSchId(schId);
|
||||
schSettings.setCId(user.getId());
|
||||
return schSettings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.utils.MapCacheUtil;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* WebSocket 鉴权地址端点(CAS 登录态,路径 {@code /{platform}/})。
|
||||
* <p>对齐旧 UserHome SocketController.getMsgAddress:生成 uuid → 缓存(uuid→userId, 20s) →
|
||||
* 返回 {@code {WEBSOCKET_URL}/{uuid}},供前端握手阶段鉴权。WS 推送服务端本期可后置,
|
||||
* 前端改轮询 {@code getMessageByUserId} 兜底刷新。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH)
|
||||
public class SocketController {
|
||||
|
||||
@Value("${WEBSOCKET_URL:ws://localhost:8091/ws}")
|
||||
private String websocketUrl;
|
||||
|
||||
@GetMapping("getMsgAddress")
|
||||
public Map<String, String> getMsgAddress(BoaUser authUserWithDept) {
|
||||
Integer uid = authUserWithDept.getId();
|
||||
UUID uuid = UUID.randomUUID();
|
||||
MapCacheUtil.getInstance().add(uuid.toString(), uid.toString(), 20 * 1000L);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("url", this.websocketUrl + "/" + uuid.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.StatsBar;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.service.IStatsBarService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 统计栏目控制器(对齐旧 UserHome contorller.api.StatsBarController)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH)
|
||||
public class StatsBarController {
|
||||
|
||||
@Autowired
|
||||
private IStatsBarService statsBarService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@GetMapping("getStatsBarSchList")
|
||||
public List<StatsBar> getStatsBarSchList(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
return statsBarService.getStatsBarList(userCurrentRole.getSchId());
|
||||
}
|
||||
|
||||
@GetMapping("getStatsBarList")
|
||||
public List<Map> getStatsBarList(BoaUser authUserWithDept) {
|
||||
List<Map> result = new ArrayList<>();
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return result;
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
Integer roleId = userCurrentRole.getRoleId();
|
||||
if (roleId == 7 || roleId == 2) roleId = 2;
|
||||
Boolean isSuperAdmin = authUserWithDept.getType() == 1;
|
||||
List<StatsBar> list = statsBarService.findStatsBarById(authUserWithDept.getId());
|
||||
for (StatsBar model : list) {
|
||||
Map map = new HashMap();
|
||||
if (!isSuperAdmin && !model.getRoleIds().contains(roleId)) continue;
|
||||
map.put("id", model.getId());
|
||||
map.put("name", model.getName());
|
||||
map.put("url", model.getUrl());
|
||||
map.put("icon", model.getIcon());
|
||||
map.put("openType", model.getLinkOpenType());
|
||||
result.add(map);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveStatsBar", produces = "application/json")
|
||||
public void saveStatsBar(BoaUser authUserWithDept, @RequestBody StatsBar statsBar) {
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(com.yunhai.platform.utils.exception.ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
statsBar.setCId(authUserWithDept.getId());
|
||||
statsBar.setSchId(userCurrentRole.getSchId());
|
||||
if (statsBar.getId() == null) statsBar.setCDate(new Date());
|
||||
statsBarService.saveStatsBar(statsBar);
|
||||
}
|
||||
|
||||
@PostMapping(value = "toggleStatsBarShow", produces = "application/json")
|
||||
public void toggleStatsBarShow(BoaUser authUserWithDept, Integer id) {
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(com.yunhai.platform.utils.exception.ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
statsBarService.toggleStatsBarShow(id, userCurrentRole.getSchId());
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveStatsBarOrderNum", produces = "application/json")
|
||||
public void saveStatsBarOrderNum(BoaUser authUserWithDept, @RequestBody Map<String, Integer> params) {
|
||||
Integer id = params.get("id"),
|
||||
orderNum = params.get("orderNum");
|
||||
statsBarService.saveStatsBarOrderNum(id, orderNum);
|
||||
}
|
||||
|
||||
@PostMapping(value = "deleteStatsBar", produces = "application/json")
|
||||
public void deleteStatsBar(BoaUser authUserWithDept, Integer id) {
|
||||
statsBarService.deleteStatsBar(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.TeacherCopilot;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.service.ITeacherCopilotService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 教师助手控制器(对齐旧 UserHome contorller.api.TeacherCopilotController)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH)
|
||||
public class TeacherCopilotController {
|
||||
|
||||
@Autowired
|
||||
private ITeacherCopilotService teacherCopilotService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@GetMapping("TeacherCopilot/getSchList")
|
||||
public List<TeacherCopilot> getList(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
return teacherCopilotService.getList(userCurrentRole.getSchId());
|
||||
}
|
||||
|
||||
@GetMapping("TeacherCopilot/getUserList")
|
||||
public List<TeacherCopilot.Dto> getUserList(BoaUser authUserWithDept) {
|
||||
List<TeacherCopilot.Dto> result = new ArrayList<>();
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return result;
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
Integer roleId = userCurrentRole.getRoleId();
|
||||
if (roleId == 7 || roleId == 2) roleId = 2;
|
||||
Boolean isSuperAdmin = authUserWithDept.getType() == 1;
|
||||
List<TeacherCopilot> list = teacherCopilotService.findById(authUserWithDept.getId());
|
||||
for (TeacherCopilot model : list) {
|
||||
TeacherCopilot.Dto dto = new TeacherCopilot.Dto();
|
||||
if (!isSuperAdmin && !model.getRoleIds().contains(roleId)) continue;
|
||||
dto.setId(model.getId());
|
||||
dto.setName(model.getName());
|
||||
dto.setUrl(model.getUrl());
|
||||
dto.setIcon(model.getIcon());
|
||||
dto.setOpenType(model.getLinkOpenType());
|
||||
dto.setType(model.getType());
|
||||
result.add(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "TeacherCopilot/save", produces = "application/json")
|
||||
public void save(BoaUser authUserWithDept, @RequestBody TeacherCopilot teacherCopilot) {
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(com.yunhai.platform.utils.exception.ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
teacherCopilot.setCId(authUserWithDept.getId());
|
||||
teacherCopilot.setSchId(userCurrentRole.getSchId());
|
||||
if (teacherCopilot.getId() == null) teacherCopilot.setCDate(new Date());
|
||||
teacherCopilotService.save(teacherCopilot);
|
||||
}
|
||||
|
||||
@PostMapping(value = "TeacherCopilot/toggleShow", produces = "application/json")
|
||||
public void toggleShow(BoaUser authUserWithDept, Integer id) {
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(com.yunhai.platform.utils.exception.ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
teacherCopilotService.toggleShow(id, userCurrentRole.getSchId());
|
||||
}
|
||||
|
||||
@PostMapping(value = "TeacherCopilot/saveOrderNum", produces = "application/json")
|
||||
public void saveOrderNum(BoaUser authUserWithDept, @RequestBody Map<String, Integer> params) {
|
||||
Integer id = params.get("id"),
|
||||
orderNum = params.get("orderNum");
|
||||
teacherCopilotService.saveOrderNum(id, orderNum);
|
||||
}
|
||||
|
||||
@PostMapping(value = "TeacherCopilot/delete", produces = "application/json")
|
||||
public void delete(BoaUser authUserWithDept, Integer id) {
|
||||
teacherCopilotService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.UsedApp;
|
||||
import com.yunhai.userhome.model.AppIconEntryVo;
|
||||
import com.yunhai.userhome.model.SetType;
|
||||
import com.yunhai.userhome.service.AppIconKeyService;
|
||||
import com.yunhai.userhome.service.AppRemoteService;
|
||||
import com.yunhai.userhome.service.UsedAppService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -36,6 +38,9 @@ public class UsedAppController {
|
||||
@Autowired
|
||||
private AppRemoteService appRemoteService;
|
||||
|
||||
@Autowired
|
||||
private AppIconKeyService appIconKeyService;
|
||||
|
||||
/**
|
||||
* 取当前角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。
|
||||
*
|
||||
@@ -112,7 +117,7 @@ public class UsedAppController {
|
||||
usedAppService.saveUserId(usedApp);
|
||||
}
|
||||
|
||||
/** 按 appIds(逗号分隔)取应用精简映射(id/name/icon/url),在全集中查找。 */
|
||||
/** 按 appIds(逗号分隔)取应用精简映射(id/name/icon/url/iconKey/iconColor),在全集中查找。 */
|
||||
@GetMapping("getAppByIds")
|
||||
public List<Map<String, Object>> getAppByIds(BoaUser authUserWithDept, String appIds) {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
@@ -132,6 +137,10 @@ public class UsedAppController {
|
||||
m.put("name", app.getName());
|
||||
m.put("icon", app.getIcon());
|
||||
m.put("url", app.getWebAddr());
|
||||
// 矢量图标键(AppIcon 通道;未配置为 null,前端按空走原 img 回退)
|
||||
AppIconEntryVo iconEntry = appIconKeyService.byId(app.getId());
|
||||
m.put("iconKey", iconEntry == null ? null : iconEntry.getIconKey());
|
||||
m.put("iconColor", iconEntry == null ? null : iconEntry.getIconColor());
|
||||
list.add(m);
|
||||
});
|
||||
} catch (NumberFormatException ignore) {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.openapi.core.OpenApiException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.model.AppIconEntryVo;
|
||||
import com.yunhai.userhome.service.AppIconKeyService;
|
||||
import com.yunhai.userhome.service.AppRemoteService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -19,7 +26,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户首页接口(CAS 登录态,路径 {@code /{platform}/user/})。
|
||||
@@ -29,11 +38,25 @@ import java.util.List;
|
||||
@RequestMapping(value = Config.API_PATH + "/user/")
|
||||
public class UserController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserController.class);
|
||||
|
||||
/** 服务目录目标名:userbase(yh.open.services / 枚举兜底 = base-host + /userbase,与 MsgCenterProxyService 同口径) */
|
||||
private static final String TARGET_USERBASE = "userbase";
|
||||
|
||||
@Autowired
|
||||
private AppRemoteService appRemoteService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
private OpenApiUserClient openApiUserClient;
|
||||
|
||||
@Autowired
|
||||
private YschOpenCall openCall;
|
||||
|
||||
@Autowired
|
||||
private AppIconKeyService appIconKeyService;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
|
||||
/** 当前登录用户信息(CAS 注入)。 */
|
||||
@@ -55,19 +78,56 @@ public class UserController {
|
||||
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
|
||||
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
|
||||
*
|
||||
* @param schId 兼容旧前端入参(yh-platform getUserApp 当前按 userId 全局权限过滤,schId 暂不参与)
|
||||
* @param schId 学校上下文:透传 UserBase 按该校单点开通集合收窄(为空则由服务端按当前身份/主归属学校兜底)
|
||||
* @param userId 指定用户(缺省用登录用户)
|
||||
*/
|
||||
@GetMapping("GetUserApp")
|
||||
public List<BoaAppType> getUserApp(BoaUser authUserWithDept, Integer schId,
|
||||
public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
|
||||
@RequestParam(name = "userId", required = false) Integer userId) {
|
||||
return appRemoteService.getUserApps(authUserWithDept);
|
||||
return withIconKeys(appRemoteService.getUserApps(authUserWithDept, schId));
|
||||
}
|
||||
|
||||
/** 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。 */
|
||||
/**
|
||||
* 应用分组结果上合并注入矢量图标字段(iconKey/iconColor,AppIcon 资产库通道)。<br>
|
||||
* BoaApp 属 BasePlatform SDK 本期不动:SDK 反序列化会丢弃 icon-map 的扩展字段,
|
||||
* 故序列化前经 JsonNode 平铺注入;icon-map 不可达时按空映射跳过,出参结构与原值完全一致(零回归)。
|
||||
*/
|
||||
private JsonNode withIconKeys(List<BoaAppType> types) {
|
||||
JsonNode root = objectMapper.valueToTree(types);
|
||||
Map<Integer, AppIconEntryVo> iconMap = appIconKeyService.iconMap();
|
||||
if (iconMap.isEmpty() || root == null || !root.isArray()) {
|
||||
return root;
|
||||
}
|
||||
for (JsonNode type : root) {
|
||||
JsonNode apps = type.get("apps");
|
||||
if (apps == null || !apps.isArray()) {
|
||||
continue;
|
||||
}
|
||||
for (JsonNode app : apps) {
|
||||
JsonNode idNode = app.get("id");
|
||||
if (idNode == null || !idNode.isNumber()) {
|
||||
continue;
|
||||
}
|
||||
AppIconEntryVo entry = iconMap.get(idNode.asInt());
|
||||
if (entry != null) {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) app)
|
||||
.put("iconKey", entry.getIconKey())
|
||||
.put("iconColor", entry.getIconColor());
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。<br>
|
||||
* schId 透传(可选):与 GetUserApp 同口径按学校收窄全集,避免「列表已收窄、单个查询仍全集」的不一致;
|
||||
* 为空则由 UserBase 按当前身份/主归属学校兜底。
|
||||
*/
|
||||
@GetMapping("GetAppInfoById")
|
||||
public BoaApp getAppInfoById(BoaUser authUserWithDept, @RequestParam(name = "id", required = false) Integer id) {
|
||||
return appRemoteService.getAppById(authUserWithDept, id).orElse(null);
|
||||
public BoaApp getAppInfoById(BoaUser authUserWithDept, Integer schId,
|
||||
@RequestParam(name = "id", required = false) Integer id) {
|
||||
return appRemoteService.getAppById(authUserWithDept, id, schId).orElse(null);
|
||||
}
|
||||
|
||||
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
|
||||
@@ -76,23 +136,60 @@ public class UserController {
|
||||
return YschBaseOpenApi.getUserRoleList(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
/** 当前用户激活的角色(本地 user_current_role,可能为空)。 */
|
||||
/** 当前用户激活的身份(UserBase 单一权威源;未切换时兜底主身份,无身份为 null)。 */
|
||||
@GetMapping("findUserRole")
|
||||
public UserCurrentRole findUserRole(BoaUser authUserWithDept) {
|
||||
return userCurrentRoleService.findById(authUserWithDept.getId()).orElse(null);
|
||||
public BoaRoleInfo findUserRole(BoaUser authUserWithDept) {
|
||||
return YschBaseOpenApi.getCurrentIdentity(authUserWithDept.getId());
|
||||
}
|
||||
|
||||
/** 保存当前激活角色(校验所选 学校+角色 属于用户角色列表,防越权)。 */
|
||||
/**
|
||||
* 保存当前激活角色:userId 只取 CAS 会话(无视请求体,防切他人),
|
||||
* 目标 (roleId, schId) 是否属该用户由 UserBase 侧 sys_user_role_rel 三元组校验。
|
||||
*/
|
||||
@PostMapping("savUserRole")
|
||||
public void savUserRole(BoaUser authUserWithDept, @RequestBody UserCurrentRole roleInfo) {
|
||||
List<BoaRoleInfo> roleList = YschBaseOpenApi.getUserRoleList(authUserWithDept.getId());
|
||||
boolean exists = roleList.stream().anyMatch(r ->
|
||||
r.getRoleId() != null && r.getRoleId().equals(roleInfo.getRoleId())
|
||||
&& r.getSchId() != null && r.getSchId().equals(roleInfo.getSchId()));
|
||||
if (!exists) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "非法操作,没有权限");
|
||||
public void savUserRole(BoaUser authUserWithDept, @RequestBody BoaRoleInfo roleInfo) {
|
||||
if (roleInfo.getRoleId() == null || roleInfo.getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "角色或学校不能为空");
|
||||
}
|
||||
roleInfo.setUserId(authUserWithDept.getId());
|
||||
userCurrentRoleService.save(roleInfo);
|
||||
openApiUserClient.switchIdentity(authUserWithDept.getId(), roleInfo.getRoleId(), roleInfo.getSchId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 本人联系方式修改(手机号/邮箱):body {phone, email},userId 只取 CAS 会话(无视请求体,防替改他人),
|
||||
* 经 {@link YschOpenCall} 泛化签名直调(Tier 0)转发 UserBase {@code POST /oapi/user/self/contact}
|
||||
* (与 MsgCenterProxyService 直调 userbase 同范式:服务目录按名取址 + 信封异常透传,零新增配置项)。
|
||||
* <ul>
|
||||
* <li>phone/email 至少一项非空(前端与 UserBase 侧同口径校验,此处仅挡空请求);</li>
|
||||
* <li>UserBase 业务失败(格式/查重等)以 DException(code,msg) 冒泡,由 GlobalExceptionHandler
|
||||
* 原样包成信封返回(msg 即「手机号已被其他用户使用」等明确错误);</li>
|
||||
* <li>UserBase 不可达(ioError/验签失败等 OpenApiException)收口为友好提示,不抛 500 裸栈;</li>
|
||||
* <li>成功返回 UserBase 更新后的最新 {@link BoaUser}(含新 phone/email/groups),前端以此
|
||||
* 覆盖 store.userInfo,规避 CAS principal 缓存导致的旧值回显。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@PostMapping("UpdateContact")
|
||||
public BoaUser updateContact(BoaUser authUserWithDept, @RequestBody Map<String, String> body) {
|
||||
String phone = body == null ? null : body.get("phone");
|
||||
String email = body == null ? null : body.get("email");
|
||||
boolean phoneBlank = phone == null || phone.trim().isEmpty();
|
||||
boolean emailBlank = email == null || email.trim().isEmpty();
|
||||
if (phoneBlank && emailBlank) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "手机号与邮箱至少填写一项");
|
||||
}
|
||||
Map<String, String> payload = new LinkedHashMap<>();
|
||||
payload.put("userId", String.valueOf(authUserWithDept.getId()));
|
||||
if (!phoneBlank) {
|
||||
payload.put("phone", phone.trim());
|
||||
}
|
||||
if (!emailBlank) {
|
||||
payload.put("email", email.trim());
|
||||
}
|
||||
try {
|
||||
return openCall.post(TARGET_USERBASE, "/oapi/user/self/contact", null, payload, BoaUser.class);
|
||||
} catch (OpenApiException e) {
|
||||
log.warn("[user] UpdateContact 直调 UserBase 失败 userId={}:{}", authUserWithDept.getId(), e.toString());
|
||||
throw new BizException(ErrorCode.CUSTOM, "用户中心暂不可达,请稍后重试");
|
||||
}
|
||||
// DException(UserBase 业务错误,code!=0)刻意冒泡:GlobalExceptionHandler 按原 code/msg 包信封
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.*;
|
||||
import com.yunhai.userhome.enums.DesktopAppType;
|
||||
import com.yunhai.userhome.enums.LightAppGroup;
|
||||
import com.yunhai.userhome.enums.LinkOpenType;
|
||||
import com.yunhai.userhome.model.AppIconEntryVo;
|
||||
import com.yunhai.userhome.service.AppIconKeyService;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.service.IUserSettingsService;
|
||||
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||
import com.yunhai.userhome.service.UsedAppService;
|
||||
import com.yunhai.userhome.service.WidgetLinkResolver;
|
||||
import com.yunhai.userhome.utils.EnumUtil;
|
||||
import com.yunhai.userhome.utils.FileUtil;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH)
|
||||
public class UserSettingsController {
|
||||
|
||||
@Value("${FILE_PATH:./upload/}")
|
||||
private String FILE_PATH;
|
||||
|
||||
@Value("${FILE_WEB_PATH:}")
|
||||
private String FILE_WEB_PATH;
|
||||
|
||||
@Autowired
|
||||
private IUserSettingsService userSettingsService;
|
||||
|
||||
@Autowired
|
||||
private ISchSettingsService schSettingsService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private UsedAppService usedAppService;
|
||||
|
||||
@Autowired
|
||||
private WidgetLinkResolver widgetLinkResolver;
|
||||
|
||||
@Autowired
|
||||
private OpenApiUserClient openApiUserClient;
|
||||
|
||||
@Autowired
|
||||
private AppIconKeyService appIconKeyService;
|
||||
|
||||
@Autowired
|
||||
private IWidgetCatalogService widgetCatalogService;
|
||||
|
||||
// ==================== 壁纸 ====================
|
||||
|
||||
/**
|
||||
* 上传壁纸(Header 弹窗与系统设置页共用)。
|
||||
*
|
||||
* <p>超管:可选 schId 参数——传 schId>0 归属该校(type=1),不传/0 维持系统级(type=0);
|
||||
* 校管理员:忽略入参固定本校(type=1);普通用户维持个人壁纸(type=2)。</p>
|
||||
*/
|
||||
@PostMapping(value = "wallpaperUpload", produces = "application/json")
|
||||
public void wallpaperUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "schId", required = false) Integer schId) {
|
||||
checkFile(file);
|
||||
String uploadUrl = this.upload(file, "wallpaper");
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
Integer type;
|
||||
Integer targetSchId;
|
||||
if (authUserWithDept.getType() == 1) {
|
||||
if (schId != null && schId > 0) {
|
||||
type = 1;
|
||||
targetSchId = schId;
|
||||
} else {
|
||||
// 超管上传的系统壁纸 schId 强制 0,避免切学校身份后产生孤儿数据
|
||||
type = 0;
|
||||
targetSchId = 0;
|
||||
}
|
||||
} else {
|
||||
type = OtherUtil.isAdminById(currentRole.getRoleId()) ? 1 : 2;
|
||||
targetSchId = currentRole.getSchId();
|
||||
}
|
||||
Wallpaper wallpaper = new Wallpaper();
|
||||
wallpaper.setSchId(targetSchId);
|
||||
wallpaper.setWallpaperUrl(uploadUrl);
|
||||
wallpaper.setUserId(authUserWithDept.getId());
|
||||
wallpaper.setType(type);
|
||||
userSettingsService.saveWallpaper(wallpaper);
|
||||
}
|
||||
|
||||
@PostMapping(value = "iconUpload", produces = "application/json")
|
||||
public Map<String, String> iconUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) {
|
||||
checkFile(file);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("url", this.upload(file, "icon"));
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "deleteWallById", produces = "application/json")
|
||||
public void deleteWallById(BoaUser authUserWithDept, Integer id) {
|
||||
userSettingsService.deleteWallById(id, authUserWithDept);
|
||||
}
|
||||
|
||||
@PostMapping(value = "checkWallpaper", produces = "application/json")
|
||||
public void checkWallpaper(BoaUser authUserWithDept, @RequestBody Map res) {
|
||||
String wallpaperUrl = res.get("wallpaperUrl").toString();
|
||||
// 校验 URL 属于该用户可见壁纸集合(本地 wallpaper 表),防止设置任意字符串
|
||||
String normalized = normalizeWallpaperUrl(wallpaperUrl);
|
||||
Set<String> visible = new HashSet<>();
|
||||
userSettingsService.findWallList(authUserWithDept.getId())
|
||||
.forEach(w -> visible.add(w.getWallpaperUrl()));
|
||||
if (!visible.contains(normalized)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "壁纸不存在或无权使用");
|
||||
}
|
||||
userSettingsService.checkWallpaper(authUserWithDept.getId(), wallpaperUrl);
|
||||
}
|
||||
|
||||
@GetMapping(value = "getUserWallpaperList")
|
||||
public List<Map> getUserWallpaperList(BoaUser authUserWithDept, Integer schId) {
|
||||
List<Map> result = new ArrayList<>();
|
||||
List<Wallpaper> list = userSettingsService.findWallList(authUserWithDept.getId());
|
||||
list = list.stream().sorted(Comparator.comparing(Wallpaper::getId).reversed()).collect(Collectors.toList());
|
||||
list.forEach(model -> {
|
||||
Map map = new HashMap();
|
||||
map.put("id", model.getId());
|
||||
map.put("url", FILE_WEB_PATH + model.getWallpaperUrl().replace("/upload", ""));
|
||||
map.put("type", model.getType());
|
||||
map.put("isEdit", model.getIsEdit());
|
||||
result.add(map);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Logo ====================
|
||||
|
||||
@PostMapping(value = "logoUpload", produces = "application/json")
|
||||
public void logoUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "schId", required = false) Integer schId) {
|
||||
// 仅超管(系统默认/指定学校)与校管理员(本校)可上传,普通用户无权设置 Logo
|
||||
Integer type;
|
||||
Integer targetSchId;
|
||||
if (authUserWithDept.getType() == 1) {
|
||||
if (schId != null && schId > 0) {
|
||||
type = 1;
|
||||
targetSchId = schId;
|
||||
} else {
|
||||
type = 0;
|
||||
targetSchId = 0;
|
||||
}
|
||||
} else {
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
if (!OtherUtil.isAdminById(currentRole.getRoleId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权设置Logo");
|
||||
}
|
||||
type = 1;
|
||||
targetSchId = currentRole.getSchId();
|
||||
}
|
||||
checkFile(file);
|
||||
String uploadUrl = this.upload(file, "logo");
|
||||
Logo logo = new Logo();
|
||||
logo.setType(type);
|
||||
logo.setSchId(targetSchId);
|
||||
logo.setLogoUrl(uploadUrl);
|
||||
logo.setUserId(authUserWithDept.getId());
|
||||
userSettingsService.saveLogo(logo);
|
||||
}
|
||||
|
||||
@GetMapping(value = "getLogoList")
|
||||
public List<Map> getLogoList(BoaUser authUserWithDept) {
|
||||
List<Map> result = new ArrayList<>();
|
||||
List<Logo> list = userSettingsService.findLogoList(authUserWithDept.getId());
|
||||
list.forEach(model -> {
|
||||
Map map = new HashMap();
|
||||
map.put("id", model.getId());
|
||||
map.put("url", FILE_WEB_PATH + model.getLogoUrl().replace("/upload", ""));
|
||||
map.put("type", model.getType());
|
||||
map.put("isEdit", model.getIsEdit());
|
||||
result.add(map);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "deleteLogoById", produces = "application/json")
|
||||
public void deleteLogoById(BoaUser authUserWithDept, Integer id) {
|
||||
userSettingsService.deleteLogoById(id, authUserWithDept);
|
||||
}
|
||||
|
||||
// ==================== 系统设置(学校维度管理视图) ====================
|
||||
|
||||
/**
|
||||
* 机构树(仅超管,403 兜底):YschBaseOpenApi.getGroupTree() → 递归裁剪到学校层
|
||||
* (对齐原 systemsetting SysController.RecursionGroup:type==1 普通组跳过、type==2 学校组置叶子)。
|
||||
*/
|
||||
@GetMapping(value = "getSchTree")
|
||||
public BoaGroup getSchTree(BoaUser authUserWithDept) {
|
||||
if (authUserWithDept.getType() != 1) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
BoaGroup group = YschBaseOpenApi.getGroupTree();
|
||||
return recursionGroup(group);
|
||||
}
|
||||
|
||||
/**
|
||||
* 壁纸管理列表(系统设置页):系统壁纸(type=0) + 目标学校壁纸(type=1),不含个人壁纸(type=2)。
|
||||
* <p>超管可传任意 schId(不传按 0 即平台默认);校管理员忽略入参固定本校,系统壁纸仅超管可删。</p>
|
||||
*/
|
||||
@GetMapping(value = "getSchWallpaperManageList")
|
||||
public List<Map> getSchWallpaperManageList(BoaUser authUserWithDept, Integer schId) {
|
||||
List<Map> result = new ArrayList<>();
|
||||
userSettingsService.findSchWallpaperManageList(authUserWithDept, schId).forEach(model -> {
|
||||
Map map = new HashMap();
|
||||
map.put("id", model.getId());
|
||||
map.put("url", FILE_WEB_PATH + model.getWallpaperUrl().replace("/upload", ""));
|
||||
map.put("type", model.getType());
|
||||
map.put("isEdit", model.getIsEdit());
|
||||
result.add(map);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo 管理列表(系统设置页):系统默认(type=0) + 目标学校(type=1)。
|
||||
* <p>同 getSchWallpaperManageList 权限语义;logo 单张,前端取第一张显示,上传即覆盖。</p>
|
||||
*/
|
||||
@GetMapping(value = "getSchLogoManageList")
|
||||
public List<Map> getSchLogoManageList(BoaUser authUserWithDept, Integer schId) {
|
||||
List<Map> result = new ArrayList<>();
|
||||
userSettingsService.findSchLogoManageList(authUserWithDept, schId).forEach(model -> {
|
||||
Map map = new HashMap();
|
||||
map.put("id", model.getId());
|
||||
map.put("url", FILE_WEB_PATH + model.getLogoUrl().replace("/upload", ""));
|
||||
map.put("type", model.getType());
|
||||
map.put("isEdit", model.getIsEdit());
|
||||
result.add(map);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归裁剪机构树到学校层(对齐原 systemsetting SysController.RecursionGroup)。
|
||||
*/
|
||||
private BoaGroup recursionGroup(BoaGroup group) {
|
||||
// 组类型:1 普通组 2 学校组 3 年级组 4 班级组 5 区域组 6 学段组 7 教师组 8 学生组
|
||||
List<BoaGroup> list = new ArrayList<>();
|
||||
List<BoaGroup> children = group.getChildren();
|
||||
if (children == null) {
|
||||
return group;
|
||||
}
|
||||
for (BoaGroup boaGroup : children) {
|
||||
if (boaGroup.getType() == null) continue;
|
||||
if (boaGroup.getType() == 1) continue;
|
||||
if (boaGroup.getType() == 2) {
|
||||
boaGroup.setChildren(null);
|
||||
list.add(boaGroup);
|
||||
} else {
|
||||
BoaGroup model = this.recursionGroup(boaGroup);
|
||||
list.add(model);
|
||||
}
|
||||
}
|
||||
group.setChildren(list);
|
||||
return group;
|
||||
}
|
||||
|
||||
// ==================== 用户设置 ====================
|
||||
|
||||
@GetMapping(value = "getUserSettings")
|
||||
public UserSettings getUserSettings(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
|
||||
UserSettings model = new UserSettings();
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||
// 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
|
||||
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
|
||||
if (optional.isPresent()) model = optional.get();
|
||||
// 壁纸池一次取全:悬空自愈判定与兜底选择共用
|
||||
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
|
||||
// 悬空引用自愈:自选壁纸已被删除(删除清理修复前的存量脏数据)时视为未自选,避免返回死 URL 白板
|
||||
String chosenUrl = normalizeWallpaperUrl(model.getWallpaperUrl());
|
||||
if (model.getWallpaperUrl() != null && !model.getWallpaperUrl().isEmpty()
|
||||
&& wallList.stream().noneMatch(w -> chosenUrl != null && chosenUrl.equals(w.getWallpaperUrl()))) {
|
||||
model.setWallpaperUrl(null);
|
||||
}
|
||||
if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) {
|
||||
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId, wallList));
|
||||
}
|
||||
// id/appId 同值:对齐既有 widgetAppIds 的存取与面板排序链路;不再有 url
|
||||
List<Map> mapList = catalogList.stream().map(catalog -> {
|
||||
Map map = new HashMap<>();
|
||||
map.put("id", catalog.getId());
|
||||
map.put("appId", catalog.getId());
|
||||
map.put("name", catalog.getName());
|
||||
map.put("code", catalog.getCode());
|
||||
map.put("compType", catalog.getCompType());
|
||||
// 外跳入口:库里只存「子应用 code + 路由」,绝对地址按应用注册表 pc_url 解析后下发;
|
||||
// 解析不出(未配置 / 查无 / 注册表不可达)则不下发,前端不渲染入口
|
||||
String overviewUrl = widgetLinkResolver.resolve(catalog.getLinkAppCode(), catalog.getLinkPath());
|
||||
if (overviewUrl != null) {
|
||||
map.put("overviewUrl", overviewUrl);
|
||||
}
|
||||
return map;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
model.setWidgetAppList(mapList);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底壁纸(用户未自选时,壁纸三级默认轮 2026-10-09):本校默认(sch_settings.wallpaper_url,管理员选定)
|
||||
* → 平台默认(schId=0 行)→ 本地学校壁纸池第一张 → 平台池第一张(历史兜底,两级皆未配置时保持原体验)。
|
||||
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。
|
||||
* 管理员选定默认返回前校验其仍在壁纸池中——被删池视为未配置继续下落,避免兜底本身返回死 URL(2026-10-09 白板修复)。</p>
|
||||
*/
|
||||
private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId, List<Wallpaper> wallList) {
|
||||
Set<String> poolUrls = new HashSet<>();
|
||||
wallList.forEach(w -> poolUrls.add(w.getWallpaperUrl()));
|
||||
// 1/2. 管理员选定的默认壁纸(本校 → 平台 级联,读侧 LayerValue)
|
||||
ISchSettingsService.LayerValue wallHit = schSettingsService.readWallpaperWithFallback(schId);
|
||||
if (wallHit.getValue() != null && poolUrls.contains(normalizeWallpaperUrl(wallHit.getValue()))) {
|
||||
return wallHit.getValue();
|
||||
}
|
||||
// 3/4. 历史兜底:壁纸池第一张(校 type=1 → 平台 type=0,最早上传者优先)
|
||||
if (schId != null) {
|
||||
// 3. 本地学校壁纸第一张(type=1)
|
||||
Optional<Wallpaper> schWall = wallList.stream()
|
||||
.filter(w -> w.getType() != null && w.getType() == 1
|
||||
&& w.getSchId() != null && w.getSchId().equals(schId))
|
||||
.min(Comparator.comparing(Wallpaper::getId));
|
||||
if (schWall.isPresent()) {
|
||||
return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", "");
|
||||
}
|
||||
}
|
||||
// 4. 超管全局壁纸第一张(type=0)
|
||||
Optional<Wallpaper> sysWall = wallList.stream()
|
||||
.filter(w -> w.getType() != null && w.getType() == 0)
|
||||
.min(Comparator.comparing(Wallpaper::getId));
|
||||
if (sysWall.isPresent()) {
|
||||
return FILE_WEB_PATH + sysWall.get().getWallpaperUrl().replace("/upload", "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@GetMapping(value = "getUserDesktopAppList")
|
||||
public List<Map> getUserDesktopAppList(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!currentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole currentRole = currentRoleOpt.get();
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||
Integer schId = currentRole.getSchId(), userId = currentRole.getUserId();
|
||||
UserSettings model = new UserSettings();
|
||||
List<UserSettingsDesktopApp> desktopAppList = new ArrayList<>();
|
||||
List<Map> result = new ArrayList<>();
|
||||
if (optional.isPresent()) model = optional.get();
|
||||
// 初始化设置为常用应用
|
||||
if (model.getDesktopApps().isEmpty()) desktopAppList = getUserUsedByUserId(authUserWithDept.getId());
|
||||
else desktopAppList = model.getDesktopApps();
|
||||
|
||||
List<BoaApp> bschAllList = new ArrayList<>();
|
||||
List<LightApp> lightAllList = new ArrayList<>();
|
||||
List<Integer> bschAppIds = desktopAppList.stream()
|
||||
.filter(x -> x.getAppType() == DesktopAppType.Bsch)
|
||||
.map(UserSettingsDesktopApp::getAppId)
|
||||
.collect(Collectors.toList());
|
||||
List<Integer> lightAppIds = desktopAppList.stream()
|
||||
.filter(x -> x.getAppType() == DesktopAppType.Light)
|
||||
.map(UserSettingsDesktopApp::getAppId)
|
||||
.collect(Collectors.toList());
|
||||
if (bschAppIds.size() > 0) {
|
||||
List<BoaAppType> appTypeList = YschBaseOpenApi.getUserApp(authUserWithDept.getId(), ClientEnum.PC_BROWSER);
|
||||
for (BoaAppType mode : appTypeList) bschAllList.addAll(mode.getApps());
|
||||
}
|
||||
if (lightAppIds.size() > 0) {
|
||||
lightAllList = userSettingsService.findLightAppList(schId, userId);
|
||||
}
|
||||
desktopAppList = desktopAppList.stream()
|
||||
.sorted(Comparator.comparing(UserSettingsDesktopApp::getOrderNum, Comparator.nullsLast(Integer::compareTo)))
|
||||
.collect(Collectors.toList());
|
||||
for (UserSettingsDesktopApp desktopApp : desktopAppList) {
|
||||
Map map = new HashMap();
|
||||
map.put("id", desktopApp.getId());
|
||||
map.put("appId", desktopApp.getAppId());
|
||||
if (desktopApp.getAppType() == DesktopAppType.Bsch) {
|
||||
Optional<BoaApp> optionalBoaApp = bschAllList.stream()
|
||||
.filter(boa -> desktopApp.getAppId().equals(boa.getId())).findFirst();
|
||||
if (!optionalBoaApp.isPresent()) continue;
|
||||
map.put("name", optionalBoaApp.get().getName());
|
||||
map.put("icon", optionalBoaApp.get().getIcon());
|
||||
map.put("url", optionalBoaApp.get().getWebAddr());
|
||||
map.put("type", DesktopAppType.Bsch.getKey());
|
||||
map.put("linkType", LinkOpenType.Open.getKey());
|
||||
map.put("layout", null);
|
||||
// 矢量图标键(AppIcon 通道;未配置为 null,前端按空走原 img 回退)
|
||||
AppIconEntryVo iconEntry = appIconKeyService.byId(optionalBoaApp.get().getId());
|
||||
map.put("iconKey", iconEntry == null ? null : iconEntry.getIconKey());
|
||||
map.put("iconColor", iconEntry == null ? null : iconEntry.getIconColor());
|
||||
} else if (desktopApp.getAppType() == DesktopAppType.Light) {
|
||||
Optional<LightApp> optionalLightApp = lightAllList.stream()
|
||||
.filter(app -> desktopApp.getAppId().equals(app.getId())).findFirst();
|
||||
if (!optionalLightApp.isPresent()) continue;
|
||||
map.put("name", optionalLightApp.get().getName());
|
||||
map.put("icon", optionalLightApp.get().getIcon());
|
||||
map.put("url", optionalLightApp.get().getUrl());
|
||||
map.put("type", DesktopAppType.Light.getKey());
|
||||
map.put("linkType", optionalLightApp.get().getLinkOpenType());
|
||||
map.put("layout", optionalLightApp.get().getLayout());
|
||||
}
|
||||
result.add(map);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveUserWidget", produces = "application/json")
|
||||
public void saveUserWidget(BoaUser authUserWithDept, @RequestBody UserSettings userSettings) {
|
||||
userSettings.setUserId(authUserWithDept.getId());
|
||||
userSettingsService.saveUserWidget(userSettings);
|
||||
}
|
||||
|
||||
@PostMapping(value = "toggleWidgetShow", produces = "application/json")
|
||||
public void toggleWidgetShow(BoaUser authUserWithDept, @RequestBody Map<String, Integer> params) {
|
||||
Integer id = params.get("id");
|
||||
userSettingsService.toggleWidgetShow(id, authUserWithDept.getId());
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveUserAppIds", produces = "application/json")
|
||||
public void saveUserAppIds(BoaUser authUserWithDept, @RequestBody Map<String, String> params) {
|
||||
Integer id = null;
|
||||
if (params.get("id") != null) id = Integer.parseInt(params.get("id"));
|
||||
String appIds = params.get("appIds");
|
||||
userSettingsService.saveUserAppIds(id, authUserWithDept.getId(), appIds);
|
||||
}
|
||||
|
||||
// ==================== 轻应用 ====================
|
||||
|
||||
@GetMapping(value = "getLightAppGroup")
|
||||
public Object getLightAppGroup(BoaUser authUserWithDept) {
|
||||
return EnumUtil.toMapList(LightAppGroup.class);
|
||||
}
|
||||
|
||||
@GetMapping(value = "getLightAppList", produces = "application/json")
|
||||
public List<LightApp> getLightAppList(BoaUser authUserWithDept) {
|
||||
Integer schId, userId = null;
|
||||
if (authUserWithDept.getType() == 1) {
|
||||
schId = 0;
|
||||
} else {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!currentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole currentRole = currentRoleOpt.get();
|
||||
if (currentRole.getRoleId() == 7 || currentRole.getRoleId() == 2) {
|
||||
schId = currentRole.getSchId();
|
||||
} else {
|
||||
schId = currentRole.getSchId();
|
||||
userId = currentRole.getUserId();
|
||||
}
|
||||
}
|
||||
return userSettingsService.findLightAppList(schId, userId);
|
||||
}
|
||||
|
||||
@PostMapping(value = "saveLightApp", produces = "application/json")
|
||||
public void saveLightApp(BoaUser authUserWithDept, @RequestBody LightApp lightApp) {
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(authUserWithDept.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
Integer schId, userId = null;
|
||||
if (authUserWithDept.getType() == 1) {
|
||||
schId = 0;
|
||||
} else if (currentRole.getRoleId() == 7 || currentRole.getRoleId() == 2) {
|
||||
schId = currentRole.getSchId();
|
||||
} else {
|
||||
schId = currentRole.getSchId();
|
||||
userId = currentRole.getUserId();
|
||||
}
|
||||
lightApp.setUserId(userId);
|
||||
lightApp.setSchId(schId);
|
||||
lightApp.setCId(authUserWithDept.getId());
|
||||
lightApp.setCDate(new Date());
|
||||
userSettingsService.saveLightApp(lightApp);
|
||||
}
|
||||
|
||||
@PostMapping(value = "deleteLightAppById", produces = "application/json")
|
||||
public void deleteLightAppById(BoaUser authUserWithDept, Integer id) {
|
||||
userSettingsService.deleteLightAppById(id);
|
||||
}
|
||||
|
||||
// ==================== 桌面应用 ====================
|
||||
|
||||
@PostMapping(value = "saveUserDesktopAppById", produces = "application/json")
|
||||
public void saveUserDesktopAppById(BoaUser authUserWithDept, @RequestBody Map<String, Integer> params) {
|
||||
Integer id = params.get("id");
|
||||
Integer type = params.get("type");
|
||||
DesktopAppType desktopAppType = DesktopAppType.valueOf(type);
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||
if (!optional.isPresent() || optional.get().getDesktopApps().isEmpty()) {
|
||||
initUserSettingsDesktopApp(authUserWithDept.getId());
|
||||
optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||
}
|
||||
UserSettings userSettings = optional.get();
|
||||
if (userSettings.getDesktopApps().stream().anyMatch(x -> x.getAppId().equals(id)))
|
||||
throw new BizException(ErrorCode.CUSTOM, "该应用已存在于桌面,请勿重复添加!");
|
||||
|
||||
UserSettingsDesktopApp desktopApp = new UserSettingsDesktopApp();
|
||||
desktopApp.setAppType(desktopAppType);
|
||||
desktopApp.setAppId(id);
|
||||
desktopApp.setUserSettingsId(userSettings.getId());
|
||||
userSettingsService.saveDesktopApp(desktopApp);
|
||||
}
|
||||
|
||||
@PostMapping(value = "deleteDesktopAppById", produces = "application/json")
|
||||
public void deleteDesktopAppById(BoaUser authUserWithDept, @RequestBody Map<String, Integer> params) {
|
||||
Integer id = params.get("id");
|
||||
Integer appId = params.get("appId");
|
||||
if (id != null) {
|
||||
userSettingsService.deleteDesktopAppById(id);
|
||||
} else {
|
||||
Integer userId = authUserWithDept.getId();
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(userId);
|
||||
List<UserSettingsDesktopApp> desktopList = getUserUsedByUserId(userId);
|
||||
desktopList = desktopList.stream().filter(x -> !x.getAppId().equals(appId)).collect(Collectors.toList());
|
||||
UserSettings model;
|
||||
if (!optional.isPresent()) model = userSettingsService.initUserSettings(userId);
|
||||
else model = optional.get();
|
||||
desktopList.forEach(x -> x.setUserSettingsId(model.getId()));
|
||||
userSettingsService.saveDesktopAppList(desktopList);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "sortUserDesktopApp", produces = "application/json")
|
||||
public void sortUserDesktopApp(BoaUser authUserWithDept, @RequestBody Map<String, String> params) {
|
||||
String idsStr = params.get("ids");
|
||||
List<Integer> ids = Arrays.stream(idsStr.split(",")).map(s -> Integer.parseInt(s.trim())).collect(Collectors.toList());
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
|
||||
List<UserSettingsDesktopApp> desktopApps = new ArrayList<>();
|
||||
if (!optional.isPresent() || optional.get().getDesktopApps().isEmpty()) {
|
||||
desktopApps = initUserSettingsDesktopApp(authUserWithDept.getId());
|
||||
desktopApps.forEach(x -> {
|
||||
Integer i = ids.indexOf(x.getAppId());
|
||||
x.setOrderNum(i);
|
||||
});
|
||||
} else {
|
||||
desktopApps = optional.get().getDesktopApps();
|
||||
desktopApps.forEach(x -> {
|
||||
Integer i = ids.indexOf(x.getId());
|
||||
x.setOrderNum(i);
|
||||
});
|
||||
}
|
||||
userSettingsService.saveDesktopAppList(desktopApps);
|
||||
}
|
||||
|
||||
// ==================== 头像 ====================
|
||||
|
||||
/**
|
||||
* 上传头像:校验后经 OpenAPI 转发至 UserBase 统一存储(压缩并写入 sys_user.profile_picture)。<br>
|
||||
* 前端调用:UserInfoModal 更换头像 → POST /api/avatarUpload。<br>
|
||||
* 注意不复用 checkFile(其 5MB 上限被壁纸/图标共用),头像单独限制 20MB(与前端一致,拍照原图普遍偏大)。
|
||||
*/
|
||||
@PostMapping(value = "avatarUpload", produces = "application/json")
|
||||
public Map<String, String> avatarUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
|
||||
}
|
||||
if (file.getSize() > 20L * 1024 * 1024) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "图片大小不能超过20MB");
|
||||
}
|
||||
String url;
|
||||
try {
|
||||
url = openApiUserClient.updateUserProfilePicture(
|
||||
authUserWithDept.getId(), file.getBytes(), file.getOriginalFilename());
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
|
||||
}
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("url", url);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
private String upload(@RequestParam("file") MultipartFile file, String pathName) {
|
||||
File fl = new File(FILE_PATH + "/" + pathName + "/");
|
||||
if (!fl.exists()) fl.mkdirs();
|
||||
String fileName = FileUtil.uploadImg(file, FILE_PATH + "/" + pathName + "/");
|
||||
return "/upload/" + pathName + "/" + fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件前置校验:非空 + 大小上限(5MB)。扩展名白名单在 FileUtil 内校验。
|
||||
*/
|
||||
private void checkFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
|
||||
}
|
||||
if (file.getSize() > 5 * 1024 * 1024) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "图片大小不能超过5MB");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化前端回传的壁纸 URL:去掉 FILE_WEB_PATH 前缀,并还原 getUserWallpaperList
|
||||
* 输出时被 replace("/upload","") 移除的 /upload 前缀,得到与 wall 表存储一致的形式。
|
||||
*/
|
||||
private String normalizeWallpaperUrl(String url) {
|
||||
if (url == null) return null;
|
||||
String u = url.trim();
|
||||
if (FILE_WEB_PATH != null && !FILE_WEB_PATH.isEmpty() && u.startsWith(FILE_WEB_PATH)) {
|
||||
u = u.substring(FILE_WEB_PATH.length());
|
||||
}
|
||||
if (u.startsWith("/wallpaper/") || u.startsWith("/icon/") || u.startsWith("/logo/")) {
|
||||
u = "/upload" + u;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将某个人常用应用转化为桌面应用
|
||||
*/
|
||||
private List<UserSettingsDesktopApp> initUserSettingsDesktopApp(Integer userId) {
|
||||
Optional<UserSettings> optional = userSettingsService.findUserSettings(userId);
|
||||
List<UserSettingsDesktopApp> desktopList = getUserUsedByUserId(userId);
|
||||
UserSettings model;
|
||||
if (!optional.isPresent()) model = userSettingsService.initUserSettings(userId);
|
||||
else model = optional.get();
|
||||
desktopList.forEach(x -> x.setUserSettingsId(model.getId()));
|
||||
return userSettingsService.saveDesktopAppList(desktopList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取常用应用转化为桌面应用类型
|
||||
*/
|
||||
private List<UserSettingsDesktopApp> getUserUsedByUserId(Integer userId) {
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(userId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
Optional<UsedApp> optionalUsedApp = usedAppService.findById(currentRole.getSchId(), currentRole.getUserId(), currentRole.getRoleId());
|
||||
List<UserSettingsDesktopApp> desktopAppList = new ArrayList<>();
|
||||
if (optionalUsedApp.isPresent() && optionalUsedApp.get().getAppIds() != null && !optionalUsedApp.get().getAppIds().isEmpty()) {
|
||||
List<String> appIds = Arrays.asList(optionalUsedApp.get().getAppIds().split(","));
|
||||
List<Integer> bschAppIds = appIds.stream().mapToInt(Integer::parseInt).boxed().collect(Collectors.toList());
|
||||
Integer i = 0;
|
||||
for (Integer appId : bschAppIds) {
|
||||
UserSettingsDesktopApp desktopApp = new UserSettingsDesktopApp();
|
||||
desktopApp.setAppId(appId);
|
||||
desktopApp.setAppType(DesktopAppType.Bsch);
|
||||
desktopApp.setOrderNum(i);
|
||||
desktopAppList.add(desktopApp);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return desktopAppList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.yunhai.userhome.controller.api;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||
import com.yunhai.userhome.service.WidgetDataService;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.util.DigestUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。
|
||||
*
|
||||
* <p>写权限按「目录权 / 校级权」拆开:全局目录(新增 / 改内容 / 删除 / 三态可见范围 / 角色命名)仅超管;
|
||||
* 校级覆盖(启用 / 可见角色 / 排序 / 别名)超管或校管,且校管只能写本校。</p>
|
||||
*
|
||||
* <p>真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH)
|
||||
public class WidgetController {
|
||||
|
||||
private static final int COMP_TYPE_FRAGMENT = 2;
|
||||
|
||||
@Autowired
|
||||
private IWidgetCatalogService widgetCatalogService;
|
||||
|
||||
@Autowired
|
||||
private WidgetDataService widgetDataService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
/** 统一取数入口:params 为 urlencoded 查询串;未配置的数据键回空数据,不抛异常 */
|
||||
@GetMapping(value = "getWidgetData", produces = "application/json")
|
||||
public Object getWidgetData(BoaUser authUserWithDept,
|
||||
@RequestParam(value = "code", required = false) String code,
|
||||
@RequestParam(value = "params", required = false) String params) {
|
||||
Object data = widgetDataService.getData(code, params, resolveCaller(authUserWithDept));
|
||||
if (data != null) {
|
||||
return data;
|
||||
}
|
||||
Map<String, Object> empty = new HashMap<>();
|
||||
empty.put("msg", "该小工具暂无可用数据源:" + code);
|
||||
return empty;
|
||||
}
|
||||
|
||||
/** 仅 comp_type=2:按需取片段内容(不塞进 getUserSettings,避免每次进桌面下发全部 HTML) */
|
||||
@GetMapping(value = "getWidgetContent", produces = "application/json")
|
||||
public Map<String, Object> getWidgetContent(BoaUser authUserWithDept, HttpServletResponse response,
|
||||
@RequestParam(value = "code", required = false) String code) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("content", "");
|
||||
Optional<WidgetCatalog> optional = widgetCatalogService.findByCode(code);
|
||||
if (!optional.isPresent()) {
|
||||
result.put("msg", "小工具不存在:" + code);
|
||||
return result;
|
||||
}
|
||||
WidgetCatalog catalog = optional.get();
|
||||
if (!widgetCatalogService.visibleToSchool(catalog, currentSchId(authUserWithDept))) {
|
||||
result.put("msg", "该小工具未对本校下发");
|
||||
return result;
|
||||
}
|
||||
if (catalog.getCompType() == null || catalog.getCompType() != COMP_TYPE_FRAGMENT) {
|
||||
result.put("msg", "内置组件的内容在 front 工程里,无按需取内容这条路:" + code);
|
||||
return result;
|
||||
}
|
||||
String content = catalog.getContent() == null ? "" : catalog.getContent();
|
||||
result.put("code", catalog.getCode());
|
||||
result.put("name", catalog.getName());
|
||||
result.put("compType", catalog.getCompType());
|
||||
result.put("dataCode", catalog.getDataCode());
|
||||
result.put("content", content);
|
||||
// 内容 hash 派生 ETag:内容或更新时间变更即换标签,便于前端条件请求复用
|
||||
response.setHeader("ETag", "\"" + contentHash(content, catalog.getUpdateTime()) + "\"");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 管理端列表(超管 / 校管):按视角返回——超管=全局原值,校管=本校生效值 + 原名 + 覆盖标记 */
|
||||
@GetMapping(value = "GetWidgetCatalogList", produces = "application/json")
|
||||
public List<WidgetCatalog> getWidgetCatalogList(BoaUser authUserWithDept) {
|
||||
checkSchAdmin(authUserWithDept);
|
||||
return widgetCatalogService.findEffectiveForAdmin(currentSchId(authUserWithDept), isSuperAdmin(authUserWithDept));
|
||||
}
|
||||
|
||||
/** 管理端保存全局目录:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
|
||||
@PostMapping(value = "SaveWidgetCatalog", produces = "application/json")
|
||||
public void saveWidgetCatalog(BoaUser authUserWithDept, @RequestBody WidgetCatalog widgetCatalog) {
|
||||
checkSuperAdmin(authUserWithDept);
|
||||
widgetCatalogService.save(widgetCatalog);
|
||||
}
|
||||
|
||||
/** 删除全局目录行,连带删可见范围点名表与校级覆盖行 */
|
||||
@PostMapping(value = "DeleteWidgetCatalog", produces = "application/json")
|
||||
public void deleteWidgetCatalog(BoaUser authUserWithDept, @RequestParam(value = "id") Integer id) {
|
||||
checkSuperAdmin(authUserWithDept);
|
||||
widgetCatalogService.deleteById(id);
|
||||
}
|
||||
|
||||
/** 读取本校覆盖行(编辑回显):只认当前登录校 */
|
||||
@GetMapping(value = "GetWidgetSchoolSetting", produces = "application/json")
|
||||
public WidgetSchoolSetting getWidgetSchoolSetting(BoaUser authUserWithDept,
|
||||
@RequestParam(value = "widgetId") Integer widgetId) {
|
||||
checkSchAdmin(authUserWithDept);
|
||||
return widgetCatalogService.findSchoolSetting(widgetId, currentSchId(authUserWithDept)).orElse(null);
|
||||
}
|
||||
|
||||
/** 保存本校覆盖:schId 强制取当前登录校,不接受前端传入 */
|
||||
@PostMapping(value = "SaveWidgetSchoolSetting", produces = "application/json")
|
||||
public void saveWidgetSchoolSetting(BoaUser authUserWithDept, @RequestBody WidgetSchoolSetting setting) {
|
||||
checkSchAdmin(authUserWithDept);
|
||||
widgetCatalogService.saveSchoolSetting(setting, currentSchId(authUserWithDept));
|
||||
}
|
||||
|
||||
private Integer currentSchId(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
return currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取数身份上下文(userid / roleId / schId 全部会话推导,前端不可传):
|
||||
* userid = 平台用户ID(BoaUser.id);roleId/schId = 当前激活身份(UserBase /oapi/identity/current)。
|
||||
*/
|
||||
private WidgetDataService.Caller resolveCaller(BoaUser authUserWithDept) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
|
||||
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
|
||||
return new WidgetDataService.Caller(authUserWithDept.getId(), roleId, schId);
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(BoaUser authUserWithDept) {
|
||||
return authUserWithDept.getType() == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录权校验:仅超管(type==1)可写全局目录 / 删除 / 三态可见范围 / 角色命名 / 登记内置。
|
||||
* 注:平台 yh-api-std 的异常处理器无 @ResponseStatus,拒绝表现为 HTTP 200 + 信封 code=3(非 500)。
|
||||
*/
|
||||
private void checkSuperAdmin(BoaUser authUserWithDept) {
|
||||
if (authUserWithDept.getType() != 1) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/** 校级权校验:超管或校管理员(roleId∈{2,5,7}),其余拒绝 */
|
||||
private void checkSchAdmin(BoaUser authUserWithDept) {
|
||||
if (authUserWithDept.getType() == 1) {
|
||||
return;
|
||||
}
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
|
||||
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private String contentHash(String content, java.util.Date updateTime) {
|
||||
long stamp = updateTime == null ? 0L : updateTime.getTime();
|
||||
return DigestUtils.md5DigestAsHex((stamp + "\n" + content).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.userhome.controller.oapi;
|
||||
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import com.yunhai.userhome.service.IAppearanceService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 外观偏好开放接口(oapi 代理轮 2026-10-08):子应用后端经 open-gateway 签名直调(target=userhome),
|
||||
* 替代浏览器直连 /api/appearance + CAS iframe 引导。入站验签由 {@code OpenApiAuthEnforceConfiguration}
|
||||
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
|
||||
*
|
||||
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
|
||||
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感;
|
||||
* logoText 已于 2026-10-09 定稿摘除——子应用不再消费 LOGO 文字,门户走 /api/schLogoText 自有链)。
|
||||
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.OPEN_API_PATH)
|
||||
public class OapiAppearanceController {
|
||||
|
||||
@Autowired
|
||||
private IAppearanceService appearanceService;
|
||||
|
||||
/** 当前用户生效外观。userId 由调用方(子应用后端)从其登录态取,服务间签名保证调用方可信。 */
|
||||
@GetMapping("appearance")
|
||||
public Map<String, Object> appearance(@RequestParam("userId") Integer userId) {
|
||||
return appearanceService.getEffectiveAppearance(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 轻应用数据实体(对齐旧 UserHome model.LightApp)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "light_app")
|
||||
public class LightApp {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = UsedApp.Update.class)
|
||||
private Integer id;
|
||||
|
||||
private String name;
|
||||
|
||||
private Integer groupId;
|
||||
|
||||
private String icon;
|
||||
|
||||
private String url;
|
||||
|
||||
@Column(name = "desc_txt")
|
||||
private String desc;
|
||||
|
||||
@JsonIgnore
|
||||
private Boolean isPublic = false;
|
||||
|
||||
@Transient
|
||||
private Boolean isEdit = false;
|
||||
|
||||
private Integer linkOpenType;
|
||||
|
||||
private String layout;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
private Integer userId;
|
||||
|
||||
@Column(name = "c_id")
|
||||
private Integer cId;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* Logo 实体(镜像 Wallpaper 模式:超管设置系统默认,学校设置自身)。
|
||||
* <p>type:0 系统默认(schId=0) | 1 学校(schId=学校ID);一校一行,type+sch_id 唯一。</p>
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "logo", uniqueConstraints = {@UniqueConstraint(columnNames = {"type", "sch_id"}, name = "uk_type_sch")})
|
||||
public class Logo {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = Update.class)
|
||||
private Integer id;
|
||||
|
||||
/** 0:系统默认 | 1:学校 */
|
||||
private Integer type;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
private Integer userId;
|
||||
|
||||
@Column(name = "logo_url")
|
||||
private String logoUrl;
|
||||
|
||||
@Transient
|
||||
private Boolean isEdit = false;
|
||||
|
||||
public @interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.CollectionTable;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.ElementCollection;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.FetchType;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 菜单栏实体类(对齐旧 UserHome model.MenuBar)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "menu_bar")
|
||||
public class MenuBar {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = Update.class)
|
||||
private Integer id;
|
||||
|
||||
private String name;
|
||||
|
||||
private String url;
|
||||
|
||||
private String icon;
|
||||
|
||||
private Integer linkOpenType;
|
||||
|
||||
private Boolean isNative = false;
|
||||
|
||||
private Boolean isHeader = false;
|
||||
|
||||
private Boolean isBottom = false;
|
||||
|
||||
private Integer orderNum;
|
||||
|
||||
private Integer schId;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "menu_bar_role")
|
||||
@Column(name = "role_ids")
|
||||
private List<Integer> roleIds = new ArrayList<>();
|
||||
|
||||
@JsonIgnore
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "menu_bar_hide_sch")
|
||||
@Column(name = "sch_ids")
|
||||
private List<Integer> hideSchIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "c_id")
|
||||
private Integer cId;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
@Transient
|
||||
private Boolean isShow = true;
|
||||
|
||||
@Transient
|
||||
private Boolean isEdit = false;
|
||||
|
||||
public @interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
/**
|
||||
* 学校设置(sch_settings 表,字段名/表名对齐旧 UserHome model.SchSettings)。
|
||||
* <p>本工程学校设置完全本地化:saveSchTheme/saveSchLogo 写入本地表,getSchSettings 直接查本地表,
|
||||
* 不再依赖旧版跨仓 {@code SCH_CONF_URL/oapi/getSchSetting}。</p>
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sch_settings")
|
||||
public class SchSettings {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "theme_name")
|
||||
private String themeName;
|
||||
|
||||
/** 学校默认头像样式(默认头像轮 2026-09-30):text|silhouette;null=未配置(读侧可空不兜底) */
|
||||
@Column(name = "avatar_style")
|
||||
private String avatarStyle;
|
||||
|
||||
@Column(name = "logo_url")
|
||||
private String logoUrl;
|
||||
|
||||
/** LOGO 配套平台/学校名称文字(LOGO 图在 logo 表池,文字是单值配置随 settings;NULL=未配置回落上级) */
|
||||
@Column(name = "logo_text")
|
||||
private String logoText;
|
||||
|
||||
/** 默认壁纸(壁纸三级默认轮 2026-10-09;绝对 URL,管理员从本级壁纸池选定;NULL=未配置回落上级。
|
||||
* 个人未自选时生效链:本校默认 → 平台默认(schId=0 行)→ 壁纸池最新一张(历史兜底) */
|
||||
@Column(name = "wallpaper_url")
|
||||
private String wallpaperUrl;
|
||||
|
||||
@Column(name = "home_img_url")
|
||||
private String homeImgUrl;
|
||||
|
||||
@Column(name = "app_background_url")
|
||||
private String appBgUrl;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
// 是否开启桌面
|
||||
private Boolean isEnableDesktop = false;
|
||||
|
||||
// 是否默认跳转桌面
|
||||
private Boolean isLinkDesktop = false;
|
||||
|
||||
// 操作人id
|
||||
@Column(name = "c_id")
|
||||
private Integer cId;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private java.util.Date cDate;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.CollectionTable;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.ElementCollection;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统计栏目实体(对齐旧 UserHome model.StatsBar)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "stats_bar")
|
||||
public class StatsBar {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = MenuBar.Update.class)
|
||||
private Integer id;
|
||||
|
||||
private String name;
|
||||
|
||||
private String url;
|
||||
|
||||
private String icon;
|
||||
|
||||
private Integer linkOpenType;
|
||||
|
||||
private Integer orderNum;
|
||||
|
||||
private Integer schId;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "stats_bar_role")
|
||||
@Column(name = "role_ids")
|
||||
private List<Integer> roleIds = new ArrayList<>();
|
||||
|
||||
@JsonIgnore
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "stats_bar_show_sch")
|
||||
@Column(name = "sch_ids")
|
||||
private List<Integer> showSchIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "c_id")
|
||||
private Integer cId;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
@Transient
|
||||
private Boolean isShow = true;
|
||||
|
||||
@Transient
|
||||
private Boolean isEdit = false;
|
||||
|
||||
public @interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.CollectionTable;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.ElementCollection;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 教师助手实体(对齐旧 UserHome model.TeacherCopilot)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "teacher_copilot")
|
||||
public class TeacherCopilot {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = MenuBar.Update.class)
|
||||
private Integer id;
|
||||
|
||||
private String name;
|
||||
|
||||
private String url;
|
||||
|
||||
private String icon;
|
||||
|
||||
private Integer linkOpenType;
|
||||
|
||||
private Integer orderNum;
|
||||
|
||||
/** 数据类别 1课前 2课堂 3课后 */
|
||||
private Integer type;
|
||||
|
||||
private Integer schId;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "teacher_copilot_role")
|
||||
@Column(name = "role_ids")
|
||||
private List<Integer> roleIds = new ArrayList<>();
|
||||
|
||||
@JsonIgnore
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "teacher_copilot_show_sch")
|
||||
@Column(name = "sch_ids")
|
||||
private List<Integer> showSchIds = new ArrayList<>();
|
||||
|
||||
@Column(name = "c_id")
|
||||
private Integer cId;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
@Transient
|
||||
private Boolean isShow = true;
|
||||
|
||||
@Transient
|
||||
private Boolean isEdit = false;
|
||||
|
||||
public @interface Update {}
|
||||
|
||||
@Data
|
||||
public static class Dto {
|
||||
private Integer id;
|
||||
private String name;
|
||||
private String url;
|
||||
private String icon;
|
||||
private Integer openType;
|
||||
private Integer type;
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,7 @@ public class UsedApp extends BaseEntity {
|
||||
/** 操作时间。 */
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
/** 验证分组:更新操作时 id 必须非空(对齐旧 UserHome UsedApp.Update)。 */
|
||||
public @interface Update {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用户外观偏好(user_appearance 表,外观偏好服务端化 2026-10-08)。
|
||||
* <p>跨浏览器一致的真源,全局 per-user 一行(user_id 唯一)——与 localStorage 键同维度,
|
||||
* 刻意不挂 user_settings 的 sch/role 维度。</p>
|
||||
* <p>theme_state 存紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code ocean|1|0|1});
|
||||
* avatar_style 取值 {@code text|silhouette};两列 NULL = 跟随学校默认。</p>
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "user_appearance", uniqueConstraints = {@UniqueConstraint(columnNames = "user_id", name = "uk_user")})
|
||||
public class UserAppearance {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 用户自选主题档紧凑编码;NULL=跟随学校默认 */
|
||||
@Column(name = "theme_state")
|
||||
private String themeState;
|
||||
|
||||
/** 用户自选头像样式 text|silhouette;NULL=跟随学校默认 */
|
||||
@Column(name = "avatar_style")
|
||||
private String avatarStyle;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
@Column(name = "u_date")
|
||||
private Date uDate;
|
||||
}
|
||||
@@ -1,30 +1,16 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 用户当前激活角色(会话态,本地表 user_current_role)。
|
||||
* 对应旧 UserHome 同名单表:一用户一行,记录当前角色 + 学校。
|
||||
* 用户当前激活角色(纯 DTO,非持久化)。<br>
|
||||
* 自多角色迁移方案落地后,UserHome 不再持有本地角色存储——数据由 UserBase 单一权威源提供:
|
||||
* {@code UserCurrentRoleServiceImpl.findById} 经 SDK 调 UserBase {@code /oapi/identity/current}。
|
||||
* 字段与 {@code BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName)完全一致,保留以稳定既有调用点。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "user_current_role",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"userId"}),
|
||||
indexes = {@Index(columnList = "userId")}
|
||||
)
|
||||
public class UserCurrentRole {
|
||||
|
||||
@Id
|
||||
@JsonIgnore
|
||||
private Integer userId;
|
||||
|
||||
private Integer roleId;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用户消息表(user_message,字段名/表名对齐旧 UserHome model.UserMessage)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "user_message")
|
||||
public class UserMessage {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "app_id")
|
||||
private Integer appId;
|
||||
|
||||
@Column(name = "c_date")
|
||||
private Date cDate;
|
||||
|
||||
@Column(name = "imgs")
|
||||
private String imgs;
|
||||
|
||||
private String urls;
|
||||
|
||||
@Column(name = "btn_texts")
|
||||
private String btnTexts;
|
||||
|
||||
private String content;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 消息类型(0文字/1图文/2审批,对齐旧 MsgType)。 */
|
||||
private Integer type;
|
||||
|
||||
@Column(name = "is_read")
|
||||
private Boolean isRead = false;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.CascadeType;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.OneToMany;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户个性化配置(对齐旧 UserHome model.UserSettings)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "user_settings", indexes = {@Index(columnList = "user_id,sch_id,role_id", name = "index_setting_user_sch_role")})
|
||||
public class UserSettings {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = Update.class)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "widget_app_ids")
|
||||
private String widgetAppIds;
|
||||
|
||||
@Column(name = "is_widget_show")
|
||||
private Boolean isWidgetShow = true;
|
||||
|
||||
@Column(name = "width")
|
||||
private Integer width;
|
||||
|
||||
@Column(name = "height")
|
||||
private Integer height;
|
||||
|
||||
@Column(name = "x_axis")
|
||||
private Integer xAxis;
|
||||
|
||||
@Column(name = "y_axis")
|
||||
private Integer yAxis;
|
||||
|
||||
@Column(name = "wallpaper_url")
|
||||
private String wallpaperUrl;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
@Column(name = "role_id")
|
||||
private Integer roleId;
|
||||
|
||||
@JsonIgnore
|
||||
@OneToMany(mappedBy = "userSettings", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<UserSettingsDesktopApp> desktopApps = new ArrayList<>();
|
||||
|
||||
@Transient
|
||||
private List<Map> widgetAppList;
|
||||
|
||||
public @interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import com.yunhai.userhome.enums.DesktopAppType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.EnumType;
|
||||
import javax.persistence.Enumerated;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.JoinColumn;
|
||||
import javax.persistence.ManyToOne;
|
||||
import javax.persistence.Table;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 用户设置桌面应用(对齐旧 UserHome model.UserSettingsDesktopApp)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "user_settings_desktop_app")
|
||||
public class UserSettingsDesktopApp {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = UserSettings.Update.class)
|
||||
private Integer id;
|
||||
|
||||
private Integer appId;
|
||||
|
||||
@Enumerated(EnumType.ORDINAL)
|
||||
private DesktopAppType appType;
|
||||
|
||||
private Integer orderNum;
|
||||
|
||||
@Column(name = "user_settings_id")
|
||||
private Integer userSettingsId;
|
||||
|
||||
@ManyToOne(cascade = {javax.persistence.CascadeType.PERSIST}, optional = false)
|
||||
@JoinColumn(name = "user_settings_id", insertable = false, updatable = false)
|
||||
private UserSettings userSettings;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 壁纸实体(对齐旧 UserHome model.Wallpaper)。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "wallpaper")
|
||||
public class Wallpaper {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@NotNull(groups = Update.class)
|
||||
private Integer id;
|
||||
|
||||
/** 0:随机壁纸 | 1:个人上传壁纸 */
|
||||
private Integer type;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
private Integer userId;
|
||||
|
||||
@Column(name = "wallpaper_url")
|
||||
private String wallpaperUrl;
|
||||
|
||||
@Transient
|
||||
private Boolean isEdit = false;
|
||||
|
||||
public @interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Transient;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 小组件候选池(widget_catalog 表):内容自持,全表无 html 地址概念。
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "widget_catalog", uniqueConstraints = @UniqueConstraint(columnNames = "code"))
|
||||
public class WidgetCatalog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/** 内置组件 = 前端 widgetRegistry 的键;自定义片段 = 自定义标识 */
|
||||
@Column(name = "code", nullable = false, length = 32)
|
||||
private String code;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
/** 1 = front 工程内置组件(内容在代码库,本行不存 content);2 = 自定义 HTML 片段(内容在 content) */
|
||||
@Column(name = "comp_type", nullable = false)
|
||||
private Integer compType = 1;
|
||||
|
||||
/** 仅 comp_type=2:片段 HTML 源码 */
|
||||
@Column(name = "content", columnDefinition = "mediumtext")
|
||||
private String content;
|
||||
|
||||
/** 片段取数用的数据键,须在 WidgetDataService 白名单内 */
|
||||
@Column(name = "data_code", length = 32)
|
||||
private String dataCode;
|
||||
|
||||
/** 下发范围:旧语义(NULL = 全校可见)已被 scopeType 取代,字段保留仅为兼容存量数据,读侧不再使用 */
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
/** 下发范围三态:0 = 全部学校 1 = 白名单(仅点名的学校可见) 2 = 黑名单(点名的学校不可见) */
|
||||
@Column(name = "scope_type", nullable = false)
|
||||
private Integer scopeType = 0;
|
||||
|
||||
/** 可见角色:逗号分隔 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生);NULL 或空 = 全角色 */
|
||||
@Column(name = "role_types", length = 16)
|
||||
private String roleTypes;
|
||||
|
||||
/** 角色专属命名(JSON 文本,如 {"2":"全校课表"},键 = RoleTypeEnum);读侧 Jackson 解析,失败按无角色命名降级 */
|
||||
@Column(name = "role_alias", length = 255)
|
||||
private String roleAlias;
|
||||
|
||||
/**
|
||||
* 外跳目标子应用:应用注册表(UserBase /oapi/app/by-code/{code})的 app_code,如 work-week-plan。
|
||||
* NULL = 该组件不显示外跳入口。地址由 linkPath + 注册表 pc_url 解析而成,**不落库绝对 URL**。
|
||||
*/
|
||||
@Column(name = "link_app_code", length = 32)
|
||||
private String linkAppCode;
|
||||
|
||||
/** 外跳路由(hash 路由,如 /weekplan);拼接口径同消息中心 jumpUrl:归一化 pc_url + "#" + 本路径 */
|
||||
@Column(name = "link_path", length = 128)
|
||||
private String linkPath;
|
||||
|
||||
@Column(name = "sort", nullable = false)
|
||||
private Integer sort = 0;
|
||||
|
||||
/** 1 = 进候选池,0 = 下线 */
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private Integer enabled = 1;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private Date updateTime;
|
||||
|
||||
/** 管理端视图:scopeType=1/2 时点名的学校 id 列表(非持久化,随 SaveWidgetCatalog 一并提交) */
|
||||
@Transient
|
||||
private List<Integer> scopeSchIds;
|
||||
|
||||
/** 管理端视图(校管视角):本校别名(widget_school_setting.alias,非持久化) */
|
||||
@Transient
|
||||
private String alias;
|
||||
|
||||
/** 管理端视图(校管视角):超管下发的全局名称(非持久化,用于弹窗回显原名) */
|
||||
@Transient
|
||||
private String originalName;
|
||||
|
||||
/** 管理端视图(校管视角):本校是否已有覆盖行(非持久化) */
|
||||
@Transient
|
||||
private Boolean covered;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 小组件可见范围点名表(widget_catalog_school):只存被点名的学校行。
|
||||
*
|
||||
* <p>配合 {@link WidgetCatalog#getScopeType()}:1(白名单)= 表中有记录的学校可见;
|
||||
* 2(黑名单)= 表中有记录的学校不可见。</p>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026-10-11
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "widget_catalog_school",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_widget_school", columnNames = {"widget_id", "sch_id"}))
|
||||
public class WidgetCatalogSchool {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/** widget_catalog.id */
|
||||
@Column(name = "widget_id", nullable = false)
|
||||
private Integer widgetId;
|
||||
|
||||
/** 被点名的学校 id */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Integer schId;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yunhai.userhome.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 小组件校级覆盖层(widget_school_setting):一校一行。
|
||||
*
|
||||
* <p>字段为 NULL 表示沿用全局值,生效值 = COALESCE(本行, widget_catalog 全局值);
|
||||
* 「一键清除覆盖」= 删除本行。</p>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026-10-11
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "widget_school_setting",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_widget_sch", columnNames = {"widget_id", "sch_id"}))
|
||||
public class WidgetSchoolSetting {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "widget_id", nullable = false)
|
||||
private Integer widgetId;
|
||||
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Integer schId;
|
||||
|
||||
/** NULL = 沿用全局 enabled */
|
||||
@Column(name = "enabled")
|
||||
private Integer enabled;
|
||||
|
||||
/** NULL = 沿用全局 role_types */
|
||||
@Column(name = "role_types", length = 16)
|
||||
private String roleTypes;
|
||||
|
||||
/** NULL = 沿用全局 sort */
|
||||
@Column(name = "sort_no")
|
||||
private Integer sortNo;
|
||||
|
||||
/** 本校别名,NULL = 沿用全局 name */
|
||||
@Column(name = "alias", length = 64)
|
||||
private String alias;
|
||||
|
||||
/** 本校覆盖的外跳子应用 code,NULL = 沿用全局 link_app_code */
|
||||
@Column(name = "link_app_code", length = 32)
|
||||
private String linkAppCode;
|
||||
|
||||
/** 本校覆盖的外跳路由,NULL = 沿用全局 link_path */
|
||||
@Column(name = "link_path", length = 128)
|
||||
private String linkPath;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private Date updateTime;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yunhai.userhome.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 桌面应用类型(对齐旧 UserHome enums.DesktopAppType)。
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public enum DesktopAppType {
|
||||
Bsch(1, "应用中心应用"),
|
||||
Light(2, "轻应用"),
|
||||
;
|
||||
|
||||
private Integer key;
|
||||
private String value;
|
||||
|
||||
public static DesktopAppType valueOf(Integer value) {
|
||||
switch (value) {
|
||||
case 1:
|
||||
return Bsch;
|
||||
case 2:
|
||||
return Light;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yunhai.userhome.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 轻应用分组类型(对齐旧 UserHome enums.LightAppGroup)。
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public enum LightAppGroup {
|
||||
|
||||
AI(1, "AI助手"),
|
||||
VR(2, "虚拟现实"),
|
||||
Program(3, "编程体验"),
|
||||
Tools(4, "在线工具"),
|
||||
Resource(5, "资源推荐"),
|
||||
Others(6, "其他"),
|
||||
;
|
||||
|
||||
private Integer key;
|
||||
private String value;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.userhome.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 链接打开方式(对齐旧 UserHome enums.LinkOpenType)。
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public enum LinkOpenType {
|
||||
|
||||
Open(1, "新窗口"),
|
||||
Href(2, "当前页"),
|
||||
IFrame(3, "嵌入页面"),
|
||||
;
|
||||
|
||||
private Integer key;
|
||||
private String value;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.userhome.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 桌面应用角色枚举(替代原 GetBaseDict 字典接口,前端本地枚举)。
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public enum RoleType {
|
||||
|
||||
SCHOOL_ADMIN(1, "校管理员"),
|
||||
TEACHER(2, "教师"),
|
||||
STUDENT(3, "学生"),
|
||||
;
|
||||
|
||||
private Integer key;
|
||||
private String value;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.userhome.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 统计日期类型(对齐旧 UserHome enums.StatsDateType)。
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public enum StatsDateType {
|
||||
Day(1, "今日"),
|
||||
Week(2, "本周"),
|
||||
Mont(3, "本月"),
|
||||
Academic(4, "本学期"),
|
||||
Total(5, "累计"),
|
||||
Custom(6, "自定义"),
|
||||
;
|
||||
|
||||
private Integer key;
|
||||
private String value;
|
||||
|
||||
public static StatsDateType valueOf(Integer value) {
|
||||
switch (value) {
|
||||
case 1:
|
||||
return Day;
|
||||
case 2:
|
||||
return Week;
|
||||
case 3:
|
||||
return Mont;
|
||||
case 4:
|
||||
return Academic;
|
||||
case 5:
|
||||
return Total;
|
||||
case 6:
|
||||
return Custom;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] UserHome 指标内存累加器(方案 §3)。<br>
|
||||
* 按 (uri, method, direction, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加,
|
||||
* 不走队列、不做同步 IO;每 60s 快照落库 api_metrics,失败加回不丢数。<br>
|
||||
* Nacos 可下发 api.metrics.enabled=false 整体停用。<br>
|
||||
* (本类自带 @EnableScheduling 见 ApiMetricsConfig,UserHome 主类原本未开启定时任务,
|
||||
* 删除本包后定时能力一并消失,不留痕迹。)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApiMetricsCollector {
|
||||
|
||||
/** 聚合维度 key(uri×method×direction×压测标记) */
|
||||
private static final class Dim {
|
||||
final String uri;
|
||||
final String method;
|
||||
final String direction;
|
||||
final boolean loadTest;
|
||||
|
||||
Dim(String uri, String method, String direction, boolean loadTest) {
|
||||
this.uri = uri;
|
||||
this.method = method;
|
||||
this.direction = direction;
|
||||
this.loadTest = loadTest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof Dim)) {
|
||||
return false;
|
||||
}
|
||||
Dim d = (Dim) o;
|
||||
return loadTest == d.loadTest && uri.equals(d.uri) && method.equals(d.method)
|
||||
&& direction.equals(d.direction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(uri, method, direction, loadTest);
|
||||
}
|
||||
}
|
||||
|
||||
/** 单维度三指标:次数 / 耗时累计 / 错误数 */
|
||||
private static final class Counter {
|
||||
final AtomicLong cnt = new AtomicLong();
|
||||
final AtomicLong cost = new AtomicLong();
|
||||
final AtomicLong err = new AtomicLong();
|
||||
}
|
||||
|
||||
private final ConcurrentHashMap<Dim, Counter> counters = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
private ApiMetricsRepository repository;
|
||||
|
||||
/** 埋点总开关(Nacos 可下发紧急关停) */
|
||||
@Value("${api.metrics.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
/** 入站 Filter / 出站切面调用:一次请求(或一次出站调用)结束时记一笔 */
|
||||
public void record(String uri, String method, String direction, long costMs, boolean error, boolean loadTest) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
Counter c = counters.computeIfAbsent(new Dim(uri, method, direction, loadTest), k -> new Counter());
|
||||
c.cnt.incrementAndGet();
|
||||
c.cost.addAndGet(costMs);
|
||||
if (error) {
|
||||
c.err.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
/** 快照窗口(ms),对齐方案 60s */
|
||||
@Scheduled(fixedDelayString = "${api.metrics.flush-interval-ms:60000}")
|
||||
public void flush() {
|
||||
if (!enabled || counters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LocalDateTime sliceTime = LocalDateTime.now();
|
||||
List<ApiMetricsEntity> rows = new ArrayList<>();
|
||||
List<Object[]> snapshot = new ArrayList<>(counters.size());
|
||||
for (ConcurrentHashMap.Entry<Dim, Counter> e : counters.entrySet()) {
|
||||
long cnt = e.getValue().cnt.getAndSet(0);
|
||||
long cost = e.getValue().cost.getAndSet(0);
|
||||
long err = e.getValue().err.getAndSet(0);
|
||||
if (cnt <= 0) {
|
||||
continue;
|
||||
}
|
||||
snapshot.add(new Object[]{e.getKey(), cnt, cost, err});
|
||||
ApiMetricsEntity row = new ApiMetricsEntity();
|
||||
row.setStatTime(sliceTime);
|
||||
row.setUri(e.getKey().uri);
|
||||
row.setMethod(e.getKey().method);
|
||||
row.setDirection(e.getKey().direction);
|
||||
row.setCnt(cnt);
|
||||
row.setTotalCost(cost);
|
||||
row.setErrCnt(err);
|
||||
row.setIsLoadTest(e.getKey().loadTest ? 1 : 0);
|
||||
rows.add(row);
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
repository.saveAll(rows);
|
||||
} catch (Exception ex) {
|
||||
// 落库失败:数值加回内存,并入下个窗口,不静默丢数
|
||||
log.warn("[LOADTEST-METRICS] api_metrics 落库失败,{} 行并入下个窗口: {}", rows.size(), ex.getMessage());
|
||||
for (Object[] s : snapshot) {
|
||||
Dim dim = (Dim) s[0];
|
||||
Counter c = counters.computeIfAbsent(dim, k -> new Counter());
|
||||
c.cnt.addAndGet((Long) s[1]);
|
||||
c.cost.addAndGet((Long) s[2]);
|
||||
c.err.addAndGet((Long) s[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] 埋点装配:注册入站指标过滤器 + 开启定时快照。<br>
|
||||
* @EnableScheduling 只服务于 {@link ApiMetricsCollector#flush()}(UserHome 主类原本未开定时任务,
|
||||
* 删包后此能力一并消失,主类零改动);过滤器取最前置 order,连 CAS 302 也计入入站口径。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class ApiMetricsConfig {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<ApiMetricsFilter> apiMetricsFilter(ApiMetricsCollector collector) {
|
||||
FilterRegistrationBean<ApiMetricsFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new ApiMetricsFilter(collector));
|
||||
bean.addUrlPatterns("/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
bean.setName("apiMetricsFilter");
|
||||
return bean;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import com.yunhai.userhome.config.Config;
|
||||
import lombok.Data;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] UserHome 热度 + 放大系数报告查询(方案 §3)。<br>
|
||||
* 默认只统计线上流量(is_load_test=0),压测流量单独给合计数。<br>
|
||||
* 例:GET /api/apimetrics/TopN?days=7&top=10
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/apimetrics/")
|
||||
public class ApiMetricsController {
|
||||
|
||||
@Autowired
|
||||
private ApiMetricsRepository repository;
|
||||
|
||||
/**
|
||||
* 报告:入站 TopN、出站 oapi TopN、入站/出站总量与放大系数。
|
||||
*/
|
||||
@GetMapping("/TopN")
|
||||
public ReportVo topN(
|
||||
@RequestParam(value = "days", defaultValue = "1") Integer days,
|
||||
@RequestParam(value = "top", defaultValue = "10") Integer top) {
|
||||
|
||||
int d = clamp(days == null ? 1 : days, 1, 30);
|
||||
int n = clamp(top == null ? 10 : top, 1, 100);
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(d);
|
||||
ReportVo vo = new ReportVo();
|
||||
vo.setGeneratedAt(LocalDateTime.now().toString());
|
||||
vo.setDays(d);
|
||||
vo.setTop(n);
|
||||
|
||||
Object[] in = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 0));
|
||||
vo.setInCnt(asLong(in, 0));
|
||||
vo.setInAvgCostMs(avg(asLong(in, 0), asLong(in, 1)));
|
||||
vo.setInErrCnt(asLong(in, 2));
|
||||
|
||||
Object[] out = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 0));
|
||||
vo.setOutCnt(asLong(out, 0));
|
||||
vo.setOutAvgCostMs(avg(asLong(out, 0), asLong(out, 1)));
|
||||
vo.setOutErrCnt(asLong(out, 2));
|
||||
|
||||
// 放大系数 = 出站 oapi 调用数 / 入站请求数(首屏放大问题的量化,方案 §5 缓存决策输入)
|
||||
vo.setAmplification(vo.getInCnt() <= 0 ? 0.0
|
||||
: Math.round(vo.getOutCnt() * 10000.0 / vo.getInCnt()) / 100.0);
|
||||
|
||||
vo.setTopInUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_IN, 0), n));
|
||||
vo.setTopOutUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_OUT, 0), n));
|
||||
|
||||
// 压测流量合计(单列口径,不与线上热度混算)
|
||||
Object[] ltIn = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 1));
|
||||
Object[] ltOut = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 1));
|
||||
vo.setLoadTestInCnt(asLong(ltIn, 0));
|
||||
vo.setLoadTestOutCnt(asLong(ltOut, 0));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private List<UriRow> toRows(List<Object[]> aggregated, int limit) {
|
||||
List<UriRow> rows = new ArrayList<>();
|
||||
for (Object[] r : aggregated) {
|
||||
UriRow row = new UriRow();
|
||||
row.setUri((String) r[0]);
|
||||
row.setMethod((String) r[1]);
|
||||
row.setCnt(asLong(r, 2));
|
||||
row.setAvgCostMs(avg(asLong(r, 2), asLong(r, 3)));
|
||||
row.setErrCnt(asLong(r, 4));
|
||||
rows.add(row);
|
||||
if (rows.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构
|
||||
* (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。
|
||||
*/
|
||||
private Object[] firstRow(Object[] arr) {
|
||||
if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) {
|
||||
return (Object[]) arr[0];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private long asLong(Object[] arr, int i) {
|
||||
if (arr == null || i >= arr.length || arr[i] == null) {
|
||||
return 0L;
|
||||
}
|
||||
return ((Number) arr[i]).longValue();
|
||||
}
|
||||
|
||||
private double avg(long cnt, long total) {
|
||||
return cnt <= 0 ? 0.0 : Math.round(total * 100.0 / cnt) / 100.0;
|
||||
}
|
||||
|
||||
private int clamp(int v, int min, int max) {
|
||||
return v < min ? min : (v > max ? max : v);
|
||||
}
|
||||
|
||||
/** 报告 VO(嵌套声明,压测结束随包整体删除) */
|
||||
@Data
|
||||
public static class ReportVo {
|
||||
private String generatedAt;
|
||||
private Integer days;
|
||||
private Integer top;
|
||||
/** 入站请求总数 */
|
||||
private Long inCnt;
|
||||
private Double inAvgCostMs;
|
||||
private Long inErrCnt;
|
||||
/** 出站 UserBase oapi 调用总数 */
|
||||
private Long outCnt;
|
||||
private Double outAvgCostMs;
|
||||
private Long outErrCnt;
|
||||
/** 放大系数 = outCnt / inCnt(1 次入站扇出多少次跨仓调用) */
|
||||
private Double amplification;
|
||||
/** 入站热度 TopN */
|
||||
private List<UriRow> topInUris;
|
||||
/** 出站 oapi 热度 TopN(即 UserBase 侧被扇出的接口) */
|
||||
private List<UriRow> topOutUris;
|
||||
/** 压测流量合计(单列,不混入上面各项) */
|
||||
private Long loadTestInCnt;
|
||||
private Long loadTestOutCnt;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UriRow {
|
||||
private String uri;
|
||||
private String method;
|
||||
private Long cnt;
|
||||
private Double avgCostMs;
|
||||
private Long errCnt;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] UserHome 访问频次指标(60s 切片聚合,方案 §3)。<br>
|
||||
* direction=in:入站 HTTP 请求(Filter 层采集);<br>
|
||||
* direction=out:出站 UserBase oapi 调用(切 OpenApiHttpClient 采集);<br>
|
||||
* 两者之比即首屏「放大系数」(1 次入站扇出多少次跨仓调用)。<br>
|
||||
* 表结构由 ddl-auto=update 自动创建;DBA 手工建表脚本见
|
||||
* resources/sql/api_metrics_loadtest.sql。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "api_metrics", indexes = {
|
||||
@Index(name = "idx_time", columnList = "stat_time"),
|
||||
@Index(name = "idx_direction_time", columnList = "direction, stat_time")
|
||||
})
|
||||
public class ApiMetricsEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String DIRECTION_IN = "in";
|
||||
public static final String DIRECTION_OUT = "out";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 统计切片时间(该 60s 窗口的结束时刻) */
|
||||
@Column(name = "stat_time", nullable = false, columnDefinition = "DATETIME")
|
||||
private LocalDateTime statTime;
|
||||
|
||||
/** 规范化路由:入站为本地路径(去 query、数字段归一);出站为 oapi path(如 /oapi/app/list) */
|
||||
@Column(name = "uri", nullable = false, length = 200)
|
||||
private String uri;
|
||||
|
||||
/** GET/POST/PUT/DELETE */
|
||||
@Column(name = "method", nullable = false, length = 10)
|
||||
private String method;
|
||||
|
||||
/** in=入站请求,out=出站 oapi 调用 */
|
||||
@Column(name = "direction", nullable = false, length = 8)
|
||||
private String direction;
|
||||
|
||||
/** 窗口内请求次数 */
|
||||
@Column(name = "cnt", nullable = false)
|
||||
private Long cnt;
|
||||
|
||||
/** 窗口内耗时累计 ms */
|
||||
@Column(name = "total_cost", nullable = false)
|
||||
private Long totalCost;
|
||||
|
||||
/** 窗口内错误次数:入站为 HTTP 非 200;出站为抛异常(HTTP 非 2xx / IO 错误) */
|
||||
@Column(name = "err_cnt", nullable = false)
|
||||
private Long errCnt;
|
||||
|
||||
/** 压测流量单列:入站看 X-Load-Test 头,出站继承触发它的入站请求标记 */
|
||||
@Column(name = "is_load_test", nullable = false, columnDefinition = "TINYINT(1)")
|
||||
private Integer isLoadTest;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二(方案 §3.1)
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] UserHome 入站热度采集过滤器(direction=in)。<br>
|
||||
* 挂 /* 最前置:连 CAS 未登录被 302 重定向的请求也计入(真实的入站压力口径)。<br>
|
||||
* 排除 /upload/**(静态文件,路径基数不可控)与 /actuator/**(prometheus 周期抓取,
|
||||
* 会污染热度),只统计 API 热度。
|
||||
*/
|
||||
public class ApiMetricsFilter implements Filter {
|
||||
|
||||
/** 压测流量标记头(方案 §2.4,k6 全部请求携带;出站切面会继承此标记) */
|
||||
public static final String HEADER_LOAD_TEST = "X-Load-Test";
|
||||
|
||||
private static final int MAX_URI_LEN = 200;
|
||||
|
||||
private final ApiMetricsCollector collector;
|
||||
|
||||
public ApiMetricsFilter(ApiMetricsCollector collector) {
|
||||
this.collector = collector;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
|
||||
String uri = normalizeUri(req);
|
||||
if (uri.startsWith("/upload/") || uri.startsWith("/actuator/")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String method = req.getMethod();
|
||||
boolean loadTest = isLoadTest(req);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
boolean error = false;
|
||||
try {
|
||||
chain.doFilter(request, response);
|
||||
error = res.getStatus() != 200;
|
||||
} catch (IOException | ServletException e) {
|
||||
error = true;
|
||||
throw e;
|
||||
} finally {
|
||||
collector.record(uri, method, ApiMetricsEntity.DIRECTION_IN,
|
||||
System.currentTimeMillis() - start, error, loadTest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化路由:去 context-path、去 query、纯数字路径段归一为 {id}
|
||||
* (如 /mobiapi/app/detail/12 → /mobiapi/app/detail/{id}),防止路径参数撑爆维度基数。
|
||||
*/
|
||||
private String normalizeUri(HttpServletRequest req) {
|
||||
String uri = req.getRequestURI();
|
||||
String ctx = req.getContextPath();
|
||||
if (ctx != null && ctx.length() > 1 && uri.startsWith(ctx)) {
|
||||
uri = uri.substring(ctx.length());
|
||||
}
|
||||
if (uri.indexOf('/') >= 0) {
|
||||
String[] segs = uri.split("/");
|
||||
StringBuilder sb = new StringBuilder(uri.length());
|
||||
for (String seg : segs) {
|
||||
if (seg.length() > 0 && isDigits(seg)) {
|
||||
seg = "{id}";
|
||||
}
|
||||
sb.append('/').append(seg);
|
||||
}
|
||||
uri = sb.toString();
|
||||
}
|
||||
if (uri.length() > MAX_URI_LEN) {
|
||||
uri = uri.substring(0, MAX_URI_LEN);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private boolean isDigits(String s) {
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
if (!Character.isDigit(s.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 压测标记:X-Load-Test 为 1/true/非 0 值即认为压测流量 */
|
||||
private boolean isLoadTest(HttpServletRequest req) {
|
||||
String v = req.getHeader(HEADER_LOAD_TEST);
|
||||
if (v == null || v.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
v = v.trim();
|
||||
return !"0".equals(v) && !"false".equalsIgnoreCase(v);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** [LOADTEST-METRICS] api_metrics 聚合查询(热度 + 放大系数报告用)。 */
|
||||
public interface ApiMetricsRepository extends JpaRepository<ApiMetricsEntity, Long> {
|
||||
|
||||
/** 按方向聚合 uri 热度,按总次数降序 */
|
||||
@Query("select e.uri, e.method, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from ApiMetricsEntity e "
|
||||
+ "where e.statTime >= :since and e.direction = :direction and e.isLoadTest = :isLoadTest "
|
||||
+ "group by e.uri, e.method order by sum(e.cnt) desc")
|
||||
List<Object[]> aggregateByUri(@Param("since") LocalDateTime since,
|
||||
@Param("direction") String direction,
|
||||
@Param("isLoadTest") Integer isLoadTest);
|
||||
|
||||
/** 按方向全量汇总(总次数/总耗时/总错误),入站与出站合计相除即放大系数 */
|
||||
@Query("select sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from ApiMetricsEntity e "
|
||||
+ "where e.statTime >= :since and e.direction = :direction and e.isLoadTest = :isLoadTest")
|
||||
Object[] aggregateTotal(@Param("since") LocalDateTime since,
|
||||
@Param("direction") String direction,
|
||||
@Param("isLoadTest") Integer isLoadTest);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.yunhai.userhome.loadtestmetrics;
|
||||
|
||||
/* =============================================================================
|
||||
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二(方案 §3.2)
|
||||
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
|
||||
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
|
||||
* 移除步骤详见仓库根《埋点移除清单.md》。
|
||||
* ============================================================================= */
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* [LOADTEST-METRICS] 出站 UserBase oapi 调用计数(direction=out,方案 §3.2)。<br>
|
||||
* 切在 yh-openapi-client 的 {@code YschOpenCall}(SDK 重构后所有出站开放接口调用的唯一
|
||||
* 公共入口,Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient
|
||||
* 注入式调用全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。<br>
|
||||
* 入站请求数(direction=in)与出站调用数(direction=out)相除即「放大系数」,
|
||||
* 是缓存层次决策的直接依据(方案 §5)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class OutboundOapiMetricsAspect {
|
||||
|
||||
private static final int MAX_URI_LEN = 200;
|
||||
|
||||
private final ApiMetricsCollector collector;
|
||||
|
||||
public OutboundOapiMetricsAspect(ApiMetricsCollector collector) {
|
||||
this.collector = collector;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截 YschOpenCall 全部公开调用方法(get/getList/post/postMultipart/download)。<br>
|
||||
* 按名重载第 2 参均为 path(如 /oapi/app/list),据此归一化记 uri。
|
||||
*/
|
||||
@Around("execution(* com.yunhai.platform.openapi.core.YschOpenCall.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
String uri = resolveUri(point);
|
||||
String method = resolveHttpMethod(point.getSignature().getName());
|
||||
boolean loadTest = inheritLoadTestFlag();
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
boolean error = false;
|
||||
try {
|
||||
return point.proceed();
|
||||
} catch (Throwable e) {
|
||||
error = true;
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
collector.record(uri, method, ApiMetricsEntity.DIRECTION_OUT,
|
||||
System.currentTimeMillis() - start, error, loadTest);
|
||||
} catch (Exception recordEx) {
|
||||
// 埋点自身绝不影响出站调用
|
||||
log.warn("[LOADTEST-METRICS] 出站计数失败: {}", recordEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 方法名 → HTTP 方法(getJson/get→GET,postJson/post/postMultipart→POST) */
|
||||
private String resolveHttpMethod(String signatureName) {
|
||||
return signatureName.startsWith("post") ? "POST" : "GET";
|
||||
}
|
||||
|
||||
/** 取第 2 参 path 归一化;防御签名变化时记方法名兜底,不抛错 */
|
||||
private String resolveUri(ProceedingJoinPoint point) {
|
||||
try {
|
||||
Object[] args = point.getArgs();
|
||||
if (args.length >= 2 && args[1] instanceof String) {
|
||||
return normalize((String) args[1]);
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 兜底
|
||||
}
|
||||
return "oapi:" + point.getSignature().getName();
|
||||
}
|
||||
|
||||
/** 出站 path 归一化:保证前导 /、纯数字段归一为 {id}、截断 200 */
|
||||
private String normalize(String path) {
|
||||
String p = path == null ? "" : path.trim();
|
||||
if (p.isEmpty()) {
|
||||
return "/";
|
||||
}
|
||||
if (!p.startsWith("/")) {
|
||||
p = "/" + p;
|
||||
}
|
||||
int q = p.indexOf('?');
|
||||
if (q >= 0) {
|
||||
p = p.substring(0, q);
|
||||
}
|
||||
String[] segs = p.split("/");
|
||||
StringBuilder sb = new StringBuilder(p.length());
|
||||
for (String seg : segs) {
|
||||
if (seg.length() > 0 && isDigits(seg)) {
|
||||
seg = "{id}";
|
||||
}
|
||||
sb.append('/').append(seg);
|
||||
}
|
||||
p = sb.toString();
|
||||
return p.length() > MAX_URI_LEN ? p.substring(0, MAX_URI_LEN) : p;
|
||||
}
|
||||
|
||||
private boolean isDigits(String s) {
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
if (!Character.isDigit(s.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 出站调用的压测标记继承自触发它的入站请求(X-Load-Test 头);定时任务等无请求上下文则为 false */
|
||||
private boolean inheritLoadTestFlag() {
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes) {
|
||||
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
||||
String v = req.getHeader(ApiMetricsFilter.HEADER_LOAD_TEST);
|
||||
if (v != null && !v.trim().isEmpty()) {
|
||||
v = v.trim();
|
||||
return !"0".equals(v) && !"false".equalsIgnoreCase(v);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.userhome.model;
|
||||
|
||||
/**
|
||||
* UserBase 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参镜像)。<br>
|
||||
* BoaApp 属 BasePlatform SDK 本期不动,PC 通道的 iconKey/iconColor 经本 VO 独立补拉后
|
||||
* 在 GetUserApp / 常用区出参上合并注入(加法式改造,不动既有 oapi 契约)。
|
||||
*/
|
||||
public class AppIconEntryVo {
|
||||
|
||||
/** 应用主键 ID(与 BoaApp.id 同源,按此合并) */
|
||||
private Integer id;
|
||||
|
||||
/** 矢量图标键(AppIcon 资产库图标名) */
|
||||
private String iconKey;
|
||||
|
||||
/** 矢量图标着色(CSS 色值);null = 端侧默认 */
|
||||
private String iconColor;
|
||||
|
||||
public Integer getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getIconKey() {
|
||||
return iconKey;
|
||||
}
|
||||
|
||||
public void setIconKey(String iconKey) {
|
||||
this.iconKey = iconKey;
|
||||
}
|
||||
|
||||
public String getIconColor() {
|
||||
return iconColor;
|
||||
}
|
||||
|
||||
public void setIconColor(String iconColor) {
|
||||
this.iconColor = iconColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.userhome.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 卡片直批入参(B3 §2)。只含消息定位与动作——operatorId/schId/operatorName
|
||||
* 一律服务端从当前会话取,绝不从前端接收(防越权代批)。
|
||||
*/
|
||||
@Data
|
||||
public class MsgHandleRequest {
|
||||
|
||||
private Long msgId;
|
||||
|
||||
/** approve | reject */
|
||||
private String action;
|
||||
|
||||
/** 办理意见:拒绝必填,同意可选 */
|
||||
private String opinion;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.LightApp;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
public interface LightAppRepository extends JpaRepository<LightApp, Integer>, JpaSpecificationExecutor<LightApp> {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.Logo;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface LogoRepository extends JpaRepository<Logo, Integer>, JpaSpecificationExecutor<Logo> {
|
||||
|
||||
List<Logo> findBySchIdAndType(Integer schId, Integer type);
|
||||
|
||||
Optional<Logo> findByTypeAndSchId(Integer type, Integer schId);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.MenuBar;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface MenuBarRepository extends JpaRepository<MenuBar, Integer>, JpaSpecificationExecutor<MenuBar> {
|
||||
|
||||
@Query("select m from MenuBar m left join fetch m.hideSchIds where m.schId = :schId")
|
||||
List<MenuBar> findBySchId(@Param("schId") Integer schId);
|
||||
|
||||
@Query("select m from MenuBar m left join fetch m.hideSchIds where m.schId in :schIds")
|
||||
List<MenuBar> findBySchIdIn(@Param("schIds") List<Integer> schIds);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface SchSettingsRepository extends JpaRepository<SchSettings, Integer>, JpaSpecificationExecutor<SchSettings> {
|
||||
|
||||
Optional<SchSettings> findFirstBySchId(Integer schId);
|
||||
|
||||
@Query(nativeQuery = true, value = "SELECT * FROM `sch_settings` where sch_id in ?1 ")
|
||||
List<SchSettings> getSchSettingList(List<Integer> schIds);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.StatsBar;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface StatsBarRepository extends JpaRepository<StatsBar, Integer>, JpaSpecificationExecutor<StatsBar> {
|
||||
|
||||
List<StatsBar> findBySchId(Integer schId);
|
||||
|
||||
List<StatsBar> findBySchIdIn(List<Integer> schIds);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.TeacherCopilot;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TeacherCopilotRepository extends JpaRepository<TeacherCopilot, Integer>, JpaSpecificationExecutor<TeacherCopilot> {
|
||||
|
||||
List<TeacherCopilot> findBySchId(Integer schId);
|
||||
|
||||
List<TeacherCopilot> findBySchIdIn(List<Integer> schIds);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.UserAppearance;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserAppearanceRepository extends JpaRepository<UserAppearance, Integer> {
|
||||
|
||||
Optional<UserAppearance> findByUserId(Integer userId);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserCurrentRoleRepository extends JpaRepository<UserCurrentRole, Integer> {
|
||||
|
||||
Optional<UserCurrentRole> findByUserId(Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.UserMessage;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface UserMessageRepository extends JpaRepository<UserMessage, Integer>, JpaSpecificationExecutor<UserMessage> {
|
||||
|
||||
List<UserMessage> findUserMessageByUserIdAndAppId(Integer userId, Integer appId);
|
||||
|
||||
/** 按用户分组取「每应用最新一条消息」(对齐旧 getUserMessageAppList 原生 SQL)。 */
|
||||
@Query(nativeQuery = true, value = "select * from (SELECT * FROM `user_message` where user_id=?1 group by app_id,c_date desc) as Base group by app_id order by c_date desc")
|
||||
List<UserMessage> getUserMessageAppList(Integer userId, Integer schId);
|
||||
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query(nativeQuery = true, value = "update user_message set is_read =1 where user_id=?1 and app_id=?2 and is_read = 0")
|
||||
void updateRead(Integer userId, Integer appId);
|
||||
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query(nativeQuery = true, value = "update user_message set is_read =1 where user_id=?1 and is_read = 0")
|
||||
void updateReadAll(Integer userId);
|
||||
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query(nativeQuery = true, value = "update user_message set is_read =1 where id=:msgId ")
|
||||
void updateReadById(Integer msgId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.UserSettingsDesktopApp;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
public interface UserSettingsDesktopAppRepository extends JpaRepository<UserSettingsDesktopApp, Integer>, JpaSpecificationExecutor<UserSettingsDesktopApp> {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.UserSettings;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> {
|
||||
|
||||
/**
|
||||
* desktopApps 为 LAZY 集合且 open-in-view=false,service 层无事务返回后 controller 仍需遍历该集合,
|
||||
* 故查询时立即抓取,避免 LazyInitializationException。
|
||||
*/
|
||||
@EntityGraph(attributePaths = "desktopApps")
|
||||
Optional<UserSettings> findByUserIdAndSchIdAndRoleId(Integer userId, Integer schId, Integer roleId);
|
||||
|
||||
/**
|
||||
* 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。
|
||||
* user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),wall 表存原始形态,
|
||||
* 两种形态都需传入匹配,漏形态会留下悬空引用(读侧拿到已删壁纸的死 URL 白板)。
|
||||
*/
|
||||
@Modifying
|
||||
@Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl in :urls")
|
||||
int clearWallpaperUrl(@Param("urls") Collection<String> urls);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.Wallpaper;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface WallpaperRepository extends JpaRepository<Wallpaper, Integer>, JpaSpecificationExecutor<Wallpaper> {
|
||||
|
||||
List<Wallpaper> findByUserIdAndType(Integer userId, Integer type);
|
||||
|
||||
List<Wallpaper> findBySchIdAndType(Integer schId, Integer type);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface WidgetCatalogRepository extends JpaRepository<WidgetCatalog, Integer>,
|
||||
JpaSpecificationExecutor<WidgetCatalog> {
|
||||
|
||||
Optional<WidgetCatalog> findByCode(String code);
|
||||
|
||||
List<WidgetCatalog> findByEnabledOrderBySortAscIdAsc(Integer enabled);
|
||||
|
||||
List<WidgetCatalog> findAllByOrderBySortAscIdAsc();
|
||||
|
||||
/**
|
||||
* 某校「可见范围命中」的目录行(不含启用状态、角色过滤与校级覆盖,由服务层合成)。
|
||||
* 一处 SQL 决定 scope_type 语义:0 全放;1 仅点名表命中;2 仅点名表未命中。
|
||||
*/
|
||||
@Query("select w from WidgetCatalog w where coalesce(w.scopeType, 0) = 0 "
|
||||
+ "or (coalesce(w.scopeType, 0) = 1 and exists "
|
||||
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
|
||||
+ "or (coalesce(w.scopeType, 0) = 2 and not exists "
|
||||
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
|
||||
+ "order by w.sort asc, w.id asc")
|
||||
List<WidgetCatalog> findVisibleForSchool(@Param("schId") Integer schId);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.WidgetCatalogSchool;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 小组件可见范围点名表访问。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026-10-11
|
||||
*/
|
||||
@Repository
|
||||
public interface WidgetCatalogSchoolRepository extends JpaRepository<WidgetCatalogSchool, Integer> {
|
||||
|
||||
/** 命中判定:某组件是否点名了某校(scope_type 白/黑名单共用) */
|
||||
boolean existsByWidgetIdAndSchId(Integer widgetId, Integer schId);
|
||||
|
||||
List<WidgetCatalogSchool> findByWidgetId(Integer widgetId);
|
||||
|
||||
List<WidgetCatalogSchool> findByWidgetIdIn(Collection<Integer> widgetIds);
|
||||
|
||||
void deleteByWidgetId(Integer widgetId);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.userhome.repository;
|
||||
|
||||
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 小组件校级覆盖层访问。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026-10-11
|
||||
*/
|
||||
@Repository
|
||||
public interface WidgetSchoolSettingRepository extends JpaRepository<WidgetSchoolSetting, Integer> {
|
||||
|
||||
Optional<WidgetSchoolSetting> findByWidgetIdAndSchId(Integer widgetId, Integer schId);
|
||||
|
||||
List<WidgetSchoolSetting> findBySchId(Integer schId);
|
||||
|
||||
List<WidgetSchoolSetting> findByWidgetId(Integer widgetId);
|
||||
|
||||
void deleteByWidgetId(Integer widgetId);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.userhome.model.AppIconEntryVo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 应用矢量图标映射补拉(AppIcon 资产库 P2/P3 数据通道)。<br>
|
||||
* UserBase 的 {@code GET /oapi/app/icon-map} 返回全量已配置行 [{id, iconKey, iconColor}],
|
||||
* 本服务拉取后建 id 索引供各出参组装点合并注入(BoaApp 属 SDK 本期不动,矢量字段走本通道)。<br>
|
||||
* <ul>
|
||||
* <li>60 秒本地缓存:多个出参点(GetUserApp/常用区/搜索)一次渲染只打一次跨仓调用;</li>
|
||||
* <li>失败降级:返回上次成功值(无则空映射),绝不阻塞主应用列表——空映射下端侧
|
||||
* 走原 关键词→URL→首字 回退链,表现与改造前一致(零回归要求);</li>
|
||||
* <li>失败后 10 秒内不重试(简单防雪崩)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class AppIconKeyService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AppIconKeyService.class);
|
||||
|
||||
/** 服务目录目标名:userbase(与 UserController.TARGET_USERBASE 同口径) */
|
||||
private static final String TARGET_USERBASE = "userbase";
|
||||
|
||||
private static final String PATH_ICON_MAP = "/oapi/app/icon-map";
|
||||
|
||||
/** 成功结果缓存时长 */
|
||||
private static final long TTL_MS = 60_000L;
|
||||
|
||||
/** 拉取失败后的静默期 */
|
||||
private static final long FAIL_BACKOFF_MS = 10_000L;
|
||||
|
||||
@Autowired
|
||||
private YschOpenCall openCall;
|
||||
|
||||
private volatile Map<Integer, AppIconEntryVo> cached = Collections.emptyMap();
|
||||
private volatile long fetchedAt = Long.MIN_VALUE;
|
||||
|
||||
/**
|
||||
* id → 矢量图标条目 映射(含缓存与降级)。空映射 = 无任何应用配置矢量键或通道不可达。
|
||||
*/
|
||||
public Map<Integer, AppIconEntryVo> iconMap() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - fetchedAt < TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
List<AppIconEntryVo> list = openCall.getList(TARGET_USERBASE, PATH_ICON_MAP, null,
|
||||
AppIconEntryVo.class);
|
||||
Map<Integer, AppIconEntryVo> map = (list == null) ? Collections.emptyMap()
|
||||
: list.stream()
|
||||
.filter(v -> v != null && v.getId() != null && v.getIconKey() != null)
|
||||
.collect(Collectors.toMap(AppIconEntryVo::getId, Function.identity(), (a, b) -> a));
|
||||
cached = Collections.unmodifiableMap(map);
|
||||
fetchedAt = now;
|
||||
return cached;
|
||||
} catch (Exception e) {
|
||||
// 降级:沿用上次值;10 秒内不再重试。应用列表主链路绝不因此报错
|
||||
fetchedAt = now - TTL_MS + FAIL_BACKOFF_MS;
|
||||
log.warn("[appicon] icon-map 拉取失败,按空映射降级:{}", e.toString());
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 id 取单条;无配置返回 null。 */
|
||||
public AppIconEntryVo byId(Integer appId) {
|
||||
return (appId == null) ? null : iconMap().get(appId);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,31 @@ import java.util.Optional;
|
||||
*/
|
||||
public interface AppRemoteService {
|
||||
|
||||
/** 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。 */
|
||||
/**
|
||||
* 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。
|
||||
* 不带学校上下文:由 UserBase 侧按「当前激活身份学校 → 主归属学校」兜底推导生效学校。
|
||||
*/
|
||||
List<BoaAppType> getUserApps(BoaUser user);
|
||||
|
||||
/**
|
||||
* 按学校上下文取用户可访问应用全集(2026-10-12 学校单点开通硬闸门贯通)。<br>
|
||||
* {@code schoolId} 非空 → UserBase 按该校自身单点开通集合收窄;为空 → 服务端按身份/主归属兜底。
|
||||
*
|
||||
* @param user 登录用户
|
||||
* @param schoolId 学校上下文(可为 null)
|
||||
*/
|
||||
List<BoaAppType> getUserApps(BoaUser user, Integer schoolId);
|
||||
|
||||
/** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
|
||||
Optional<BoaApp> getAppById(BoaUser user, Integer appId);
|
||||
|
||||
/**
|
||||
* 按应用ID取单个应用,并按学校上下文收窄全集(与 {@link #getUserApps(BoaUser, Integer)} 同口径,
|
||||
* 避免「列表已按校收窄、单个查询仍全集」的不一致)。
|
||||
*
|
||||
* @param user 登录用户
|
||||
* @param appId 应用ID
|
||||
* @param schoolId 学校上下文(可为 null)
|
||||
*/
|
||||
Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户外观偏好聚合服务(外观偏好服务端化 2026-10-08):
|
||||
* 主题档/头像样式 per-user 真源(user_appearance)+ 学校默认(sch_settings)+ 学校 Logo(logo 表)。
|
||||
*/
|
||||
public interface IAppearanceService {
|
||||
|
||||
/**
|
||||
* 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl},查不到/脏值/依赖故障的字段一律 null,
|
||||
* 不抛异常(子应用异步补齐段按"缺字段=清键回落默认"消费)。
|
||||
*/
|
||||
Map<String, Object> getAppearance(BoaUser user);
|
||||
|
||||
/**
|
||||
* 当前用户生效外观(oapi 代理轮 2026-10-08):{theme, avatar, logoUrl}——主题/头像为合成后的
|
||||
* 【生效值】(个人 → 学校 → 平台级联,层级知识不外露);logoUrl 本校→平台择优。
|
||||
* 查不到/脏值/依赖故障的字段 null(init 侧清对应键回落默认观感)。
|
||||
*/
|
||||
Map<String, Object> getEffectiveAppearance(Integer userId);
|
||||
|
||||
/** 保存用户自选主题档(紧凑编码 theme|sideDeep|gradOn|sideFollow);themeState=null 表示跟随学校默认 */
|
||||
void saveMyTheme(Integer userId, String themeState);
|
||||
|
||||
/** 保存用户自选头像样式(text|silhouette);avatarStyle=null 表示跟随学校默认 */
|
||||
void saveMyAvatarStyle(Integer userId, String avatarStyle);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.userhome.entity.MenuBar;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface IMenuBarService {
|
||||
|
||||
List<MenuBar> getMenuBarList(Integer schId);
|
||||
|
||||
List<MenuBar> findMenuBarById(Integer userId);
|
||||
|
||||
void saveMenuBar(MenuBar menuBar);
|
||||
|
||||
void toggleMenuBarShow(Integer id, Integer schId);
|
||||
|
||||
void saveMenuBarOrderNum(Integer id, Integer orderNum);
|
||||
|
||||
void deleteMenuBar(Integer id);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ISchSettingsService {
|
||||
|
||||
void save(SchSettings schSettings);
|
||||
|
||||
Optional<SchSettings> findBySchId(Integer schId);
|
||||
|
||||
/** 原样读(无兜底):学校默认主题档等"可空语义"场景专用——查无返回 empty,不合成默认对象 */
|
||||
Optional<SchSettings> findBySchIdRaw(Integer schId);
|
||||
|
||||
List<SchSettings> findBySchIds(List<Integer> schIds);
|
||||
|
||||
/** 主学校 → 主归属组 → 0(对齐旧 SchSettingsController.resolveSchId) */
|
||||
Integer resolveSchId(BoaUser user);
|
||||
|
||||
/** 主题/头像默认档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属)。
|
||||
* 原为 SchSettingsController 私有方法,外观偏好轮(2026-10-08)下沉供 AppearanceController 共享。 */
|
||||
Integer resolveThemeSchId(BoaUser user, Integer explicitSchId);
|
||||
|
||||
/** 平台默认层在 sch_settings 中的固定 schId(与 UsedAppController/壁纸/LOGO 的 0 层同口径) */
|
||||
int PLATFORM_SCH_ID = 0;
|
||||
|
||||
/**
|
||||
* 主题档读侧级联(设置中心三级重构 S7,2026-10-08;oapi 代理轮下沉 service 供 schTheme 读端点与
|
||||
* /oapi/appearance 合成共享):本校行未配置/旧格式/脏值 → 再查平台默认层(schId=0),
|
||||
* 命中即返回平台档;两级都无 → value=null。schId 自身即平台层时不二次回落(避免自查两次)。
|
||||
*/
|
||||
LayerValue readThemeWithFallback(Integer schId);
|
||||
|
||||
/** 头像样式读侧级联:语义与 {@link #readThemeWithFallback} 同构(本校空/脏 → 平台层 → null)。 */
|
||||
LayerValue readAvatarWithFallback(Integer schId);
|
||||
|
||||
/** LOGO 文字读侧级联(LOGO 文字轮 2026-10-09):本校空 → 平台层 → null。 */
|
||||
LayerValue readLogoTextWithFallback(Integer schId);
|
||||
|
||||
/** 默认壁纸读侧级联(壁纸三级默认轮 2026-10-09):本校空 → 平台层 → null。 */
|
||||
LayerValue readWallpaperWithFallback(Integer schId);
|
||||
|
||||
/** 级联读命中结果:{@code value}=命中的档位值(两级都无则 null),{@code hitSchId}=实际命中层的 schId
|
||||
* (回落发生时是 0,不是解析出的本校 schId)。设置中心读端点的响应体 schId 即此语义——
|
||||
* 前端据此区分「本校已配置」与「本校未配置、正在回落平台默认」。 */
|
||||
final class LayerValue {
|
||||
private final String value;
|
||||
private final Integer hitSchId;
|
||||
|
||||
public LayerValue(String value, Integer hitSchId) {
|
||||
this.value = value;
|
||||
this.hitSchId = hitSchId;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public Integer getHitSchId() {
|
||||
return hitSchId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.userhome.entity.StatsBar;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface IStatsBarService {
|
||||
|
||||
List<StatsBar> getStatsBarList(Integer schId);
|
||||
|
||||
List<StatsBar> findStatsBarById(Integer userId);
|
||||
|
||||
void saveStatsBar(StatsBar statsBar);
|
||||
|
||||
void toggleStatsBarShow(Integer id, Integer schId);
|
||||
|
||||
void saveStatsBarOrderNum(Integer id, Integer orderNum);
|
||||
|
||||
void deleteStatsBar(Integer id);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.userhome.entity.TeacherCopilot;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ITeacherCopilotService {
|
||||
|
||||
List<TeacherCopilot> getList(Integer schId);
|
||||
|
||||
List<TeacherCopilot> findById(Integer userId);
|
||||
|
||||
void save(TeacherCopilot teacherCopilot);
|
||||
|
||||
void toggleShow(Integer id, Integer schId);
|
||||
|
||||
void saveOrderNum(Integer id, Integer orderNum);
|
||||
|
||||
void delete(Integer id);
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户当前激活角色(user_current_role 表读写)。
|
||||
* 用户当前激活角色访问(UserBase 单一权威源,经 SDK 走 oapi,本地无存储)。<br>
|
||||
* 保留接口以稳定既有 81 处消费点(仅读 roleId/schId);实现由本地表改为 UserBase 开放接口。
|
||||
*/
|
||||
public interface IUserCurrentRoleService {
|
||||
|
||||
/** 取用户当前角色(可能不存在)。 */
|
||||
/** 取用户当前激活角色(未切换时兜底主身份;无任何身份返回 Optional.empty)。 */
|
||||
Optional<UserCurrentRole> findById(Integer userId);
|
||||
|
||||
/** 保存/覆盖用户当前角色。 */
|
||||
/** 切换用户当前激活角色(目标身份须属该用户,由 UserBase 校验)。 */
|
||||
void save(UserCurrentRole role);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.userhome.entity.UserMessage;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface IUserMessageService {
|
||||
|
||||
List<UserMessage> getUserMessageAppIds(Integer userId, Integer schId);
|
||||
|
||||
Page<UserMessage> findUserMessageByAppId(UserMessage userMessage, Pageable pageable);
|
||||
|
||||
Page<UserMessage> findUserMessageByUserId(Integer userId, Pageable pageable);
|
||||
|
||||
List<UserMessage> findUserMessageByUserId(Integer userId);
|
||||
|
||||
Boolean findUserMessageUnRead(Integer userId);
|
||||
|
||||
void updateRead(Integer userId, Integer appId);
|
||||
|
||||
void updateUserRead(Integer id, Integer userId);
|
||||
|
||||
void updateReadAll(Integer userId);
|
||||
|
||||
Page<UserMessage> findUserMessageById(Integer userId, Integer page, Integer count);
|
||||
|
||||
void readMsgById(Integer msgId);
|
||||
|
||||
void readMsgAll(Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.LightApp;
|
||||
import com.yunhai.userhome.entity.Logo;
|
||||
import com.yunhai.userhome.entity.UserSettings;
|
||||
import com.yunhai.userhome.entity.UserSettingsDesktopApp;
|
||||
import com.yunhai.userhome.entity.Wallpaper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface IUserSettingsService {
|
||||
|
||||
List<Wallpaper> findWallList(Integer userId);
|
||||
|
||||
/** 管理视角壁纸列表(系统设置页):系统 + 目标学校,不含个人壁纸;isEdit 按调用者权限标记 */
|
||||
List<Wallpaper> findSchWallpaperManageList(BoaUser user, Integer targetSchId);
|
||||
|
||||
/** 管理视角 Logo 列表(系统设置页):系统 + 目标学校 */
|
||||
List<Logo> findSchLogoManageList(BoaUser user, Integer targetSchId);
|
||||
|
||||
void deleteWallById(Integer id, BoaUser user);
|
||||
|
||||
void saveWallpaper(Wallpaper wallpaper);
|
||||
|
||||
void checkWallpaper(Integer userId, String wallpaperUrl);
|
||||
|
||||
List<Logo> findLogoList(Integer userId);
|
||||
|
||||
void saveLogo(Logo logo);
|
||||
|
||||
void deleteLogoById(Integer id, BoaUser user);
|
||||
|
||||
Optional<UserSettings> findUserSettings(Integer userId);
|
||||
|
||||
void saveUserSettings(UserSettings userSettings);
|
||||
|
||||
void saveUserWidget(UserSettings userSettings);
|
||||
|
||||
void saveUserAppIds(Integer id, Integer userId, String appIds);
|
||||
|
||||
void toggleWidgetShow(Integer id, Integer userId);
|
||||
|
||||
List<LightApp> findLightAppList(Integer schId, Integer userId);
|
||||
|
||||
void saveLightApp(LightApp lightApp);
|
||||
|
||||
void deleteLightAppById(Integer id);
|
||||
|
||||
void deleteDesktopAppById(Integer id);
|
||||
|
||||
void saveDesktopApp(UserSettingsDesktopApp model);
|
||||
|
||||
List<UserSettingsDesktopApp> saveDesktopAppList(List<UserSettingsDesktopApp> list);
|
||||
|
||||
UserSettings initUserSettings(Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 小组件候选池服务。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026-10-11
|
||||
*/
|
||||
public interface IWidgetCatalogService {
|
||||
|
||||
/**
|
||||
* 候选池(登录侧):可见范围命中 + 生效启用 + 角色命中,按生效 sort 升序。
|
||||
*
|
||||
* @param schId 当前登录校(用于 scope_type 点名判定与校级覆盖合成)
|
||||
* @param roleId 当前登录角色 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生),用于 role_types 过滤
|
||||
* @return 生效值视图(name 已按「校级别名 > 角色命名 > 名称」合成)
|
||||
*/
|
||||
List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId);
|
||||
|
||||
Optional<WidgetCatalog> findByCode(String code);
|
||||
|
||||
/**
|
||||
* 可见范围判定:scope_type = 0 全放;1 仅点名表命中可见;2 仅点名表未命中可见。
|
||||
*
|
||||
* @param catalog 目录行
|
||||
* @param schId 当前登录校
|
||||
* @return 命中返回 true
|
||||
*/
|
||||
boolean visibleToSchool(WidgetCatalog catalog, Integer schId);
|
||||
|
||||
/** 管理端列表(全局原值,含 scopeSchIds 回显),鉴权在 Controller */
|
||||
List<WidgetCatalog> findAllForAdmin();
|
||||
|
||||
/**
|
||||
* 管理端按视角取列表:超管 = 全局原值;校管 = 本校生效值 + 别名 + 原名 + 覆盖标记。
|
||||
*
|
||||
* @param viewerSchId 访问者当前登录校
|
||||
* @param superAdmin 访问者是否超管(BoaUser.type == 1)
|
||||
* @return 视角化的目录列表
|
||||
*/
|
||||
List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin);
|
||||
|
||||
/**
|
||||
* 保存目录行(仅超管调用),并整体重写 scope_type 的点名表。
|
||||
*
|
||||
* @param model 目录行(scopeSchIds 为点名的学校列表)
|
||||
*/
|
||||
void save(WidgetCatalog model);
|
||||
|
||||
/**
|
||||
* 删除目录行,连带删点名表与校级覆盖行。
|
||||
*
|
||||
* @param id 目录 id
|
||||
*/
|
||||
void deleteById(Integer id);
|
||||
|
||||
Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId);
|
||||
|
||||
/**
|
||||
* 保存校级覆盖(仅本校)。schId 一律取登录校,忽略入参里的 schId,防越权。
|
||||
* 四个可覆盖字段全空表示「清除覆盖」,直接删行。
|
||||
*
|
||||
* @param model 覆盖行(widgetId 必填)
|
||||
* @param currentSchId 当前登录校
|
||||
*/
|
||||
void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId);
|
||||
|
||||
/**
|
||||
* 显示名三级链:校级别名 > 角色专属命名 > 组件名称。
|
||||
*
|
||||
* @param catalog 目录行
|
||||
* @param schId 当前登录校
|
||||
* @param roleId 当前登录角色
|
||||
* @return 最终显示名
|
||||
*/
|
||||
String displayName(WidgetCatalog catalog, Integer schId, Integer roleId);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.userhome.config.MsgCenterProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 卡片直批代理(方案 §2,B3):消息卡片上的「同意/拒绝」在 UserHome 服务端完成
|
||||
* 消息定位 → flow-center 待办匹配 → 办理 → 标已读 全链路。
|
||||
* <ul>
|
||||
* <li><b>红线</b>:operatorId / schId / operatorName 一律由控制器从当前 CAS 会话取
|
||||
* (BoaUser),绝不接受前端传参——防越权代批;flow-center 侧再按任务办理人逐字复核,双层防护。</li>
|
||||
* <li>taskId 定位(§2.3):我的待办(tasks/todo,assigneeId=登录人)里按 bizId 精确匹配。
|
||||
* 实测契约:flow-center 待办消息 bizId=办件 id(instanceId),故匹配键 = instanceId,
|
||||
* bizId(业务记录 id)作为通用回落;bizType 过滤键用消息 jumpApp(=流程业务类型,
|
||||
* 消息 bizType「flow.task」是消息分类,与待办的 bizType 不同命名空间,勿混)。</li>
|
||||
* <li>匹配 0 条=已处理或已失效;>1 条(同办件多出口/多节点)=请前往详情页办理(一期不做歧义猜选)。</li>
|
||||
* <li>出站走 {@link YschOpenCall} 签名直调,目标 flow-center(服务目录/枚举兜底,
|
||||
* flow-center-base-url 显式覆盖优先)。flow-center 不可达降级为提示,不影响消息卡片其余功能。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MsgApprovalProxyService {
|
||||
|
||||
private static final String TARGET_FLOW_CENTER = "flow-center";
|
||||
|
||||
/** 我的待办分页拉取上限:5 页 × 100 条(todo 量级远小于此,防御性封顶) */
|
||||
private static final int TODO_MAX_PAGES = 5;
|
||||
private static final int TODO_PAGE_SIZE = 100;
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final MsgCenterProxyService msgProxy;
|
||||
private final MsgCenterProperties properties;
|
||||
|
||||
public MsgApprovalProxyService(YschOpenCall openCall, MsgCenterProxyService msgProxy,
|
||||
MsgCenterProperties properties) {
|
||||
this.openCall = openCall;
|
||||
this.msgProxy = msgProxy;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 办理消息对应的审批待办。action=approve|reject;reject 意见必填。
|
||||
*
|
||||
* @return {ok, message}——ok=false 时 message 为可直接展示的用户文案
|
||||
*/
|
||||
public Map<String, Object> handle(Integer userId, Integer schId, String operatorName,
|
||||
Long msgId, String action, String opinion) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
boolean approve = "approve".equals(action);
|
||||
if (!approve && !"reject".equals(action)) {
|
||||
return fail(out, "不支持的操作类型");
|
||||
}
|
||||
if (!approve && !StringUtils.hasText(opinion)) {
|
||||
return fail(out, "拒绝必须填写意见");
|
||||
}
|
||||
|
||||
JsonNode msg = msgProxy.findMessage(userId, msgId);
|
||||
if (msg == null) {
|
||||
return fail(out, "消息不存在或已失效");
|
||||
}
|
||||
String bizId = text(msg, "bizId");
|
||||
if (!StringUtils.hasText(bizId)) {
|
||||
return fail(out, "该消息不含可办理的业务标识");
|
||||
}
|
||||
String flowBizType = text(msg, "jumpApp");
|
||||
|
||||
List<JsonNode> matched = matchTodoTasks(schId, userId, flowBizType, bizId.trim());
|
||||
if (matched == null) {
|
||||
// flow-center 不可达 ≠ 已处理:降级提示,不误导用户
|
||||
return fail(out, "流程中心暂不可用,请稍后重试");
|
||||
}
|
||||
if (matched.isEmpty()) {
|
||||
return fail(out, "该待办已处理或已失效");
|
||||
}
|
||||
if (matched.size() > 1) {
|
||||
return fail(out, "同一办件匹配到多个待办节点,请前往详情页办理");
|
||||
}
|
||||
long taskId = matched.get(0).path("taskId").asLong();
|
||||
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId == null ? 0 : schId));
|
||||
query.put("operatorId", String.valueOf(userId));
|
||||
query.put("operatorName", operatorName == null ? "" : operatorName);
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("opinion", opinion);
|
||||
postFlowcenter("/oapi/flow/task/" + taskId + "/" + action, query, body);
|
||||
} catch (DException e) {
|
||||
// flow-center 业务码拒绝(非办理人/多出口需 edgeName 等)——原文透出给用户
|
||||
log.info("[msgcenter] 直批被 flow-center 业务拒绝 msgId={} taskId={}:{}", msgId, taskId, e.getMessage());
|
||||
return fail(out, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] flow-center 直批不可达(降级提示)msgId={}:{}", msgId, e.toString());
|
||||
return fail(out, "流程中心暂不可用,请稍后重试");
|
||||
}
|
||||
|
||||
// 直批后标读:schId 用消息读取口径(当前激活身份学校),保证 UserMsg 侧失效的是本校缓存键
|
||||
msgProxy.read(userId, java.util.Collections.singletonList(msgId), msgProxy.resolveReadSchId(userId));
|
||||
out.put("ok", true);
|
||||
out.put("message", "办理成功");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的待办中精确匹配目标办件:先按 flowBizType(=消息 jumpApp)过滤(未声明则不过滤),
|
||||
* 再按 instanceId(flow-center 待办消息约定)或 bizId(业务方自发消息约定)匹配。
|
||||
*
|
||||
* @return 匹配行(可能为空);flow-center 不可达返回 null(区别于「已处理」的 0 匹配)
|
||||
*/
|
||||
private List<JsonNode> matchTodoTasks(Integer schId, Integer userId, String flowBizType, String bizId) {
|
||||
List<JsonNode> matched = new ArrayList<>();
|
||||
for (int page = 1; page <= TODO_MAX_PAGES; page++) {
|
||||
List<JsonNode> content;
|
||||
try {
|
||||
content = fetchTodoPage(schId, userId, flowBizType, page);
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] flow-center 待办查询不可达 msgId 定位失败:{}", e.toString());
|
||||
return null;
|
||||
}
|
||||
for (JsonNode task : content) {
|
||||
String taskBizType = text(task, "bizType");
|
||||
if (StringUtils.hasText(flowBizType) && !flowBizType.equals(taskBizType)) {
|
||||
continue;
|
||||
}
|
||||
String instanceId = task.has("instanceId") && task.get("instanceId").isNumber()
|
||||
? String.valueOf(task.get("instanceId").asLong()) : null;
|
||||
if (bizId.equals(instanceId) || bizId.equals(text(task, "bizId"))) {
|
||||
matched.add(task);
|
||||
}
|
||||
}
|
||||
if (content.size() < TODO_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
private List<JsonNode> fetchTodoPage(Integer schId, Integer userId, String flowBizType, int page) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("schId", String.valueOf(schId == null ? 0 : schId));
|
||||
query.put("assigneeId", String.valueOf(userId));
|
||||
if (StringUtils.hasText(flowBizType)) {
|
||||
query.put("bizType", flowBizType);
|
||||
}
|
||||
query.put("page", String.valueOf(page));
|
||||
query.put("size", String.valueOf(TODO_PAGE_SIZE));
|
||||
JsonNode data = getFlowcenter("/oapi/flow/tasks/todo", query);
|
||||
if (data != null && data.path("content").isArray()) {
|
||||
List<JsonNode> rows = new ArrayList<>();
|
||||
data.get("content").forEach(rows::add);
|
||||
return rows;
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 显式 base-url → ad-hoc TargetSpec(对齐 usermsg 通道形态);否则按名走服务目录/枚举兜底 */
|
||||
private JsonNode getFlowcenter(String path, Map<String, String> query) {
|
||||
String base = properties.getFlowCenterBaseUrl();
|
||||
if (StringUtils.hasText(base)) {
|
||||
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
|
||||
}
|
||||
return openCall.get(TARGET_FLOW_CENTER, path, query, JsonNode.class);
|
||||
}
|
||||
|
||||
private JsonNode postFlowcenter(String path, Map<String, String> query, Object body) {
|
||||
String base = properties.getFlowCenterBaseUrl();
|
||||
if (StringUtils.hasText(base)) {
|
||||
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
|
||||
}
|
||||
return openCall.post(TARGET_FLOW_CENTER, path, query, body, JsonNode.class);
|
||||
}
|
||||
|
||||
private static Map<String, Object> fail(Map<String, Object> out, String message) {
|
||||
out.put("ok", false);
|
||||
out.put("message", message);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.openapi.msg.OpenApiMsgClient;
|
||||
import com.yunhai.platform.openapi.msg.impl.DefaultOpenApiMsgClient;
|
||||
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
|
||||
import com.yunhai.platform.openapi.msg.model.MsgListItem;
|
||||
import com.yunhai.platform.openapi.msg.model.MsgListPage;
|
||||
import com.yunhai.platform.openapi.msg.model.MsgListQuery;
|
||||
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
|
||||
import com.yunhai.userhome.config.MsgCenterProperties;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
|
||||
* (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
|
||||
* UserHome 前端不直连 UserMsg。
|
||||
* <ul>
|
||||
* <li>消息读取(列表/摘要/未读数/按应用计数/全部已读)走 SDK msg 域类型化读方法
|
||||
* ({@link OpenApiMsgClient}),并带「当前激活身份」所属学校口径 schId
|
||||
* ({@link #resolveReadSchId})——按学校隔离读取侧;</li>
|
||||
* <li>待办角标 todo/list(仍按主学校 schId,链路口径不变)、单条已读 read、WS 票据
|
||||
* 继续走 {@link YschOpenCall} 泛化直调;</li>
|
||||
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
|
||||
* 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
|
||||
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
|
||||
* <li>showJump 判权(C1):jumpApp 有值时按 /oapi/app/authorized-codes 集合判定,
|
||||
* 无权 → showJump=false 且 jumpUrl 一并置 null;jumpApp 为空或 UserBase 不可达不判权放行。</li>
|
||||
* </ul>
|
||||
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
|
||||
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
|
||||
* 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为
|
||||
* {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。
|
||||
* 目标地址来源:usermsg-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址),
|
||||
* 其次服务目录 usermsg(yh.open.services / 枚举兜底)。
|
||||
* UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MsgCenterProxyService {
|
||||
|
||||
private static final String TARGET_USERMSG = "usermsg";
|
||||
private static final String TARGET_USERBASE = "userbase";
|
||||
|
||||
/** UserBase 查址失败短路窗口:列表逐行查址,下游停摆时避免每行都吃一次连接超时 */
|
||||
static final long USERBASE_RETRY_MS = 30_000L;
|
||||
|
||||
/** 应用字典 TTL:appKey↔appId 映射按用户缓存,到期或失败即重拉/沿用旧值 */
|
||||
static final long APP_DICT_TTL_MS = 300_000L;
|
||||
|
||||
/** 消息定位扫描上限:5 页 × 100 条(UserMsg 无按 id 直查端点,逐页扫描兜底) */
|
||||
static final int MSG_SCAN_PAGES = 5;
|
||||
static final int MSG_SCAN_PAGE_SIZE = 100;
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final MsgCenterProperties properties;
|
||||
private final IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
|
||||
private volatile long userbaseDownUntil;
|
||||
|
||||
/** 应用字典按用户缓存(volatile 整体替换,并发重拉无害) */
|
||||
private final java.util.concurrent.ConcurrentHashMap<Integer, AppDict> appDictCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentHashMap<Integer, Long> appDictAt = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/** 应用字典条目:注册表 appId / appKey / 名称(UserBase BoaApp) */
|
||||
private static final class AppDictEntry {
|
||||
final int appId;
|
||||
final String appKey;
|
||||
final String name;
|
||||
|
||||
AppDictEntry(int appId, String appKey, String name) {
|
||||
this.appId = appId;
|
||||
this.appKey = appKey;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用字典:byKey 正查(行富化/出参)+ byId 反查(appId 筛选参数翻译) */
|
||||
private static final class AppDict {
|
||||
static final AppDict EMPTY = new AppDict(java.util.Collections.emptyMap(), java.util.Collections.emptyMap());
|
||||
final Map<String, AppDictEntry> byKey;
|
||||
final Map<Integer, AppDictEntry> byId;
|
||||
|
||||
AppDict(Map<String, AppDictEntry> byKey, Map<Integer, AppDictEntry> byId) {
|
||||
this.byKey = byKey;
|
||||
this.byId = byId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* by-code 解析条目(UserBase /oapi/app/by-code/{appCode},值域=消息 jump_app)。
|
||||
* appId=注册表主键 id(2026-10-13 160 实测与 /oapi/app/list 同体系:notice 主键=100001、
|
||||
* 流程中心主键=100011/app_key=100009,无混体系风险)。
|
||||
*/
|
||||
private static final class ByCodeEntry {
|
||||
final int appId;
|
||||
final String appKey;
|
||||
final String name;
|
||||
final String pcUrl;
|
||||
final String code;
|
||||
|
||||
ByCodeEntry(int appId, String appKey, String name, String pcUrl, String code) {
|
||||
this.appId = appId;
|
||||
this.appKey = appKey;
|
||||
this.name = name;
|
||||
this.pcUrl = pcUrl;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查无哨兵(ConcurrentHashMap 不允许 null 值):脏 code 不重复出站 */
|
||||
private static final ByCodeEntry CODE_MISS = new ByCodeEntry(-1, null, null, null, null);
|
||||
|
||||
/** by-code 缓存本体与时间戳(TTL 对齐 {@link #APP_DICT_TTL_MS}) */
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, ByCodeEntry> byCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, Long> byCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* by-code 应用缓存(TTL 对齐 {@link #APP_DICT_TTL_MS}):一次调用同时取 name/appId/pcUrl,
|
||||
* 消掉逐行 by-code 查址的 N+1(旧实现每行现查)。UserBase 不可达复用
|
||||
* {@link #userbaseDownUntil} 短路窗口;返回 null=不可达(调用方降级),返回
|
||||
* {@link #CODE_MISS}=查无(回落发送方口径)。
|
||||
*/
|
||||
private ByCodeEntry lookupByCode(String appCode) {
|
||||
if (appCode == null || appCode.isEmpty()) {
|
||||
return CODE_MISS;
|
||||
}
|
||||
Long at = byCodeAt.get(appCode);
|
||||
boolean fresh = at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS;
|
||||
if (fresh) {
|
||||
ByCodeEntry cached = byCodeCache.get(appCode);
|
||||
return cached == null ? CODE_MISS : cached;
|
||||
}
|
||||
if (userbaseDown()) {
|
||||
ByCodeEntry stale = byCodeCache.get(appCode);
|
||||
return stale == null ? CODE_MISS : stale;
|
||||
}
|
||||
try {
|
||||
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-code/" + appCode, null, JsonNode.class);
|
||||
userbaseDownUntil = 0L;
|
||||
ByCodeEntry entry = app != null && app.hasNonNull("id")
|
||||
? new ByCodeEntry(app.get("id").asInt(),
|
||||
app.hasNonNull("appKey") ? String.valueOf(app.get("appKey").asInt()) : null,
|
||||
text(app, "name"), text(app, "pcUrl"), text(app, "appCode"))
|
||||
: CODE_MISS;
|
||||
byCodeCache.put(appCode, entry);
|
||||
byCodeAt.put(appCode, System.currentTimeMillis());
|
||||
return entry;
|
||||
} catch (Exception e) {
|
||||
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
|
||||
log.warn("[msgcenter] UserBase by-code 解析不可达(降级回落发送方口径)code={}:{}", appCode, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** appKey→app_code 反查缓存(list 的 appId 双值翻译用;空串值=查无,退化为单值过滤) */
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, String> appCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentHashMap<String, Long> appCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/** appKey→app_code 反查(/oapi/app/by-key,VO 含 appCode):首次查询后缓存,查无/不可达返回 null */
|
||||
private String appCodeByKey(String appKey) {
|
||||
if (appKey == null || appKey.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Long at = appCodeAt.get(appKey);
|
||||
if (at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS) {
|
||||
return appCodeCache.get(appKey);
|
||||
}
|
||||
if (appCodeCache.containsKey(appKey) && userbaseDown()) {
|
||||
return appCodeCache.get(appKey);
|
||||
}
|
||||
try {
|
||||
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-key/" + appKey, null, JsonNode.class);
|
||||
userbaseDownUntil = 0L;
|
||||
String code = app == null || app.isNull() ? null : text(app, "appCode");
|
||||
appCodeCache.put(appKey, code == null ? "" : code);
|
||||
appCodeAt.put(appKey, System.currentTimeMillis());
|
||||
return StringUtils.hasText(code) ? code : null;
|
||||
} catch (Exception e) {
|
||||
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
|
||||
log.warn("[msgcenter] UserBase by-key 反查 app_code 不可达(退化单值过滤)appKey={}:{}", appKey, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
|
||||
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
|
||||
*/
|
||||
private AppDict appDict(Integer userId) {
|
||||
if (userId == null) {
|
||||
return AppDict.EMPTY;
|
||||
}
|
||||
AppDict cached = appDictCache.get(userId);
|
||||
Long at = appDictAt.get(userId);
|
||||
long now = System.currentTimeMillis();
|
||||
if (cached != null && at != null && now - at < APP_DICT_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
if (userbaseDown()) {
|
||||
return cached != null ? cached : AppDict.EMPTY;
|
||||
}
|
||||
try {
|
||||
Map<String, String> q = new LinkedHashMap<>();
|
||||
q.put("userId", String.valueOf(userId));
|
||||
JsonNode groups = openCall.get(TARGET_USERBASE, "/oapi/app/list", q, JsonNode.class);
|
||||
userbaseDownUntil = 0L;
|
||||
Map<String, AppDictEntry> byKey = new LinkedHashMap<>();
|
||||
Map<Integer, AppDictEntry> byId = new LinkedHashMap<>();
|
||||
if (groups != null && groups.isArray()) {
|
||||
for (JsonNode group : groups) {
|
||||
JsonNode apps = group.path("apps");
|
||||
if (!apps.isArray()) {
|
||||
continue;
|
||||
}
|
||||
for (JsonNode app : apps) {
|
||||
if (app == null || !app.hasNonNull("id") || !app.hasNonNull("appKey")) {
|
||||
continue;
|
||||
}
|
||||
AppDictEntry entry = new AppDictEntry(app.get("id").asInt(),
|
||||
app.get("appKey").asText(), text(app, "name"));
|
||||
byKey.put(entry.appKey, entry);
|
||||
byId.put(entry.appId, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppDict dict = new AppDict(byKey, byId);
|
||||
appDictCache.put(userId, dict);
|
||||
appDictAt.put(userId, now);
|
||||
return dict;
|
||||
} catch (Exception e) {
|
||||
userbaseDownUntil = now + USERBASE_RETRY_MS;
|
||||
log.warn("[msgcenter] UserBase 应用字典不可达({}ms 内沿用旧字典/空)userId={}:{}",
|
||||
USERBASE_RETRY_MS, userId, e.toString());
|
||||
return cached != null ? cached : AppDict.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties,
|
||||
IUserCurrentRoleService userCurrentRoleService) {
|
||||
this.openCall = openCall;
|
||||
this.properties = properties;
|
||||
this.userCurrentRoleService = userCurrentRoleService;
|
||||
}
|
||||
|
||||
private boolean enabled() {
|
||||
return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息读取口径 schId(按学校隔离读取侧,R2):取<b>当前激活身份</b>所属学校
|
||||
* ({@code userCurrentRoleService.findById} → UserBase /oapi/identity/current),
|
||||
* <b>严禁</b>用会话主学校(跨校切换会错)。解析为 0(平台/超管)或身份链不可达时返回 null
|
||||
* =不下发过滤(看全部,D5,且与改造前行为一致)。
|
||||
*/
|
||||
public Integer resolveReadSchId(Integer userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return userCurrentRoleService.findById(userId)
|
||||
.map(UserCurrentRole::getSchId)
|
||||
.filter(schId -> schId != null && schId > 0)
|
||||
.orElse(null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] 当前身份学校解析不可达(降级不过滤=全量口径)userId={}:{}", userId, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx);否则类型化读方法按服务目录 usermsg 寻址 */
|
||||
private OpenApiMsgClient msgClient() {
|
||||
String base = properties.getUsermsgBaseUrl();
|
||||
return StringUtils.hasText(base)
|
||||
? new DefaultOpenApiMsgClient(openCall, TargetSpec.fromUrl(base.trim()))
|
||||
: new DefaultOpenApiMsgClient(openCall);
|
||||
}
|
||||
|
||||
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */
|
||||
private JsonNode get(String path, Map<String, String> query) {
|
||||
String base = properties.getUsermsgBaseUrl();
|
||||
if (StringUtils.hasText(base)) {
|
||||
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
|
||||
}
|
||||
return openCall.get(TARGET_USERMSG, path, query, JsonNode.class);
|
||||
}
|
||||
|
||||
private JsonNode post(String path, Map<String, String> query, Object body) {
|
||||
String base = properties.getUsermsgBaseUrl();
|
||||
if (StringUtils.hasText(base)) {
|
||||
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
|
||||
}
|
||||
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。
|
||||
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
|
||||
* msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。
|
||||
* schId=当前身份学校口径({@link #resolveReadSchId}),null=不过滤(平台/超管视角,看全部)。
|
||||
*/
|
||||
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread,
|
||||
Integer msgType, String appId, Integer isRead, String keyword, Integer schId) {
|
||||
Map<String, Object> empty = emptyPage();
|
||||
if (!enabled()) {
|
||||
return empty;
|
||||
}
|
||||
PerRequestAuthz authz = new PerRequestAuthz(userId);
|
||||
AppDict dict = appDict(userId);
|
||||
try {
|
||||
MsgListQuery query = new MsgListQuery();
|
||||
query.setUserId(userId);
|
||||
query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex);
|
||||
query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100));
|
||||
query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread));
|
||||
query.setMsgType(msgType);
|
||||
if (StringUtils.hasText(appId)) {
|
||||
AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim()));
|
||||
if (entry == null) {
|
||||
return empty; // 字典查无此应用:视作该应用无消息
|
||||
}
|
||||
// 归属口径双值过滤:该应用自发行桶值=app_key,流程中心代发行桶值=app_code
|
||||
List<String> appKeys = new ArrayList<>();
|
||||
appKeys.add(entry.appKey);
|
||||
String code = appCodeByKey(entry.appKey);
|
||||
if (StringUtils.hasText(code) && !code.equals(entry.appKey)) {
|
||||
appKeys.add(code);
|
||||
}
|
||||
query.setAppKeys(appKeys);
|
||||
}
|
||||
query.setIsRead(isRead);
|
||||
query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
|
||||
query.setSchId(schId);
|
||||
MsgListPage page = msgClient().list(query);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (page != null && page.getContent() != null) {
|
||||
for (MsgListItem item : page.getContent()) {
|
||||
rows.add(rewrite(item, authz, dict));
|
||||
}
|
||||
}
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", page == null ? 0 : page.getTotal());
|
||||
out.put("list", rows);
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg 列表不可达(降级空)userId={}:{}", userId, e.toString());
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
|
||||
* 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary
|
||||
* (消息中心左栏一次喂饱),按 msgSchId 学校口径取数;UserMsg 不可达/字段缺省时降级 unreadCount 单查。
|
||||
*
|
||||
* @param msgSchId 消息读取口径(当前激活身份学校,null=全部)
|
||||
* @param todoSchId 待办角标口径(沿用会话主学校,链路口径不变,见类注释)
|
||||
*/
|
||||
public Map<String, Object> summary(Integer userId, Integer msgSchId, Integer todoSchId) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("unreadCount", 0L);
|
||||
out.put("todos", new ArrayList<>());
|
||||
if (!enabled()) {
|
||||
return out;
|
||||
}
|
||||
boolean summaryFetched = false;
|
||||
try {
|
||||
UserMsgSummary vo = msgClient().summary(userId, msgSchId);
|
||||
if (vo != null) {
|
||||
out.put("unreadCount", vo.getUnreadCount());
|
||||
out.put("sysUnread", vo.getSysUnread());
|
||||
out.put("bizUnread", vo.getBizUnread());
|
||||
out.put("approvalUnread", vo.getApprovalUnread());
|
||||
out.put("total", vo.getTotal());
|
||||
out.put("readCount", vo.getReadCount());
|
||||
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径
|
||||
out.put("sysTotal", vo.getSysTotal());
|
||||
out.put("bizTotal", vo.getBizTotal());
|
||||
out.put("approvalTotal", vo.getApprovalTotal());
|
||||
summaryFetched = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 旧版 UserMsg 无 summary 端点:降级走 unreadCount 单查,字段缺省由前端兜底
|
||||
log.debug("[msgcenter] UserMsg 摘要端点不可用(降级未读单查)userId={}:{}", userId, e.toString());
|
||||
}
|
||||
if (!summaryFetched) {
|
||||
try {
|
||||
out.put("unreadCount", msgClient().unreadCount(userId, msgSchId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
|
||||
}
|
||||
}
|
||||
try {
|
||||
Map<String, String> q = new LinkedHashMap<>();
|
||||
q.put("userId", String.valueOf(userId));
|
||||
if (todoSchId != null) {
|
||||
q.put("schId", String.valueOf(todoSchId));
|
||||
}
|
||||
JsonNode todos = get("/oapi/msg/todo/list", q);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if (todos != null && todos.isArray()) {
|
||||
for (JsonNode t : todos) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
// appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp
|
||||
row.put("count", t.has("count") ? t.get("count").asInt() : 0);
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
out.put("todos", rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg 待办角标不可达(降级空)userId={}:{}", userId, e.toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
|
||||
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
|
||||
* UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key):
|
||||
* 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread
|
||||
* (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。
|
||||
* schId=当前身份学校口径(null=全部)。停机降级空列表。
|
||||
*/
|
||||
public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
|
||||
List<Map<String, Object>> empty = new ArrayList<>();
|
||||
if (!enabled()) {
|
||||
return empty;
|
||||
}
|
||||
try {
|
||||
List<AppMsgCount> counts = msgClient().countByApp(userId, schId);
|
||||
if (counts == null || counts.isEmpty()) {
|
||||
return empty;
|
||||
}
|
||||
AppDict dict = appDict(userId);
|
||||
Map<Object, Map<String, Object>> merged = new LinkedHashMap<>();
|
||||
for (AppMsgCount a : counts) {
|
||||
String bucket = a == null ? null : a.getAppKey();
|
||||
if (bucket == null || bucket.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
AppDictEntry entry = dict.byKey.get(bucket);
|
||||
// 纯数字桶值不可能是 app_code,免一次注定查无的出站;
|
||||
// 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏
|
||||
ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+")
|
||||
? lookupByCode(bucket) : null;
|
||||
Integer appId;
|
||||
String name;
|
||||
if (entry != null) {
|
||||
appId = entry.appId;
|
||||
name = entry.name;
|
||||
} else if (codeEntry != null && codeEntry != CODE_MISS) {
|
||||
appId = codeEntry.appId;
|
||||
name = codeEntry.name;
|
||||
} else if (codeEntry == CODE_MISS) {
|
||||
continue; // code 脏桶双查无:丢弃,与改造前一致
|
||||
} else {
|
||||
appId = null;
|
||||
name = properties.getAppNames().get(bucket);
|
||||
}
|
||||
// 合桶键=注册表主键 appId:自发桶(byKey)与代发桶(byCode)同应用并成一类目
|
||||
Map<String, Object> row = merged.get(appId);
|
||||
if (row == null) {
|
||||
row = new LinkedHashMap<>();
|
||||
row.put("appId", appId);
|
||||
row.put("appName", name);
|
||||
row.put("total", 0L);
|
||||
row.put("unread", 0L);
|
||||
merged.put(appId, row);
|
||||
}
|
||||
row.put("total", (Long) row.get("total") + a.getTotal());
|
||||
row.put("unread", (Long) row.get("unread") + a.getUnread());
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>(merged.values());
|
||||
out.sort((x, y) -> {
|
||||
int c = Long.compare((Long) y.get("total"), (Long) x.get("total"));
|
||||
return c != 0 ? c : Long.compare((Long) y.get("unread"), (Long) x.get("unread"));
|
||||
});
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪)。
|
||||
* schId 仅用于 UserMsg 侧缓存键(含学校口径,R1)与 readState 帧口径对齐,不参与 UPDATE 归属判定。
|
||||
*/
|
||||
public void read(Integer userId, List<Long> msgIds, Integer schId) {
|
||||
if (!enabled() || msgIds == null || msgIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("msgIds", msgIds);
|
||||
body.put("userId", userId);
|
||||
if (schId != null) {
|
||||
body.put("schId", schId);
|
||||
}
|
||||
post("/oapi/msg/read", new HashMap<>(), body);
|
||||
}
|
||||
|
||||
/** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
|
||||
public void readAll(Integer userId, Integer schId) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
msgClient().readAll(userId, schId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 msgId 定位当前登录人的消息(卡片直批 B3 用)。归属校验=按登录人分页拉列表后命中:
|
||||
* 他人消息/已删除/已过期一律查无,统一返回 null(不区分原因,防越权探测)。
|
||||
*/
|
||||
public JsonNode findMessage(Integer userId, Long msgId) {
|
||||
if (!enabled() || msgId == null) {
|
||||
return null;
|
||||
}
|
||||
for (int page = 1; page <= MSG_SCAN_PAGES; page++) {
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("userId", String.valueOf(userId));
|
||||
query.put("pageIndex", String.valueOf(page));
|
||||
query.put("pageSize", String.valueOf(MSG_SCAN_PAGE_SIZE));
|
||||
JsonNode data = get("/oapi/msg/list", query);
|
||||
if (data == null || !data.path("content").isArray()) {
|
||||
return null;
|
||||
}
|
||||
for (JsonNode row : data.get("content")) {
|
||||
if (row.has("msgId") && row.get("msgId").asLong() == msgId) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
if (data.get("content").size() < MSG_SCAN_PAGE_SIZE
|
||||
|| data.path("total").asLong() <= (long) page * MSG_SCAN_PAGE_SIZE) {
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg 消息定位不可达 msgId={}:{}", msgId, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 握手票据:以应用身份为登录人换一次性票据(UserMsg /oapi/msg/ws/ticket),
|
||||
* 拼上浏览器可达的 WS 端点返回。未配置 WS 地址 / UserMsg 不可达 → null(前端维持轮询兜底表现)。
|
||||
*/
|
||||
public String createWsTicket(Integer userId) {
|
||||
if (!enabled() || !StringUtils.hasText(properties.getUsermsgWsUrl())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("userId", userId);
|
||||
JsonNode data = post("/oapi/msg/ws/ticket", new HashMap<>(), body);
|
||||
String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText();
|
||||
if (!StringUtils.hasText(ticket)) {
|
||||
return null;
|
||||
}
|
||||
String wsUrl = properties.getUsermsgWsUrl().trim();
|
||||
return wsUrl + (wsUrl.contains("?") ? "&" : "?") + "ticket=" + ticket;
|
||||
} catch (Exception e) {
|
||||
log.warn("[msgcenter] UserMsg WS 票据不可达(降级 null,前端不建立长连接)userId={}:{}",
|
||||
userId, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 出参改写 =====
|
||||
|
||||
/**
|
||||
* MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
|
||||
* 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时
|
||||
* 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。
|
||||
* appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。
|
||||
*/
|
||||
private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("msgId", row.getMsgId());
|
||||
out.put("title", row.getTitle());
|
||||
out.put("content", row.getContent());
|
||||
out.put("msgType", row.getMsgType());
|
||||
out.put("jumpType", row.getJumpType());
|
||||
out.put("jumpPath", row.getJumpPath());
|
||||
out.put("jumpApp", row.getJumpApp());
|
||||
// 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用;
|
||||
// jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。
|
||||
// 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。
|
||||
String jumpApp = row.getJumpApp();
|
||||
String ownerCode = jumpApp == null ? null : jumpApp.trim();
|
||||
AppDictEntry entry = null;
|
||||
Integer appId = null;
|
||||
String appName = null;
|
||||
if (StringUtils.hasText(ownerCode)) {
|
||||
ByCodeEntry codeEntry = lookupByCode(ownerCode);
|
||||
if (codeEntry != null && codeEntry != CODE_MISS) {
|
||||
appId = codeEntry.appId;
|
||||
appName = codeEntry.name;
|
||||
}
|
||||
}
|
||||
if (appId == null) {
|
||||
String appKey = row.getAppKey();
|
||||
entry = appKey == null ? null : dict.byKey.get(appKey);
|
||||
if (entry != null) {
|
||||
appId = entry.appId;
|
||||
appName = entry.name;
|
||||
} else {
|
||||
appName = properties.getAppNames().get(appKey);
|
||||
}
|
||||
}
|
||||
out.put("appId", appId);
|
||||
out.put("appName", appName);
|
||||
out.put("bizType", row.getBizType());
|
||||
out.put("bizId", row.getBizId());
|
||||
out.put("sendTime", row.getSendTime());
|
||||
out.put("isRead", row.getIsRead() == null ? 0 : row.getIsRead());
|
||||
String jumpUrl = jumpUrl(row);
|
||||
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
|
||||
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
|
||||
Boolean allowed = null;
|
||||
if (jumpUrl != null && StringUtils.hasText(ownerCode)) {
|
||||
allowed = authz.authorized(ownerCode);
|
||||
}
|
||||
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
|
||||
if (Boolean.FALSE.equals(allowed)) {
|
||||
jumpUrl = null;
|
||||
}
|
||||
out.put("jumpUrl", jumpUrl);
|
||||
out.put("showJump", showJump);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基址解析(B2 跳转基址统一,C2 收口):jumpType=1 且 jumpPath 非空时——
|
||||
* <ol>
|
||||
* <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li>
|
||||
* <li>应用注册表严格二选一:jumpApp(值域=app_code)有值 → 只认 by-code 结果;
|
||||
* jumpApp 为空才按发送方 appKey 走 by-key(自发场景发送方=页面归属)。
|
||||
* pc_url 按 §3.2 归一化(丢 fragment、去尾斜杠、保留 index.htm(l))后拼 "#";</li>
|
||||
* <li>查无 pc_url → null(前端不渲染跳转入口)——pc_url 空就是空,不猜测、不再回落发送方首页。</li>
|
||||
* </ol>
|
||||
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
|
||||
*/
|
||||
private String jumpUrl(MsgListItem row) {
|
||||
Integer jumpType = row.getJumpType();
|
||||
String jumpPath = row.getJumpPath();
|
||||
String jumpApp = row.getJumpApp();
|
||||
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
|
||||
return null;
|
||||
}
|
||||
String path = jumpPath.trim();
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path;
|
||||
}
|
||||
if (userbaseDown()) {
|
||||
return null;
|
||||
}
|
||||
String p = path.startsWith("/") ? path : "/" + path;
|
||||
String appKey = row.getAppKey();
|
||||
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
|
||||
: (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
|
||||
if (!StringUtils.hasText(pcUrl)) {
|
||||
return null;
|
||||
}
|
||||
String base = pcUrl.trim();
|
||||
int hash = base.indexOf('#');
|
||||
if (hash >= 0) {
|
||||
base = base.substring(0, hash);
|
||||
}
|
||||
while (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
return base + "#" + p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl
|
||||
* (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变,
|
||||
* 查不到/无缓存才隐藏跳转入口)。
|
||||
*/
|
||||
private String pcUrlByCode(String appCode) {
|
||||
ByCodeEntry entry = lookupByCode(appCode.trim());
|
||||
return entry == null || entry == CODE_MISS ? null : entry.pcUrl;
|
||||
}
|
||||
|
||||
/** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */
|
||||
private String pcUrlByKey(String appKey) {
|
||||
return lookupPcUrl("/oapi/app/by-key/" + appKey);
|
||||
}
|
||||
|
||||
private String lookupPcUrl(String path) {
|
||||
try {
|
||||
JsonNode app = openCall.get(TARGET_USERBASE, path, null, JsonNode.class);
|
||||
boolean ok = app != null && app.hasNonNull("pcUrl") && StringUtils.hasText(app.get("pcUrl").asText());
|
||||
userbaseDownUntil = 0L;
|
||||
return ok ? app.get("pcUrl").asText() : null;
|
||||
} catch (Exception e) {
|
||||
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
|
||||
log.warn("[msgcenter] UserBase 查址不可达({}ms 内跳转入口整体隐藏)path={}:{}", USERBASE_RETRY_MS, path, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean userbaseDown() {
|
||||
return System.currentTimeMillis() < userbaseDownUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次请求内的判权懒加载器:列表内首个带 jumpApp 的行触发一次出站,之后行间共用
|
||||
* (禁按行重复出站);UserBase 不可达 → 判权结果 null(放行),失败进 30s 短路窗口。
|
||||
*/
|
||||
private class PerRequestAuthz {
|
||||
private final Integer userId;
|
||||
private boolean fetched;
|
||||
private Set<String> codes;
|
||||
|
||||
PerRequestAuthz(Integer userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
/** @return true=有权 false=无权 null=不可判(UserBase 不可达)→ 调用方放行 */
|
||||
Boolean authorized(String appCode) {
|
||||
if (!fetched) {
|
||||
fetched = true;
|
||||
codes = fetchAuthorizedCodes(userId);
|
||||
}
|
||||
return codes == null ? null : codes.contains(appCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉当前用户有权应用的 app_code 集合(UserBase /oapi/app/authorized-codes)。
|
||||
* 复用 userbaseDownUntil 30s 短路:短路中不出站;请求失败同样进短路窗口。
|
||||
*
|
||||
* @return app_code 集合;UserBase 不可达 → null(调用方不判权放行,绝不批量隐藏)
|
||||
*/
|
||||
private Set<String> fetchAuthorizedCodes(Integer userId) {
|
||||
if (userId == null || userbaseDown()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("userId", String.valueOf(userId));
|
||||
JsonNode data = openCall.get(TARGET_USERBASE, "/oapi/app/authorized-codes", query, JsonNode.class);
|
||||
userbaseDownUntil = 0L;
|
||||
Set<String> codes = new HashSet<>();
|
||||
if (data != null && data.isArray()) {
|
||||
for (JsonNode n : data) {
|
||||
if (n != null && n.isTextual() && StringUtils.hasText(n.asText())) {
|
||||
codes.add(n.asText().trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
} catch (Exception e) {
|
||||
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
|
||||
log.warn("[msgcenter] UserBase 判权不可达({}ms 内不判权放行)userId={}:{}",
|
||||
USERBASE_RETRY_MS, userId, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> emptyPage() {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", 0);
|
||||
out.put("list", new ArrayList<>());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLDecoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 小组件统一取数。
|
||||
*
|
||||
* <p>{@code syllabus}(我的课表)已对接 AcaSyllabu {@code GET /oapi/timetable/Get}(出站走 {@link YschOpenCall},
|
||||
* 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]}
|
||||
* 班级数组(含 {@code timetable.slots[].subjects[7]}),由前端按班级维度渲染。</p>
|
||||
*
|
||||
* <p>{@code weekplan}(一周安排)已对接 WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(出站走
|
||||
* {@link YschOpenCall})。全校共享数据、不区分角色,**原样透传**单个周对象 {@code data}(含 {@code days[7]});
|
||||
* {@code week} 为相对本周偏移(0=本周、-1=上一周、+1=下一周),合法范围见响应 {@code minWeek}/{@code maxWeek};
|
||||
* 返回内容与身份无关但随周偏移变化,缓存按 {@code schId|week偏移} 双段键。</p>
|
||||
*
|
||||
* <p>{@code evaluate} 仍为 L2 可溯源 mock(字段名逐一等于
|
||||
* {@code design/widget-snapshots-old-bsch/*.js} 的旧接口口径);未知 code 返回 null 由调用方兜空数据,不抛异常。</p>
|
||||
*/
|
||||
@Service
|
||||
public class WidgetDataService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WidgetDataService.class);
|
||||
|
||||
private static final String CODE_EVALUATE = "evaluate";
|
||||
|
||||
private static final String CODE_SYLLABUS = "syllabus";
|
||||
|
||||
private static final String CODE_WEEKPLAN = "weekplan";
|
||||
|
||||
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */
|
||||
private static final int EVALUATE_DEFAULT_COUNT = 5;
|
||||
|
||||
/** 课表 oapi 目标名(见 yh.call.targets.acasyllabu) */
|
||||
private static final String TARGET_ACASYLLABU = "acasyllabu";
|
||||
|
||||
/** 课表 oapi 路径(签名按目标 ctx 剥离,故此处不含 /acasyllabu) */
|
||||
private static final String PATH_TIMETABLE = "/oapi/timetable/Get";
|
||||
|
||||
/** 课表缓存时长:课表低频变更,避免每次进桌面都打出站 */
|
||||
private static final long SYLLABUS_TTL_MS = 90_000L;
|
||||
|
||||
/** 课表取数失败后的静默期(短 TTL 空结果,避免下游停摆时持续打出站) */
|
||||
private static final long SYLLABUS_FAIL_BACKOFF_MS = 15_000L;
|
||||
|
||||
/** 课表缓存条目上限,超出时清理过期项 */
|
||||
private static final int SYLLABUS_CACHE_MAX = 500;
|
||||
|
||||
/** 一周安排 oapi 目标名(见 yh.call.targets.workplan) */
|
||||
private static final String TARGET_WORKPLAN = "workplan";
|
||||
|
||||
/** 一周安排 oapi 路径(签名按目标 ctx 剥离,故此处不含 /work-week-plan) */
|
||||
private static final String PATH_WORKPLAN = "/oapi/workplan/Get";
|
||||
|
||||
/** 一周安排缓存时长:全校共享、低频变更 */
|
||||
private static final long WEEKPLAN_TTL_MS = 90_000L;
|
||||
|
||||
/** 一周安排取数失败后的静默期 */
|
||||
private static final long WEEKPLAN_FAIL_BACKOFF_MS = 15_000L;
|
||||
|
||||
/** 一周安排缓存条目上限,超出时清理过期项 */
|
||||
private static final int WEEKPLAN_CACHE_MAX = 500;
|
||||
|
||||
@Autowired
|
||||
private YschOpenCall openCall;
|
||||
|
||||
/** key = userId|roleId|schId → 缓存条目 */
|
||||
private final ConcurrentHashMap<String, CacheEntry> syllabusCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** key = schId|weekOffset → 缓存条目(周安排为全校共享数据,与 userid/roleId 无关;但不同周内容不同) */
|
||||
private final ConcurrentHashMap<String, CacheEntry> weekplanCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。
|
||||
*
|
||||
* @return 数据结构由数据键决定;null = 该键未接入
|
||||
*/
|
||||
public Object getData(String code, String params, Caller caller) {
|
||||
if (CODE_EVALUATE.equals(code)) return evaluate(params);
|
||||
if (CODE_SYLLABUS.equals(code)) return syllabus(caller);
|
||||
if (CODE_WEEKPLAN.equals(code)) return weekplan(caller, params);
|
||||
log.warn("[widget] 未配置的数据键:{}", code);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 听评课:分页 {list, count, pageSize},入参 {page, count}(page 从 0 起)。
|
||||
* // TODO 接入 oapi: openCall.post(TARGET_USERBASE, "/oapi/...", null, body, Vo.class)
|
||||
*/
|
||||
private Map<String, Object> evaluate(String params) {
|
||||
int page = Math.max(0, intParam(params, "page", 0));
|
||||
int count = Math.min(50, Math.max(1, intParam(params, "count", EVALUATE_DEFAULT_COUNT)));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row("id", 101, "title", "《圆的认识》课堂观察",
|
||||
"lectureTime", "2026-09-28 08:30:00", "lectureteaName", "王晓梅", "subName", "数学"));
|
||||
rows.add(row("id", 102, "title", "《Unit 5 Reading》公开课",
|
||||
"lectureTime", "2026-09-29 10:00:00", "lectureteaName", "李强", "subName", "英语"));
|
||||
rows.add(row("id", 103, "title", "《光的折射》实验课",
|
||||
"lectureTime", "2026-09-30 14:00:00", "lectureteaName", "陈雷", "subName", "物理"));
|
||||
rows.add(row("id", 104, "title", "《三角形内角和》研讨课",
|
||||
"lectureTime", "2026-10-09 09:00:00", "lectureteaName", "赵敏", "subName", "数学"));
|
||||
int from = Math.min(page * count, rows.size());
|
||||
int to = Math.min(from + count, rows.size());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("list", rows.subList(from, to));
|
||||
result.put("count", rows.size());
|
||||
result.put("pageSize", count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的课表:AcaSyllabu {@code GET /oapi/timetable/Get?userid=&roleId=&schId=}(SDK 出站,签名自动)。
|
||||
* 原样返回 {@code data[]} 班级数组;无可见范围返回 {@code []};不可达 / 验签失败降级空数组(不抛 500)。
|
||||
*
|
||||
* <p>身份三参均来自服务端会话推导({@link Caller}),不接受前端传入;roleId<=0 时不透传
|
||||
* (oapi 侧默认按普通教师处理)。</p>
|
||||
*/
|
||||
private Object syllabus(Caller caller) {
|
||||
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
|
||||
// 缺身份(超管无当前身份等)→ 空数据,不打出站
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String key = caller.getUserId() + "|" + caller.getRoleId() + "|" + caller.getSchId();
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry hit = syllabusCache.get(key);
|
||||
if (hit != null && now < hit.expireAt) {
|
||||
return hit.data;
|
||||
}
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("userid", String.valueOf(caller.getUserId()));
|
||||
if (caller.getRoleId() != null && caller.getRoleId() > 0) {
|
||||
query.put("roleId", String.valueOf(caller.getRoleId()));
|
||||
}
|
||||
query.put("schId", String.valueOf(caller.getSchId()));
|
||||
JsonNode data = openCall.get(TARGET_ACASYLLABU, PATH_TIMETABLE, query, JsonNode.class);
|
||||
Object value = (data != null && data.isArray()) ? data : Collections.emptyList();
|
||||
cache(syllabusCache, SYLLABUS_CACHE_MAX, key, value, now + SYLLABUS_TTL_MS, now);
|
||||
return value;
|
||||
} catch (Exception e) {
|
||||
log.warn("[widget] 课表 oapi 不可达(降级空)user={} role={} sch={}:{}",
|
||||
caller.getUserId(), caller.getRoleId(), caller.getSchId(), e.toString());
|
||||
// 失败退避:短 TTL 空结果,避免下游停摆时每次进桌面都吃一次超时
|
||||
cache(syllabusCache, SYLLABUS_CACHE_MAX, key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一周安排:WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(SDK 出站,签名自动)。
|
||||
* 全校共享数据(不区分角色),原样透传单个周对象 {@code data}(含 {@code days[7]});
|
||||
* 缺身份 / 不可达 / 验签失败 / week 越界(oapi {@code code:2},SDK 抛异常)均降级空对象(不抛 500)。
|
||||
*
|
||||
* <p>{@code week} 为**相对本周的偏移**(0=本周、-1=上一周、+1=下一周),合法范围由响应
|
||||
* {@code minWeek}/{@code maxWeek} 提示(前端据此驱动翻页边界);注意与数据字段 {@code data.week}
|
||||
* (绝对周次)同名不同义。</p>
|
||||
*
|
||||
* <p>userid 仅用于 oapi 侧审计、不影响返回内容,故缓存按 {@code schId|week偏移}
|
||||
* (全校共享、与身份无关,但不同周内容不同,不可共用单键)。</p>
|
||||
*/
|
||||
private Object weekplan(Caller caller, String params) {
|
||||
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
|
||||
// 缺身份(超管无当前身份等)→ 空数据,不打出站
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
// week = 相对本周偏移(0=本周 / -1=上一周 / +1=下一周),复用 evaluate 同款解析(支持负数)
|
||||
int weekOffset = intParam(params, "week", 0);
|
||||
String key = caller.getSchId() + "|" + weekOffset;
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry hit = weekplanCache.get(key);
|
||||
if (hit != null && now < hit.expireAt) {
|
||||
return hit.data;
|
||||
}
|
||||
try {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("userid", String.valueOf(caller.getUserId()));
|
||||
query.put("schId", String.valueOf(caller.getSchId()));
|
||||
if (weekOffset != 0) {
|
||||
// 0 为缺省值(本周):不传可让本周请求与签名 target 保持最小化
|
||||
query.put("week", String.valueOf(weekOffset));
|
||||
}
|
||||
JsonNode data = openCall.get(TARGET_WORKPLAN, PATH_WORKPLAN, query, JsonNode.class);
|
||||
Object value = (data != null && data.isObject()) ? data : Collections.emptyMap();
|
||||
cache(weekplanCache, WEEKPLAN_CACHE_MAX, key, value, now + WEEKPLAN_TTL_MS, now);
|
||||
return value;
|
||||
} catch (Exception e) {
|
||||
log.warn("[widget] 一周安排 oapi 不可达(降级空)sch={} week={}:{}",
|
||||
caller.getSchId(), weekOffset, e.toString());
|
||||
// 失败退避:短 TTL 空结果,避免下游停摆时每次翻页都吃一次超时
|
||||
cache(weekplanCache, WEEKPLAN_CACHE_MAX, key, Collections.emptyMap(), now + WEEKPLAN_FAIL_BACKOFF_MS, now);
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
/** 写缓存并做上限清理(只清过期项,避免无界增长) */
|
||||
private void cache(ConcurrentHashMap<String, CacheEntry> store, int max, String key, Object value,
|
||||
long expireAt, long now) {
|
||||
if (store.size() >= max) {
|
||||
for (Map.Entry<String, CacheEntry> entry : store.entrySet()) {
|
||||
if (now >= entry.getValue().expireAt) {
|
||||
store.remove(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
store.put(key, new CacheEntry(value, expireAt));
|
||||
}
|
||||
|
||||
private int intParam(String params, String key, int defaultValue) {
|
||||
if (params == null || params.isEmpty()) return defaultValue;
|
||||
for (String pair : params.split("&")) {
|
||||
int split = pair.indexOf('=');
|
||||
if (split <= 0 || !key.equals(pair.substring(0, split))) continue;
|
||||
try {
|
||||
return Integer.parseInt(URLDecoder.decode(pair.substring(split + 1), "UTF-8").trim());
|
||||
} catch (NumberFormatException | UnsupportedEncodingException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private Map<String, Object> row(Object... keyValues) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
||||
map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 取数调用方上下文:三参均会话推导,不接受前端传入 */
|
||||
public static final class Caller {
|
||||
|
||||
private final Integer userId;
|
||||
private final Integer roleId;
|
||||
private final Integer schId;
|
||||
|
||||
public Caller(Integer userId, Integer roleId, Integer schId) {
|
||||
this.userId = userId;
|
||||
this.roleId = roleId;
|
||||
this.schId = schId;
|
||||
}
|
||||
|
||||
public Integer getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public Integer getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public Integer getSchId() {
|
||||
return schId;
|
||||
}
|
||||
}
|
||||
|
||||
/** 课表缓存条目 */
|
||||
private static final class CacheEntry {
|
||||
|
||||
private final Object data;
|
||||
private final long expireAt;
|
||||
|
||||
private CacheEntry(Object data, long expireAt) {
|
||||
this.data = data;
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.yunhai.userhome.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 小组件外跳地址解析:{@code app_code} → 应用注册表 {@code pc_url} → 绝对跳转地址。
|
||||
*
|
||||
* <p>「地址不写死」落点:库里只存**子应用 code + 路由**,域名一律来自应用注册表
|
||||
* (UserBase {@code /oapi/app/by-code/{appCode}},经 SDK {@link OpenApiAppClient#getAppByCode} 调用,
|
||||
* 签名自动)。拼接口径与消息中心 {@code jumpUrl} 一致:{@code pc_url} 归一化
|
||||
* (丢 fragment、去尾斜杠)后拼 {@code "#" + path}。</p>
|
||||
*
|
||||
* <p>降级纪律:未配置 / 查无 pc_url / 注册表不可达 / 路由写成绝对 URL —— 一律返回 {@code null},
|
||||
* 调用方不渲染跳转入口(pc_url 空就是空,不猜测、不回落)。</p>
|
||||
*
|
||||
* <p>缓存与短路:注册表地址 60s TTL(低频变更,避免每次进桌面都打一次 UserBase);查询失败进 30s
|
||||
* 短路窗口,窗口内沿用旧缓存,避免下游停摆时每次渲染都吃一次连接超时。</p>
|
||||
*
|
||||
* <p>TODO 跨服务公共抽取:本实现只解决「小组件这一侧」;消息中心 {@code MsgCenterProxyService}
|
||||
* 仍保留自己的 by-code 缓存与短路窗口。待平台层({{@code yh-spring-boot-starter}})提供带缓存的
|
||||
* 地址解析,或 UserBase oapi 直接下发归一化跳转基址后,本类应退化为薄壳。</p>
|
||||
*/
|
||||
@Service
|
||||
public class WidgetLinkResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WidgetLinkResolver.class);
|
||||
|
||||
/** 注册表地址缓存时长 */
|
||||
private static final long PC_URL_TTL_MS = 60_000L;
|
||||
|
||||
/** 注册表不可达之后的静默期 */
|
||||
private static final long PC_URL_DOWN_MS = 30_000L;
|
||||
|
||||
/** 注册表应用域客户端(SDK 自动装配,见 yh-openapi-spring-boot-starter 的 OpenApiAutoConfiguration) */
|
||||
private final OpenApiAppClient appClient;
|
||||
|
||||
public WidgetLinkResolver(OpenApiAppClient appClient) {
|
||||
this.appClient = appClient;
|
||||
}
|
||||
|
||||
/** app_code → pc_url(空串 = 查无,避免脏 code 反复出站) */
|
||||
private final ConcurrentHashMap<String, String> pcUrlCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentHashMap<String, Long> pcUrlAt = new ConcurrentHashMap<>();
|
||||
|
||||
/** 注册表不可达短路截止时间戳 */
|
||||
private volatile long downUntil;
|
||||
|
||||
/**
|
||||
* 解析外跳地址;无法解析返回 null(调用方不渲染入口)。
|
||||
*
|
||||
* @param appCode 应用注册表 app_code,如 {@code work-week-plan}
|
||||
* @param linkPath 子应用内路由(hash 路由),如 {@code /weekplan};允许写成 {@code #/weekplan} 或 {@code weekplan}
|
||||
*/
|
||||
public String resolve(String appCode, String linkPath) {
|
||||
if (!StringUtils.hasText(appCode) || !StringUtils.hasText(linkPath)) {
|
||||
return null;
|
||||
}
|
||||
String path = normalizePath(linkPath);
|
||||
if (path == null) {
|
||||
log.warn("[widget] 外跳路由不合法(应为子应用内相对路由,禁止写绝对 URL):app={} path={}", appCode, linkPath);
|
||||
return null;
|
||||
}
|
||||
String pcUrl = pcUrlOf(appCode.trim());
|
||||
if (!StringUtils.hasText(pcUrl)) {
|
||||
return null;
|
||||
}
|
||||
String base = pcUrl.trim();
|
||||
int hash = base.indexOf('#');
|
||||
if (hash >= 0) {
|
||||
base = base.substring(0, hash);
|
||||
}
|
||||
while (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
if (!StringUtils.hasText(base)) {
|
||||
return null;
|
||||
}
|
||||
// hash 路由只能拼在「基址」之后:目录形态必须补回斜杠(…/front/ + #/weekplan),
|
||||
// 文件形态直接拼(…/front/index.html + #/list)。缺斜杠会被反代 301 到带斜杠路径,
|
||||
// 且逐跳有丢 fragment 的风险(注册表两种形态并存:work-week-plan 是目录、notice 是 index.htm)。
|
||||
int slash = base.lastIndexOf('/');
|
||||
String lastSegment = slash >= 0 ? base.substring(slash + 1) : base;
|
||||
if (lastSegment.indexOf('.') < 0) {
|
||||
base = base + "/";
|
||||
}
|
||||
return base + "#" + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由归一化:去首部 {@code #}、去空白,保证以 {@code /} 开头;
|
||||
* 绝对 URL(http/https)视为配置错误返回 null(域名只能来自注册表)。
|
||||
*/
|
||||
private String normalizePath(String linkPath) {
|
||||
String path = linkPath.trim();
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return null;
|
||||
}
|
||||
if (path.startsWith("#")) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
if (path.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
/** 注册表 pc_url 查询:命中缓存则返回;查无记空串;不可达走短路窗口(窗口内沿用旧值) */
|
||||
private String pcUrlOf(String appCode) {
|
||||
Long at = pcUrlAt.get(appCode);
|
||||
if (at != null && System.currentTimeMillis() - at < PC_URL_TTL_MS) {
|
||||
return emptyToNull(pcUrlCache.get(appCode));
|
||||
}
|
||||
if (System.currentTimeMillis() < downUntil) {
|
||||
return emptyToNull(pcUrlCache.get(appCode));
|
||||
}
|
||||
try {
|
||||
JsonNode app = appClient.getAppByCode(appCode);
|
||||
downUntil = 0L;
|
||||
String pcUrl = app == null || app.isNull() ? null : text(app, "pcUrl");
|
||||
pcUrlCache.put(appCode, pcUrl == null ? "" : pcUrl);
|
||||
pcUrlAt.put(appCode, System.currentTimeMillis());
|
||||
if (!StringUtils.hasText(pcUrl)) {
|
||||
log.warn("[widget] 注册表无 pc_url,外跳入口不渲染:app={}", appCode);
|
||||
}
|
||||
return pcUrl;
|
||||
} catch (Exception e) {
|
||||
downUntil = System.currentTimeMillis() + PC_URL_DOWN_MS;
|
||||
log.warn("[widget] 应用注册表不可达({}ms 内外跳入口沿用旧值/不渲染):app={} {}",
|
||||
PC_URL_DOWN_MS, appCode, e.toString());
|
||||
return emptyToNull(pcUrlCache.get(appCode));
|
||||
}
|
||||
}
|
||||
|
||||
private String emptyToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value : null;
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
return value == null || value.isNull() ? null : value.asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.Logo;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.entity.UserAppearance;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.LogoRepository;
|
||||
import com.yunhai.userhome.repository.UserAppearanceRepository;
|
||||
import com.yunhai.userhome.service.IAppearanceService;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.utils.ThemeCodec;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户外观偏好聚合实现(外观偏好服务端化 2026-10-08)。
|
||||
*
|
||||
* <p>消费方是各子应用 init 的异步补齐段(挂载后 fetch,失败静默回落默认观感),因此本服务
|
||||
* 对外承诺:聚合读永不抛异常——个人段/学校段各自独立降级(一段故障不影响另一段),
|
||||
* 查不到/脏值/依赖故障的字段一律 null。</p>
|
||||
*
|
||||
* <p>数据来源:myTheme/avatarMy ← user_appearance(纯本地,per-user 一行);
|
||||
* schTheme/avatarSch/logoUrl ← sch_settings + logo 表(schId 复用 resolveThemeSchId——
|
||||
* 超管=平台层 0,其余=当前角色学校;identity 跨仓查询失败时学校段整体降级 null)。
|
||||
* Logo 择优复用 UserSettingsServiceImpl.findLogoList 口径:本校 type=1 优先 → 系统 type=0 → null。</p>
|
||||
*/
|
||||
@Service
|
||||
public class AppearanceServiceImpl implements IAppearanceService {
|
||||
|
||||
@Autowired
|
||||
private UserAppearanceRepository userAppearanceRepository;
|
||||
|
||||
@Autowired
|
||||
private ISchSettingsService schSettingsService;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private LogoRepository logoRepository;
|
||||
|
||||
@Value("${FILE_WEB_PATH:}")
|
||||
private String fileWebPath;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAppearance(BoaUser user) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("myTheme", null);
|
||||
out.put("schTheme", null);
|
||||
out.put("avatarMy", null);
|
||||
out.put("avatarSch", null);
|
||||
out.put("logoUrl", null);
|
||||
// 个人段(纯本地查询)
|
||||
try {
|
||||
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(user.getId());
|
||||
if (ua.isPresent()) {
|
||||
out.put("myTheme", validThemeState(ua.get().getThemeState()));
|
||||
out.put("avatarMy", validAvatarStyle(ua.get().getAvatarStyle()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 降级:个人段 null,不影响学校段
|
||||
}
|
||||
// 学校段(schId 解析含 identity 跨仓调用;失败整体降级 null)
|
||||
try {
|
||||
Integer schId = schSettingsService.resolveThemeSchId(user, null);
|
||||
if (schId != null) {
|
||||
SchSettings sch = schSettingsService.findBySchIdRaw(schId).orElse(null);
|
||||
if (sch != null) {
|
||||
out.put("schTheme", validThemeState(sch.getThemeName()));
|
||||
out.put("avatarSch", validAvatarStyle(sch.getAvatarStyle()));
|
||||
}
|
||||
out.put("logoUrl", resolveLogoUrl(schId));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 降级:学校段 null(含 logo),功能不受影响
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化;
|
||||
* 同日定稿摘除 logoText——子应用 header 定稿只出 logo 图,文字仅门户消费且走 /api/schLogoText 自有链,
|
||||
* init 侧对缺失字段按 null 语义自动清 yh-logo-text 本地键):
|
||||
* {@code {theme, avatar, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0)
|
||||
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;
|
||||
* logoUrl 为本校 type=1 → 平台 type=0 择优。
|
||||
*
|
||||
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
|
||||
* 无角色/跨校态回落 0(平台层)。与 /api/appearance 的 resolveThemeSchId(超管强制 0)仅在「超管临时切入
|
||||
* 某校角色」场景不同——该场景按当前角色学校取默认反而更贴近其当下观感,属登记偏差而非回归。</p>
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> getEffectiveAppearance(Integer userId) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("theme", null);
|
||||
out.put("avatar", null);
|
||||
out.put("logoUrl", null);
|
||||
if (userId == null) {
|
||||
return out;
|
||||
}
|
||||
Integer schId = userCurrentRoleService.findById(userId)
|
||||
.map(UserCurrentRole::getSchId)
|
||||
.filter(s -> s != null && s > 0)
|
||||
.orElse(ISchSettingsService.PLATFORM_SCH_ID);
|
||||
String myTheme = null;
|
||||
String myAvatar = null;
|
||||
try {
|
||||
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(userId);
|
||||
if (ua.isPresent()) {
|
||||
myTheme = validThemeState(ua.get().getThemeState());
|
||||
myAvatar = validAvatarStyle(ua.get().getAvatarStyle());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 个人段降级:myTheme/myAvatar 保持 null,不影响学校/平台段
|
||||
}
|
||||
String theme = myTheme;
|
||||
String avatar = myAvatar;
|
||||
try {
|
||||
if (theme == null) {
|
||||
theme = schSettingsService.readThemeWithFallback(schId).getValue();
|
||||
}
|
||||
if (avatar == null) {
|
||||
avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
|
||||
}
|
||||
out.put("logoUrl", resolveLogoUrl(schId));
|
||||
} catch (Exception e) {
|
||||
// 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null
|
||||
}
|
||||
// theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级
|
||||
out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme));
|
||||
out.put("avatar", avatar);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveMyTheme(Integer userId, String themeState) {
|
||||
UserAppearance ua = loadOrCreate(userId);
|
||||
if (themeState == null) {
|
||||
// 跟随学校默认:置 NULL(列语义见实体注释)
|
||||
ua.setThemeState(null);
|
||||
} else {
|
||||
// 写侧从严:只收 JSON、产规范化 JSON 串(紧凑入参抛 DException,2026-10-09 JSON 化契约)
|
||||
ua.setThemeState(ThemeCodec.encodeThemeState(themeState));
|
||||
}
|
||||
ua.setUDate(new Date());
|
||||
userAppearanceRepository.save(ua);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveMyAvatarStyle(Integer userId, String avatarStyle) {
|
||||
UserAppearance ua = loadOrCreate(userId);
|
||||
if (avatarStyle == null) {
|
||||
ua.setAvatarStyle(null);
|
||||
} else {
|
||||
String style = avatarStyle.trim();
|
||||
if (!ThemeCodec.isAvatarStyle(style)) {
|
||||
throw new DException("头像样式不合法: " + avatarStyle);
|
||||
}
|
||||
ua.setAvatarStyle(style);
|
||||
}
|
||||
ua.setUDate(new Date());
|
||||
userAppearanceRepository.save(ua);
|
||||
}
|
||||
|
||||
private UserAppearance loadOrCreate(Integer userId) {
|
||||
return userAppearanceRepository.findByUserId(userId).orElseGet(() -> {
|
||||
UserAppearance ua = new UserAppearance();
|
||||
ua.setUserId(userId);
|
||||
ua.setCDate(new Date());
|
||||
return ua;
|
||||
});
|
||||
}
|
||||
|
||||
/** 主题档校验读(JSON 校验→规范化;旧紧凑四段兼容转 JSON;脏值 → null 视同未配置) */
|
||||
private String validThemeState(String raw) {
|
||||
return StringUtils.hasText(raw) ? ThemeCodec.decodeThemeState(raw) : null;
|
||||
}
|
||||
|
||||
private String validAvatarStyle(String raw) {
|
||||
return ThemeCodec.isAvatarStyle(raw) ? raw : null;
|
||||
}
|
||||
|
||||
/** Logo 择优:本校(type=1)优先 → 系统(type=0) → null;URL 拼法对齐 UserSettingsController.getLogoList */
|
||||
private String resolveLogoUrl(Integer schId) {
|
||||
if (schId != null) {
|
||||
String url = firstLogoUrl(logoRepository.findBySchIdAndType(schId, 1));
|
||||
if (url != null) return url;
|
||||
}
|
||||
return firstLogoUrl(logoRepository.findBySchIdAndType(0, 0));
|
||||
}
|
||||
|
||||
private String firstLogoUrl(List<Logo> list) {
|
||||
if (list == null) return null;
|
||||
for (Logo logo : list) {
|
||||
String path = logo == null ? null : logo.getLogoUrl();
|
||||
if (!StringUtils.hasText(path)) continue;
|
||||
// 本地上传路径拼 FILE_WEB_PATH 绝对 URL;已是绝对地址的(预留跨仓存储)原样透传
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return fileWebPath + path.replace("/upload", "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.userhome.entity.MenuBar;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.MenuBarRepository;
|
||||
import com.yunhai.userhome.service.IMenuBarService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 菜单栏服务(对齐旧 UserHome service.impl.MenuBarService)。
|
||||
*/
|
||||
@Service
|
||||
public class MenuBarServiceImpl implements IMenuBarService {
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private MenuBarRepository menuBarRepository;
|
||||
|
||||
@Override
|
||||
public List<MenuBar> getMenuBarList(Integer schId) {
|
||||
List<MenuBar> list;
|
||||
if (schId == 0)
|
||||
list = menuBarRepository.findBySchId(0);
|
||||
else
|
||||
list = menuBarRepository.findBySchIdIn(Arrays.asList(0, schId));
|
||||
list.forEach(x -> {
|
||||
Boolean isHide = x.getHideSchIds().contains(schId);
|
||||
x.setIsShow(!isHide);
|
||||
x.setIsEdit(x.getSchId().equals(schId));
|
||||
});
|
||||
list = list.stream().sorted(Comparator.comparing(MenuBar::getOrderNum, Comparator.nullsLast(Integer::compareTo))).collect(Collectors.toList());
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MenuBar> findMenuBarById(Integer userId) {
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
List<MenuBar> list = getMenuBarList(userCurrentRole.getSchId());
|
||||
List<MenuBar> result = list.stream().filter(MenuBar::getIsShow).collect(Collectors.toList());
|
||||
result = result.stream().sorted(Comparator.comparing(MenuBar::getOrderNum, Comparator.nullsLast(Integer::compareTo))).collect(Collectors.toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveMenuBar(MenuBar menuBar) {
|
||||
if (menuBar.getId() != null) {
|
||||
Optional<MenuBar> optional = menuBarRepository.findById(menuBar.getId());
|
||||
if (optional.isPresent()) {
|
||||
menuBar.setHideSchIds(optional.get().getHideSchIds());
|
||||
}
|
||||
}
|
||||
menuBarRepository.save(menuBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveMenuBarOrderNum(Integer id, Integer orderNum) {
|
||||
Optional<MenuBar> optionalMenuBar = menuBarRepository.findById(id);
|
||||
if (!optionalMenuBar.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
MenuBar menuBar = optionalMenuBar.get();
|
||||
menuBar.setOrderNum(orderNum);
|
||||
menuBarRepository.save(menuBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void toggleMenuBarShow(Integer id, Integer schId) {
|
||||
Optional<MenuBar> optionalMenuBar = menuBarRepository.findById(id);
|
||||
if (!optionalMenuBar.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
MenuBar menuBar = optionalMenuBar.get();
|
||||
Integer index = menuBar.getHideSchIds().indexOf(schId);
|
||||
List<Integer> schIds = menuBar.getHideSchIds();
|
||||
if (index.equals(-1))
|
||||
schIds.add(schId);
|
||||
else
|
||||
schIds.remove(Integer.valueOf(schId));
|
||||
menuBar.setHideSchIds(schIds);
|
||||
menuBarRepository.save(menuBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteMenuBar(Integer id) {
|
||||
Optional<MenuBar> optionalMenuBar = menuBarRepository.findById(id);
|
||||
if (!optionalMenuBar.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
menuBarRepository.delete(optionalMenuBar.get());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.enums.ClientEnum;
|
||||
import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
@@ -21,19 +21,35 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
|
||||
|
||||
@Override
|
||||
public List<BoaAppType> getUserApps(BoaUser user) {
|
||||
return getUserApps(user, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaAppType> getUserApps(BoaUser user, Integer schoolId) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// PC 浏览器端;UserBase 侧已按 userId 权限过滤(组开通 ∪ 角色模块所属应用,超管全量)
|
||||
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER);
|
||||
// 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
|
||||
// UserBase 2026-10-12 硬闸门对显式 schoolId 先过 assertSchoolId,-1 会被拒("学校分组不存在"),
|
||||
// 且超管豁免(isSuperAdmin bypass)在校验之后根本走不到——边界归一为 null 交服务端按激活身份/主归属兜底。
|
||||
if (schoolId != null && schoolId <= 0) {
|
||||
schoolId = null;
|
||||
}
|
||||
// PC 浏览器端;schoolId 透传 UserBase 做学校单点开通收窄(为空则服务端按身份/主归属兜底)
|
||||
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BoaApp> getAppById(BoaUser user, Integer appId) {
|
||||
return getAppById(user, appId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId) {
|
||||
if (appId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return getUserApps(user).stream()
|
||||
return getUserApps(user, schoolId).stream()
|
||||
.filter(t -> t.getApps() != null)
|
||||
.flatMap(t -> t.getApps().stream())
|
||||
.filter(a -> appId.equals(a.getId()))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.SchSettings;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.SchSettingsRepository;
|
||||
import com.yunhai.userhome.service.ISchSettingsService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.utils.ThemeCodec;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 学校设置服务:学校设置完全本地化(sch_settings 表),不再依赖跨仓 schConfig。
|
||||
* <p>findBySchId 直接查本地表,查不到兜底默认主题;saveSchTheme/saveSchLogo 写本地表即可闭环。</p>
|
||||
*/
|
||||
@Service
|
||||
public class SchSettingsServiceImpl implements ISchSettingsService {
|
||||
|
||||
@Autowired
|
||||
private SchSettingsRepository schSettingsRepository;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Override
|
||||
public void save(SchSettings schSettings) {
|
||||
schSettingsRepository.save(schSettings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SchSettings> findBySchIdRaw(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SchSettings> findBySchId(Integer schId) {
|
||||
Optional<SchSettings> local = schSettingsRepository.findFirstBySchId(schId);
|
||||
if (local.isPresent()) {
|
||||
return local;
|
||||
}
|
||||
SchSettings def = new SchSettings();
|
||||
def.setSchId(schId);
|
||||
def.setThemeName("default-theme");
|
||||
def.setAppBgUrl("");
|
||||
def.setLogoUrl("");
|
||||
return Optional.of(def);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<SchSettings> findBySchIds(java.util.List<Integer> schId) {
|
||||
return schSettingsRepository.getSchSettingList(schId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer resolveSchId(BoaUser user) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
}
|
||||
if (user.getBureau() != null) {
|
||||
return user.getBureau().getId();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) {
|
||||
if (user.getType() != null && user.getType() == 1) {
|
||||
return explicitSchId == null ? 0 : explicitSchId;
|
||||
}
|
||||
return userCurrentRoleService.findById(user.getId())
|
||||
.map(UserCurrentRole::getSchId)
|
||||
.filter(schId -> schId != null && schId > 0)
|
||||
.orElseGet(() -> resolveSchId(user));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readThemeWithFallback(Integer schId) {
|
||||
String themeState = decodeTheme(schId);
|
||||
if (themeState != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(themeState, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(decodeTheme(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readAvatarWithFallback(Integer schId) {
|
||||
String avatarStyle = readAvatarAt(schId);
|
||||
if (avatarStyle != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(avatarStyle, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(readAvatarAt(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readLogoTextWithFallback(Integer schId) {
|
||||
String text = readLogoTextAt(schId);
|
||||
if (text != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(text, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(readLogoTextAt(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
/** 原样读某层 LOGO 文字(查无行/空白 → null;trim 后存储口径) */
|
||||
private String readLogoTextAt(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getLogoText)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ISchSettingsService.LayerValue readWallpaperWithFallback(Integer schId) {
|
||||
String url = readWallpaperAt(schId);
|
||||
if (url != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
|
||||
return new ISchSettingsService.LayerValue(url, schId);
|
||||
}
|
||||
return new ISchSettingsService.LayerValue(readWallpaperAt(ISchSettingsService.PLATFORM_SCH_ID),
|
||||
ISchSettingsService.PLATFORM_SCH_ID);
|
||||
}
|
||||
|
||||
/** 原样读某层默认壁纸(查无行/空白 → null;trim 后存储口径,URL 为管理员从池中选定的原值) */
|
||||
private String readWallpaperAt(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getWallpaperUrl)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 原样读某层主题档并解码(查无行/旧格式/脏值 → null) */
|
||||
private String decodeTheme(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getThemeName)
|
||||
.map(ThemeCodec::decodeThemeState)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 原样读某层头像样式(查无行/非法值 → null) */
|
||||
private String readAvatarAt(Integer schId) {
|
||||
return schSettingsRepository.findFirstBySchId(schId)
|
||||
.map(SchSettings::getAvatarStyle)
|
||||
.filter(ThemeCodec::isAvatarStyle)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.userhome.entity.StatsBar;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.StatsBarRepository;
|
||||
import com.yunhai.userhome.service.IStatsBarService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 统计栏目服务(对齐旧 UserHome service.impl.StatsBarService)。
|
||||
*/
|
||||
@Service
|
||||
public class StatsBarServiceImpl implements IStatsBarService {
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private StatsBarRepository statsBarRepository;
|
||||
|
||||
@Override
|
||||
public List<StatsBar> getStatsBarList(Integer schId) {
|
||||
List<StatsBar> list;
|
||||
if (schId == 0)
|
||||
list = statsBarRepository.findBySchId(0);
|
||||
else
|
||||
list = statsBarRepository.findBySchIdIn(Arrays.asList(0, schId));
|
||||
list.forEach(x -> {
|
||||
x.setIsShow(x.getShowSchIds().contains(schId));
|
||||
x.setIsEdit(x.getSchId().equals(schId));
|
||||
});
|
||||
list = list.stream().sorted(Comparator.comparing(StatsBar::getOrderNum, Comparator.nullsLast(Integer::compareTo))).collect(Collectors.toList());
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StatsBar> findStatsBarById(Integer userId) {
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
List<StatsBar> list = getStatsBarList(userCurrentRole.getSchId());
|
||||
List<StatsBar> result = list.stream().filter(StatsBar::getIsShow).collect(Collectors.toList());
|
||||
result = result.stream().sorted(Comparator.comparing(StatsBar::getOrderNum, Comparator.nullsLast(Integer::compareTo))).collect(Collectors.toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveStatsBar(StatsBar statsBar) {
|
||||
if (statsBar.getId() != null) {
|
||||
Optional<StatsBar> optional = statsBarRepository.findById(statsBar.getId());
|
||||
if (optional.isPresent()) {
|
||||
statsBar.setShowSchIds(optional.get().getShowSchIds());
|
||||
}
|
||||
}
|
||||
statsBarRepository.save(statsBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveStatsBarOrderNum(Integer id, Integer orderNum) {
|
||||
Optional<StatsBar> optionalStatsBar = statsBarRepository.findById(id);
|
||||
if (!optionalStatsBar.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
StatsBar statsBar = optionalStatsBar.get();
|
||||
statsBar.setOrderNum(orderNum);
|
||||
statsBarRepository.save(statsBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleStatsBarShow(Integer id, Integer schId) {
|
||||
Optional<StatsBar> optionalStatsBar = statsBarRepository.findById(id);
|
||||
if (!optionalStatsBar.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
StatsBar statsBar = optionalStatsBar.get();
|
||||
Integer index = statsBar.getShowSchIds().indexOf(schId);
|
||||
List<Integer> schIds = statsBar.getShowSchIds();
|
||||
if (index.equals(-1))
|
||||
schIds.add(schId);
|
||||
else
|
||||
schIds.remove(Integer.valueOf(schId));
|
||||
statsBar.setShowSchIds(schIds);
|
||||
statsBarRepository.save(statsBar);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteStatsBar(Integer id) {
|
||||
Optional<StatsBar> optionalStatsBar = statsBarRepository.findById(id);
|
||||
if (!optionalStatsBar.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
statsBarRepository.delete(optionalStatsBar.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.userhome.entity.TeacherCopilot;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.TeacherCopilotRepository;
|
||||
import com.yunhai.userhome.service.ITeacherCopilotService;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 教师助手服务(对齐旧 UserHome service.impl.TeacherCopilotService)。
|
||||
*/
|
||||
@Service
|
||||
public class TeacherCopilotServiceImpl implements ITeacherCopilotService {
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private TeacherCopilotRepository teacherCopilotRepository;
|
||||
|
||||
@Override
|
||||
public List<TeacherCopilot> getList(Integer schId) {
|
||||
List<TeacherCopilot> list;
|
||||
if (schId == 0)
|
||||
list = teacherCopilotRepository.findBySchId(0);
|
||||
else
|
||||
list = teacherCopilotRepository.findBySchIdIn(Arrays.asList(0, schId));
|
||||
list.forEach(x -> {
|
||||
x.setIsShow(x.getShowSchIds().contains(schId));
|
||||
x.setIsEdit(x.getSchId().equals(schId));
|
||||
});
|
||||
list = list.stream().sorted(Comparator.comparing(TeacherCopilot::getOrderNum, Comparator.nullsLast(Integer::compareTo))).collect(Collectors.toList());
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TeacherCopilot> findById(Integer userId) {
|
||||
Optional<UserCurrentRole> userCurrentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
if (!userCurrentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole userCurrentRole = userCurrentRoleOpt.get();
|
||||
List<TeacherCopilot> list = getList(userCurrentRole.getSchId());
|
||||
List<TeacherCopilot> result = list.stream().filter(TeacherCopilot::getIsShow).collect(Collectors.toList());
|
||||
result = result.stream().sorted(Comparator.comparing(TeacherCopilot::getOrderNum, Comparator.nullsLast(Integer::compareTo))).collect(Collectors.toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(TeacherCopilot teacherCopilot) {
|
||||
if (teacherCopilot.getId() != null) {
|
||||
Optional<TeacherCopilot> optional = teacherCopilotRepository.findById(teacherCopilot.getId());
|
||||
if (optional.isPresent()) {
|
||||
teacherCopilot.setShowSchIds(optional.get().getShowSchIds());
|
||||
}
|
||||
}
|
||||
teacherCopilotRepository.save(teacherCopilot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveOrderNum(Integer id, Integer orderNum) {
|
||||
Optional<TeacherCopilot> optional = teacherCopilotRepository.findById(id);
|
||||
if (!optional.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
TeacherCopilot teacherCopilot = optional.get();
|
||||
teacherCopilot.setOrderNum(orderNum);
|
||||
teacherCopilotRepository.save(teacherCopilot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleShow(Integer id, Integer schId) {
|
||||
Optional<TeacherCopilot> optional = teacherCopilotRepository.findById(id);
|
||||
if (!optional.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
TeacherCopilot teacherCopilot = optional.get();
|
||||
Integer index = teacherCopilot.getShowSchIds().indexOf(schId);
|
||||
List<Integer> schIds = teacherCopilot.getShowSchIds();
|
||||
if (index.equals(-1))
|
||||
schIds.add(schId);
|
||||
else
|
||||
schIds.remove(Integer.valueOf(schId));
|
||||
teacherCopilot.setShowSchIds(schIds);
|
||||
teacherCopilotRepository.save(teacherCopilot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(Integer id) {
|
||||
Optional<TeacherCopilot> optional = teacherCopilotRepository.findById(id);
|
||||
if (!optional.isPresent())
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||
teacherCopilotRepository.delete(optional.get());
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.UsedApp;
|
||||
import com.yunhai.userhome.model.AppIconEntryVo;
|
||||
import com.yunhai.userhome.model.SetType;
|
||||
import com.yunhai.userhome.repository.UsedAppRepository;
|
||||
import com.yunhai.userhome.service.AppIconKeyService;
|
||||
import com.yunhai.userhome.service.AppRemoteService;
|
||||
import com.yunhai.userhome.service.UsedAppService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -34,6 +36,9 @@ public class UsedAppServiceImpl implements UsedAppService {
|
||||
@Autowired
|
||||
private AppRemoteService appRemoteService;
|
||||
|
||||
@Autowired
|
||||
private AppIconKeyService appIconKeyService;
|
||||
|
||||
@Override
|
||||
public void save(UsedApp usedApp) {
|
||||
Optional<UsedApp> model = usedAppRepository
|
||||
@@ -138,6 +143,7 @@ public class UsedAppServiceImpl implements UsedAppService {
|
||||
m.put("name", app.getName());
|
||||
m.put("icon", app.getIcon());
|
||||
m.put("url", app.getWebAddr());
|
||||
putIconKeys(m, app.getId());
|
||||
list.add(m);
|
||||
}
|
||||
} catch (NumberFormatException ignore) {
|
||||
@@ -147,6 +153,13 @@ public class UsedAppServiceImpl implements UsedAppService {
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 精简映射上补矢量图标键(iconKey/iconColor,AppIcon 通道;未配置时值为 null,前端按空走原回退链)。 */
|
||||
private void putIconKeys(Map<String, Object> m, Integer appId) {
|
||||
AppIconEntryVo entry = appIconKeyService.byId(appId);
|
||||
m.put("iconKey", entry == null ? null : entry.getIconKey());
|
||||
m.put("iconColor", entry == null ? null : entry.getIconColor());
|
||||
}
|
||||
|
||||
/** BoaAppType 分组平铺为 BoaApp 列表。 */
|
||||
private List<BoaApp> flattenApps(List<BoaAppType> types) {
|
||||
List<BoaApp> apps = new ArrayList<>();
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.userhome.entity.UserCurrentRole;
|
||||
import com.yunhai.userhome.repository.UserCurrentRoleRepository;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户当前激活角色访问实现:数据源由本地 {@code user_current_role} 表迁移至 UserBase 单一权威源
|
||||
* (多角色迁移方案)。<br>
|
||||
* {@link #findById} → UserBase {@code /oapi/identity/current}(未切换兜底 is_main 主身份);
|
||||
* {@link #save} → UserBase {@code /oapi/identity/switch}(目标身份属主校验 + 白名单,均在 UserBase 侧)。
|
||||
*/
|
||||
@Service
|
||||
public class UserCurrentRoleServiceImpl implements IUserCurrentRoleService {
|
||||
|
||||
@Autowired
|
||||
private UserCurrentRoleRepository repository;
|
||||
private OpenApiUserClient openApiUserClient;
|
||||
|
||||
@Override
|
||||
public Optional<UserCurrentRole> findById(Integer userId) {
|
||||
return repository.findByUserId(userId);
|
||||
if (userId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
BoaRoleInfo info = openApiUserClient.getCurrentIdentity(userId);
|
||||
if (info == null) {
|
||||
// 无当前身份(超管等平台账号):兜底平台级默认身份(schId=0),避免下游 orElseThrow 抛错 / 拿空菜单
|
||||
return Optional.of(platformDefaultRole(userId));
|
||||
}
|
||||
return Optional.of(toRole(info));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(UserCurrentRole role) {
|
||||
repository.save(role);
|
||||
if (role == null || role.getUserId() == null) {
|
||||
throw new IllegalArgumentException("用户ID不能为空");
|
||||
}
|
||||
openApiUserClient.switchIdentity(role.getUserId(), role.getRoleId(), role.getSchId());
|
||||
}
|
||||
|
||||
/** BoaRoleInfo → UserCurrentRole(字段同名,纯 DTO 映射) */
|
||||
private UserCurrentRole toRole(BoaRoleInfo info) {
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
UserCurrentRole role = new UserCurrentRole();
|
||||
role.setRoleId(info.getRoleId());
|
||||
role.setRoleName(info.getRoleName());
|
||||
role.setSchId(info.getSchId());
|
||||
role.setSchName(info.getSchName());
|
||||
role.setChildId(info.getChildId());
|
||||
role.setChildName(info.getChildName());
|
||||
return role;
|
||||
}
|
||||
|
||||
/** 平台级默认身份:超管 roleId=0、schId=0(对齐各控制器 type==1 → schId=0 的既有约定) */
|
||||
private UserCurrentRole platformDefaultRole(Integer userId) {
|
||||
UserCurrentRole role = new UserCurrentRole();
|
||||
role.setUserId(userId);
|
||||
role.setRoleId(0);
|
||||
role.setSchId(0);
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.userhome.entity.UserMessage;
|
||||
import com.yunhai.userhome.repository.UserMessageRepository;
|
||||
import com.yunhai.userhome.service.IUserMessageService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户消息服务:行为对齐旧 UserHome UserMessageService(本地 user_message 表)。
|
||||
* WS 推送态由前端轮询兜底,本服务不消费 Redis 缓存(旧工程依赖 Redis,新工程未引入)。
|
||||
*/
|
||||
@Service
|
||||
public class UserMessageServiceImpl implements IUserMessageService {
|
||||
|
||||
@Autowired
|
||||
private UserMessageRepository userMessageRepository;
|
||||
|
||||
@Override
|
||||
public List<UserMessage> getUserMessageAppIds(Integer userId, Integer schId) {
|
||||
return userMessageRepository.getUserMessageAppList(userId, schId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<UserMessage> findUserMessageByAppId(UserMessage userMessage, Pageable pageable) {
|
||||
return userMessageRepository.findAll(select(userMessage), pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<UserMessage> findUserMessageByUserId(Integer userId, Pageable pageable) {
|
||||
return userMessageRepository.findAll(select(userId, null), pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserMessage> findUserMessageByUserId(Integer userId) {
|
||||
// 旧版优先读 Redis 缓存,缓存缺失再查库;新工程无 Redis,直接查库(行为等价:返回该用户全部消息)
|
||||
return userMessageRepository.findAll(select(userId, null), PageRequest.of(0, Integer.MAX_VALUE, Sort.by(Sort.Direction.DESC, "cDate"))).getContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean findUserMessageUnRead(Integer userId) {
|
||||
return userMessageRepository.count(select(userId, false)) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateRead(Integer userId, Integer appId) {
|
||||
userMessageRepository.updateRead(userId, appId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUserRead(Integer id, Integer userId) {
|
||||
// 单条已读:前端按 id 移除本地列表后再调;服务端标记该条已读
|
||||
userMessageRepository.updateReadById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateReadAll(Integer userId) {
|
||||
userMessageRepository.updateReadAll(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<UserMessage> findUserMessageById(Integer userId, Integer page, Integer count) {
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "id");
|
||||
Pageable pageable = PageRequest.of(page - 1, count, sort);
|
||||
return userMessageRepository.findAll(select(userId, null), pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readMsgById(Integer msgId) {
|
||||
userMessageRepository.updateReadById(msgId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readMsgAll(Integer userId) {
|
||||
userMessageRepository.updateReadAll(userId);
|
||||
}
|
||||
|
||||
private Specification<UserMessage> select(UserMessage userMessage) {
|
||||
return (Root<UserMessage> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (userMessage != null) {
|
||||
if (userMessage.getUserId() != null) {
|
||||
predicates.add(cb.equal(root.get("userId"), userMessage.getUserId()));
|
||||
}
|
||||
if (userMessage.getAppId() != null) {
|
||||
predicates.add(cb.equal(root.get("appId"), userMessage.getAppId()));
|
||||
}
|
||||
}
|
||||
return query.where(predicates.toArray(new Predicate[0])).getRestriction();
|
||||
};
|
||||
}
|
||||
|
||||
private Specification<UserMessage> select(Integer userId, Boolean isRead) {
|
||||
return (Root<UserMessage> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (userId != null) {
|
||||
predicates.add(cb.equal(root.get("userId"), userId));
|
||||
}
|
||||
if (isRead != null) {
|
||||
predicates.add(cb.equal(root.get("isRead"), isRead));
|
||||
}
|
||||
return query.where(predicates.toArray(new Predicate[0])).getRestriction();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userhome.entity.*;
|
||||
import com.yunhai.userhome.repository.LightAppRepository;
|
||||
import com.yunhai.userhome.repository.LogoRepository;
|
||||
import com.yunhai.userhome.repository.UserSettingsDesktopAppRepository;
|
||||
import com.yunhai.userhome.repository.UserSettingsRepository;
|
||||
import com.yunhai.userhome.repository.WallpaperRepository;
|
||||
import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.service.IUserSettingsService;
|
||||
import com.yunhai.userhome.utils.FileUtil;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户设置服务(对齐旧 UserHome service.impl.UserSettingsService)。
|
||||
*/
|
||||
@Service
|
||||
public class UserSettingsServiceImpl implements IUserSettingsService {
|
||||
|
||||
@Autowired
|
||||
private WallpaperRepository wallpaperRepository;
|
||||
|
||||
@Autowired
|
||||
private LogoRepository logoRepository;
|
||||
|
||||
@Autowired
|
||||
private UserSettingsRepository userSettingsRepository;
|
||||
|
||||
@Autowired
|
||||
private IUserCurrentRoleService userCurrentRoleService;
|
||||
|
||||
@Autowired
|
||||
private LightAppRepository lightAppRepository;
|
||||
|
||||
@Autowired
|
||||
private UserSettingsDesktopAppRepository userSettingsDesktopAppRepository;
|
||||
|
||||
@Value("${FILE_PATH:./upload/}")
|
||||
private String FILE_PATH;
|
||||
|
||||
/** 与 UserSettingsController 同源:展示形态 = 该前缀 + 去掉 /upload 的池内原始路径 */
|
||||
@Value("${FILE_WEB_PATH:}")
|
||||
private String FILE_WEB_PATH;
|
||||
|
||||
@Override
|
||||
public List<Wallpaper> findWallList(Integer userId) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
if (!currentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole currentRole = currentRoleOpt.get();
|
||||
List<Wallpaper> resultList = new ArrayList<>();
|
||||
List<Wallpaper> baseList = wallpaperRepository.findBySchIdAndType(0, 0);
|
||||
List<Wallpaper> schList = wallpaperRepository.findBySchIdAndType(currentRole.getSchId(), 1);
|
||||
List<Wallpaper> userList = wallpaperRepository.findByUserIdAndType(userId, 2);
|
||||
if (currentRole.getSchId() == 0) baseList.forEach(x -> x.setIsEdit(true));
|
||||
if (OtherUtil.isAdminById(currentRole.getRoleId())) schList.forEach(x -> x.setIsEdit(true));
|
||||
userList.forEach(x -> x.setIsEdit(true));
|
||||
resultList.addAll(baseList);
|
||||
resultList.addAll(schList);
|
||||
resultList.addAll(userList);
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Wallpaper> findSchWallpaperManageList(BoaUser user, Integer targetSchId) {
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
boolean superAdmin = user.getType() == 1;
|
||||
if (!superAdmin && !OtherUtil.isAdminById(currentRole.getRoleId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
Integer schId = superAdmin ? (targetSchId == null ? 0 : targetSchId) : currentRole.getSchId();
|
||||
List<Wallpaper> resultList = new ArrayList<>();
|
||||
List<Wallpaper> baseList = wallpaperRepository.findBySchIdAndType(0, 0);
|
||||
List<Wallpaper> schList = wallpaperRepository.findBySchIdAndType(schId, 1);
|
||||
if (superAdmin) {
|
||||
baseList.forEach(x -> x.setIsEdit(true));
|
||||
schList.forEach(x -> x.setIsEdit(true));
|
||||
} else {
|
||||
schList.forEach(x -> x.setIsEdit(true));
|
||||
}
|
||||
resultList.addAll(baseList);
|
||||
resultList.addAll(schList);
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Logo> findSchLogoManageList(BoaUser user, Integer targetSchId) {
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
boolean superAdmin = user.getType() == 1;
|
||||
if (!superAdmin && !OtherUtil.isAdminById(currentRole.getRoleId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
Integer schId = superAdmin ? (targetSchId == null ? 0 : targetSchId) : currentRole.getSchId();
|
||||
List<Logo> resultList = new ArrayList<>();
|
||||
List<Logo> baseList = logoRepository.findBySchIdAndType(0, 0);
|
||||
List<Logo> schList = logoRepository.findBySchIdAndType(schId, 1);
|
||||
if (superAdmin) {
|
||||
baseList.forEach(x -> x.setIsEdit(true));
|
||||
schList.forEach(x -> x.setIsEdit(true));
|
||||
} else {
|
||||
schList.forEach(x -> x.setIsEdit(true));
|
||||
}
|
||||
resultList.addAll(baseList);
|
||||
resultList.addAll(schList);
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteWallById(Integer id, BoaUser user) {
|
||||
Wallpaper wallpaper = wallpaperRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
// 属主校验:超管可删任意;校管理员可删本校;普通用户仅可删自己上传的个人壁纸
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
boolean superAdmin = user.getType() == 1;
|
||||
boolean ownPersonal = wallpaper.getType() != null && wallpaper.getType() == 2
|
||||
&& wallpaper.getUserId() != null && wallpaper.getUserId().equals(user.getId());
|
||||
boolean schAdminOwn = wallpaper.getType() != null && wallpaper.getType() == 1
|
||||
&& OtherUtil.isAdminById(currentRole.getRoleId())
|
||||
&& currentRole.getSchId() != null && currentRole.getSchId().equals(wallpaper.getSchId());
|
||||
if (!(superAdmin || ownPersonal || schAdminOwn)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
// 使用中清理:被删壁纸被用户设置引用时置空,避免裂图。
|
||||
// user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),与 wall 表原始形态不同,两种都清
|
||||
String rawUrl = wallpaper.getWallpaperUrl();
|
||||
String displayUrl = (FILE_WEB_PATH == null ? "" : FILE_WEB_PATH) + rawUrl.replace("/upload", "");
|
||||
userSettingsRepository.clearWallpaperUrl(java.util.Arrays.asList(rawUrl, displayUrl));
|
||||
// 清理物理文件(非本地上传的跨仓地址静默跳过)
|
||||
FileUtil.deleteFile(FILE_PATH, wallpaper.getWallpaperUrl());
|
||||
wallpaperRepository.delete(wallpaper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveWallpaper(Wallpaper wallpaper) {
|
||||
wallpaperRepository.save(wallpaper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkWallpaper(Integer userId, String wallpaperUrl) {
|
||||
UserSettings model = getUserSettings(null, userId);
|
||||
model.setWallpaperUrl(wallpaperUrl);
|
||||
userSettingsRepository.save(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Logo> findLogoList(Integer userId) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
if (!currentRoleOpt.isPresent()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
UserCurrentRole currentRole = currentRoleOpt.get();
|
||||
List<Logo> resultList = new ArrayList<>();
|
||||
List<Logo> baseList = logoRepository.findBySchIdAndType(0, 0);
|
||||
List<Logo> schList = logoRepository.findBySchIdAndType(currentRole.getSchId(), 1);
|
||||
if (currentRole.getSchId() == 0) baseList.forEach(x -> x.setIsEdit(true));
|
||||
if (OtherUtil.isAdminById(currentRole.getRoleId())) schList.forEach(x -> x.setIsEdit(true));
|
||||
resultList.addAll(baseList);
|
||||
resultList.addAll(schList);
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveLogo(Logo logo) {
|
||||
// 同 type+schId 已存在则覆盖(一校/系统各一行)
|
||||
Optional<Logo> exist = logoRepository.findByTypeAndSchId(logo.getType(), logo.getSchId());
|
||||
if (exist.isPresent()) {
|
||||
Logo model = exist.get();
|
||||
model.setLogoUrl(logo.getLogoUrl());
|
||||
model.setUserId(logo.getUserId());
|
||||
logoRepository.save(model);
|
||||
} else {
|
||||
logoRepository.save(logo);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteLogoById(Integer id, BoaUser user) {
|
||||
Logo logo = logoRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
// 属主校验:超管可删系统默认;校管理员仅可删本校
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
boolean superAdmin = user.getType() == 1;
|
||||
boolean schAdminOwn = logo.getType() != null && logo.getType() == 1
|
||||
&& OtherUtil.isAdminById(currentRole.getRoleId())
|
||||
&& currentRole.getSchId() != null && currentRole.getSchId().equals(logo.getSchId());
|
||||
if (!(superAdmin || schAdminOwn)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
FileUtil.deleteFile(FILE_PATH, logo.getLogoUrl());
|
||||
logoRepository.delete(logo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UserSettings> findUserSettings(Integer userId) {
|
||||
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
|
||||
if (!currentRoleOpt.isPresent()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
UserCurrentRole currentRole = currentRoleOpt.get();
|
||||
return userSettingsRepository.findByUserIdAndSchIdAndRoleId(currentRole.getUserId(), currentRole.getSchId(), currentRole.getRoleId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveUserSettings(UserSettings userSettings) {
|
||||
userSettingsRepository.save(userSettings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveUserWidget(UserSettings userSettings) {
|
||||
UserSettings model = getUserSettings(userSettings.getId(), userSettings.getUserId());
|
||||
model.setXAxis(userSettings.getXAxis());
|
||||
model.setYAxis(userSettings.getYAxis());
|
||||
model.setWidth(userSettings.getWidth());
|
||||
model.setHeight(userSettings.getHeight());
|
||||
model.setIsWidgetShow(true);
|
||||
userSettingsRepository.save(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveUserAppIds(Integer id, Integer userId, String appIds) {
|
||||
UserSettings model = getUserSettings(id, userId);
|
||||
model.setWidgetAppIds(appIds);
|
||||
userSettingsRepository.save(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleWidgetShow(Integer id, Integer userId) {
|
||||
UserSettings model = getUserSettings(id, userId);
|
||||
if (model.getIsWidgetShow() == null) model.setIsWidgetShow(true);
|
||||
model.setIsWidgetShow(!model.getIsWidgetShow());
|
||||
userSettingsRepository.save(model);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
@Override
|
||||
public List<LightApp> findLightAppList(Integer schId, Integer userId) {
|
||||
Specification specification = (Specification) (root, criteriaQuery, criteriaBuilder) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
Predicate basePre = criteriaBuilder.and(
|
||||
criteriaBuilder.equal(root.get("schId"), 0),
|
||||
criteriaBuilder.equal(root.get("isPublic"), true));
|
||||
Predicate schPre = criteriaBuilder.and(
|
||||
criteriaBuilder.equal(root.get("schId"), schId),
|
||||
criteriaBuilder.equal(root.get("isPublic"), true));
|
||||
Predicate userPre = criteriaBuilder.and(
|
||||
criteriaBuilder.equal(root.get("schId"), schId),
|
||||
criteriaBuilder.equal(root.get("userId"), userId));
|
||||
if (schId == 0)
|
||||
predicates.add(basePre);
|
||||
else if (userId == null)
|
||||
predicates.add(criteriaBuilder.or(basePre, schPre));
|
||||
else
|
||||
predicates.add(criteriaBuilder.or(basePre, schPre, userPre));
|
||||
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
|
||||
};
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "id");
|
||||
List<LightApp> result = lightAppRepository.findAll(specification, sort);
|
||||
|
||||
if (schId == 0) result.forEach(x -> x.setIsEdit(true));
|
||||
else if (userId == null)
|
||||
result.stream().filter(x -> x.getSchId() != null && x.getSchId().equals(schId) && x.getUserId() == null)
|
||||
.forEach(x -> x.setIsEdit(true));
|
||||
else
|
||||
result.stream().filter(x -> x.getSchId() != null && x.getUserId() != null && x.getSchId().equals(schId) && x.getUserId().equals(userId))
|
||||
.forEach(x -> x.setIsEdit(true));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveLightApp(LightApp lightApp) {
|
||||
if (lightApp.getSchId() == 0) lightApp.setUserId(null);
|
||||
if (lightApp.getUserId() == null) lightApp.setIsPublic(true);
|
||||
lightAppRepository.save(lightApp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteLightAppById(Integer id) {
|
||||
lightAppRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDesktopAppById(Integer id) {
|
||||
userSettingsDesktopAppRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveDesktopApp(UserSettingsDesktopApp model) {
|
||||
userSettingsDesktopAppRepository.save(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserSettingsDesktopApp> saveDesktopAppList(List<UserSettingsDesktopApp> list) {
|
||||
return userSettingsDesktopAppRepository.saveAll(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserSettings initUserSettings(Integer userId) {
|
||||
UserCurrentRole currentRole = userCurrentRoleService.findById(userId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||
UserSettings model = new UserSettings();
|
||||
model.setSchId(currentRole.getSchId());
|
||||
model.setRoleId(currentRole.getRoleId());
|
||||
model.setUserId(currentRole.getUserId());
|
||||
return userSettingsRepository.save(model);
|
||||
}
|
||||
|
||||
private UserSettings getUserSettings(Integer id, Integer userId) {
|
||||
Optional<UserSettings> optional;
|
||||
if (id != null)
|
||||
optional = userSettingsRepository.findById(id);
|
||||
else
|
||||
optional = findUserSettings(userId);
|
||||
if (optional.isPresent()) return optional.get();
|
||||
else {
|
||||
return initUserSettings(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package com.yunhai.userhome.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.userhome.entity.WidgetCatalog;
|
||||
import com.yunhai.userhome.entity.WidgetCatalogSchool;
|
||||
import com.yunhai.userhome.entity.WidgetSchoolSetting;
|
||||
import com.yunhai.userhome.repository.WidgetCatalogRepository;
|
||||
import com.yunhai.userhome.repository.WidgetCatalogSchoolRepository;
|
||||
import com.yunhai.userhome.repository.WidgetSchoolSettingRepository;
|
||||
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 小组件候选池服务(对齐 IUserSettingsService 分层:Controller 只做鉴权与出参组装,规则在此)。
|
||||
*
|
||||
* <p>生效值合成口径:生效值 = COALESCE(校级覆盖行, 全局值);显示名 = 校级别名 > 角色专属命名 > 组件名称;
|
||||
* 可见性 = 生效启用 AND 可见范围命中 AND 可见角色命中。</p>
|
||||
*/
|
||||
@Service
|
||||
public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogServiceImpl.class);
|
||||
|
||||
private static final int COMP_TYPE_BUILTIN = 1;
|
||||
|
||||
private static final int COMP_TYPE_FRAGMENT = 2;
|
||||
|
||||
private static final int ENABLED = 1;
|
||||
|
||||
/** 下发范围:全部学校 */
|
||||
private static final int SCOPE_ALL = 0;
|
||||
|
||||
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private WidgetCatalogRepository widgetCatalogRepository;
|
||||
|
||||
@Autowired
|
||||
private WidgetCatalogSchoolRepository widgetCatalogSchoolRepository;
|
||||
|
||||
@Autowired
|
||||
private WidgetSchoolSettingRepository widgetSchoolSettingRepository;
|
||||
|
||||
@Override
|
||||
public List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId) {
|
||||
Map<Integer, WidgetSchoolSetting> overrideMap = loadOverrideMap(schId);
|
||||
List<WidgetCatalog> result = new ArrayList<>();
|
||||
for (WidgetCatalog catalog : widgetCatalogRepository.findVisibleForSchool(schId)) {
|
||||
WidgetSchoolSetting override = overrideMap.get(catalog.getId());
|
||||
if (effectiveEnabled(catalog, override) != ENABLED) continue;
|
||||
if (!roleHit(catalog, override, roleId)) continue;
|
||||
result.add(effectiveCopy(catalog, override, schId, roleId));
|
||||
}
|
||||
result.sort(Comparator.comparing(WidgetCatalog::getSort, Comparator.nullsFirst(Integer::compareTo))
|
||||
.thenComparing(WidgetCatalog::getId, Comparator.nullsFirst(Integer::compareTo)));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<WidgetCatalog> findByCode(String code) {
|
||||
if (code == null || code.trim().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return widgetCatalogRepository.findByCode(code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visibleToSchool(WidgetCatalog catalog, Integer schId) {
|
||||
int scopeType = catalog.getScopeType() == null ? SCOPE_ALL : catalog.getScopeType();
|
||||
if (scopeType == SCOPE_ALL) {
|
||||
return true;
|
||||
}
|
||||
boolean pointed = schId != null
|
||||
&& widgetCatalogSchoolRepository.existsByWidgetIdAndSchId(catalog.getId(), schId);
|
||||
// 1 = 白名单(点名才可见);2 = 黑名单(点名不可见)
|
||||
return scopeType == 1 ? pointed : !pointed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WidgetCatalog> findAllForAdmin() {
|
||||
List<WidgetCatalog> list = widgetCatalogRepository.findAllByOrderBySortAscIdAsc();
|
||||
fillScopeSchIds(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin) {
|
||||
if (superAdmin || viewerSchId == null) {
|
||||
return findAllForAdmin();
|
||||
}
|
||||
Map<Integer, WidgetSchoolSetting> overrideMap = loadOverrideMap(viewerSchId);
|
||||
List<WidgetCatalog> result = new ArrayList<>();
|
||||
for (WidgetCatalog catalog : widgetCatalogRepository.findVisibleForSchool(viewerSchId)) {
|
||||
WidgetSchoolSetting override = overrideMap.get(catalog.getId());
|
||||
WidgetCatalog item = new WidgetCatalog();
|
||||
item.setId(catalog.getId());
|
||||
item.setCode(catalog.getCode());
|
||||
item.setName(effectiveName(catalog, override, null));
|
||||
item.setOriginalName(catalog.getName());
|
||||
item.setAlias(override == null ? null : override.getAlias());
|
||||
item.setCovered(override != null);
|
||||
item.setCompType(catalog.getCompType());
|
||||
item.setContent(catalog.getContent());
|
||||
item.setDataCode(catalog.getDataCode());
|
||||
item.setScopeType(catalog.getScopeType());
|
||||
item.setRoleTypes(effectiveRoleTypes(catalog, override));
|
||||
item.setRoleAlias(catalog.getRoleAlias());
|
||||
item.setSort(effectiveSort(catalog, override));
|
||||
item.setEnabled(effectiveEnabled(catalog, override));
|
||||
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
|
||||
item.setLinkPath(effectiveLinkPath(catalog, override));
|
||||
item.setUpdateTime(resolveUpdateTime(catalog, override));
|
||||
result.add(item);
|
||||
}
|
||||
fillScopeSchIds(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void save(WidgetCatalog model) {
|
||||
if (model.getCode() == null || model.getCode().trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "缺少组件键 code");
|
||||
}
|
||||
if (model.getName() == null || model.getName().trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "缺少名称");
|
||||
}
|
||||
if (model.getCompType() == null
|
||||
|| (model.getCompType() != COMP_TYPE_BUILTIN && model.getCompType() != COMP_TYPE_FRAGMENT)) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "类型只能是内置(1)或自定义片段(2)");
|
||||
}
|
||||
Optional<WidgetCatalog> exist = widgetCatalogRepository.findByCode(model.getCode());
|
||||
if (exist.isPresent() && !exist.get().getId().equals(model.getId())) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "组件键已存在:" + model.getCode());
|
||||
}
|
||||
int scopeType = model.getScopeType() == null ? SCOPE_ALL : model.getScopeType();
|
||||
if (scopeType != SCOPE_ALL && scopeType != 1 && scopeType != 2) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "可见范围只能是 全部学校(0) / 指定可见(1) / 指定不可见(2)");
|
||||
}
|
||||
model.setScopeType(scopeType);
|
||||
if (scopeType != SCOPE_ALL && (model.getScopeSchIds() == null || model.getScopeSchIds().isEmpty())) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "请选择学校后再保存可见范围");
|
||||
}
|
||||
if (model.getSort() == null) model.setSort(0);
|
||||
if (model.getEnabled() == null) model.setEnabled(ENABLED);
|
||||
// 内置组件的内容在 front 工程里,库里只登记键
|
||||
if (model.getCompType() == COMP_TYPE_BUILTIN) model.setContent(null);
|
||||
// 外跳目标(可选):只落「子应用 code + 路由」,绝对 URL 由注册表解析,不落库
|
||||
if (!StringUtils.hasText(model.getLinkAppCode())) model.setLinkAppCode(null);
|
||||
if (!StringUtils.hasText(model.getLinkPath())) model.setLinkPath(null);
|
||||
if ((model.getLinkAppCode() == null) != (model.getLinkPath() == null)) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "外跳配置需同时填「子应用 code」与「路由」,或都留空");
|
||||
}
|
||||
model.setUpdateTime(new Date());
|
||||
widgetCatalogRepository.save(model);
|
||||
rewritePointSchools(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteById(Integer id) {
|
||||
if (id == null) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "缺少组件 id");
|
||||
}
|
||||
widgetCatalogSchoolRepository.deleteByWidgetId(id);
|
||||
widgetSchoolSettingRepository.deleteByWidgetId(id);
|
||||
widgetCatalogRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId) {
|
||||
if (widgetId == null || schId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return widgetSchoolSettingRepository.findByWidgetIdAndSchId(widgetId, schId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId) {
|
||||
if (model == null || model.getWidgetId() == null) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "缺少组件 id");
|
||||
}
|
||||
if (currentSchId == null) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "当前登录身份没有学校,无法保存本校设置");
|
||||
}
|
||||
// 防越权:schId 一律取当前登录校,不采信前端传参
|
||||
model.setSchId(currentSchId);
|
||||
if (!widgetCatalogRepository.findById(model.getWidgetId()).isPresent()) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "组件不存在:" + model.getWidgetId());
|
||||
}
|
||||
if (!StringUtils.hasText(model.getRoleTypes())) model.setRoleTypes(null);
|
||||
if (!StringUtils.hasText(model.getAlias())) model.setAlias(null);
|
||||
if (!StringUtils.hasText(model.getLinkAppCode())) model.setLinkAppCode(null);
|
||||
if (!StringUtils.hasText(model.getLinkPath())) model.setLinkPath(null);
|
||||
// 外跳必须成对配置:只填 code 或只填路由都是半截配置,直接拒绝
|
||||
if ((model.getLinkAppCode() == null) != (model.getLinkPath() == null)) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "外跳配置需同时填「子应用 code」与「路由」,或都留空");
|
||||
}
|
||||
WidgetSchoolSetting exist = widgetSchoolSettingRepository
|
||||
.findByWidgetIdAndSchId(model.getWidgetId(), currentSchId).orElse(null);
|
||||
// 可覆盖字段全空 = 清除覆盖(删行,恢复沿用全局)
|
||||
boolean clearing = model.getEnabled() == null && model.getRoleTypes() == null
|
||||
&& model.getSortNo() == null && model.getAlias() == null
|
||||
&& model.getLinkAppCode() == null && model.getLinkPath() == null;
|
||||
if (clearing) {
|
||||
if (exist != null) widgetSchoolSettingRepository.delete(exist);
|
||||
return;
|
||||
}
|
||||
if (exist != null) model.setId(exist.getId());
|
||||
model.setUpdateTime(new Date());
|
||||
widgetSchoolSettingRepository.save(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayName(WidgetCatalog catalog, Integer schId, Integer roleId) {
|
||||
WidgetSchoolSetting override = null;
|
||||
if (schId != null && catalog.getId() != null) {
|
||||
override = widgetSchoolSettingRepository
|
||||
.findByWidgetIdAndSchId(catalog.getId(), schId).orElse(null);
|
||||
}
|
||||
return effectiveName(catalog, override, roleId);
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/** 显示名三级链:校级别名 > 角色专属命名 > 组件名称 */
|
||||
private String effectiveName(WidgetCatalog catalog, WidgetSchoolSetting override, Integer roleId) {
|
||||
if (override != null && StringUtils.hasText(override.getAlias())) {
|
||||
return override.getAlias();
|
||||
}
|
||||
String aliasByRole = roleAliasOf(catalog.getRoleAlias(), roleId);
|
||||
if (StringUtils.hasText(aliasByRole)) {
|
||||
return aliasByRole;
|
||||
}
|
||||
return catalog.getName();
|
||||
}
|
||||
|
||||
/** 角色专属命名读取:JSON 文本里取当前角色那一项;解析失败按「无角色命名」降级,不抛 500 */
|
||||
private String roleAliasOf(String roleAliasJson, Integer roleId) {
|
||||
if (!StringUtils.hasText(roleAliasJson) || roleId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode value = JSON_MAPPER.readTree(roleAliasJson).get(String.valueOf(roleId));
|
||||
return value == null || value.isNull() ? null : value.asText();
|
||||
} catch (Exception e) {
|
||||
log.warn("[widget] role_alias 解析失败,按无角色命名降级:{}", roleAliasJson);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean roleHit(WidgetCatalog catalog, WidgetSchoolSetting override, Integer roleId) {
|
||||
String roleTypes = effectiveRoleTypes(catalog, override);
|
||||
if (!StringUtils.hasText(roleTypes)) {
|
||||
return true;
|
||||
}
|
||||
if (roleId == null) {
|
||||
return false;
|
||||
}
|
||||
for (String token : roleTypes.split(",")) {
|
||||
if (roleId.equals(parseInt(token))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String effectiveRoleTypes(WidgetCatalog catalog, WidgetSchoolSetting override) {
|
||||
if (override != null && StringUtils.hasText(override.getRoleTypes())) {
|
||||
return override.getRoleTypes();
|
||||
}
|
||||
return catalog.getRoleTypes();
|
||||
}
|
||||
|
||||
/** 外跳子应用 code:校级覆盖优先(COALESCE 口径) */
|
||||
private String effectiveLinkAppCode(WidgetCatalog catalog, WidgetSchoolSetting override) {
|
||||
if (override != null && StringUtils.hasText(override.getLinkAppCode())) {
|
||||
return override.getLinkAppCode();
|
||||
}
|
||||
return catalog.getLinkAppCode();
|
||||
}
|
||||
|
||||
/** 外跳路由:校级覆盖优先(COALESCE 口径) */
|
||||
private String effectiveLinkPath(WidgetCatalog catalog, WidgetSchoolSetting override) {
|
||||
if (override != null && StringUtils.hasText(override.getLinkPath())) {
|
||||
return override.getLinkPath();
|
||||
}
|
||||
return catalog.getLinkPath();
|
||||
}
|
||||
|
||||
private int effectiveEnabled(WidgetCatalog catalog, WidgetSchoolSetting override) {
|
||||
if (override != null && override.getEnabled() != null) {
|
||||
return override.getEnabled();
|
||||
}
|
||||
return catalog.getEnabled() == null ? ENABLED : catalog.getEnabled();
|
||||
}
|
||||
|
||||
private int effectiveSort(WidgetCatalog catalog, WidgetSchoolSetting override) {
|
||||
if (override != null && override.getSortNo() != null) {
|
||||
return override.getSortNo();
|
||||
}
|
||||
return catalog.getSort() == null ? 0 : catalog.getSort();
|
||||
}
|
||||
|
||||
private Date resolveUpdateTime(WidgetCatalog catalog, WidgetSchoolSetting override) {
|
||||
if (override != null && override.getUpdateTime() != null) {
|
||||
return override.getUpdateTime();
|
||||
}
|
||||
return catalog.getUpdateTime();
|
||||
}
|
||||
|
||||
/** 下发视图:复制出独立对象并写入生效值,避免改动 JPA 托管实体(脏检查) */
|
||||
private WidgetCatalog effectiveCopy(WidgetCatalog catalog, WidgetSchoolSetting override,
|
||||
Integer schId, Integer roleId) {
|
||||
WidgetCatalog item = new WidgetCatalog();
|
||||
item.setId(catalog.getId());
|
||||
item.setCode(catalog.getCode());
|
||||
item.setName(effectiveName(catalog, override, roleId));
|
||||
item.setCompType(catalog.getCompType());
|
||||
item.setContent(catalog.getContent());
|
||||
item.setDataCode(catalog.getDataCode());
|
||||
item.setScopeType(catalog.getScopeType());
|
||||
item.setRoleTypes(effectiveRoleTypes(catalog, override));
|
||||
item.setRoleAlias(catalog.getRoleAlias());
|
||||
item.setSort(effectiveSort(catalog, override));
|
||||
item.setEnabled(effectiveEnabled(catalog, override));
|
||||
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
|
||||
item.setLinkPath(effectiveLinkPath(catalog, override));
|
||||
item.setUpdateTime(resolveUpdateTime(catalog, override));
|
||||
return item;
|
||||
}
|
||||
|
||||
private Map<Integer, WidgetSchoolSetting> loadOverrideMap(Integer schId) {
|
||||
Map<Integer, WidgetSchoolSetting> map = new HashMap<>();
|
||||
if (schId == null) {
|
||||
return map;
|
||||
}
|
||||
for (WidgetSchoolSetting setting : widgetSchoolSettingRepository.findBySchId(schId)) {
|
||||
map.put(setting.getWidgetId(), setting);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void fillScopeSchIds(Collection<WidgetCatalog> catalogs) {
|
||||
Map<Integer, List<Integer>> pointMap = loadPointSchoolMap(catalogs);
|
||||
for (WidgetCatalog catalog : catalogs) {
|
||||
List<Integer> ids = pointMap.get(catalog.getId());
|
||||
catalog.setScopeSchIds(ids == null ? new ArrayList<>() : ids);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Integer, List<Integer>> loadPointSchoolMap(Collection<WidgetCatalog> catalogs) {
|
||||
Map<Integer, List<Integer>> map = new HashMap<>();
|
||||
List<Integer> widgetIds = new ArrayList<>();
|
||||
for (WidgetCatalog catalog : catalogs) {
|
||||
if (catalog.getId() != null) widgetIds.add(catalog.getId());
|
||||
}
|
||||
if (widgetIds.isEmpty()) {
|
||||
return map;
|
||||
}
|
||||
for (WidgetCatalogSchool row : widgetCatalogSchoolRepository.findByWidgetIdIn(widgetIds)) {
|
||||
map.computeIfAbsent(row.getWidgetId(), key -> new ArrayList<>()).add(row.getSchId());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 整体重写点名表:scope_type=0 或未选校时清空 */
|
||||
private void rewritePointSchools(WidgetCatalog model) {
|
||||
widgetCatalogSchoolRepository.deleteByWidgetId(model.getId());
|
||||
if (model.getScopeType() == null || model.getScopeType() == SCOPE_ALL) {
|
||||
return;
|
||||
}
|
||||
List<Integer> schIds = model.getScopeSchIds();
|
||||
if (schIds == null || schIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<WidgetCatalogSchool> rows = new ArrayList<>();
|
||||
for (Integer schId : schIds) {
|
||||
if (schId == null) continue;
|
||||
WidgetCatalogSchool row = new WidgetCatalogSchool();
|
||||
row.setWidgetId(model.getId());
|
||||
row.setSchId(schId);
|
||||
rows.add(row);
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
widgetCatalogSchoolRepository.saveAll(rows);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer parseInt(String text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(text.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 枚举工具类(对齐旧 UserHome utils.EnumUtil,commons-lang → hutool)。
|
||||
*/
|
||||
public final class EnumUtil {
|
||||
|
||||
/**
|
||||
* 枚举转 List<Map> 格式(每个枚举项 → {key, value, enum})。
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static List<Map<String, Object>> toMapList(Class targetEnumClazz) {
|
||||
Method[] methods = targetEnumClazz.getMethods();
|
||||
Field[] fields = targetEnumClazz.getDeclaredFields();
|
||||
List<Field> fieldList = new ArrayList<>();
|
||||
for (Method method : methods) {
|
||||
for (Field field : fields) {
|
||||
String capitalized = Character.toUpperCase(field.getName().charAt(0)) + field.getName().substring(1);
|
||||
if (method.getName().endsWith(capitalized)) {
|
||||
fieldList.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> resultList = new ArrayList<>();
|
||||
Enum[] enums = (Enum[]) targetEnumClazz.getEnumConstants();
|
||||
Object value = null;
|
||||
try {
|
||||
for (Enum e : enums) {
|
||||
Map<String, Object> eMap = new HashMap<>();
|
||||
for (Field field : fieldList) {
|
||||
field.setAccessible(true);
|
||||
value = field.get(e);
|
||||
eMap.put(field.getName(), value);
|
||||
}
|
||||
eMap.put("enum", e);
|
||||
resultList.add(eMap);
|
||||
}
|
||||
} catch (IllegalAccessException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return resultList;
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static String getValue(Class targetEnumClazz, String key) {
|
||||
List<Map<String, Object>> list = toMapList(targetEnumClazz);
|
||||
for (Map map : list) {
|
||||
if (map.get("key").toString().equals(key)) {
|
||||
return map.get("value").toString();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static String getKey(Class targetEnumClazz, String value) {
|
||||
List<Map<String, Object>> list = toMapList(targetEnumClazz);
|
||||
for (Map map : list) {
|
||||
if (map.get("value").toString().equals(value)) {
|
||||
return map.get("key").toString();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static boolean isExistKey(Class targetEnumClazz, String key) {
|
||||
List<Map<String, Object>> list = toMapList(targetEnumClazz);
|
||||
return list.stream().filter(x -> x.get("key").equals(key)).count() > 0;
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static boolean isExistValue(Class targetEnumClazz, String value) {
|
||||
List<Map<String, Object>> list = toMapList(targetEnumClazz);
|
||||
return list.stream().filter(x -> x.get("value").equals(value)).count() > 0;
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static Object getEnumByKey(Class targetEnumClazz, String key) {
|
||||
List<Map<String, Object>> list = toMapList(targetEnumClazz);
|
||||
for (Map map : list) {
|
||||
if (map.get("key").toString().equals(key)) {
|
||||
return map.get("enum");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
public static List<Map<String, Object>> toMapList(Class targetEnumClazz, String keyName, String valueName) {
|
||||
List<Map<String, Object>> list = toMapList(targetEnumClazz);
|
||||
List result = new ArrayList();
|
||||
for (Map map : list) {
|
||||
Map newMap = new HashMap();
|
||||
newMap.put(keyName, map.get("key"));
|
||||
newMap.put(valueName, map.get("value"));
|
||||
result.add(newMap);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* File工具类(对齐旧 UserHome utils.FileUtil,仅保留 uploadImg 方法——本期迁移实际使用的唯一方法)。
|
||||
*/
|
||||
public class FileUtil {
|
||||
|
||||
/** 允许上传的图片扩展名(与前端 accept 对齐,含 icon 场景的 svg) */
|
||||
private static final Set<String> ALLOWED_EXT = new HashSet<>(Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"));
|
||||
|
||||
/**
|
||||
* 上传图片(byte[] 版),返回生成的文件名。
|
||||
*/
|
||||
public static String uploadImg(byte[] imgData, String pathName) {
|
||||
String imgName = null;
|
||||
String path = null;
|
||||
File floorFile = null;
|
||||
try {
|
||||
for (int i = 0; i < 100; i++) {
|
||||
imgName = UUID.randomUUID().toString().substring(0, 8) + ".png";
|
||||
path = pathName + imgName;
|
||||
floorFile = new File(path);
|
||||
if (floorFile.exists()) continue;
|
||||
else break;
|
||||
}
|
||||
FileOutputStream fos = new FileOutputStream(floorFile);
|
||||
fos.write(imgData);
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传失败");
|
||||
}
|
||||
return imgName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片(MultipartFile 版),保留原始扩展名,返回生成的文件名。
|
||||
* <p>扩展名不在白名单内抛 {@link BizException}(服务端校验,防绕过前端 accept)。</p>
|
||||
*/
|
||||
public static String uploadImg(MultipartFile file, String pathName) {
|
||||
String imgName = null;
|
||||
String path = null;
|
||||
File floorFile = null;
|
||||
try {
|
||||
byte[] imgData = file.getBytes();
|
||||
String fileName = file.getOriginalFilename();
|
||||
int index = fileName.lastIndexOf(".");
|
||||
String ext = "";
|
||||
if (index > 0) {
|
||||
ext = fileName.substring(index).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
if (!ALLOWED_EXT.contains(ext)) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "不支持的图片格式");
|
||||
}
|
||||
for (int i = 0; i < 100; i++) {
|
||||
imgName = UUID.randomUUID().toString().substring(0, 8) + ext;
|
||||
path = pathName + imgName;
|
||||
floorFile = new File(path);
|
||||
if (floorFile.exists()) continue;
|
||||
else break;
|
||||
}
|
||||
FileOutputStream fos = new FileOutputStream(floorFile);
|
||||
fos.write(imgData);
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传失败");
|
||||
}
|
||||
return imgName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除上传的物理文件(按 /upload/ 相对 URL 还原路径)。
|
||||
* <p>非本地上传(完整 URL / 跨仓地址)或文件不存在时静默跳过。</p>
|
||||
*/
|
||||
public static void deleteFile(String basePath, String relativeUrl) {
|
||||
if (relativeUrl == null || relativeUrl.isEmpty() || !relativeUrl.startsWith("/upload/")) {
|
||||
return;
|
||||
}
|
||||
String rel = relativeUrl.substring("/upload/".length());
|
||||
File file = new File(basePath, rel);
|
||||
if (file.exists()) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文件上传(MultipartFile 版),保留原始扩展名,返回生成的文件名。
|
||||
* <p>与 {@link #uploadImg(MultipartFile, String)} 的区别:不做图片扩展名白名单校验,
|
||||
* 供模板上传(zip/rar)等非图片场景使用,扩展名合法性由调用方自行校验。</p>
|
||||
*/
|
||||
public static String uploadFile(MultipartFile file, String pathName) {
|
||||
String imgName = null;
|
||||
String path = null;
|
||||
File floorFile = null;
|
||||
try {
|
||||
byte[] fileData = file.getBytes();
|
||||
String fileName = file.getOriginalFilename();
|
||||
int index = fileName == null ? -1 : fileName.lastIndexOf(".");
|
||||
String ext = "";
|
||||
if (index > 0) {
|
||||
ext = fileName.substring(index).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
for (int i = 0; i < 100; i++) {
|
||||
imgName = UUID.randomUUID().toString().substring(0, 8) + ext;
|
||||
path = pathName + imgName;
|
||||
floorFile = new File(path);
|
||||
if (floorFile.exists()) continue;
|
||||
else break;
|
||||
}
|
||||
FileOutputStream fos = new FileOutputStream(floorFile);
|
||||
fos.write(fileData);
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传失败");
|
||||
}
|
||||
return imgName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩 ZIP 文件到目标目录(覆盖同名文件,防 zip-slip 路径穿越)。
|
||||
*
|
||||
* @param zipFile 压缩包
|
||||
* @param destDir 目标目录
|
||||
*/
|
||||
public static void zipDecompress(File zipFile, File destDir) {
|
||||
byte[] buffer = new byte[1024];
|
||||
try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
|
||||
ZipEntry entry = zis.getNextEntry();
|
||||
while (entry != null) {
|
||||
File file = new File(destDir, entry.getName());
|
||||
String destPath = destDir.getCanonicalPath();
|
||||
String childPath = file.getCanonicalPath();
|
||||
if (!childPath.startsWith(destPath + File.separator)) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "压缩包内存在非法路径");
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
file.mkdirs();
|
||||
} else {
|
||||
File parent = file.getParentFile();
|
||||
if (!parent.exists()) {
|
||||
parent.mkdirs();
|
||||
}
|
||||
try (FileOutputStream fos = new FileOutputStream(file)) {
|
||||
int len;
|
||||
while ((len = zis.read(buffer)) > 0) {
|
||||
fos.write(buffer, 0, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
entry = zis.getNextEntry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "解压失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩文件/文件夹为 ZIP。
|
||||
*
|
||||
* @param sourceFile 被压缩文件/文件夹
|
||||
* @param zipFile Zip 文件
|
||||
*/
|
||||
public static void zipCompress(File sourceFile, File zipFile) {
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
|
||||
zos.setMethod(ZipOutputStream.DEFLATED);
|
||||
zos.setLevel(Deflater.BEST_COMPRESSION);
|
||||
if (sourceFile.exists() && sourceFile.isDirectory() && sourceFile.listFiles() != null) {
|
||||
Arrays.stream(sourceFile.listFiles()).forEach(file -> addZipFile(file, zos));
|
||||
} else {
|
||||
addZipFile(sourceFile, zos);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "压缩失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 ZIP 中添加单个文件(zipCompress 辅助)。
|
||||
*/
|
||||
private static void addZipFile(File file, ZipOutputStream zos) {
|
||||
if (!file.exists() || file.isDirectory()) {
|
||||
throw new RuntimeException("文件不存在或该文件为文件夹,请检查");
|
||||
}
|
||||
try (FileInputStream fis = new FileInputStream(file)) {
|
||||
ZipEntry entry = new ZipEntry(file.getName());
|
||||
entry.setMethod(ZipEntry.DEFLATED);
|
||||
entry.setSize(file.length());
|
||||
entry.setCreationTime(FileTime.from(Instant.now()));
|
||||
entry.setLastAccessTime(FileTime.from(Instant.now()));
|
||||
entry.setLastModifiedTime(FileTime.from(Instant.now()));
|
||||
zos.putNextEntry(entry);
|
||||
byte[] buf = new byte[1024];
|
||||
int len;
|
||||
while ((len = fis.read(buf)) > 0) {
|
||||
zos.write(buf, 0, len);
|
||||
}
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "压缩失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件夹(含内容)。
|
||||
*/
|
||||
public static void delFolder(String folderPath) {
|
||||
delAllFile(folderPath);
|
||||
new File(folderPath).delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定文件夹下所有文件(保留目录本身)。
|
||||
*/
|
||||
public static boolean delAllFile(String path) {
|
||||
boolean flag = false;
|
||||
File file = new File(path);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
return flag;
|
||||
}
|
||||
String[] tempList = file.list();
|
||||
if (tempList == null) {
|
||||
return flag;
|
||||
}
|
||||
for (String s : tempList) {
|
||||
File temp = new File(path, s);
|
||||
if (temp.isFile()) {
|
||||
temp.delete();
|
||||
}
|
||||
if (temp.isDirectory()) {
|
||||
delAllFile(temp.getPath());
|
||||
delFolder(temp.getPath());
|
||||
flag = true;
|
||||
}
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 轻量内存缓存(替换旧工程 bsch MapCacheUtil)。
|
||||
* 用于 getMsgAddress 的 uuid→userId 鉴权握手缓存,20s 有效。
|
||||
* 单例 + 守护线程周期清扫过期项,线程安全(ConcurrentHashMap)。
|
||||
*/
|
||||
@Component
|
||||
public class MapCacheUtil {
|
||||
|
||||
private static final int CLEAN_UP_PERIOD_IN_SEC = 5;
|
||||
|
||||
private final ConcurrentHashMap<String, CacheObject> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private static MapCacheUtil instance;
|
||||
|
||||
public static synchronized MapCacheUtil getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new MapCacheUtil();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private MapCacheUtil() {
|
||||
Thread cleanerThread = new Thread(() -> {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
Thread.sleep(CLEAN_UP_PERIOD_IN_SEC * 1000L);
|
||||
cache.entrySet().removeIf(entry ->
|
||||
Optional.ofNullable(entry.getValue()).map(CacheObject::isExpired).orElse(false));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
});
|
||||
cleanerThread.setDaemon(true);
|
||||
cleanerThread.start();
|
||||
}
|
||||
|
||||
public void add(String key, Object value, long periodInMillis) {
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
if (value == null) {
|
||||
cache.remove(key);
|
||||
} else {
|
||||
long expiryTime = System.currentTimeMillis() + periodInMillis;
|
||||
cache.put(key, new CacheObject(value, expiryTime));
|
||||
}
|
||||
}
|
||||
|
||||
public void remove(String key) {
|
||||
cache.remove(key);
|
||||
}
|
||||
|
||||
public Object get(String key) {
|
||||
return Optional.ofNullable(cache.get(key))
|
||||
.filter(c -> !c.isExpired())
|
||||
.map(CacheObject::getValue)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
private static class CacheObject {
|
||||
private final Object value;
|
||||
private final long expiryTime;
|
||||
|
||||
private CacheObject(Object value, long expiryTime) {
|
||||
this.value = value;
|
||||
this.expiryTime = expiryTime;
|
||||
}
|
||||
|
||||
boolean isExpired() {
|
||||
return System.currentTimeMillis() > expiryTime;
|
||||
}
|
||||
|
||||
public Object getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 杂项公用方法集合(对齐旧 UserHome utils.OtherUtil,fastjson 相关方法移除——新框架用 Jackson)。
|
||||
*/
|
||||
public class OtherUtil {
|
||||
|
||||
/**
|
||||
* 十六进制转字符串
|
||||
*/
|
||||
public final static String hexToString(String hex) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < hex.length(); i += 2) {
|
||||
String hexSubstring = hex.substring(i, i + 2);
|
||||
int decimalValue = Integer.parseInt(hexSubstring, 16);
|
||||
sb.append((char) decimalValue);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为校领导
|
||||
*/
|
||||
public final static Boolean isAdminById(Integer roleId) {
|
||||
return Arrays.asList(2, 5, 7).contains(roleId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.yunhai.userhome.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 主题档/头像样式的编解码与校验(原 SchSettingsController 私有实现下沉,2026-10-08 外观偏好轮;
|
||||
* 2026-10-09 存储与传输全面 JSON 化)。
|
||||
* <p>存储形态:{@code {"theme":"teal","sideDeep":false,"gradOn":false,"sideFollow":false}}
|
||||
* (固定字段序的规范化 JSON 串;sch_settings.theme_name 与 user_appearance.theme_state 同形态)。
|
||||
* 历史紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01} 仅在【读侧】兼容——解码时自动转 JSON,
|
||||
* 写侧一律产出 JSON(存量行靠迁移 UPDATE 或读时懒迁移消化)。
|
||||
* 读侧宽松(脏值/不可解析一律 null 视同未配置),写侧从严(非法抛 DException)。</p>
|
||||
*/
|
||||
public final class ThemeCodec {
|
||||
|
||||
/** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */
|
||||
public static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"};
|
||||
|
||||
/** 合法头像样式(与前端 avatar.ts 的 AvatarStyle 一致;写入侧从严,对齐 encodeThemeState 做法) */
|
||||
public static final String[] AVATAR_STYLES = {"text", "silhouette"};
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ThemeCodec() {
|
||||
}
|
||||
|
||||
/** 解析并校验 ThemeState JSON(四字段齐全、档名合法),合法返回【规范化 JSON 串】;非法 null */
|
||||
private static String normalizeJson(String themeStateJson) {
|
||||
try {
|
||||
JsonNode n = MAPPER.readTree(themeStateJson);
|
||||
if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep")
|
||||
|| !n.has("gradOn") || !n.has("sideFollow")) {
|
||||
return null;
|
||||
}
|
||||
String theme = n.get("theme").asText();
|
||||
if (!isThemeKey(theme)) {
|
||||
return null;
|
||||
}
|
||||
return "{\"theme\":\"" + theme + "\",\"sideDeep\":" + n.get("sideDeep").asBoolean()
|
||||
+ ",\"gradOn\":" + n.get("gradOn").asBoolean()
|
||||
+ ",\"sideFollow\":" + n.get("sideFollow").asBoolean() + "}";
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧紧凑编码 theme|01|01|01 → JSON;非本形态返回 null */
|
||||
private static String fromCompact(String compact) {
|
||||
String[] p = compact.trim().split("\\|");
|
||||
if (p.length != 4 || !isThemeKey(p[0])) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 1; i < 4; i++) {
|
||||
if (p[i].length() != 1 || (p[i].charAt(0) != '0' && p[i].charAt(0) != '1')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "{\"theme\":\"" + p[0] + "\",\"sideDeep\":" + ("1".equals(p[1]))
|
||||
+ ",\"gradOn\":" + ("1".equals(p[2]))
|
||||
+ ",\"sideFollow\":" + ("1".equals(p[3])) + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读侧统一入口:JSON 校验 → 规范化 JSON;旧紧凑四段 → 转 JSON;其余(旧档名/脏值)null(视同未配置)。
|
||||
* 返回值形态:ThemeState JSON 串(与 localStorage yh-theme 键形态一致)。
|
||||
*/
|
||||
public static String decodeThemeState(String raw) {
|
||||
if (!StringUtils.hasText(raw)) return null;
|
||||
String s = raw.trim();
|
||||
return s.startsWith("{") ? normalizeJson(s) : fromCompact(s);
|
||||
}
|
||||
|
||||
/** ThemeState JSON 校验(四字段齐全、档名合法)+ 规范化(固定字段序);非法抛业务异常(写入侧从严) */
|
||||
public static String encodeThemeState(String themeStateJson) {
|
||||
if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空");
|
||||
String normalized = normalizeJson(themeStateJson.trim());
|
||||
if (normalized == null) {
|
||||
throw new DException("主题档格式不合法");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** ThemeState JSON 串 → 节点(oapi 出参对象形态用);入串须先过 decodeThemeState 校验 */
|
||||
public static JsonNode toJsonNode(String themeStateJson) {
|
||||
try {
|
||||
return MAPPER.readTree(themeStateJson);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isThemeKey(String theme) {
|
||||
if (theme == null) return false;
|
||||
for (String k : THEME_KEYS) {
|
||||
if (k.equals(theme)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isAvatarStyle(String style) {
|
||||
if (style == null) return false;
|
||||
for (String k : AVATAR_STYLES) {
|
||||
if (k.equals(style)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,33 @@ spring:
|
||||
hibernate:
|
||||
jdbc:
|
||||
time_zone: Asia/Shanghai
|
||||
servlet:
|
||||
multipart:
|
||||
# 模板上传(zip/rar)等大文件支持,对齐 Nacos prod 配置
|
||||
max-file-size: 50MB
|
||||
max-request-size: 200MB
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
database: 0
|
||||
# 本地 Redis 开启了 requirepass(联调环境约定);健康检查据此转绿
|
||||
password: 123456
|
||||
|
||||
# ===== 基础平台 yh 配置(遵循 yh-* spring-boot-starter 约定,具体 key 以对应 starter 版本为准)=====
|
||||
yh:
|
||||
auth:
|
||||
enabled: true
|
||||
sso:
|
||||
# CAS 单点登录(浏览器 SSO 重定向 / ticket 校验)
|
||||
cas:
|
||||
server-url-prefix: https://sso.yunhaixingtong.com/cas
|
||||
service: http://localhost:8070
|
||||
# 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),票据校验=根路径
|
||||
# /serviceValidate(160 实测;/cas/ 为存量本地默认值,160 网关已无此路径)
|
||||
server-url-prefix: http://sso.yunhaixingtong.com/
|
||||
# 本地 context-path=/、端口 8070;prod 由 Nacos 的 yh.sso.client-host-url 覆盖
|
||||
client-host-url: http://localhost:8070
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
url-pattern: /api/*
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
# 应用间签名鉴权(UserHome 作为调用方访问 UserBase 跨仓接口时使用)
|
||||
enabled: true
|
||||
@@ -49,14 +64,83 @@ yh:
|
||||
app-key: 100001
|
||||
app-secret: ec4e860206374c80938719fff8e14a86
|
||||
open:
|
||||
# yh-openapi 开放接口客户端(前缀必须是 yh.open,OpenApiProperties 绑定;base-host + base-path 拼出
|
||||
# 完整跨仓基址,openapi 客户端再拼 /oapi/** 端点;app-key/app-secret 同时驱动应用间签名)
|
||||
enabled: true
|
||||
base-host: http://localhost:8060
|
||||
# yh-openapi 服务目录:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx;
|
||||
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
base-path: /userbase
|
||||
app-key: 100001
|
||||
app-secret: ec4e860206374c80938719fff8e14a86
|
||||
call:
|
||||
# YschCall 直调目标目录(寻址优先级高于 yh.open.services 与 PlatformService 枚举兜底)。
|
||||
# acasyllabu:课程表 oapi(小组件 syllabus 取数)。base-path 即目标 ctx,
|
||||
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
|
||||
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
|
||||
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
|
||||
# base-host 指 open-gateway(开放网关,容器网地址):20261015 切门收口,oapi 统一经网关
|
||||
# 验签/转发/台账;签名口径不变(网关 canonical 与 starter 逐字节一致)。base-path 即
|
||||
# 网关 target 段(= 目标 ctx)。
|
||||
targets:
|
||||
acasyllabu:
|
||||
base-host: http://open-gateway-service:10013
|
||||
base-path: /aca-syllabu
|
||||
# workplan:一周安排 oapi(小组件 weekplan 取数)。base-path 即目标 ctx,
|
||||
# 客户端签名按该 ctx 剥离(待签 target = /oapi/workplan/Get?...,不含 /work-week-plan)。
|
||||
# 真实 ctx = /work-week-plan(20261015 网关签名 200 实测,网关注册表已登记该 target)。
|
||||
# 本地联调:WorkWeekPlan 在 8110 且无 ctx ⇒ 覆盖 base-host: http://localhost:8110、base-path 留空。
|
||||
workplan:
|
||||
base-host: http://open-gateway-service:10013
|
||||
base-path: /work-week-plan
|
||||
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
|
||||
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
||||
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
|
||||
# 或 Nacos userhome.yaml 同名键 yh.env-config.source.front 注入(projman 配置管理维护),
|
||||
# 未配置时端点返回 {},前端走同域推导兜底。如需本地联调可打开:
|
||||
# env-config:
|
||||
# source:
|
||||
# front: '{"baseHost":"http://base.yunhaixingtong.com","ssoHost":"http://sso.yunhaixingtong.com","serviceHost":"http://localhost:8070","fileHost":"http://localhost:8070/upload/","statsRealyConf":[]}'
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.yunhai.userhome: debug
|
||||
|
||||
# ===== 消息中心代理(§3.5 最小消息接入 + WS 实时推送)=====
|
||||
userhome:
|
||||
msgcenter:
|
||||
# UserMsg 服务基址(IDHttp 出站自动签名,身份=yh.app-auth appKey=100001,
|
||||
# UserMsg trusted-apps 已含)。空 = 未接入(代理降级空,铃铛维持原表现)
|
||||
usermsg-base-url: http://localhost:8080
|
||||
# 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底);
|
||||
# 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg
|
||||
usermsg-ws-url: ws://localhost:8080/ws/msg
|
||||
# flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**)。空 = 走服务目录 flow-center
|
||||
# (PlatformService 枚举兜底);不可达时直批降级提示,不阻塞消息卡片
|
||||
flow-center-base-url: http://localhost:8085
|
||||
# appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案)
|
||||
app-names:
|
||||
100009: 通知公告
|
||||
100617: 通知公告
|
||||
100019: 流程中心
|
||||
100001: 用户主页
|
||||
# 跳转基址唯一来源 = 应用注册表(UserBase /oapi/app/by-code|by-key 取 pc_url,§3.2 归一化后
|
||||
# 拼 "#" + jumpPath);已是绝对 URL 的存量消息直接透传。手工基址表 jump-bases 已退役(C2),
|
||||
# 生产 Nacos userhome.yaml 若残留 jump-bases 段应一并清理(Spring 静默忽略,留着误导)。
|
||||
|
||||
# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。
|
||||
# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患;
|
||||
# show-details=never 不外泄 DB/Redis 细节,仅返回总体状态。
|
||||
# 压测结束后:保留 health(生产探活需要),按《埋点移除清单.md》评估是否去掉 prometheus。
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
# [LOADTEST-METRICS-END]
|
||||
|
||||
# ===== 文件上传与学校配置 =====
|
||||
# 文件存储根目录(UserSettingsController 壁纸/图标上传使用)
|
||||
# 本地默认 ./upload/(相对工作目录);生产由 Nacos 覆盖为 /data/upload(容器卷挂载到宿主机)
|
||||
FILE_PATH: D:/bsch/userhome/upload/
|
||||
# 文件 Web 访问前缀(拼接壁纸完整 URL 用,见 UserSettingsController.getUserWallpaperList)
|
||||
# 本地 http://localhost:8070/upload/;生产由 Nacos 覆盖为 http://home.yunhaixingtong.com/userhome/upload/
|
||||
FILE_WEB_PATH: http://localhost:8070/upload/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user