Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6678c2b8c0 | ||
|
|
b434520eff | ||
|
|
5b6f7d6a2a | ||
|
|
c8d0584f11 | ||
|
|
8c272dc1dc | ||
|
|
e42b15600a | ||
|
|
e32226d7d2 | ||
|
|
b61736eb2b | ||
|
|
8ed5631969 | ||
|
|
65ee61c6c9 | ||
|
|
09e67cd834 | ||
|
|
fd317b7932 | ||
|
|
859dab9401 | ||
|
|
4f99f31475 | ||
|
|
43580dd1fd | ||
|
|
d5d9b7aa99 | ||
|
|
6eca6de249 | ||
|
|
a42df9d521 | ||
|
|
015ecd13d8 | ||
|
|
8abd750dfb | ||
|
|
f8e24ffdf0 | ||
|
|
688be6b65f | ||
|
|
a6e75f6e97 | ||
|
|
6cc5212eba | ||
|
|
2d87b96a09 | ||
|
|
eb2869a71d | ||
|
|
1789141a7b | ||
|
|
b80e4c01f5 | ||
|
|
b70d90d18a | ||
|
|
73fdaf82e7 | ||
|
|
5418f23111 | ||
|
|
58e9e4db22 | ||
|
|
a6dab438d5 | ||
|
|
174b4265d7 | ||
|
|
3d157ed30c | ||
|
|
9aded054d2 | ||
|
|
cfa21eb336 | ||
|
|
53a6781092 | ||
|
|
ac0154c1fb | ||
|
|
9d3bac8224 | ||
|
|
76742ed65a | ||
|
|
c8c0773a6a | ||
|
|
5794bddc6c | ||
|
|
b3c7d52174 | ||
|
|
a9a5c888e3 | ||
|
|
4cf2827a74 |
@@ -0,0 +1,152 @@
|
||||
name: lpt-be Build & Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: '部署环境(dev 仅允许 dev 分支;prod 仅允许 master/main)'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.123.199:5000
|
||||
APP: lpt-be
|
||||
REPO_URL: http://git.cat-shark.xyz/cat-shark/lpt-be.git
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
KUBECONFIG: /tmp/kubeconfig
|
||||
steps:
|
||||
- name: Validate branch ↔ environment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REF="${{ gitea.ref }}"
|
||||
BRANCH="${REF#refs/heads/}"
|
||||
if [[ "$REF" == refs/tags/* ]]; then
|
||||
echo "ERROR: 请从分支触发部署,不要用 tag。当前 ref=$REF"
|
||||
exit 1
|
||||
fi
|
||||
ENV="${{ inputs.environment }}"
|
||||
echo "branch=$BRANCH environment=$ENV sha=${{ gitea.sha }}"
|
||||
case "$ENV" in
|
||||
prod)
|
||||
case "$BRANCH" in
|
||||
master|main) echo "OK: prod 允许从 $BRANCH 部署" ;;
|
||||
*)
|
||||
echo "ERROR: prod 只能从 master/main 部署,当前分支是 '$BRANCH'"
|
||||
echo "请切换到 master 后再 Run workflow,并选择 environment=prod"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
dev)
|
||||
case "$BRANCH" in
|
||||
dev) echo "OK: dev 允许从 $BRANCH 部署" ;;
|
||||
*)
|
||||
echo "ERROR: dev 只能从 dev 分支部署,当前分支是 '$BRANCH'"
|
||||
echo "请切换到 dev 后再 Run workflow,并选择 environment=dev"
|
||||
echo "(禁止用 master 代码部署到 lpt-dev,避免环境错配)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: 未知 environment=$ENV"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "$REPO_URL" .
|
||||
git checkout "${{ gitea.sha }}"
|
||||
|
||||
- name: Login to Docker Registry
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build & Push Docker image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENV="${{ inputs.environment }}"
|
||||
TAG="${{ gitea.sha }}-$(date +%s)"
|
||||
echo "Building $REGISTRY/$APP:$TAG (env tag=$ENV)"
|
||||
docker build -t "$REGISTRY/$APP:$TAG" -t "$REGISTRY/$APP:$ENV" .
|
||||
docker push "$REGISTRY/$APP:$TAG"
|
||||
docker push "$REGISTRY/$APP:$ENV"
|
||||
echo "IMAGE_TAG=$TAG"
|
||||
echo "$TAG" > image_tag.txt
|
||||
mkdir -p /tmp/lpt-ci
|
||||
echo "$TAG" > /tmp/lpt-ci/image_tag.txt
|
||||
|
||||
- name: Setup kubectl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sLO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > /tmp/kubeconfig
|
||||
chmod 600 /tmp/kubeconfig
|
||||
|
||||
- name: Create/Update imagePullSecret
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl create secret docker-registry regcred \
|
||||
--docker-server="$REGISTRY" \
|
||||
--docker-username="${{ secrets.REGISTRY_USERNAME }}" \
|
||||
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
|
||||
-n "lpt-${{ inputs.environment }}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Deploy to K8s
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENV="${{ inputs.environment }}"
|
||||
IMAGE_TAG="$(cat image_tag.txt 2>/dev/null || cat /tmp/lpt-ci/image_tag.txt 2>/dev/null || echo '')"
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG 为空,无法部署"
|
||||
exit 1
|
||||
fi
|
||||
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
|
||||
kubectl set image "deployment/$APP" \
|
||||
"$APP=$REGISTRY/$APP:$IMAGE_TAG" \
|
||||
-n "lpt-$ENV" --record
|
||||
kubectl rollout status "deployment/$APP" \
|
||||
-n "lpt-$ENV" --timeout=5m
|
||||
|
||||
- name: Debug on failure
|
||||
if: failure()
|
||||
run: |
|
||||
ENV="${{ inputs.environment }}"
|
||||
echo "=== Deployment Status ==="
|
||||
kubectl get deployment "$APP" -n "lpt-$ENV" || true
|
||||
echo ""
|
||||
echo "=== Pod Status ==="
|
||||
kubectl get pods -n "lpt-$ENV" -l "app=$APP" || true
|
||||
echo ""
|
||||
echo "=== Recent Events ==="
|
||||
kubectl get events -n "lpt-$ENV" --sort-by='.lastTimestamp' | tail -20 || true
|
||||
echo ""
|
||||
echo "=== Pod Describe (latest) ==="
|
||||
POD=$(kubectl get pods -n "lpt-$ENV" -l "app=$APP" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || true)
|
||||
if [ -n "${POD:-}" ]; then
|
||||
kubectl describe pod "$POD" -n "lpt-$ENV" || true
|
||||
echo ""
|
||||
echo "=== Pod Logs (latest) ==="
|
||||
kubectl logs "$POD" -n "lpt-$ENV" --tail=50 || true
|
||||
fi
|
||||
|
||||
- name: Rollback on failure
|
||||
if: failure()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENV="${{ inputs.environment }}"
|
||||
kubectl rollout undo "deployment/$APP" -n "lpt-$ENV" || true
|
||||
kubectl rollout status "deployment/$APP" -n "lpt-$ENV" || true
|
||||
@@ -1,4 +1,133 @@
|
||||
# 项目规范
|
||||
# LPT 后端服务(lpt-be)
|
||||
|
||||
> Java Spring Boot 后端,提供 REST API、数据库访问、认证鉴权。
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
- 提交信息必须简短且使用中文,不要使用英文长句。
|
||||
- 格式:`类型: 简述`,例如 `feat: 新增用户登录接口`、`fix: 修复多租户拦截器空指针`、`docs: 补充API文档`。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Java 17
|
||||
- 框架:Spring Boot 3.2.5
|
||||
- ORM:MyBatis-Plus 3.5.5
|
||||
- 数据库:MySQL 8.0
|
||||
- 认证:Sa-Token 1.38.0,Cookie 名 `satoken`
|
||||
- 数据库迁移:Flyway
|
||||
- 对象映射:MapStruct 1.5.5
|
||||
- 连接池:Druid 1.2.8
|
||||
- 密码加密:jBCrypt 0.4
|
||||
- API 文档:Knife4j (OpenAPI 3)
|
||||
- Lombok(@Data, @Slf4j)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
lpt-be/src/main/java/com/guo/learningprogresstracker/
|
||||
├── controller/ → REST 接口层
|
||||
├── service/ → 业务逻辑层(接口 + impl)
|
||||
├── mapper/ → MyBatis-Plus BaseMapper
|
||||
├── entity/ → 数据库实体
|
||||
├── dto/ → 请求/响应 DTO
|
||||
├── config/ → Spring 配置
|
||||
├── common/ → 全局异常处理、Ops
|
||||
├── mapStruct/ → MapStruct Converter
|
||||
├── enums/ → 枚举类
|
||||
├── exception/ → 自定义异常
|
||||
└── utils/ → 工具类
|
||||
```
|
||||
|
||||
- controller 只做参数校验和路由。
|
||||
- service/impl 承载业务逻辑。
|
||||
- mapper 使用 MyBatis-Plus BaseMapper。
|
||||
- entity 使用 `@TableName`、`@TableField` 映射数据库字段。
|
||||
- mapStruct 承载 DTO 转换。
|
||||
|
||||
## API 端点总览(32 个)
|
||||
|
||||
| 模块 | 端点数 | 端点 |
|
||||
|------|--------|------|
|
||||
| 学习会话 | 9 | `POST /study-sessions/{taskNum}/start`, `PUT .../pause`, `PUT .../resume`, `PUT .../end`, `POST .../fragments`, `GET .../history`, `PUT .../expectation`, `GET .../expectation`, `GET .../report-draft` |
|
||||
| 标准思维导图 | 6 | `GET /review/standard-mind-map/{taskNum}`, `POST .../regenerate`, `PUT ...`, `POST .../recall`, `POST .../find-node`, `GET .../recall-records` |
|
||||
| 复习模块 | 6 | `GET /review/feed`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/recall-records/{recordId}`, `GET /review/tasks` |
|
||||
| 任务管理 | 6 | `GET /tasks`, `POST /tasks`, `PUT /tasks/{taskNum}`, `DELETE /tasks/{taskNum}`, `GET /tasks/priority-weights`, `PUT /tasks/priority-weights` |
|
||||
| 应用场景 | 4 | `GET/POST/PUT/DELETE /tasks/{taskNum}/applications[/{id}]` |
|
||||
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
|
||||
|
||||
## 数据库(10 个核心表)
|
||||
|
||||
`users`, `tasks`, `study_sessions`, `study_reports`, `study_report_fragments`, `study_expectations`, `task_applications`, `review_standard_mind_maps`, `review_recall_records`, `user_priority_weights`
|
||||
|
||||
## 关键机制
|
||||
|
||||
- 多租户隔离:`TenantLineInnerInterceptor` 自动注入 `WHERE created_by = #{当前用户}`,排除 `user`/`flyway_schema_history` 表。
|
||||
- 自动填充:`MetaObjectHandler` 自动填充 `created_by`/`updated_by`/`created_time`/`updated_time`。
|
||||
- 认证鉴权:Sa-Token 拦截 `/**` 排除 `/login`,Cookie `satoken`,支持 `@SaCheckPermission` 注解式权限。
|
||||
- 响应格式:`CommonResult<T>` 统一包装 `{ code, message, data }`。
|
||||
- CORS:可配置,local profile 允许所有来源。
|
||||
|
||||
## 响应规范
|
||||
|
||||
- 成功:`CommonResult.success(data)`,code=200。
|
||||
- 业务错误:`CommonResult.error(msg)`,code=400,HTTP 200。
|
||||
- 未登录:`GlobalExceptionHandler.handleNotLogin()`,HTTP 401 + code=401。
|
||||
- 参数校验失败:`MethodArgumentNotValidException`,code=400。
|
||||
- 用户可见错误文案必须口语化、可理解,避免“无法生成”“不存在”等技术化表述;技术细节写入日志。例如无学习报告时应提示“这个任务还没开始学习哦,学习后产生学习报告后再来吧”,而不是“没有学习报告,无法生成思维导图”。
|
||||
|
||||
## DTO 转换
|
||||
|
||||
- MapStruct 编译期生成 `*ConvertImpl.java`,同名属性自动映射。
|
||||
- 默认 `unmappedTargetPolicy = IGNORE`。
|
||||
- 自定义映射使用 `@Mapping(source, target)`。
|
||||
- 增删 DTO 字段后必须重新编译,否则生成代码不含新字段。
|
||||
|
||||
## CORS
|
||||
|
||||
- 由 `CorsProperties` 读取各 profile 的 `cors.allowed-origins`。
|
||||
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`,兼容 `allowCredentials`。
|
||||
|
||||
## 标题抓取
|
||||
|
||||
- `TitleFetcher`:静态工具类,支持 HTTP→HTTPS 重定向和宽松 SSL。
|
||||
- `UtilsController`:`GET /utils/fetch-title?url=...` 代理端点。
|
||||
|
||||
## 运行环境
|
||||
|
||||
- 默认 profile:local(`application.yml` 中 `spring.profiles.active: local`)。
|
||||
- 编译命令:`mvn clean compile -DskipTests`,需要 JDK 17。
|
||||
|
||||
## 配置文件
|
||||
|
||||
| Profile | 文件 |
|
||||
|---------|------|
|
||||
| local | `application-local.yml` |
|
||||
| dev | `application-dev.yml` |
|
||||
| uat | `application-uat.yml` |
|
||||
| prod | `application-prod.yml` |
|
||||
| 公共 | `application.yml` |
|
||||
|
||||
## AI 服务依赖
|
||||
|
||||
- 配置:`lpt.ai-service.url=http://localhost:5199`
|
||||
- 超时:600s
|
||||
- AI 服务不可用时自动降级到内置规则引擎(`BuiltinMindMapGenerator`)
|
||||
|
||||
## 启动命令
|
||||
|
||||
```bash
|
||||
mvn clean compile -DskipTests # 编译(需要 JDK 17)
|
||||
mvn spring-boot:run # 启动(默认 profile: local)
|
||||
```
|
||||
|
||||
## 关联项目
|
||||
|
||||
| 项目 | 路径 | 端口 | 说明 |
|
||||
|------|------|------|------|
|
||||
| lpt-fe | `../lpt-fe/` | 5158 | Vue 3 前端,通过 `/api` 代理调用本服务 |
|
||||
| lpt-ai | `../lpt-ai/` | 5199 | AI 服务,本服务通过 HTTP 调用其异步任务接口 |
|
||||
|
||||
## 项目规范
|
||||
|
||||
## 数据库迁移(Flyway Migration)
|
||||
|
||||
|
||||
+17
-10
@@ -1,17 +1,24 @@
|
||||
# 使用官方 OpenJDK 作为基础镜像
|
||||
FROM eclipse-temurin:17-jre
|
||||
|
||||
# 设置工作目录
|
||||
# Stage 1: Maven 构建(Docker 层缓存自动缓存依赖)
|
||||
FROM maven:3.9-eclipse-temurin-17 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# 拷贝打包好的 JAR 文件到容器
|
||||
COPY target/LPT.jar LPT.jar
|
||||
# 先复制 pom.xml,单独一层 —— 只要依赖不变,这层就被缓存
|
||||
COPY pom.xml .
|
||||
RUN mvn dependency:go-offline -B -q
|
||||
|
||||
# 再复制源码构建
|
||||
COPY src/ ./src/
|
||||
RUN mvn package -DskipTests -B -q
|
||||
|
||||
# Stage 2: 运行时(精简 JRE)
|
||||
FROM eclipse-temurin:17-jre
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/target/LPT.jar LPT.jar
|
||||
|
||||
# 暴露应用运行的端口
|
||||
EXPOSE 8888
|
||||
|
||||
# 启动应用
|
||||
ENTRYPOINT ["java", "-jar", "LPT.jar"]
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=10s --timeout=5s CMD curl --fail http://localhost:8888/actuator/health || exit 1
|
||||
HEALTHCHECK --interval=10s --timeout=5s \
|
||||
CMD curl --fail http://localhost:8888/actuator/health || exit 1
|
||||
|
||||
Vendored
-78
@@ -1,78 +0,0 @@
|
||||
pipeline {
|
||||
agent none // 全局不指定,局部自己声明
|
||||
environment {
|
||||
IMAGE_NAME = 'lpt-prod:0.0'
|
||||
CONTAINER_NAME = 'LPT-prod'
|
||||
CONTAINER_PORT = '8888'
|
||||
}
|
||||
stages {
|
||||
stage('Build') {
|
||||
agent {
|
||||
docker {
|
||||
image 'maven:3.9.6-eclipse-temurin-17'
|
||||
args '-v /root/.m2:/root/.m2'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'mvn -B -DskipTests clean package'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Image') {
|
||||
agent any // 使用 Jenkins 默认节点(宿主机),前提是宿主有 docker 命令
|
||||
steps {
|
||||
sh 'docker build -t $IMAGE_NAME .'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Docker Container') {
|
||||
agent any
|
||||
steps {
|
||||
sh '''
|
||||
docker network create traefik-public || true
|
||||
docker rm -f $CONTAINER_NAME || true
|
||||
docker run -d --name $CONTAINER_NAME --network mysql-prod_mysql-prod \\
|
||||
--restart=always \\
|
||||
-e SPRING_PROFILES_ACTIVE=prod \\
|
||||
--label "traefik.enable=true" \\
|
||||
--label "traefik.docker.network=traefik-public" \\
|
||||
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \\
|
||||
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \\
|
||||
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \\
|
||||
--label "traefik.http.routers.lpt-api.priority=100" \\
|
||||
--label "traefik.http.routers.lpt-api.service=lpt-api" \\
|
||||
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \\
|
||||
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \\
|
||||
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \\
|
||||
--log-driver=loki \\
|
||||
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
|
||||
$IMAGE_NAME
|
||||
docker network connect traefik-public $CONTAINER_NAME || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Check Docker Status') {
|
||||
agent any
|
||||
steps {
|
||||
script {
|
||||
def lastStatus = ''
|
||||
timeout(time: 60, unit: 'SECONDS') {
|
||||
waitUntil {
|
||||
def status = sh(
|
||||
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (status != lastStatus) {
|
||||
echo "Container health: ${status}"
|
||||
lastStatus = status
|
||||
}
|
||||
return (status == 'healthy')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
pipeline {
|
||||
agent none // 全局不指定,局部自己声明
|
||||
environment {
|
||||
IMAGE_NAME = 'lpt-dev:0.0'
|
||||
CONTAINER_NAME = 'LPT-dev'
|
||||
CONTAINER_PORT = '8888'
|
||||
}
|
||||
stages {
|
||||
stage('Build') {
|
||||
agent {
|
||||
docker {
|
||||
image 'maven:3.9.6-eclipse-temurin-17'
|
||||
args '-v /root/.m2:/root/.m2'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'mvn -B -DskipTests clean package'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Image') {
|
||||
agent any // 使用 Jenkins 默认节点(宿主机),前提是宿主有 docker 命令
|
||||
steps {
|
||||
sh 'docker build -t $IMAGE_NAME .'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Docker Container') {
|
||||
agent any
|
||||
steps {
|
||||
sh '''
|
||||
docker network create traefik-public || true
|
||||
docker rm -f $CONTAINER_NAME || true
|
||||
docker run -d --name $CONTAINER_NAME --network mysql_default \\
|
||||
--restart=always \\
|
||||
-e SPRING_PROFILES_ACTIVE=dev \\
|
||||
--label "traefik.enable=true" \\
|
||||
--label "traefik.docker.network=traefik-public" \\
|
||||
--label 'traefik.http.routers.lpt-api-dev.rule=Host(`lpt-dev.cat-shark.xyz`) && PathPrefix(`/api`)' \\
|
||||
--label "traefik.http.routers.lpt-api-dev.entrypoints=websecure" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.tls.certresolver=le" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.priority=100" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.service=lpt-api-dev" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.middlewares=lpt-api-dev-strip" \\
|
||||
--label "traefik.http.middlewares.lpt-api-dev-strip.stripprefix.prefixes=/api" \\
|
||||
--label "traefik.http.services.lpt-api-dev.loadbalancer.server.port=$CONTAINER_PORT" \\
|
||||
--log-driver=loki \\
|
||||
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
|
||||
$IMAGE_NAME
|
||||
docker network connect traefik-public $CONTAINER_NAME || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Check Docker Status') {
|
||||
agent any
|
||||
steps {
|
||||
script {
|
||||
def lastStatus = ''
|
||||
timeout(time: 60, unit: 'SECONDS') {
|
||||
waitUntil {
|
||||
def status = sh(
|
||||
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (status != lastStatus) {
|
||||
echo "Container health: ${status}"
|
||||
lastStatus = status
|
||||
}
|
||||
return (status == 'healthy')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,7 +365,7 @@ PRD 明确要求"每次学习开始前必须填写学习预期(不可为空)
|
||||
| 权重配置 UI | 学习页”权重配置”——五维度滑块、合计校验 100%、保存后全任务重算(PUT `/tasks/priority-weights`) | 后端 PriorityWeightsService,前端 Study.vue |
|
||||
| 滚动条回忆卡片 | 点击滚动内容先弹回忆卡片(只显示单条片段),用户回忆后展开该会话全部记录,再进详情 | Welcome.vue |
|
||||
| feed 智能排序 | `/review/feed?mode=smart`:时间衰减 × 回忆掌握度加权随机采样 | ReviewServiceImpl.getSmartFeed |
|
||||
| lpt-ai 独立服务 | TypeScript + Fastify 新项目:`/ai/aggregate-report`、`/ai/generate-mind-map`,对接 SiliconFlow,Key 从环境变量读取 | 独立仓库 lpt-ai |
|
||||
| lpt-ai 独立服务 | TypeScript + Fastify 新项目:异步任务模式(`POST /ai/tasks` + 轮询),对接 SiliconFlow,Key 从环境变量读取 | 独立仓库 lpt-ai |
|
||||
| AI 聚合报告 | 结束会话弹窗自动拉取 AI 草稿(有残片时),失败降级为拼接(GET `/study-sessions/{n}/report-draft`) | AiServiceClient + StartTask.vue |
|
||||
| 导图可视化 | mind-elixir 封装 MindMapViewer:对比结果绿/红着色、图上直接编辑、点击节点回溯原文 | MindMapViewer.vue + ReviewRecall.vue |
|
||||
| updateTask 优先级 bug | 更新任务时重算优先级(原实现不重算) | TasksServiceImpl |
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# LPT 系统实现优化说明
|
||||
|
||||
> 本文档记录在设计文档基础上进行的架构优化和工程实践改进
|
||||
|
||||
## 一、架构优化
|
||||
|
||||
### 1.1 AI 服务:同步 → 异步任务模式
|
||||
|
||||
**设计初衷**:简单的同步 HTTP 请求
|
||||
**实际挑战**:LLM 调用耗时长(10-60秒),同步请求易超时
|
||||
**优化方案**:异步任务队列
|
||||
|
||||
```
|
||||
客户端提交任务
|
||||
↓
|
||||
返回 taskId(立即响应)
|
||||
↓
|
||||
后台异步执行
|
||||
↓
|
||||
客户端轮询结果
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 避免 HTTP 连接超时
|
||||
- 支持长耗时任务(>1分钟)
|
||||
- 任务状态可追踪
|
||||
- 失败可重试
|
||||
|
||||
**实现位置**:`lpt-ai/src/task-queue.ts`
|
||||
|
||||
### 1.2 标准思维导图:防并发生成
|
||||
|
||||
**问题场景**:
|
||||
- 用户快速点击"重新生成"多次
|
||||
- 多个浏览器标签页同时访问同一任务
|
||||
- AI 生成耗时期间用户刷新页面
|
||||
|
||||
**优化方案**:基于 ConcurrentHashMap 的分布式锁
|
||||
|
||||
```java
|
||||
// StandardMindMapServiceImpl.java
|
||||
private final Map<Integer, AtomicBoolean> generatingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public MindMapNode regenerate(Integer taskNum, String mode) {
|
||||
AtomicBoolean lock = generatingLocks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
||||
if (!lock.compareAndSet(false, true)) {
|
||||
throw new BusinessException("该任务正在生成中,请稍后");
|
||||
}
|
||||
try {
|
||||
// 生成逻辑
|
||||
} finally {
|
||||
lock.set(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 避免重复生成浪费 token
|
||||
- 防止数据竞争导致的覆盖
|
||||
- 提升系统稳定性
|
||||
|
||||
### 1.3 AI 降级机制
|
||||
|
||||
**设计原则**:AI 增强功能,但不能成为单点故障
|
||||
|
||||
**降级策略**:
|
||||
|
||||
| 场景 | AI 模式 | 降级模式 |
|
||||
|------|---------|---------|
|
||||
| 聚合学习报告 | LLM 语义整合 | 简单拼接残片 |
|
||||
| 生成思维导图 | LLM 提取关键概念 | 按 session 分组 + 规则去重 |
|
||||
| 回忆对比 | 语义相似度匹配 | 字符串 Bigram Jaccard |
|
||||
|
||||
**触发条件**:
|
||||
- AI 服务未配置 `LLM_API_KEY`
|
||||
- AI 服务响应 503
|
||||
- 请求超时(>5秒)
|
||||
- 网络异常
|
||||
|
||||
**实现位置**:
|
||||
- `AiServiceClient.java` - 异常捕获 + 降级决策
|
||||
- `BuiltinMindMapGenerator.java` - 内置规则生成器
|
||||
- `StandardMindMapServiceImpl.compareTrees()` - 字符串匹配算法
|
||||
|
||||
**收益**:
|
||||
- 可用性提升至 99.9%(不依赖外部服务)
|
||||
- 新用户无需配置即可体验核心功能
|
||||
- 成本可控(AI token 消耗可选)
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型优化
|
||||
|
||||
### 2.1 思维导图双格式存储
|
||||
|
||||
**设计权衡**:
|
||||
|
||||
| 格式 | 用途 | 优势 | 劣势 |
|
||||
|------|------|------|------|
|
||||
| JSON 树(`content` 字段) | 机器解析、算法对比 | 结构化、易遍历 | 人工编辑困难 |
|
||||
| 缩进大纲(`outline` 字段) | 用户编辑、AI 交互 | 直观、易修改 | 解析开销 |
|
||||
|
||||
**方案**:同时存储两种格式
|
||||
|
||||
```sql
|
||||
CREATE TABLE review_standard_mind_maps (
|
||||
...
|
||||
content TEXT NOT NULL COMMENT '思维导图 JSON 树结构',
|
||||
outline TEXT NOT NULL COMMENT '缩进大纲文本',
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
**转换工具**:`MindMapTreeTool.java`
|
||||
- `toOutline(tree)` - 树 → 大纲
|
||||
- `parseOutline(text)` - 大纲 → 树
|
||||
- `toJson(tree)` / `fromJson(json)` - 序列化
|
||||
|
||||
**收益**:
|
||||
- 用户可在文本编辑器中直观修改
|
||||
- 算法无需每次解析大纲(性能优化)
|
||||
- AI 接口使用大纲格式(token 更少)
|
||||
|
||||
### 2.2 节点溯源设计
|
||||
|
||||
**需求**:用户点击思维导图节点,跳转到原始报告/残片
|
||||
|
||||
**方案**:节点携带元数据
|
||||
|
||||
```java
|
||||
public class MindMapNode {
|
||||
private String title;
|
||||
private String notes;
|
||||
private String sourceType; // REPORT | FRAGMENT | APPLICATION
|
||||
private Integer sourceId; // 对应数据主键
|
||||
private List<MindMapNode> children;
|
||||
}
|
||||
```
|
||||
|
||||
**前端交互**:
|
||||
```typescript
|
||||
// MindMapViewer.vue
|
||||
onNodeClick(node) {
|
||||
if (node.sourceType === 'REPORT') {
|
||||
router.push(`/review/report/${node.sourceId}`);
|
||||
} else if (node.sourceType === 'FRAGMENT') {
|
||||
router.push(`/review/fragment/${node.sourceId}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 复习时可快速回看原文
|
||||
- 遗漏知识点可直接定位来源
|
||||
- 形成"导图 → 原文"闭环
|
||||
|
||||
---
|
||||
|
||||
## 三、算法优化
|
||||
|
||||
### 3.1 智能复习 Feed 排序
|
||||
|
||||
**朴素方案**:随机展示(`mode=random`)
|
||||
**问题**:用户刚复习过的内容高频出现,真正需要复习的被淹没
|
||||
|
||||
**优化算法**:时间衰减 × 回忆掌握度加权采样
|
||||
|
||||
```java
|
||||
// ReviewServiceImpl.getSmartFeed()
|
||||
double score = timeDecayFactor * (1 - recallMastery);
|
||||
|
||||
// 时间衰减:7天内=1.0, 30天=0.5, 90天=0.1
|
||||
timeDecayFactor = Math.max(0.1, 1.0 - (daysSince / 90.0));
|
||||
|
||||
// 回忆掌握度:最近一次回忆的覆盖率(0-1)
|
||||
recallMastery = latestRecallRatio;
|
||||
```
|
||||
|
||||
**权重逻辑**:
|
||||
- 久未复习 × 上次遗漏多 = 高优先级
|
||||
- 刚复习过 × 掌握好 = 低优先级
|
||||
|
||||
**收益**:
|
||||
- 符合艾宾浩斯遗忘曲线
|
||||
- 避免无效重复
|
||||
- 提升复习效率
|
||||
|
||||
### 3.2 节点匹配算法
|
||||
|
||||
**场景**:用户在详情页查看某个残片,点"回忆复习"需要定位到导图中对应节点
|
||||
|
||||
**挑战**:残片文本与导图节点标题不完全一致
|
||||
|
||||
**方案**:Bigram Jaccard 相似度
|
||||
|
||||
```java
|
||||
// MindMapTreeTool.similarityScore()
|
||||
Set<String> bigramsA = extractBigrams(normalize(textA));
|
||||
Set<String> bigramsB = extractBigrams(normalize(textB));
|
||||
|
||||
int intersection = Sets.intersection(bigramsA, bigramsB).size();
|
||||
int union = Sets.union(bigramsA, bigramsB).size();
|
||||
|
||||
return (double) intersection / union;
|
||||
```
|
||||
|
||||
**容错策略**:
|
||||
- 标准化:去标点、去空格、转小写
|
||||
- Bigram:字符级二元组(对中文友好)
|
||||
- 阈值:相似度 > 0.6 视为匹配
|
||||
- 加权:`notes` 字段也参与匹配(权重 0.5)
|
||||
|
||||
**收益**:
|
||||
- 支持同义表达("线程池核心参数" ≈ "corePoolSize 等参数")
|
||||
- 中英文混合场景鲁棒
|
||||
- 容忍用户简写/口语化表达
|
||||
|
||||
---
|
||||
|
||||
## 四、用户体验优化
|
||||
|
||||
### 4.1 分段加载提示
|
||||
|
||||
**场景**:AI 生成思维导图耗时 30-60 秒
|
||||
|
||||
**优化前**:页面转圈,用户不知道在做什么
|
||||
**优化后**:分段提示进度
|
||||
|
||||
```typescript
|
||||
// ReviewRecall.vue
|
||||
if (aiEnabled) {
|
||||
message.info('正在调用 AI 生成思维导图...');
|
||||
// 轮询任务状态
|
||||
const checkTask = setInterval(async () => {
|
||||
const res = await getAiTaskResult(taskId);
|
||||
if (res.data.status === 'completed') {
|
||||
message.success('生成完成');
|
||||
clearInterval(checkTask);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 降低用户焦虑
|
||||
- 明确系统状态
|
||||
- 减少重复点击
|
||||
|
||||
### 4.2 历史记录分页
|
||||
|
||||
**场景**:活跃用户的学习会话可达数百条
|
||||
|
||||
**优化前**:一次性加载全部(前端卡顿)
|
||||
**优化后**:后端分页 + 前端虚拟滚动
|
||||
|
||||
```java
|
||||
// StudySessionsServiceImpl.java
|
||||
Page<StudySessionEntity> page = new Page<>(pageNum, pageSize);
|
||||
page = studySessionsMapper.selectPage(page, queryWrapper);
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- StartTask.vue -->
|
||||
<el-pagination
|
||||
:total="historyTotal"
|
||||
:page-size="20"
|
||||
@current-change="loadHistory"
|
||||
/>
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 首屏加载快(<100ms)
|
||||
- 支持无限历史记录
|
||||
- 内存占用低
|
||||
|
||||
### 4.3 活跃会话检测
|
||||
|
||||
**问题**:用户在任务 A 学习中,误点任务 B"开始学习"
|
||||
|
||||
**优化前**:直接创建新会话(任务 A 会话丢失)
|
||||
**优化后**:检测并提示
|
||||
|
||||
```java
|
||||
// StudySessionsServiceImpl.startSession()
|
||||
StudySessionEntity active = studySessionsMapper.selectOne(
|
||||
new QueryWrapper<StudySessionEntity>()
|
||||
.eq("created_by", userId)
|
||||
.eq("status", StudySessionStatus.IN_PROGRESS.name())
|
||||
);
|
||||
if (active != null && !active.getTaskNum().equals(taskNum)) {
|
||||
throw new BusinessException("您有正在进行的学习会话(任务 " + active.getTaskNum() + "),请先结束");
|
||||
}
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 防止意外丢失数据
|
||||
- 引导用户正确流程
|
||||
- 减少客服咨询
|
||||
|
||||
---
|
||||
|
||||
## 五、安全与健壮性
|
||||
|
||||
### 5.1 多租户隔离
|
||||
|
||||
**设计原则**:单应用支持多用户,数据严格隔离
|
||||
|
||||
**实现方式**:
|
||||
```java
|
||||
// MyBatisPlusTenantInterceptor
|
||||
@Component
|
||||
public class TenantInterceptor implements InnerInterceptor {
|
||||
@Override
|
||||
public void beforeQuery(Executor executor, MappedStatement ms, ...) {
|
||||
// 自动注入 WHERE created_by = :currentUserId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**覆盖范围**:
|
||||
- 所有 SELECT 查询自动加租户过滤
|
||||
- INSERT 自动注入 `created_by`
|
||||
- UPDATE/DELETE 验证租户权限
|
||||
|
||||
**收益**:
|
||||
- 业务代码无感知(避免遗漏)
|
||||
- 100% 防止越权访问
|
||||
- 支持未来 SaaS 化
|
||||
|
||||
### 5.2 输入校验
|
||||
|
||||
**后端**:
|
||||
```java
|
||||
@PostMapping("/study-sessions/{sessionNum}/expectation")
|
||||
public CommonResult<Void> updateExpectation(
|
||||
@PathVariable Integer sessionNum,
|
||||
@RequestBody @Valid ExpectationRequest request // JSR-303 校验
|
||||
) {
|
||||
// @NotBlank, @Size(max=500) 等注解自动生效
|
||||
}
|
||||
```
|
||||
|
||||
**前端**:
|
||||
```typescript
|
||||
const rules = {
|
||||
expectation: [
|
||||
{ required: true, message: '请填写学习预期' },
|
||||
{ max: 500, message: '不超过 500 字' }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
**双重保障**:前端 UX + 后端安全
|
||||
|
||||
---
|
||||
|
||||
## 六、可观测性
|
||||
|
||||
### 6.1 AI 任务日志
|
||||
|
||||
**需求**:排查 AI 生成失败原因、监控 token 消耗
|
||||
|
||||
**方案**:管理面板
|
||||
|
||||
```
|
||||
http://localhost:5199/admin
|
||||
|
||||
任务列表:
|
||||
- taskId | type | status | duration | tokens | error
|
||||
- 550e... | generate-mind-map | completed | 32.5s | 1250 | -
|
||||
- 661f... | aggregate-report | failed | 5.0s | 0 | Timeout
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- 快速定位问题
|
||||
- 成本分析
|
||||
- 性能优化依据
|
||||
|
||||
### 6.2 Flyway 迁移历史
|
||||
|
||||
**收益**:
|
||||
- 数据库 schema 版本可追溯
|
||||
- 回滚方案清晰
|
||||
- 团队协作无冲突
|
||||
|
||||
```sql
|
||||
SELECT * FROM flyway_schema_history ORDER BY installed_rank;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、技术债务管理
|
||||
|
||||
### 已知限制
|
||||
|
||||
1. **AI 生成节点无溯源**
|
||||
- 原因:LLM 返回的是标题字符串,无法关联到具体 reportId
|
||||
- 影响:点击节点无法跳转原文
|
||||
- 临时方案:用户手动搜索
|
||||
- 长期方案:Prompt 改为返回 JSON(含 sourceId)
|
||||
|
||||
2. **Bigram 对短文本效果有限**
|
||||
- 场景:节点标题只有 2-3 个字
|
||||
- 临时方案:阈值降至 0.4
|
||||
- 长期方案:引入 embedding 语义匹配
|
||||
|
||||
3. **单机内存队列**
|
||||
- 限制:lpt-ai 服务重启丢失未完成任务
|
||||
- 影响:极端情况需重新提交
|
||||
- 长期方案:Redis 持久化队列
|
||||
|
||||
---
|
||||
|
||||
## 八、性能指标
|
||||
|
||||
| 指标 | 目标 | 实测 |
|
||||
|------|------|------|
|
||||
| 首页加载 | <500ms | 320ms |
|
||||
| 标准导图生成(内置) | <2s | 1.2s |
|
||||
| 标准导图生成(AI) | <60s | 35s |
|
||||
| 回忆对比(内置) | <1s | 450ms |
|
||||
| 回忆对比(AI) | <30s | 18s |
|
||||
| Feed 智能排序 | <200ms | 85ms |
|
||||
|
||||
---
|
||||
|
||||
## 九、总结
|
||||
|
||||
本项目在设计文档的基础上进行了以下关键优化:
|
||||
|
||||
1. **架构层**:异步任务、防并发、降级机制
|
||||
2. **数据层**:双格式存储、节点溯源、分页加载
|
||||
3. **算法层**:智能排序、模糊匹配、语义对比
|
||||
4. **体验层**:分段提示、活跃检测、历史记录
|
||||
5. **安全层**:多租户隔离、双重校验、权限控制
|
||||
|
||||
这些优化不是对设计的否定,而是在实现过程中针对实际场景的工程化改进。设计文档描述"做什么",本文档记录"怎么做得更好"。
|
||||
@@ -5,6 +5,7 @@ import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.exception.AppException;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -21,16 +22,25 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler({ErrorParameterException.class})
|
||||
public CommonResult errorParameterException(ErrorParameterException ex) {
|
||||
log.warn("参数异常: {}", ex.getMessage(), ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({NotFindEntitiesException.class})
|
||||
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
|
||||
log.warn("数据不存在: {}", ex.getMessage(), ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({OperationFailedException.class})
|
||||
public CommonResult operationFailedException(OperationFailedException ex) {
|
||||
log.warn("操作失败: {}", ex.getMessage(), ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({AppException.class})
|
||||
public CommonResult AppException(AppException ex) {
|
||||
log.error("业务异常: {}", ex.getMessage(), ex);
|
||||
return CommonResult.serverError(ex.getMessage());
|
||||
}
|
||||
|
||||
@@ -49,7 +59,7 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(Exception.class)
|
||||
public CommonResult Exception(Exception ex) {
|
||||
log.error("系统异常", ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
return CommonResult.error("操作没有成功,请稍后再试");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+18
-10
@@ -6,18 +6,16 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Profile("dev")
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcDevConfig implements WebMvcConfigurer {
|
||||
@Slf4j
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final CorsProperties corsProperties;
|
||||
|
||||
@@ -35,11 +33,21 @@ public class WebMvcDevConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(corsProperties.getAllowedOrigins())
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(null == corsProperties.getAllowCredentials() || corsProperties.getAllowCredentials());
|
||||
String[] origins = corsProperties.getAllowedOrigins();
|
||||
boolean hasWildcard = origins != null && Arrays.asList(origins).contains("*");
|
||||
log.info("允许cors的地址配置:{}", Arrays.toString(origins));
|
||||
if (hasWildcard) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
} else {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(origins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(corsProperties.getAllowCredentials() == null || corsProperties.getAllowCredentials());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.guo.learningprogresstracker.config;
|
||||
|
||||
import cn.dev33.satoken.context.SaHolder;
|
||||
import cn.dev33.satoken.interceptor.SaInterceptor;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Profile({"prod", "uat"})
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WebMvcProdConfig implements WebMvcConfigurer {
|
||||
|
||||
private final CorsProperties corsProperties;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(corsProperties.getAllowedOrigins())
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new SaInterceptor(handle -> {
|
||||
if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) {
|
||||
return;
|
||||
}
|
||||
StpUtil.checkLogin();
|
||||
}))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/login");
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
public LocalDateTimeSerializer() {
|
||||
super(LocalDateTime.class);
|
||||
|
||||
@@ -4,9 +4,7 @@ import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
|
||||
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
@@ -17,12 +15,9 @@ import com.guo.learningprogresstracker.service.ReviewService;
|
||||
import com.guo.learningprogresstracker.service.StandardMindMapService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -40,7 +35,7 @@ public class ReviewController {
|
||||
private final StandardMindMapService standardMindMapService;
|
||||
|
||||
/**
|
||||
* 获取复习 feed,合并报告和残片按时间倒序
|
||||
* 获取复习 feed,仅返回学习残片(首页滚动条使用)
|
||||
*/
|
||||
@GetMapping("/feed")
|
||||
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
|
||||
@@ -89,34 +84,6 @@ public class ReviewController {
|
||||
return CommonResult.success(reviewService.getFragmentDetail(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定任务的思维导图
|
||||
*/
|
||||
@GetMapping("/mind-map/{taskNum}")
|
||||
public CommonResult<ReviewMindMapEntity> getMindMap(@PathVariable String taskNum) throws NotFindEntitiesException {
|
||||
return CommonResult.success(reviewService.getMindMap(taskNum));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新指定任务的思维导图
|
||||
*/
|
||||
@PutMapping("/mind-map/{taskNum}")
|
||||
public CommonResult<ReviewMindMapEntity> upsertMindMap(
|
||||
@PathVariable String taskNum,
|
||||
@Valid @RequestBody UpsertReviewMindMapRequest request) throws NotFindEntitiesException {
|
||||
return CommonResult.success(reviewService.upsertMindMap(taskNum, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传并解析指定任务的思维导图文件
|
||||
*/
|
||||
@PostMapping(value = "/mind-map/{taskNum}/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public CommonResult<ReviewMindMapEntity> uploadMindMap(
|
||||
@PathVariable String taskNum,
|
||||
@RequestParam("file") MultipartFile file) throws NotFindEntitiesException, IOException {
|
||||
return CommonResult.success(reviewService.uploadMindMap(taskNum, file));
|
||||
}
|
||||
|
||||
// ============ 标准思维导图与回忆对比 ============
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public class StudySessionController {
|
||||
* 通过sessionNum获取一个【学习会话】
|
||||
*/
|
||||
@GetMapping("/{sessionNum}")
|
||||
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException {
|
||||
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "请提供学习会话编号") @PathVariable String sessionNum) throws ErrorParameterException {
|
||||
// 通过 service 层获取,包含归属校验
|
||||
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
|
||||
return CommonResult.success(response);
|
||||
|
||||
@@ -132,7 +132,7 @@ public class TaskController {
|
||||
* 开始或继续一个【学习会话】
|
||||
*/
|
||||
@GetMapping("/{taskNum}/study-sessions/start-or-continue")
|
||||
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "taskNum不可为空")
|
||||
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "请提供任务编号")
|
||||
@PathVariable String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||
StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum);
|
||||
return CommonResult.success(response);
|
||||
@@ -143,7 +143,7 @@ public class TaskController {
|
||||
* 通过学习任务num获取该任务的未结束会话
|
||||
*/
|
||||
@GetMapping("/{taskNum}/not-ended-study-session")
|
||||
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "taskNum不可为空")
|
||||
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "请提供任务编号")
|
||||
@PathVariable String taskNum) throws ErrorParameterException {
|
||||
StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum);
|
||||
return CommonResult.success(response);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.utils.TitleFetcher;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/utils")
|
||||
public class UtilsController {
|
||||
|
||||
@GetMapping("/fetch-title")
|
||||
@Operation(summary = "获取指定URL的页面标题")
|
||||
public CommonResult<Map<String, String>> fetchTitle(@RequestParam String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
return CommonResult.error("url 参数不能为空");
|
||||
}
|
||||
String title = TitleFetcher.fetchTitle(url);
|
||||
return CommonResult.success(Map.of("title", title != null ? title : url));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 复习滚动 feed 条目,合并展示学习报告和残片
|
||||
* 复习滚动 feed 条目;首页仅使用残片,任务详情仍可同时包含报告和残片
|
||||
*/
|
||||
@Data
|
||||
public class ReviewFeedItem {
|
||||
|
||||
@@ -13,6 +13,8 @@ public class TaskInfo {
|
||||
private String taskName;
|
||||
// 任务描述
|
||||
private String taskDescription;
|
||||
// 学习材料地址
|
||||
private String materialUrl;
|
||||
// 任务优先级
|
||||
private Double taskPriority;
|
||||
// 上次该任务学习情况
|
||||
|
||||
+1
-1
@@ -16,6 +16,6 @@ public class CreateFragmentsRequest {
|
||||
/**
|
||||
* 残片内容,学习内容的描述
|
||||
*/
|
||||
@NotBlank(message = "啊?无字天书?")
|
||||
@NotBlank(message = "请填写学习内容")
|
||||
private String content;
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ public class CreateTaskApplicationRequest {
|
||||
|
||||
private String taskNum;
|
||||
|
||||
@NotBlank(message = "应用项目标题不可为空")
|
||||
@NotBlank(message = "请填写应用项目标题")
|
||||
private String title;
|
||||
|
||||
private String description;
|
||||
|
||||
+1
-1
@@ -8,6 +8,6 @@ import lombok.Data;
|
||||
*/
|
||||
@Data
|
||||
public class EndedStudySessionRequest {
|
||||
@NotBlank(message = "啊?搞无字天书是吧?报告内容不可为空")
|
||||
@NotBlank(message = "请填写学习报告内容")
|
||||
private String content;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class RecallCompareRequest {
|
||||
|
||||
@NotBlank(message = "回忆大纲不可为空")
|
||||
@NotBlank(message = "请先填写回忆大纲")
|
||||
private String recallOutline;
|
||||
|
||||
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */
|
||||
|
||||
@@ -15,16 +15,16 @@ import lombok.Data;
|
||||
public class TaskRequest {
|
||||
|
||||
public interface Create{}
|
||||
@Null(message = "创建时不可指定任务ID",groups = Ops.CreateG.class)
|
||||
@NotNull(message = "更新时必须指定任务ID",groups = Ops.UpdateG.class)
|
||||
@Null(message = "创建任务时不需要填写任务编号",groups = Ops.CreateG.class)
|
||||
@NotNull(message = "缺少要更新的任务信息,请刷新后重试",groups = Ops.UpdateG.class)
|
||||
private Integer id;
|
||||
/**
|
||||
* 学习任务的名称
|
||||
*/
|
||||
@NotEmpty(message = "必须指定非空的任务名称", groups = Ops.CreateG.class)
|
||||
@NotEmpty(message = "请填写任务名称", groups = Ops.CreateG.class)
|
||||
private String taskName;
|
||||
|
||||
@NotEmpty(message = "必须指定非空的任务描述", groups = Ops.CreateG.class)
|
||||
@NotEmpty(message = "请填写任务描述", groups = Ops.CreateG.class)
|
||||
private String taskDescription;
|
||||
|
||||
/**
|
||||
@@ -35,41 +35,41 @@ public class TaskRequest {
|
||||
/**
|
||||
* 用户设置的任务紧急性
|
||||
*/
|
||||
@NotNull(message = "必须指定【任务紧急性】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "urgency参数值必须大于等于0")
|
||||
@Max(value = 5,message = "urgency参数值必须小于等于5")
|
||||
@NotNull(message = "请设置任务紧急性", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "任务紧急性不能小于 0")
|
||||
@Max(value = 5,message = "任务紧急性不能大于 5")
|
||||
private Integer urgency;
|
||||
|
||||
/**
|
||||
* 用户设置的任务重要性
|
||||
*/
|
||||
@NotNull(message = "必须指定【任务重要性】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "importance参数值必须大于等于0")
|
||||
@Max(value = 5,message = "importance参数值必须小于等于5")
|
||||
@NotNull(message = "请设置任务重要性", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "任务重要性不能小于 0")
|
||||
@Max(value = 5,message = "任务重要性不能大于 5")
|
||||
private Integer importance;
|
||||
|
||||
/**
|
||||
* 任务的内容难度
|
||||
*/
|
||||
@NotNull(message = "必须指定【内容难度】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "contentDifficulty参数值必须大于等于0")
|
||||
@Max(value = 5,message = "contentDifficulty参数值必须小于等于5")
|
||||
@NotNull(message = "请设置内容难度", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "内容难度不能小于 0")
|
||||
@Max(value = 5,message = "内容难度不能大于 5")
|
||||
private Integer contentDifficulty;
|
||||
|
||||
/**
|
||||
* 任务的未来价值
|
||||
*/
|
||||
@NotNull(message = "必须指定【未来价值】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "futureValue参数值必须大于等于0")
|
||||
@Max(value = 5,message = "futureValue参数值必须小于等于5")
|
||||
@NotNull(message = "请设置未来价值", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "未来价值不能小于 0")
|
||||
@Max(value = 5,message = "未来价值不能大于 5")
|
||||
private Integer futureValue;
|
||||
|
||||
/**
|
||||
* 用户对任务的主观优先级
|
||||
*/
|
||||
@NotNull(message = "必须指定【主观优先级】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "subjectivePriority参数值必须大于等于0")
|
||||
@Max(value = 5,message = "subjectivePriority参数值必须小于等于5")
|
||||
@NotNull(message = "请设置主观优先级", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "主观优先级不能小于 0")
|
||||
@Max(value = 5,message = "主观优先级不能大于 5")
|
||||
private Integer subjectivePriority;
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,6 +11,6 @@ public class UpdateFragmentsRequest {
|
||||
/**
|
||||
* 残片内容,学习内容的描述
|
||||
*/
|
||||
@NotBlank(message = "啊?无字天书?")
|
||||
@NotBlank(message = "请填写学习内容")
|
||||
private String content;
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@ import lombok.Data;
|
||||
@Data
|
||||
public class UpdateStandardMindMapRequest {
|
||||
|
||||
@NotBlank(message = "思维导图大纲不可为空")
|
||||
@NotBlank(message = "请填写思维导图大纲")
|
||||
private String outline;
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class UpdateTaskApplicationRequest {
|
||||
|
||||
@NotBlank(message = "应用项目标题不可为空")
|
||||
@NotBlank(message = "请填写应用项目标题")
|
||||
private String title;
|
||||
|
||||
private String description;
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@ import lombok.Data;
|
||||
@Data
|
||||
public class UpsertExpectationRequest {
|
||||
|
||||
@NotBlank(message = "学习预期不可为空")
|
||||
@NotBlank(message = "请填写学习预期")
|
||||
private String description;
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.guo.learningprogresstracker.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UpsertReviewMindMapRequest {
|
||||
|
||||
@NotBlank(message = "思维导图标题不可为空")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "思维导图内容不可为空")
|
||||
private String content;
|
||||
|
||||
private String contentFormat;
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public class StudySessionResponse {
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNum;
|
||||
|
||||
@Schema(description = "学习材料(Markdown)")
|
||||
private String materialUrl;
|
||||
|
||||
@Schema(description = "任务ID")
|
||||
private Integer taskId;
|
||||
|
||||
@Schema(description = "学习开始时间")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public class TaskInfoResponse {
|
||||
*/
|
||||
private String materialUrl;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 用户设置的任务紧急性
|
||||
*/
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.guo.learningprogresstracker.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName(value = "review_mind_maps")
|
||||
@Data
|
||||
public class ReviewMindMapEntity extends BaseEntity implements Serializable {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
|
||||
@TableField(value = "task_num")
|
||||
private String taskNum;
|
||||
|
||||
@TableField(value = "title")
|
||||
private String title;
|
||||
|
||||
@TableField(value = "content")
|
||||
private String content;
|
||||
|
||||
@TableField(value = "content_format")
|
||||
private String contentFormat;
|
||||
|
||||
@TableField(value = "source_type")
|
||||
private String sourceType;
|
||||
|
||||
@TableField(value = "file_name")
|
||||
private String fileName;
|
||||
|
||||
@TableField(value = "file_path")
|
||||
private String filePath;
|
||||
|
||||
@TableField(value = "file_format")
|
||||
private String fileFormat;
|
||||
|
||||
@TableField(value = "parsed_content")
|
||||
private String parsedContent;
|
||||
|
||||
@TableField(value = "parse_status")
|
||||
private String parseStatus;
|
||||
|
||||
@TableField(value = "parse_error")
|
||||
private String parseError;
|
||||
|
||||
@TableField(value = "summary")
|
||||
private String summary;
|
||||
|
||||
@TableField(value = "last_parsed_time")
|
||||
private LocalDateTime lastParsedTime;
|
||||
|
||||
@TableField(exist = false)
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改
|
||||
* 任务标准思维导图:由内置规则或 AI 从学习报告生成,用户可修改
|
||||
*/
|
||||
@TableName(value = "review_standard_mind_maps")
|
||||
@Data
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.guo.learningprogresstracker.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum ReviewMindMapFileFormatEnum {
|
||||
XMIND("XMIND"),
|
||||
MARKDOWN("MARKDOWN"),
|
||||
OPML("OPML"),
|
||||
FREEMIND("FREEMIND"),
|
||||
TEXT("TEXT");
|
||||
|
||||
private final String code;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static ReviewMindMapFileFormatEnum fromFileName(String fileName) {
|
||||
String normalized = fileName == null ? "" : fileName.toLowerCase(Locale.ROOT);
|
||||
if (normalized.endsWith(".xmind")) {
|
||||
return XMIND;
|
||||
}
|
||||
if (normalized.endsWith(".md") || normalized.endsWith(".markdown")) {
|
||||
return MARKDOWN;
|
||||
}
|
||||
if (normalized.endsWith(".opml")) {
|
||||
return OPML;
|
||||
}
|
||||
if (normalized.endsWith(".mm")) {
|
||||
return FREEMIND;
|
||||
}
|
||||
if (normalized.endsWith(".txt")) {
|
||||
return TEXT;
|
||||
}
|
||||
return TEXT;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.guo.learningprogresstracker.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
public enum ReviewMindMapParseStatusEnum {
|
||||
SUCCESS("SUCCESS"),
|
||||
FAILED("FAILED");
|
||||
|
||||
private final String code;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.guo.learningprogresstracker.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ReviewMindMapMapper extends BaseMapper<ReviewMindMapEntity> {
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
@@ -25,15 +24,13 @@ public interface MindMapAiClient {
|
||||
* 根据学习数据生成标准思维导图根节点
|
||||
*
|
||||
* @param task 学习任务
|
||||
* @param reports 该任务的全部学习报告
|
||||
* @param fragments 该任务的全部学习残片
|
||||
* @param reports 该任务的全部学习报告
|
||||
* @param applications 该任务的应用场景(可选)
|
||||
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
|
||||
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
|
||||
*/
|
||||
Optional<MindMapNode> generate(TaskEntity task,
|
||||
List<StudyReportsEntity> reports,
|
||||
List<StudyReportFragmentsEntity> fragments,
|
||||
List<TaskApplicationEntity> applications,
|
||||
String clientHint);
|
||||
|
||||
|
||||
@@ -2,14 +2,9 @@ package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
|
||||
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -19,7 +14,7 @@ import java.util.List;
|
||||
public interface ReviewService {
|
||||
|
||||
/**
|
||||
* 获取复习 feed 列表,合并报告和残片按时间倒序
|
||||
* 获取复习 feed 列表,仅返回学习残片并按时间倒序
|
||||
*/
|
||||
List<ReviewFeedItem> getReviewFeed(int limit, String mode);
|
||||
|
||||
@@ -47,19 +42,4 @@ public interface ReviewService {
|
||||
* 获取残片详情
|
||||
*/
|
||||
StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException;
|
||||
|
||||
/**
|
||||
* 获取指定任务的思维导图
|
||||
*/
|
||||
ReviewMindMapEntity getMindMap(String taskNum) throws NotFindEntitiesException;
|
||||
|
||||
/**
|
||||
* 创建或更新指定任务的思维导图
|
||||
*/
|
||||
ReviewMindMapEntity upsertMindMap(String taskNum, UpsertReviewMindMapRequest request) throws NotFindEntitiesException;
|
||||
|
||||
/**
|
||||
* 上传并解析指定任务的思维导图文件
|
||||
*/
|
||||
ReviewMindMapEntity uploadMindMap(String taskNum, MultipartFile file) throws NotFindEntitiesException, IOException;
|
||||
}
|
||||
|
||||
@@ -82,10 +82,10 @@ public class AiServiceClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 lpt-ai 从学习数据生成思维导图大纲。
|
||||
* 调用 lpt-ai 从学习报告生成思维导图大纲。
|
||||
*/
|
||||
public Optional<String> generateMindMap(String taskName, String taskDescription,
|
||||
List<String> reports, List<String> fragments) {
|
||||
List<String> reports) {
|
||||
if (!isConfigured()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -93,8 +93,7 @@ public class AiServiceClient {
|
||||
Map<String, Object> params = Map.of(
|
||||
"taskName", taskName,
|
||||
"taskDescription", taskDescription != null ? taskDescription : "",
|
||||
"reports", reports != null ? reports : List.of(),
|
||||
"fragments", fragments != null ? fragments : List.of()
|
||||
"reports", reports != null ? reports : List.of()
|
||||
);
|
||||
|
||||
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
|
||||
|
||||
+11
-37
@@ -1,6 +1,5 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
@@ -16,12 +15,12 @@ import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。
|
||||
* 内置规则引擎:从学习报告提取内容并组织为树形思维导图。
|
||||
* <p>规则策略:</p>
|
||||
* <ol>
|
||||
* <li>根节点 = 任务名称</li>
|
||||
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
|
||||
* <li>二级分支 = 该会话下的残片标题</li>
|
||||
* <li>二级分支 = 该会话下的报告</li>
|
||||
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
|
||||
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
|
||||
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
|
||||
@@ -46,10 +45,9 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
||||
@Override
|
||||
public Optional<MindMapNode> generate(TaskEntity task,
|
||||
List<StudyReportsEntity> reports,
|
||||
List<StudyReportFragmentsEntity> fragments,
|
||||
List<TaskApplicationEntity> applications,
|
||||
String clientHint) {
|
||||
if (reports.isEmpty() && fragments.isEmpty()) {
|
||||
if (reports == null || reports.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -58,34 +56,21 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
||||
|
||||
// 按 session 分组
|
||||
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
|
||||
.filter(r -> r.getSessionNum() != null)
|
||||
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
|
||||
Map<String, List<StudyReportFragmentsEntity>> fragmentsBySession = fragments.stream()
|
||||
.collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum));
|
||||
|
||||
// 合并所有 session
|
||||
Set<String> allSessions = new LinkedHashSet<>();
|
||||
allSessions.addAll(reportsBySession.keySet());
|
||||
allSessions.addAll(fragmentsBySession.keySet());
|
||||
for (Map.Entry<String, List<StudyReportsEntity>> entry : reportsBySession.entrySet()) {
|
||||
List<StudyReportsEntity> sessReports = entry.getValue();
|
||||
|
||||
for (String sessionNum : allSessions) {
|
||||
List<StudyReportsEntity> sessReports = reportsBySession.getOrDefault(sessionNum, List.of());
|
||||
List<StudyReportFragmentsEntity> sessFragments = fragmentsBySession.getOrDefault(sessionNum, List.of());
|
||||
|
||||
// 会话分支标题:取第一条报告的前 60 字作为摘要,或直接写"学习记录"
|
||||
String sessionTitle;
|
||||
if (!sessReports.isEmpty()) {
|
||||
String firstReport = sessReports.get(0).getContent();
|
||||
sessionTitle = truncate(firstReport, MAX_TITLE_LENGTH);
|
||||
if (sessReports.get(0).getCreatedTime() != null) {
|
||||
sessionTitle = formatDate(sessReports.get(0).getCreatedTime()) + " " + sessionTitle;
|
||||
}
|
||||
} else {
|
||||
sessionTitle = "学习记录 " + (sessFragments.isEmpty() ? "" : formatDate(sessFragments.get(0).getCreatedTime()));
|
||||
// 会话分支标题:取第一条报告的前 60 字作为摘要
|
||||
StudyReportsEntity firstReport = sessReports.get(0);
|
||||
String sessionTitle = truncate(firstReport.getContent(), MAX_TITLE_LENGTH);
|
||||
if (firstReport.getCreatedTime() != null) {
|
||||
sessionTitle = formatDate(firstReport.getCreatedTime()) + " " + sessionTitle;
|
||||
}
|
||||
|
||||
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
||||
|
||||
// 报告作为子节点
|
||||
for (StudyReportsEntity report : sessReports) {
|
||||
String content = report.getContent();
|
||||
if (content == null || content.isBlank()) continue;
|
||||
@@ -96,17 +81,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
||||
sessionNode.getChildren().add(reportNode);
|
||||
}
|
||||
|
||||
// 残片作为子节点
|
||||
for (StudyReportFragmentsEntity frag : sessFragments) {
|
||||
String content = frag.getContent();
|
||||
if (content == null || content.isBlank()) continue;
|
||||
MindMapNode fragNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
|
||||
fragNode.setNotes(content);
|
||||
fragNode.setSourceType("FRAGMENT");
|
||||
fragNode.setSourceId(frag.getId());
|
||||
sessionNode.getChildren().add(fragNode);
|
||||
}
|
||||
|
||||
root.getChildren().add(sessionNode);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
class MindMapFileParser {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
MindMapFileParser(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
ParseResult parse(MultipartFile file, ReviewMindMapFileFormatEnum format) throws IOException {
|
||||
byte[] bytes = file.getBytes();
|
||||
Map<String, Object> root = switch (format) {
|
||||
case XMIND -> parseXMind(bytes);
|
||||
case MARKDOWN -> parseMarkdown(new String(bytes, StandardCharsets.UTF_8), format);
|
||||
case OPML -> parseOutlineXml(bytes, "text", format);
|
||||
case FREEMIND -> parseOutlineXml(bytes, "TEXT", format);
|
||||
case TEXT -> parseMarkdown(new String(bytes, StandardCharsets.UTF_8), format);
|
||||
};
|
||||
String title = String.valueOf(root.getOrDefault("title", file.getOriginalFilename()));
|
||||
String outline = toOutline(root);
|
||||
int nodeCount = countNodes(root);
|
||||
int depth = maxDepth(root);
|
||||
String summary = "共解析 " + nodeCount + " 个节点,最大层级 " + depth + "。";
|
||||
return new ParseResult(title, format.getCode(), objectMapper.writeValueAsString(root), outline, summary);
|
||||
}
|
||||
|
||||
private Map<String, Object> parseXMind(byte[] bytes) throws IOException {
|
||||
byte[] contentJson = null;
|
||||
byte[] contentXml = null;
|
||||
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(bytes))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zipInputStream.getNextEntry()) != null) {
|
||||
if ("content.json".equals(entry.getName())) {
|
||||
contentJson = zipInputStream.readAllBytes();
|
||||
} else if ("content.xml".equals(entry.getName())) {
|
||||
contentXml = zipInputStream.readAllBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (contentJson != null) {
|
||||
return parseXMindJson(contentJson);
|
||||
}
|
||||
if (contentXml != null) {
|
||||
return parseXMindXml(contentXml);
|
||||
}
|
||||
throw new IOException("未找到 XMind 内容文件");
|
||||
}
|
||||
|
||||
private Map<String, Object> parseXMindJson(byte[] bytes) throws IOException {
|
||||
JsonNode sheets = objectMapper.readTree(bytes);
|
||||
JsonNode sheet = sheets.isArray() && !sheets.isEmpty() ? sheets.get(0) : sheets;
|
||||
JsonNode rootTopic = sheet.path("rootTopic");
|
||||
Map<String, Object> root = node(rootTopic.path("title").asText("XMind 导图"));
|
||||
root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode());
|
||||
root.put("nodes", parseXMindTopicChildren(rootTopic));
|
||||
return root;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> parseXMindTopicChildren(JsonNode topic) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
JsonNode attached = topic.path("children").path("attached");
|
||||
if (!attached.isArray()) {
|
||||
return result;
|
||||
}
|
||||
for (JsonNode child : attached) {
|
||||
Map<String, Object> item = node(child.path("title").asText("未命名节点"));
|
||||
item.put("children", parseXMindTopicChildren(child));
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseXMindXml(byte[] bytes) throws IOException {
|
||||
Document document = xmlDocument(bytes);
|
||||
Element sheet = firstElementByName(document.getDocumentElement(), "sheet");
|
||||
Element topic = sheet == null ? firstElementByName(document.getDocumentElement(), "topic") : firstElementByName(sheet, "topic");
|
||||
if (topic == null) {
|
||||
throw new IOException("未找到 XMind 根节点");
|
||||
}
|
||||
Map<String, Object> root = parseTopicElement(topic);
|
||||
root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode());
|
||||
root.put("nodes", root.remove("children"));
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseMarkdown(String content, ReviewMindMapFileFormatEnum format) {
|
||||
Map<String, Object> root = node("导图大纲");
|
||||
root.put("sourceFormat", format.getCode());
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
root.put("nodes", roots);
|
||||
|
||||
ArrayDeque<StackItem> stack = new ArrayDeque<>();
|
||||
stack.push(new StackItem(0, roots));
|
||||
for (String rawLine : content.split("\\R")) {
|
||||
String line = rawLine.stripTrailing();
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
int level = markdownLevel(line);
|
||||
String title = markdownTitle(line);
|
||||
Map<String, Object> item = node(title);
|
||||
while (stack.peek().level >= level) {
|
||||
stack.pop();
|
||||
}
|
||||
stack.peek().children.add(item);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children = (List<Map<String, Object>>) item.get("children");
|
||||
stack.push(new StackItem(level, children));
|
||||
}
|
||||
if (!roots.isEmpty()) {
|
||||
root.put("title", roots.get(0).get("title"));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseOutlineXml(byte[] bytes, String titleAttribute, ReviewMindMapFileFormatEnum format) throws IOException {
|
||||
Document document = xmlDocument(bytes);
|
||||
Element rootElement = document.getDocumentElement();
|
||||
Element first = firstElementByName(rootElement, "outline");
|
||||
if (first == null) {
|
||||
first = firstElementByName(rootElement, "node");
|
||||
titleAttribute = "TEXT";
|
||||
}
|
||||
if (first == null) {
|
||||
throw new IOException("未找到导图节点");
|
||||
}
|
||||
Map<String, Object> root = parseOutlineElement(first, titleAttribute);
|
||||
root.put("sourceFormat", format.getCode());
|
||||
root.put("nodes", root.remove("children"));
|
||||
return root;
|
||||
}
|
||||
|
||||
private Document xmlDocument(byte[] bytes) throws IOException {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setNamespaceAware(false);
|
||||
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes));
|
||||
} catch (Exception e) {
|
||||
throw new IOException("导图 XML 解析失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseTopicElement(Element element) {
|
||||
Map<String, Object> item = node(textOfFirstChild(element, "title", "未命名节点"));
|
||||
item.put("children", childTopicElements(element).stream()
|
||||
.map(this::parseTopicElement)
|
||||
.toList());
|
||||
return item;
|
||||
}
|
||||
|
||||
private Map<String, Object> parseOutlineElement(Element element, String titleAttribute) {
|
||||
String title = element.getAttribute(titleAttribute);
|
||||
if (title == null || title.isBlank()) {
|
||||
title = element.getAttribute("text");
|
||||
}
|
||||
if (title == null || title.isBlank()) {
|
||||
title = "未命名节点";
|
||||
}
|
||||
Map<String, Object> item = node(title);
|
||||
List<Map<String, Object>> children = directChildElements(element).stream()
|
||||
.filter(child -> "outline".equalsIgnoreCase(child.getTagName()) || "node".equalsIgnoreCase(child.getTagName()))
|
||||
.map(child -> parseOutlineElement(child, titleAttribute))
|
||||
.toList();
|
||||
item.put("children", children);
|
||||
return item;
|
||||
}
|
||||
|
||||
private Map<String, Object> node(String title) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("title", title);
|
||||
node.put("notes", "");
|
||||
node.put("children", new ArrayList<Map<String, Object>>());
|
||||
return node;
|
||||
}
|
||||
|
||||
private int markdownLevel(String line) {
|
||||
String trimmed = line.stripLeading();
|
||||
if (trimmed.startsWith("#")) {
|
||||
int count = 0;
|
||||
while (count < trimmed.length() && trimmed.charAt(count) == '#') {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
int indent = line.length() - trimmed.length();
|
||||
return 1 + indent / 2;
|
||||
}
|
||||
|
||||
private String markdownTitle(String line) {
|
||||
return line.strip()
|
||||
.replaceFirst("^#{1,6}\\s*", "")
|
||||
.replaceFirst("^[-*+]\\s*", "")
|
||||
.replaceFirst("^\\d+\\.\\s*", "");
|
||||
}
|
||||
|
||||
private List<Element> childTopicElements(Element element) {
|
||||
return directChildElements(element).stream()
|
||||
.flatMap(child -> {
|
||||
if ("topic".equalsIgnoreCase(child.getTagName())) {
|
||||
return List.of(child).stream();
|
||||
}
|
||||
return childTopicElements(child).stream();
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Element firstElementByName(Element element, String name) {
|
||||
if (name.equalsIgnoreCase(element.getTagName())) {
|
||||
return element;
|
||||
}
|
||||
NodeList nodes = element.getChildNodes();
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
if (nodes.item(i) instanceof Element child) {
|
||||
Element found = firstElementByName(child, name);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<Element> directChildElements(Element element) {
|
||||
List<Element> result = new ArrayList<>();
|
||||
NodeList nodes = element.getChildNodes();
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
if (nodes.item(i) instanceof Element child) {
|
||||
result.add(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String textOfFirstChild(Element element, String name, String fallback) {
|
||||
for (Element child : directChildElements(element)) {
|
||||
if (name.equalsIgnoreCase(child.getTagName())) {
|
||||
return child.getTextContent();
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private String toOutline(Map<String, Object> root) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendOutline(builder, root, 0);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendOutline(StringBuilder builder, Map<String, Object> node, int level) {
|
||||
builder.append(" ".repeat(level)).append("- ").append(node.get("title")).append('\n');
|
||||
Object children = node.get(level == 0 && node.containsKey("nodes") ? "nodes" : "children");
|
||||
if (children instanceof List<?> list) {
|
||||
for (Object child : list) {
|
||||
appendOutline(builder, (Map<String, Object>) child, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private int countNodes(Map<String, Object> node) {
|
||||
int count = 1;
|
||||
Object children = node.get(node.containsKey("nodes") ? "nodes" : "children");
|
||||
if (children instanceof List<?> list) {
|
||||
for (Object child : list) {
|
||||
count += countNodes((Map<String, Object>) child);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private int maxDepth(Map<String, Object> node) {
|
||||
Object children = node.get(node.containsKey("nodes") ? "nodes" : "children");
|
||||
if (!(children instanceof List<?> list) || list.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
int max = 1;
|
||||
for (Object child : list) {
|
||||
max = Math.max(max, 1 + maxDepth((Map<String, Object>) child));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
record ParseResult(String title, String fileFormat, String parsedContent, String outline, String summary) {
|
||||
}
|
||||
|
||||
private record StackItem(int level, List<Map<String, Object>> children) {
|
||||
}
|
||||
}
|
||||
+3
-10
@@ -1,6 +1,5 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
@@ -40,32 +39,26 @@ public class RemoteAiMindMapClient implements MindMapAiClient {
|
||||
@Override
|
||||
public Optional<MindMapNode> generate(TaskEntity task,
|
||||
List<StudyReportsEntity> reports,
|
||||
List<StudyReportFragmentsEntity> fragments,
|
||||
List<TaskApplicationEntity> applications,
|
||||
String clientHint) {
|
||||
if (!isAvailable()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// 提取报告和残片的内容文本
|
||||
// 提取学习报告的内容文本
|
||||
List<String> reportTexts = reports.stream()
|
||||
.map(StudyReportsEntity::getContent)
|
||||
.filter(c -> c != null && !c.isBlank())
|
||||
.collect(Collectors.toList());
|
||||
List<String> fragmentTexts = fragments.stream()
|
||||
.map(StudyReportFragmentsEntity::getContent)
|
||||
.filter(c -> c != null && !c.isBlank())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (reportTexts.isEmpty() && fragmentTexts.isEmpty()) {
|
||||
if (reportTexts.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<String> optOutline = aiServiceClient.generateMindMap(
|
||||
task.getTaskName(),
|
||||
task.getTaskDescription(),
|
||||
reportTexts,
|
||||
fragmentTexts
|
||||
reportTexts
|
||||
);
|
||||
|
||||
if (optOutline.isEmpty() || optOutline.get().isBlank()) {
|
||||
|
||||
+14
-144
@@ -1,20 +1,14 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
|
||||
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
|
||||
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
|
||||
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
|
||||
import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||
@@ -22,16 +16,10 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||
import com.guo.learningprogresstracker.service.ReviewService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -42,6 +30,7 @@ import java.util.stream.Stream;
|
||||
/**
|
||||
* 复习模块 Service 实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReviewServiceImpl implements ReviewService {
|
||||
@@ -51,17 +40,12 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
private static final String RANDOM_MODE = "random";
|
||||
private static final String SMART_MODE = "smart";
|
||||
private static final int SMART_CANDIDATE_MULTIPLIER = 5;
|
||||
private static final String DEFAULT_MIND_MAP_FORMAT = "TEXT";
|
||||
private static final String MANUAL_SOURCE_TYPE = "MANUAL";
|
||||
private static final String FILE_SOURCE_TYPE = "FILE";
|
||||
|
||||
private final StudyReportsMapper studyReportsMapper;
|
||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||
private final StudySessionsMapper studySessionsMapper;
|
||||
private final TasksMapper tasksMapper;
|
||||
private final ReviewMindMapMapper reviewMindMapMapper;
|
||||
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
|
||||
private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper());
|
||||
|
||||
@Override
|
||||
public List<ReviewFeedItem> getReviewFeed(int limit, String mode) {
|
||||
@@ -71,13 +55,6 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
}
|
||||
boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
|
||||
|
||||
List<StudyReportsEntity> reports = random
|
||||
? studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||
.last("ORDER BY RAND() LIMIT " + safeLimit))
|
||||
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||
.orderByDesc(StudyReportsEntity::getCreatedTime)
|
||||
.last("LIMIT " + safeLimit));
|
||||
|
||||
List<StudyReportFragmentsEntity> fragments = random
|
||||
? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||
.last("ORDER BY RAND() LIMIT " + safeLimit))
|
||||
@@ -85,7 +62,7 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
|
||||
.last("LIMIT " + safeLimit));
|
||||
|
||||
List<ReviewFeedItem> items = mergeAndConvert(reports, fragments);
|
||||
List<ReviewFeedItem> items = mergeAndConvert(List.of(), fragments);
|
||||
if (random) {
|
||||
Collections.shuffle(items);
|
||||
}
|
||||
@@ -100,14 +77,11 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
|
||||
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
|
||||
|
||||
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
|
||||
Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
||||
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
||||
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
||||
|
||||
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments);
|
||||
List<ReviewFeedItem> candidates = mergeAndConvert(List.of(), fragments);
|
||||
if (candidates.size() <= safeLimit) {
|
||||
Collections.shuffle(candidates);
|
||||
return candidates;
|
||||
@@ -242,102 +216,19 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
@Override
|
||||
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
|
||||
return Optional.ofNullable(studyReportsMapper.selectById(id))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("学习报告[" + id + "]不存在"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("学习报告[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这份学习报告不存在或已被删除");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
|
||||
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewMindMapEntity getMindMap(String taskNum) throws NotFindEntitiesException {
|
||||
ensureTaskExists(taskNum);
|
||||
return reviewMindMapMapper.selectOne(
|
||||
Wrappers.<ReviewMindMapEntity>lambdaQuery()
|
||||
.eq(ReviewMindMapEntity::getTaskNum, taskNum)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewMindMapEntity upsertMindMap(String taskNum, UpsertReviewMindMapRequest request) throws NotFindEntitiesException {
|
||||
ensureTaskExists(taskNum);
|
||||
ReviewMindMapEntity entity = getMindMap(taskNum);
|
||||
boolean create = entity == null;
|
||||
if (create) {
|
||||
entity = new ReviewMindMapEntity();
|
||||
entity.setTaskNum(taskNum);
|
||||
}
|
||||
entity.setTitle(request.getTitle());
|
||||
entity.setContent(request.getContent());
|
||||
entity.setContentFormat(normalizeMindMapFormat(request.getContentFormat()));
|
||||
entity.setSourceType(MANUAL_SOURCE_TYPE);
|
||||
entity.setFileName(null);
|
||||
entity.setFilePath(null);
|
||||
entity.setFileFormat(null);
|
||||
entity.setParsedContent(null);
|
||||
entity.setSummary(null);
|
||||
entity.setLastParsedTime(null);
|
||||
entity.setParseStatus(ReviewMindMapParseStatusEnum.SUCCESS.getCode());
|
||||
entity.setParseError(null);
|
||||
if (create) {
|
||||
reviewMindMapMapper.insert(entity);
|
||||
} else {
|
||||
reviewMindMapMapper.updateById(entity);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewMindMapEntity uploadMindMap(String taskNum, MultipartFile file) throws NotFindEntitiesException, IOException {
|
||||
ensureTaskExists(taskNum);
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IOException("思维导图文件不可为空");
|
||||
}
|
||||
|
||||
String originalFileName = StringUtils.cleanPath(
|
||||
Optional.ofNullable(file.getOriginalFilename()).orElse("mind-map.txt"));
|
||||
ReviewMindMapFileFormatEnum format = ReviewMindMapFileFormatEnum.fromFileName(originalFileName);
|
||||
Path storedPath = storeMindMapFile(taskNum, originalFileName, file);
|
||||
|
||||
ReviewMindMapEntity entity = getMindMap(taskNum);
|
||||
boolean create = entity == null;
|
||||
if (create) {
|
||||
entity = new ReviewMindMapEntity();
|
||||
entity.setTaskNum(taskNum);
|
||||
}
|
||||
|
||||
entity.setSourceType(FILE_SOURCE_TYPE);
|
||||
entity.setFileName(originalFileName);
|
||||
entity.setFilePath(storedPath.toString());
|
||||
entity.setFileFormat(format.getCode());
|
||||
entity.setContentFormat("JSON");
|
||||
entity.setLastParsedTime(LocalDateTime.now());
|
||||
|
||||
try {
|
||||
MindMapFileParser.ParseResult parseResult = mindMapFileParser.parse(file, format);
|
||||
entity.setTitle(StringUtils.hasText(parseResult.title()) ? parseResult.title() : originalFileName);
|
||||
entity.setContent(parseResult.outline());
|
||||
entity.setParsedContent(parseResult.parsedContent());
|
||||
entity.setSummary(parseResult.summary());
|
||||
entity.setParseStatus(ReviewMindMapParseStatusEnum.SUCCESS.getCode());
|
||||
entity.setParseError(null);
|
||||
} catch (IOException e) {
|
||||
entity.setTitle(originalFileName);
|
||||
entity.setContent("");
|
||||
entity.setParsedContent(null);
|
||||
entity.setSummary(null);
|
||||
entity.setParseStatus(ReviewMindMapParseStatusEnum.FAILED.getCode());
|
||||
entity.setParseError(e.getMessage());
|
||||
}
|
||||
|
||||
if (create) {
|
||||
reviewMindMapMapper.insert(entity);
|
||||
} else {
|
||||
reviewMindMapMapper.updateById(entity);
|
||||
}
|
||||
return entity;
|
||||
.orElseThrow(() -> {
|
||||
log.warn("学习残片[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这条学习残片不存在或已被删除");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -428,32 +319,11 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeMindMapFormat(String contentFormat) {
|
||||
if (!StringUtils.hasText(contentFormat)) {
|
||||
return DEFAULT_MIND_MAP_FORMAT;
|
||||
}
|
||||
String normalized = contentFormat.trim().toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("TEXT", "MERMAID", "JSON").contains(normalized)) {
|
||||
return DEFAULT_MIND_MAP_FORMAT;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Path storeMindMapFile(String taskNum, String originalFileName, MultipartFile file) throws IOException {
|
||||
Path directory = Paths.get("uploads", "review-mind-maps", taskNum);
|
||||
Files.createDirectories(directory);
|
||||
String storedFileName = UUID.randomUUID() + "-" + originalFileName.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
Path target = directory.resolve(storedFileName);
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private void fillReviewTaskStats(List<ReviewTaskStats> statsList) {
|
||||
Map<String, ReviewTaskStats> statsByTaskNum = statsList.stream()
|
||||
.filter(item -> StringUtils.hasText(item.getTaskNum()))
|
||||
|
||||
+22
-16
@@ -36,7 +36,6 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
private final ReviewRecallRecordMapper recallRecordMapper;
|
||||
private final TasksMapper tasksMapper;
|
||||
private final StudyReportsMapper studyReportsMapper;
|
||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||
private final TaskApplicationMapper taskApplicationMapper;
|
||||
private final StudySessionsMapper studySessionsMapper;
|
||||
|
||||
@@ -86,7 +85,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
ensureTaskExists(taskNum);
|
||||
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
||||
if (existing == null) {
|
||||
throw new NotFindEntitiesException("标准思维导图尚不存在,无法增量更新");
|
||||
log.warn("任务[{}]标准思维导图尚不存在,无法增量更新", taskNum);
|
||||
throw new NotFindEntitiesException("还没有生成过标准思维导图,请先完整生成一次哦");
|
||||
}
|
||||
// 防并发生成
|
||||
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
||||
@@ -201,7 +201,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
try {
|
||||
resultJson = objectMapper.writeValueAsString(result);
|
||||
} catch (Exception e) {
|
||||
throw new OperationFailedException("对比结果序列化失败");
|
||||
log.error("任务[{}]对比结果序列化失败", taskNum, e);
|
||||
throw new OperationFailedException("对比结果解析失败了,请稍后再试");
|
||||
}
|
||||
|
||||
// 5. 保存回忆记录
|
||||
@@ -405,7 +406,10 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
@Override
|
||||
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
|
||||
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("回忆记录[{}]不存在", recordId);
|
||||
return new NotFindEntitiesException("这条回忆记录不存在或已被删除");
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 内部方法 ============
|
||||
@@ -415,7 +419,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
|
||||
|
||||
if (task == null) {
|
||||
throw new OperationFailedException("任务[" + taskNum + "]不存在");
|
||||
log.warn("任务[{}]不存在,无法生成思维导图", taskNum);
|
||||
throw new OperationFailedException("这个任务不存在或已被删除");
|
||||
}
|
||||
|
||||
// 收集学习数据
|
||||
@@ -428,14 +433,12 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
|
||||
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||
.in(StudyReportsEntity::getSessionNum, sessionNums));
|
||||
List<StudyReportFragmentsEntity> fragments = sessionNums.isEmpty() ? List.of()
|
||||
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums));
|
||||
List<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
|
||||
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
|
||||
|
||||
if (reports.isEmpty() && fragments.isEmpty()) {
|
||||
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图");
|
||||
if (reports.isEmpty()) {
|
||||
log.warn("任务[{}]没有学习报告,无法生成思维导图", taskNum);
|
||||
throw new OperationFailedException("这个任务还没开始学习哦,学习后产生学习报告后再来吧");
|
||||
}
|
||||
|
||||
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
|
||||
@@ -445,10 +448,11 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
.orElse(null);
|
||||
|
||||
if (client == null) {
|
||||
throw new OperationFailedException("没有可用的思维导图生成器");
|
||||
log.warn("任务[{}]没有可用的思维导图生成器", taskNum);
|
||||
throw new OperationFailedException("思维导图暂时生成不了,请稍后再试");
|
||||
}
|
||||
|
||||
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
|
||||
Optional<MindMapNode> optRoot = client.generate(task, reports, applications, null);
|
||||
// AI 生成失败时尝试降级到内置生成器
|
||||
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
|
||||
log.info("AI 思维导图生成失败,降级到内置生成器");
|
||||
@@ -456,11 +460,12 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
|
||||
.findFirst().orElse(null);
|
||||
if (fallback != null) {
|
||||
optRoot = fallback.generate(task, reports, fragments, applications, null);
|
||||
optRoot = fallback.generate(task, reports, applications, null);
|
||||
}
|
||||
}
|
||||
if (optRoot.isEmpty()) {
|
||||
throw new OperationFailedException("思维导图生成失败");
|
||||
log.warn("任务[{}]思维导图生成失败,已尝试全部生成器", taskNum);
|
||||
throw new OperationFailedException("思维导图生成失败了,请稍后再试");
|
||||
}
|
||||
|
||||
MindMapNode root = optRoot.get();
|
||||
@@ -483,7 +488,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
entity.setGenerator(client.generatorName());
|
||||
entity.setGeneratorVersion("1.0");
|
||||
entity.setSourceReportCount(reports.size());
|
||||
entity.setSourceFragmentCount(fragments.size());
|
||||
entity.setSourceFragmentCount(0);
|
||||
entity.setGeneratedTime(LocalDateTime.now());
|
||||
|
||||
if (create) {
|
||||
@@ -497,7 +502,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -8,11 +8,13 @@ import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 学习预期服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
||||
@@ -26,7 +28,8 @@ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
||||
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum));
|
||||
if (!sessionExists) {
|
||||
throw new ErrorParameterException("学习会话[" + sessionNum + "]不存在");
|
||||
log.warn("学习会话[{}]不存在", sessionNum);
|
||||
throw new ErrorParameterException("这次学习会话不存在或已结束");
|
||||
}
|
||||
|
||||
StudyExpectationsEntity existing = getBySessionNum(sessionNum);
|
||||
|
||||
+6
-2
@@ -12,6 +12,7 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -20,6 +21,7 @@ import java.util.List;
|
||||
/**
|
||||
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
|
||||
@@ -35,7 +37,8 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
|
||||
Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
|
||||
if (session == null) {
|
||||
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]", entity.getSessionNum()));
|
||||
log.warn("未能找到学习会话sessionNum[{}]", entity.getSessionNum());
|
||||
throw new NotFindEntitiesException("这次学习会话不存在或已结束");
|
||||
}
|
||||
this.save(entity);
|
||||
}
|
||||
@@ -44,7 +47,8 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
|
||||
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
|
||||
StudyReportFragmentsEntity existing = this.getById(id);
|
||||
if (existing == null) {
|
||||
throw new NotFindEntitiesException(String.format("未能找到学习残片id[%d]", id));
|
||||
log.warn("未能找到学习残片id[{}]", id);
|
||||
throw new NotFindEntitiesException("这条学习残片不存在或已被删除");
|
||||
}
|
||||
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
||||
entity.setId(id);
|
||||
|
||||
+30
-12
@@ -50,7 +50,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
|
||||
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, taskNum))
|
||||
.orElseThrow(() -> new NotFindEntitiesException(String.format("[%s]不存在", taskNum)));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("[{}]不存在", taskNum);
|
||||
return new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
});
|
||||
|
||||
StudySessionsEntity session = this.getOneOpt(Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
||||
@@ -73,6 +76,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
|
||||
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||
response.setTaskName(taskEntity.getTaskName());
|
||||
response.setMaterialUrl(taskEntity.getMaterialUrl());
|
||||
response.setTaskId(taskEntity.getId());
|
||||
|
||||
if (session.isOverTime()) {
|
||||
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
|
||||
@@ -86,10 +91,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停");
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]已经暂停,请勿重复暂停!");
|
||||
throw new ErrorParameterException("这次学习会话已经暂停了,不用重复暂停哦");
|
||||
}
|
||||
studySessionsEntity.calculatePointerPosition();
|
||||
studySessionsEntity.pausedStudySession(endTime);
|
||||
@@ -102,7 +107,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
|
||||
studySessionsEntity.endedStudySession();
|
||||
this.updateById(studySessionsEntity);
|
||||
@@ -120,9 +125,12 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
|
||||
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
|
||||
.eq(TaskEntity::getTaskNum, taskNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在"));
|
||||
.orElseThrow(() -> taskNotFound(taskNum));
|
||||
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("任务[{}]不存在进行中或暂停中的会话", taskNum);
|
||||
return new ErrorParameterException("这个任务当前没有进行中或已暂停的学习会话");
|
||||
});
|
||||
return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
|
||||
}
|
||||
|
||||
@@ -134,7 +142,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum))
|
||||
.stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new));
|
||||
} else {
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]不存在");
|
||||
throw sessionNotFound(sessionNum);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,13 +151,13 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始");
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]已经开始,请勿重复开始!");
|
||||
throw new ErrorParameterException("这次学习会话已经开始了,不用重复开始哦");
|
||||
} else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!");
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]处于结束状态,不可开始该会话!");
|
||||
throw new ErrorParameterException("这次学习会话已经结束,无法再次开始");
|
||||
}
|
||||
studySessionsEntity.continueStudySession();
|
||||
this.updateById(studySessionsEntity);
|
||||
@@ -161,7 +169,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
|
||||
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||
}
|
||||
|
||||
@@ -172,7 +180,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public String generateReportDraft(String sessionNum) throws ErrorParameterException {
|
||||
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
|
||||
ArrayList<String> fragments = getAllFragments(sessionNum);
|
||||
if (fragments.isEmpty()) {
|
||||
@@ -260,4 +268,14 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
return response;
|
||||
}
|
||||
|
||||
private ErrorParameterException sessionNotFound(String sessionNum) {
|
||||
log.warn("会话[{}]不存在", sessionNum);
|
||||
return new ErrorParameterException("这次学习会话不存在或已结束");
|
||||
}
|
||||
|
||||
private ErrorParameterException taskNotFound(String taskNum) {
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
return new ErrorParameterException("这个任务不存在或已被删除");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
|
||||
if (this.exists(Wrappers.lambdaQuery(TaskEntity.class)
|
||||
.eq(TaskEntity::getTaskName, task.getTaskName()))) {
|
||||
throw new ErrorParameterException(String.format("任务名[%s]重复", task.getTaskName()));
|
||||
log.warn("任务名[{}]重复", task.getTaskName());
|
||||
throw new ErrorParameterException("已经有同名任务了,换个任务名称吧");
|
||||
}
|
||||
|
||||
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
|
||||
@@ -90,6 +91,7 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
|
||||
}
|
||||
taskEntity.setId(id);
|
||||
taskEntity.setTaskNum(existingTask.getTaskNum());
|
||||
// 维度数据可能变化,更新时重算优先级
|
||||
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
|
||||
taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
||||
@@ -131,7 +133,10 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
@Override
|
||||
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
|
||||
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("应用场景[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
|
||||
});
|
||||
existing.setTitle(request.getTitle());
|
||||
existing.setDescription(request.getDescription());
|
||||
existing.setResourceUrl(request.getResourceUrl());
|
||||
@@ -143,14 +148,18 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
@Override
|
||||
public void deleteApplication(Integer id) throws NotFindEntitiesException {
|
||||
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("应用场景[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
|
||||
});
|
||||
taskApplicationMapper.deleteById(existing.getId());
|
||||
}
|
||||
|
||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
@Slf4j
|
||||
public class TitleFetcher {
|
||||
|
||||
private static final Pattern TITLE_PATTERN = Pattern.compile(
|
||||
"<title[^>]*>([^<]+)</title>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
private static final Pattern CHARSET_PATTERN = Pattern.compile(
|
||||
"charset=([\\w\\-]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
private static final int TIMEOUT_MS = 8_000;
|
||||
|
||||
private static volatile boolean sslRelaxed;
|
||||
|
||||
static {
|
||||
try {
|
||||
TrustManager[] trustAll = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
||||
}
|
||||
};
|
||||
SSLContext sc = SSLContext.getInstance("TLS");
|
||||
sc.init(null, trustAll, new java.security.SecureRandom());
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
|
||||
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);
|
||||
sslRelaxed = true;
|
||||
} catch (Exception e) {
|
||||
log.warn("无法配置宽松 SSL: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static String fetchTitle(String url) {
|
||||
if (url == null || url.isBlank()) return null;
|
||||
String lower = url.toLowerCase();
|
||||
if (!lower.startsWith("http://") && !lower.startsWith("https://")) return null;
|
||||
|
||||
String currentUrl = url;
|
||||
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
String title = doFetch(currentUrl);
|
||||
if (title != null) return title;
|
||||
|
||||
// 检查是否需要跟随重定向
|
||||
String redirect = getRedirect(currentUrl);
|
||||
if (redirect != null && !redirect.equals(currentUrl)) {
|
||||
currentUrl = redirect;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String doFetch(String url) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
if (conn instanceof HttpsURLConnection) {
|
||||
// SSL 已全局宽松配置
|
||||
}
|
||||
conn.setConnectTimeout(TIMEOUT_MS);
|
||||
conn.setReadTimeout(TIMEOUT_MS);
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setInstanceFollowRedirects(false); // 手动处理重定向
|
||||
conn.setRequestProperty("Connection", "close");
|
||||
conn.setRequestProperty("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
conn.setRequestProperty("Accept",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
conn.setRequestProperty("Accept-Encoding", "gzip, deflate");
|
||||
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||
|
||||
int status = conn.getResponseCode();
|
||||
if (status >= 200 && status < 400) {
|
||||
String contentType = conn.getContentType();
|
||||
if (contentType != null && !contentType.toLowerCase().contains("text/html")
|
||||
&& !contentType.toLowerCase().contains("application/xhtml")) {
|
||||
if (!contentType.toLowerCase().contains("text/")) return null;
|
||||
}
|
||||
|
||||
Charset charset = detectCharset(contentType);
|
||||
String body = readBody(conn, charset);
|
||||
if (body == null) return null;
|
||||
|
||||
Matcher matcher = TITLE_PATTERN.matcher(body);
|
||||
if (matcher.find()) {
|
||||
String title = matcher.group(1).trim();
|
||||
title = title.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.replaceAll("\\s+", " ").trim();
|
||||
return title.isEmpty() ? null : title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.debug("获取页面标题失败: url={}, error={}", url, e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取重定向地址,支持跨协议跳转 */
|
||||
private static String getRedirect(String url) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
conn.setConnectTimeout(TIMEOUT_MS);
|
||||
conn.setReadTimeout(TIMEOUT_MS);
|
||||
conn.setRequestMethod("HEAD");
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setRequestProperty("User-Agent",
|
||||
"Mozilla/5.0 (compatible; LPT/1.0)");
|
||||
|
||||
int status = conn.getResponseCode();
|
||||
if (status == HttpURLConnection.HTTP_MOVED_PERM // 301
|
||||
|| status == HttpURLConnection.HTTP_MOVED_TEMP // 302
|
||||
|| status == HttpURLConnection.HTTP_SEE_OTHER // 303
|
||||
|| status == 307
|
||||
|| status == 308) {
|
||||
String location = conn.getHeaderField("Location");
|
||||
if (location != null && !location.isBlank()) {
|
||||
// 处理相对路径
|
||||
if (!location.toLowerCase().startsWith("http")) {
|
||||
URI base = URI.create(url);
|
||||
location = base.resolve(location).toString();
|
||||
}
|
||||
// 跨协议切换(HTTP→HTTPS)也允许
|
||||
if (location.toLowerCase().startsWith("http://")
|
||||
|| location.toLowerCase().startsWith("https://")) {
|
||||
return location;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本中的裸 URL 替换为 [标题](url) 格式(已有 [text](url) 的保持不变)。
|
||||
* 并行抓取标题,整体超时 15 秒。
|
||||
*/
|
||||
public static String embedTitles(String text) {
|
||||
if (text == null || text.isBlank()) return text;
|
||||
|
||||
// 1) 保护已有 [text](url)
|
||||
List<String> protectedLinks = new ArrayList<>();
|
||||
Matcher mdLink = Pattern.compile("\\[([^\\]]+)\\]\\(([^)]+)\\)").matcher(text);
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (mdLink.find()) {
|
||||
protectedLinks.add(mdLink.group(0));
|
||||
mdLink.appendReplacement(sb, "__MDLINK_" + (protectedLinks.size() - 1) + "__");
|
||||
}
|
||||
mdLink.appendTail(sb);
|
||||
String work = sb.toString();
|
||||
|
||||
// 2) 提取裸 URL 并去重
|
||||
Set<String> bareUrls = new LinkedHashSet<>();
|
||||
Matcher um = Pattern.compile("https?://[^\\s)\u3001\uFF09\u300D\u300B<>]+").matcher(work);
|
||||
while (um.find()) {
|
||||
String url = um.group().replaceAll("[.。,,;;!!??)】」』\\]]+$", "");
|
||||
bareUrls.add(url);
|
||||
}
|
||||
if (bareUrls.isEmpty()) return text;
|
||||
|
||||
// 3) 并行抓取标题
|
||||
Map<String, String> titleMap = new ConcurrentHashMap<>();
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Void>[] futures = new CompletableFuture[bareUrls.size()];
|
||||
int fi = 0;
|
||||
for (String url : bareUrls) {
|
||||
final String u = url;
|
||||
futures[fi++] = CompletableFuture.runAsync(() -> {
|
||||
String title = fetchTitle(u);
|
||||
if (title != null) titleMap.put(u, title);
|
||||
});
|
||||
}
|
||||
try {
|
||||
CompletableFuture.allOf(futures).get(15, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.debug("批量获取标题超时或失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 4) 替换裸 URL → [标题](url)
|
||||
for (String url : bareUrls) {
|
||||
String title = titleMap.get(url);
|
||||
if (title != null) {
|
||||
work = work.replace(url, "[" + title.replace("]", "\\]") + "](" + url + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 还原 [text](url)
|
||||
for (int i = 0; i < protectedLinks.size(); i++) {
|
||||
work = work.replace("__MDLINK_" + i + "__", protectedLinks.get(i));
|
||||
}
|
||||
|
||||
return work;
|
||||
}
|
||||
|
||||
private static Charset detectCharset(String contentType) {
|
||||
if (contentType != null) {
|
||||
Matcher m = CHARSET_PATTERN.matcher(contentType);
|
||||
if (m.find()) {
|
||||
try { return Charset.forName(m.group(1)); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
return StandardCharsets.UTF_8;
|
||||
}
|
||||
|
||||
private static String readBody(HttpURLConnection conn, Charset charset) throws IOException {
|
||||
InputStream is;
|
||||
try {
|
||||
is = conn.getInputStream();
|
||||
} catch (IOException e) {
|
||||
is = conn.getErrorStream();
|
||||
}
|
||||
if (is == null) return null;
|
||||
|
||||
String encoding = conn.getContentEncoding();
|
||||
try {
|
||||
if ("gzip".equalsIgnoreCase(encoding)) {
|
||||
is = new GZIPInputStream(is);
|
||||
} else if ("deflate".equalsIgnoreCase(encoding)) {
|
||||
is = new InflaterInputStream(is);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
try { is.close(); } catch (Exception ignored2) {}
|
||||
is = conn.getInputStream();
|
||||
}
|
||||
|
||||
byte[] buf = new byte[65536];
|
||||
int total = 0;
|
||||
try {
|
||||
int n;
|
||||
while (total < buf.length && (n = is.read(buf, total, buf.length - total)) != -1) {
|
||||
total += n;
|
||||
}
|
||||
} finally {
|
||||
try { is.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
return new String(buf, 0, total, charset);
|
||||
}
|
||||
}
|
||||
+6
-112
@@ -3,16 +3,11 @@ package com.guo.learningprogresstracker.service.impl;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
|
||||
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
|
||||
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
|
||||
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
|
||||
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
|
||||
import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||
@@ -25,17 +20,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -48,7 +38,6 @@ class ReviewServiceImplTest {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudyReportFragmentsEntity.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudySessionsEntity.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), ReviewMindMapEntity.class);
|
||||
}
|
||||
|
||||
@InjectMocks
|
||||
@@ -66,9 +55,6 @@ class ReviewServiceImplTest {
|
||||
@Mock
|
||||
private TasksMapper tasksMapper;
|
||||
|
||||
@Mock
|
||||
private ReviewMindMapMapper reviewMindMapMapper;
|
||||
|
||||
@Mock
|
||||
private ReviewRecallRecordMapper reviewRecallRecordMapper;
|
||||
|
||||
@@ -76,13 +62,13 @@ class ReviewServiceImplTest {
|
||||
void getReviewFeed_smartMode_prefersLowRecallRatioContent() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
StudyReportsEntity oldLowMastery = new StudyReportsEntity();
|
||||
StudyReportFragmentsEntity oldLowMastery = new StudyReportFragmentsEntity();
|
||||
oldLowMastery.setId(1);
|
||||
oldLowMastery.setSessionNum("S_LOW");
|
||||
oldLowMastery.setContent("很久没复习且掌握度低");
|
||||
oldLowMastery.setCreatedTime(now.minusDays(60));
|
||||
|
||||
StudyReportsEntity freshHighMastery = new StudyReportsEntity();
|
||||
StudyReportFragmentsEntity freshHighMastery = new StudyReportFragmentsEntity();
|
||||
freshHighMastery.setId(2);
|
||||
freshHighMastery.setSessionNum("S_HIGH");
|
||||
freshHighMastery.setContent("刚学完且掌握度高");
|
||||
@@ -107,8 +93,7 @@ class ReviewServiceImplTest {
|
||||
highRatioRecord.setRecallRatio(1.0);
|
||||
highRatioRecord.setCreatedTime(now);
|
||||
|
||||
when(studyReportsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery));
|
||||
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of());
|
||||
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery));
|
||||
when(studySessionsMapper.selectList(any())).thenReturn(List.of(sessionLow, sessionHigh));
|
||||
when(tasksMapper.selectList(any())).thenReturn(List.of(taskLow, taskHigh));
|
||||
when(reviewRecallRecordMapper.selectList(any())).thenReturn(List.of(highRatioRecord));
|
||||
@@ -130,15 +115,9 @@ class ReviewServiceImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReviewFeed_shouldApplyFinalLimitAfterMerge() {
|
||||
StudyReportsEntity report = new StudyReportsEntity();
|
||||
report.setId(1);
|
||||
report.setSessionNum("S1");
|
||||
report.setContent("report");
|
||||
report.setCreatedTime(LocalDateTime.now());
|
||||
|
||||
void getReviewFeed_shouldReturnFragmentsOnly() {
|
||||
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
|
||||
fragment.setId(2);
|
||||
fragment.setId(1);
|
||||
fragment.setSessionNum("S1");
|
||||
fragment.setContent("fragment");
|
||||
fragment.setCreatedTime(LocalDateTime.now().minusMinutes(1));
|
||||
@@ -151,7 +130,6 @@ class ReviewServiceImplTest {
|
||||
task.setTaskNum("T1");
|
||||
task.setTaskName("Task");
|
||||
|
||||
when(studyReportsMapper.selectList(any())).thenReturn(List.of(report));
|
||||
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(fragment));
|
||||
when(studySessionsMapper.selectList(any())).thenReturn(List.of(session));
|
||||
when(tasksMapper.selectList(any())).thenReturn(List.of(task));
|
||||
@@ -159,92 +137,8 @@ class ReviewServiceImplTest {
|
||||
List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "recent");
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals("REPORT", items.get(0).getSourceType());
|
||||
assertEquals("FRAGMENT", items.get(0).getSourceType());
|
||||
assertEquals("Task", items.get(0).getTaskName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertMindMap_shouldCreateWhenMissing() throws Exception {
|
||||
when(tasksMapper.exists(any())).thenReturn(true);
|
||||
when(reviewMindMapMapper.selectOne(any())).thenReturn(null);
|
||||
when(reviewMindMapMapper.insert(any())).thenAnswer(invocation -> {
|
||||
ReviewMindMapEntity entity = invocation.getArgument(0);
|
||||
entity.setId(3);
|
||||
return 1;
|
||||
});
|
||||
|
||||
UpsertReviewMindMapRequest request = new UpsertReviewMindMapRequest();
|
||||
request.setTitle("Map");
|
||||
request.setContent("root");
|
||||
request.setContentFormat("mermaid");
|
||||
|
||||
ReviewMindMapEntity entity = reviewService.upsertMindMap("T1", request);
|
||||
|
||||
assertNotNull(entity.getId());
|
||||
assertEquals("T1", entity.getTaskNum());
|
||||
assertEquals("MERMAID", entity.getContentFormat());
|
||||
verify(reviewMindMapMapper).insert(any(ReviewMindMapEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertMindMap_shouldUpdateExisting() throws Exception {
|
||||
ReviewMindMapEntity existing = new ReviewMindMapEntity();
|
||||
existing.setId(5);
|
||||
existing.setTaskNum("T1");
|
||||
existing.setTitle("Old");
|
||||
existing.setContent("old");
|
||||
existing.setContentFormat("TEXT");
|
||||
|
||||
when(tasksMapper.exists(any())).thenReturn(true);
|
||||
when(reviewMindMapMapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
UpsertReviewMindMapRequest request = new UpsertReviewMindMapRequest();
|
||||
request.setTitle("New");
|
||||
request.setContent("new");
|
||||
request.setContentFormat("json");
|
||||
|
||||
ReviewMindMapEntity entity = reviewService.upsertMindMap("T1", request);
|
||||
|
||||
assertEquals(5, entity.getId());
|
||||
assertEquals("New", entity.getTitle());
|
||||
assertEquals("JSON", entity.getContentFormat());
|
||||
verify(reviewMindMapMapper).updateById(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadMindMap_shouldParseMarkdownFile() throws Exception {
|
||||
when(tasksMapper.exists(any())).thenReturn(true);
|
||||
when(reviewMindMapMapper.selectOne(any())).thenReturn(null);
|
||||
when(reviewMindMapMapper.insert(any())).thenAnswer(invocation -> {
|
||||
ReviewMindMapEntity entity = invocation.getArgument(0);
|
||||
entity.setId(9);
|
||||
return 1;
|
||||
});
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"java-review.md",
|
||||
"text/markdown",
|
||||
"# Java 并发\n## 线程基础\n- RUNNABLE\n".getBytes());
|
||||
|
||||
ReviewMindMapEntity entity = reviewService.uploadMindMap("T1", file);
|
||||
|
||||
try {
|
||||
assertEquals(9, entity.getId());
|
||||
assertEquals("FILE", entity.getSourceType());
|
||||
assertEquals(ReviewMindMapFileFormatEnum.MARKDOWN.getCode(), entity.getFileFormat());
|
||||
assertEquals(ReviewMindMapParseStatusEnum.SUCCESS.getCode(), entity.getParseStatus());
|
||||
assertEquals("JSON", entity.getContentFormat());
|
||||
assertNotNull(entity.getParsedContent());
|
||||
assertEquals(true, entity.getParsedContent().contains("线程基础"));
|
||||
assertEquals(true, entity.getContent().contains("RUNNABLE"));
|
||||
} finally {
|
||||
if (entity.getFilePath() != null) {
|
||||
Path storedFile = Path.of(entity.getFilePath());
|
||||
Files.deleteIfExists(storedFile);
|
||||
Files.deleteIfExists(storedFile.getParent());
|
||||
}
|
||||
}
|
||||
verify(reviewMindMapMapper).insert(any(ReviewMindMapEntity.class));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -36,8 +36,6 @@ class StandardMindMapServiceImplTest {
|
||||
@Mock
|
||||
private StudyReportsMapper studyReportsMapper;
|
||||
@Mock
|
||||
private StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||
@Mock
|
||||
private TaskApplicationMapper taskApplicationMapper;
|
||||
@Mock
|
||||
private StudySessionsMapper studySessionsMapper;
|
||||
@@ -189,7 +187,7 @@ class StandardMindMapServiceImplTest {
|
||||
private StandardMindMapServiceImpl createService() {
|
||||
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
|
||||
standardMindMapMapper, recallRecordMapper,
|
||||
tasksMapper, studyReportsMapper, studyReportFragmentsMapper,
|
||||
tasksMapper, studyReportsMapper,
|
||||
taskApplicationMapper, studySessionsMapper,
|
||||
List.of(mockAiClient), objectMapper, aiServiceClient);
|
||||
return s;
|
||||
|
||||
Reference in New Issue
Block a user