Compare commits
58 Commits
81543969d8
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b18f8b4d1c | |||
| 3c3f682b2b | |||
| a636e516d1 | |||
| 22cc5dc871 | |||
| 3a30b746f5 | |||
| 0a866bde14 | |||
| c1385911c6 | |||
| eaa0e20c81 | |||
| c0fe137c48 | |||
| cca51530c6 | |||
| 55b7c1c83c | |||
| 4ec900577e | |||
| af4f536980 | |||
| 7ac7868511 | |||
| cd5ee0b404 | |||
| baf9ae8761 | |||
| 21c5b8f761 | |||
| 3c4db2fead | |||
| 7461b6b5ad | |||
| b67cf238c2 | |||
| 090d03e251 | |||
| d173b37a9b | |||
| 32b247526b | |||
| 5f3f35c53b | |||
| f170e85297 |
@@ -0,0 +1,185 @@
|
|||||||
|
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
|
||||||
|
container:
|
||||||
|
image: maven:3.9-eclipse-temurin-17
|
||||||
|
volumes:
|
||||||
|
- maven-cache:/root/.m2
|
||||||
|
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
|
||||||
|
# maven 容器可能无 git
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq git
|
||||||
|
fi
|
||||||
|
git clone "$REPO_URL" .
|
||||||
|
git checkout "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
- name: Build with Maven
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mvn clean package -DskipTests -B
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then
|
||||||
|
echo "IMAGE_TAG=$TAG" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
# runner.temp 在 container job 里可能不可用,用工作区文件兜底
|
||||||
|
echo "$TAG" > image_tag.txt
|
||||||
|
mkdir -p /tmp/lpt-ci
|
||||||
|
echo "$TAG" > /tmp/lpt-ci/image_tag.txt
|
||||||
|
echo "IMAGE_TAG=$TAG"
|
||||||
|
|
||||||
|
- 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/
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
|
||||||
|
|
||||||
|
- 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="${IMAGE_TAG:-}"
|
||||||
|
if [ -z "$IMAGE_TAG" ] && [ -f image_tag.txt ]; then
|
||||||
|
IMAGE_TAG="$(cat image_tag.txt)"
|
||||||
|
fi
|
||||||
|
if [ -z "$IMAGE_TAG" ] && [ -f /tmp/lpt-ci/image_tag.txt ]; then
|
||||||
|
IMAGE_TAG="$(cat /tmp/lpt-ci/image_tag.txt)"
|
||||||
|
fi
|
||||||
|
if [ -z "$IMAGE_TAG" ]; then
|
||||||
|
echo "ERROR: IMAGE_TAG 为空,无法部署"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
|
||||||
|
# Spring profile 由 K8s Deployment 的 SPRING_PROFILES_ACTIVE 控制,镜像本身不区分
|
||||||
|
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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# LPT 后端规范
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
- Java 17, Spring Boot 3.2.5
|
||||||
|
- MyBatis-Plus 3.5.5 + MySQL
|
||||||
|
- MapStruct 1.5.5(编译期生成代码,DTO 增删字段后需重新编译)
|
||||||
|
- Sa-Token 1.38.0(认证/鉴权)
|
||||||
|
- Flyway(数据库迁移)
|
||||||
|
- Lombok(@Data, @Slf4j)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
```
|
||||||
|
controller/ → REST 接口,只做参数校验和路由
|
||||||
|
service/ → 业务逻辑
|
||||||
|
impl/
|
||||||
|
mapper/ → MyBatis-Plus BaseMapper
|
||||||
|
entity/ → 数据库实体(@TableName, @TableField)
|
||||||
|
dto/ → 请求/响应 DTO
|
||||||
|
request/
|
||||||
|
response/
|
||||||
|
config/ → Spring 配置(WebMvcConfig, JacksonConfig 等)
|
||||||
|
utils/ → 工具类
|
||||||
|
common/ → GlobalExceptionHandler, Ops 等
|
||||||
|
mapStruct/ → MapStruct Converter 接口
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应规范
|
||||||
|
- 统一使用 `CommonResult<T>` 包装:`{ code, message, data }`
|
||||||
|
- 成功:`CommonResult.success(data)` → code=200
|
||||||
|
- 业务错误:`CommonResult.error(msg)` → code=400, HTTP 200
|
||||||
|
- 未登录:`GlobalExceptionHandler.handleNotLogin()` → HTTP 401 + code=401
|
||||||
|
- 参数校验失败走 `MethodArgumentNotValidException` → code=400
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
- SaInterceptor 注册在 `WebMvcConfig`(无 @Profile 限制,所有环境生效)
|
||||||
|
- 拦截 `/**`,排除 `/login`
|
||||||
|
- `StpUtil.checkLogin()` 失败 → NotLoginException → GlobalExceptionHandler → 401
|
||||||
|
- Cookie 名 `satoken`,前端 axios 需 `withCredentials: true`
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
- Flyway 迁移文件:`src/main/resources/db/migration/V{日期}_{序号}__{描述}.sql`
|
||||||
|
- 文件名日期格式:`yyyyMMdd`
|
||||||
|
|
||||||
|
## DTO 转换
|
||||||
|
- MapStruct 编译期生成 `*ConvertImpl.java`(target/generated-sources/)
|
||||||
|
- 同名属性自动映射,`unmappedTargetPolicy = IGNORE`
|
||||||
|
- 如需自定义映射用 `@Mapping(source, target)`
|
||||||
|
- **增删 DTO 字段后必须重新编译**,否则 MapStruct 生成代码不含新字段
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
- 由 `CorsProperties` 读取各 profile 的 `cors.allowed-origins` 配置
|
||||||
|
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`(兼容 allowCredentials)
|
||||||
|
- local profile:`allow-credentials: true`, `allowed-origins: '*'`
|
||||||
|
|
||||||
|
## 标题抓取
|
||||||
|
- `TitleFetcher`:静态工具类,支持手动跟随 HTTP→HTTPS 重定向、宽松 SSL
|
||||||
|
- `UtilsController`:`GET /utils/fetch-title?url=...` 代理端点
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
- 默认 profile:local(application.yml 中 `spring.profiles.active: local`)
|
||||||
|
- IDEA JDK:`C:/Users/cat-win/.jdks/ms-17.0.19`
|
||||||
|
- Maven:IDEA 内置 `C:/Program Files/JetBrains/IntelliJ IDEA 2026.1.3/plugins/maven/lib/maven3/bin/mvn`
|
||||||
|
- 编译:`mvn clean compile -DskipTests`(须用 JDK 17)
|
||||||
Vendored
+27
-19
@@ -1,7 +1,7 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent none // 全局不指定,局部自己声明
|
agent none
|
||||||
environment {
|
environment {
|
||||||
IMAGE_NAME = 'lpt-prod:0.0'
|
IMAGE_NAME = "lpt-prod:${env.GIT_COMMIT?.take(8) ?: '0.0'}"
|
||||||
CONTAINER_NAME = 'LPT-prod'
|
CONTAINER_NAME = 'LPT-prod'
|
||||||
CONTAINER_PORT = '8888'
|
CONTAINER_PORT = '8888'
|
||||||
}
|
}
|
||||||
@@ -31,23 +31,24 @@ pipeline {
|
|||||||
sh '''
|
sh '''
|
||||||
docker network create traefik-public || true
|
docker network create traefik-public || true
|
||||||
docker rm -f $CONTAINER_NAME || true
|
docker rm -f $CONTAINER_NAME || true
|
||||||
docker run -d --name $CONTAINER_NAME --network mysql-prod_mysql-prod \\
|
docker run -d --name $CONTAINER_NAME \
|
||||||
--restart=always \\
|
--network traefik-public \
|
||||||
-e SPRING_PROFILES_ACTIVE=prod \\
|
--restart=always \
|
||||||
--label "traefik.enable=true" \\
|
-e SPRING_PROFILES_ACTIVE=prod \
|
||||||
--label "traefik.docker.network=traefik-public" \\
|
--label "traefik.enable=true" \
|
||||||
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \\
|
--label "traefik.docker.network=traefik-public" \
|
||||||
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \\
|
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \
|
||||||
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \\
|
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \
|
||||||
--label "traefik.http.routers.lpt-api.priority=100" \\
|
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \
|
||||||
--label "traefik.http.routers.lpt-api.service=lpt-api" \\
|
--label "traefik.http.routers.lpt-api.priority=100" \
|
||||||
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \\
|
--label "traefik.http.routers.lpt-api.service=lpt-api" \
|
||||||
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \\
|
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \
|
||||||
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \\
|
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \
|
||||||
--log-driver=loki \\
|
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \
|
||||||
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
|
--log-driver=loki \
|
||||||
|
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \
|
||||||
$IMAGE_NAME
|
$IMAGE_NAME
|
||||||
docker network connect traefik-public $CONTAINER_NAME || true
|
docker network connect mysql-prod_mysql-prod $CONTAINER_NAME || true
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def lastStatus = ''
|
def lastStatus = ''
|
||||||
timeout(time: 60, unit: 'SECONDS') {
|
timeout(time: 120, unit: 'SECONDS') {
|
||||||
waitUntil {
|
waitUntil {
|
||||||
def status = sh(
|
def status = sh(
|
||||||
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
||||||
@@ -74,5 +75,12 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stage('清理旧镜像') {
|
||||||
|
agent any
|
||||||
|
steps {
|
||||||
|
sh 'docker image prune -af --filter "until=168h" || true'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# 学习进度跟踪系统(LPT)— 架构与设计评审
|
||||||
|
|
||||||
|
> 评审日期:2026-07-03
|
||||||
|
> 评审范围:learning-progress-tracker(后端 Spring Boot) + lpt-fe(前端 Vue 3)
|
||||||
|
> 代码基线:feature/review-completion 分支
|
||||||
|
|
||||||
|
## 一、整体评价
|
||||||
|
|
||||||
|
系统的底层方向是正确的:
|
||||||
|
- **量化学习投入**(番茄钟 + 学习简报)→ 解决缺乏时间感知
|
||||||
|
- **优先级排序**(五维度算法)→ 解决缺乏计划性
|
||||||
|
- **主动回忆式复习**(碎片滚动 + 思维导图对比)→ 解决缺乏复习习惯
|
||||||
|
|
||||||
|
三个痛点对应三个解决方案,逻辑链成立。代码质量方面,前后端分离、RESTful API 设计、Flyway 数据库迁移、Sa-Token 鉴权、MyBatis-Plus 多租户拦截等基础设施做得相当规范,超出业余项目的平均水准。
|
||||||
|
|
||||||
|
但在**业务设计的心理学层面**,存在几个结构性矛盾:有些地方把"功能完整"等同于"有用",对用户的实际行为模式考虑不足。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、不科学之处
|
||||||
|
|
||||||
|
### 2.1 番茄钟被当作计时器而非学习节奏工具
|
||||||
|
|
||||||
|
当前实现把 25 分钟当作一个硬性倒计时,超时 50 分钟自动暂停,"仅计 25 分钟有效时间"。但番茄钟的核心价值不是"学够 25 分钟",而是**强迫休息—回忆—重置**的节奏。
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
- 允许用户连续学习远超 25 分钟而不触发强制休息提醒
|
||||||
|
- 超时被视为"违规"(自动暂停),而不是自然的节奏重置
|
||||||
|
- 后端 session 状态机里没有独立的"休息中"状态
|
||||||
|
- 前端虽有 5 分钟倒计时但后端无法感知
|
||||||
|
|
||||||
|
**改进方向**:番茄钟应该循环——25 分钟学习 → 强制 5 分钟休息并提示填写残片 → 自动进入下一轮。每一轮结束都是自然的"复习锚点"。
|
||||||
|
|
||||||
|
**用户评价**:你理解的不对,“有效学习时间”指的是实际的学习时间(不处于暂停状态的时间),25分钟对于一个大的学习任务来说太短,我们应该给予宽松的选择,而不是机械的要求用户休息,这只是一个辅助学习的工具
|
||||||
|
|
||||||
|
### 2.2 优先级五维度权重有重叠,且缺乏解释
|
||||||
|
|
||||||
|
| 维度 | 权重 | 问题 |
|
||||||
|
|------|------|------|
|
||||||
|
| 主观判断 | 10% | 跟"重要性"、"未来价值"高度相关,用户难以区分 |
|
||||||
|
| 未来价值 | 10% | 同上 |
|
||||||
|
| 内容难度 | 20% | 假设"越难越值得优先学"不一定成立 |
|
||||||
|
| 必要性 | 25% | 与"紧急性"边界模糊 |
|
||||||
|
| 紧急性 | 35% | 权重最高,但优先级 ≠ 紧迫性 |
|
||||||
|
|
||||||
|
核心矛盾:紧急性 + 必要性占 0.60,主观判断 + 未来价值仅占 0.20。系统会自动把"明天要交的报告"排在"你想真正学会的英语"前面——但用户做这个系统是为了后者的。
|
||||||
|
|
||||||
|
**改进方向**:两套排序模式——"紧急模式"(按紧迫性)和"重要模式"(按主观+未来价值),用户在不同时间段切换,而不是混合成一个不伦不类的综合分。此外权重应当可配置(当前硬编码在 CalculatedPriorityTool 中)。
|
||||||
|
|
||||||
|
**用户评价**:用户要如何切换?什么时候切换呢?如果一个课程最开始并不急迫,但其很重要,如果等到其变为急迫后用户才开始学习,那就晚了;我这里的设计是想优先完成“重要但不紧急”的课程,权重设计本身就是要是可修改的,只是系统中没有来得及实现;
|
||||||
|
|
||||||
|
### 2.3 学习预期是系统强约束但无闭环
|
||||||
|
|
||||||
|
PRD 明确要求"每次学习开始前必须填写学习预期(不可为空)",数据库也建了 study_expectations 表。但:
|
||||||
|
- 后端没有对应的 Entity / Service / Controller
|
||||||
|
- 前端 StartTask 页面没有预期输入框
|
||||||
|
- 预期没有与实际学习报告做任何对比
|
||||||
|
|
||||||
|
被强制填写却不被使用的字段,比没有更糟——用户费心填了,却发现系统根本不看。学习预期的价值在于事后对比:"我打算学什么 vs 我实际学了什么",这是元认知训练的核心。
|
||||||
|
|
||||||
|
**改进方向**:始于创建 session 时弹窗必填预期,终于结束时展示"预期 vs 实际"对比。
|
||||||
|
|
||||||
|
### 2.4 复习模块的认知负荷偏高
|
||||||
|
|
||||||
|
当前复习模块有三个独立概念和入口:
|
||||||
|
- 碎片化提醒(Welcome 页滚动)
|
||||||
|
- 思维导图上/下载(ReviewDetail)
|
||||||
|
- 回忆对比(ReviewRecall)
|
||||||
|
|
||||||
|
但用户的真实行为大概是:打开复习页 → 看到滚动条闪过很多内容 → 不知道先看哪个 → 关掉。太多碎片而没有"今天需要复习什么"的引导。
|
||||||
|
|
||||||
|
复习行为的心理阻力主要来源于**不知道从哪开始**。
|
||||||
|
|
||||||
|
**改进方向**:复习应该"推"给用户一条清晰路径——"你今天有 3 个知识点需要复习,先看第一个"。而不是拉出一个 feed 让用户自己挑。
|
||||||
|
|
||||||
|
**用户评价**:不,如果按照你的设计,就违背了我对这个系统的设计理念“本系统只是辅助学习的工具”,滚动条的设计是想要随机的将知识展示给用户,触发用户随机的回忆,在我的感受中,总有些无意识看到的东西会停留在记忆中很久,即使看到了但没有回忆起细节,也会更努力的回忆,从而印象更深;我认同你的“推”的逻辑,但并不应该“要求”用户,这可能会让用户产生抵触情绪;我们可以让“随机”变得不那么随机,可以在滚动中将更需要复习的内容优先展示出来;为了制造这种“无意识看到”,我们也许可以在更多地方增加这种引导;因为这是我的感受,请搜索网络,看看是否有论文对此进行过论证,主要找到相反的论证,我不希望我的系统是我一厢情愿的
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、改进空间
|
||||||
|
|
||||||
|
### 3.1 学习报告的长度决策
|
||||||
|
|
||||||
|
当前,残片是"休息时记录的内容片段",报告是"结束时生成的完整总结"。但在前端两者几乎无差异——都是文本、都在同一个 feed 中滚动、都在详情页逐行显示。
|
||||||
|
|
||||||
|
实际问题:学完 25 分钟后,用户是否真的有动力写一篇"完整报告"?大概率不会——草草写一句"学了线程池"然后开始下一轮。
|
||||||
|
|
||||||
|
**改进方向**:降低报告的仪式感。残片应该是主力的学习记录单元,报告改为"自动将本次会话的残片聚合为摘要",用户只需要微调即可。
|
||||||
|
|
||||||
|
**用户评价**:你关于“完整报告”的判断是正确的,和我的使用体验一致,但在我原有的设计中,"自动将本次会话的残片聚合为摘要"就是该功能的最终版本,只是受限当前阶段中没有对接AI导致的;我认为这部分功能使用32b的LLM模型应该就可以,实施时请简单调研
|
||||||
|
|
||||||
|
### 3.2 断点续传的粒度
|
||||||
|
|
||||||
|
当前用 pointerPosition(毫秒级指针)支持关闭页面后恢复学习,设计很好。但问题在于:学习结束后,断点续传数据变成死数据。
|
||||||
|
|
||||||
|
用户周五回来面对的不是"上次学了什么"的摘要,而是"继续上次倒计时"——但用户可能想重新开始一个关于同一任务的新会话,或者先回顾上次的残片再做决定。
|
||||||
|
|
||||||
|
**改进方向**:断点续传配合"上次学习回顾"一起出现——"你上次学了 X、Y、Z,生成了 N 个残片。继续?还是开始新的?"
|
||||||
|
|
||||||
|
**用户评价**:你可以看到原有设计中“关闭页面后恢复学习”只是对一个session完成的,每个session就应该是连续的,不应该出现长时间暂停的情况;
|
||||||
|
|
||||||
|
### 3.3 有效学习时间的 10 分钟阈值
|
||||||
|
|
||||||
|
后端逻辑:effective_time < 10 分钟 → 不计入总学习时间,置为 0。
|
||||||
|
|
||||||
|
意图是过滤掉"开启后很快就关"的无效学习。但 10 分钟阈值对番茄钟来说太高——25 分钟的 session 如果被电话打断 8 分钟后回来,系统判定"白学了"。
|
||||||
|
|
||||||
|
**改进方向**:降低阈值到 3-5 分钟,或改为比例制(有效比 < 30% 才标记低效而不是直接归零)。数据是分析原材料,不是评判工具。
|
||||||
|
|
||||||
|
**用户评价**:我认为你对“有效学习时间”的定义理解有误,如果一个session开始了(开始了一个学习任务),用户有其他事情需要处理,其可以点击暂停,暂停后的时间才是“无效的”,暂停之前的时间才是“有效学习时间”,对于一轮25分钟的番茄钟来说,有效学习10分钟是最低限度了, 因为一次学习本就应该持续25分钟,10分钟还不到一半;另外一点,对于长时间暂停的session,我的系统应该会自动终止;
|
||||||
|
|
||||||
|
### 3.4 回忆覆盖率的误导性
|
||||||
|
|
||||||
|
当前:recall_ratio = matched / (matched + missed)。问题在于它衡量"记得多少节点"而非"记得多少重要节点"。
|
||||||
|
|
||||||
|
假如一个任务 100 个节点,其中 3 个核心、97 个细节。
|
||||||
|
- 用户回忆出 3 个核心 → 覆盖率 3%
|
||||||
|
- 用户回忆出 97 个细节但漏了 3 个核心 → 覆盖率 97%
|
||||||
|
|
||||||
|
显然后者更差,但指标显示前者好。
|
||||||
|
|
||||||
|
**改进方向**:节点加权——"应用场景"和"从报告中提取的关键概念"级别更高。或至少同时展示"核心节点覆盖"和"全部节点覆盖"两个指标。
|
||||||
|
|
||||||
|
**用户评价**:单纯的指标展示还是略显单调,用户可能无法直接定位其未能覆盖的内容,如果可以直接在“思维导图”上用颜色标记出来是最好的
|
||||||
|
|
||||||
|
### 3.5 多租户隔离的泛化成本
|
||||||
|
|
||||||
|
系统通过 created_by 做租户隔离,每个查询多一个 WHERE 条件、新增数据要注入用户 ID、测试要绕过拦截器。开发笔记中作者自问"这玩应儿除了自己,还能有别人用么"。
|
||||||
|
|
||||||
|
多租户本身没有错,但在当前单人使用场景下带来了不必要的复杂度。
|
||||||
|
|
||||||
|
**改进方向**:如果未来真有多人需求可还原。当前去掉租户拦截可简化 80% 的查询,减少隐式 bug。
|
||||||
|
|
||||||
|
**用户评价**:不,还是保持基本的多租户设计吧,系统必然会遇到多租户需求,但目前没有
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、优先级建议
|
||||||
|
|
||||||
|
### P0 —— 影响核心使用
|
||||||
|
- 学习预期的闭环(创建→填写→事后对比)
|
||||||
|
- 番茄钟循环节律(25分→5分休息 + 自动残片)
|
||||||
|
|
||||||
|
### P1 —— 明显改善体验
|
||||||
|
- 复习引导路径("今天有 N 个知识点需要复习")
|
||||||
|
- 降低报告仪式感(残片为主、报告自动聚合)
|
||||||
|
- 优先级权重用户可配置
|
||||||
|
|
||||||
|
### P2 —— 优化与完善
|
||||||
|
- 有效时间阈值下调
|
||||||
|
- 回忆覆盖率高阶指标(核心节点 vs 全部节点)
|
||||||
|
- 断点续传配合历史回顾
|
||||||
|
- 移除不必要的多租户拦截(或标记可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、总结
|
||||||
|
|
||||||
|
系统在基础设施层面(前后端分离、RESTful、Flyway、Sa-Token、MyBatis-Plus)做得相当规范。主要问题不在技术而在设计:**把"功能完整"当成了"有用"**。学习预期无闭环、番茄钟无节奏感、复习无引导路径——这些不需要更多技术投入,而是需要重新理解用户行为。
|
||||||
|
|
||||||
|
> 一句话:系统已经可以做很多事情,但用户不一定知道"现在应该做什么"。
|
||||||
|
> 下一步的改进目标是降低用户决策成本,引导而非开放。
|
||||||
|
>
|
||||||
|
**用户评价**:使用本系统就是为了学习任意一个课程或者领域的知识而来的,用户不需要被动接受要学习的目标,其必须有一定的”要做什么的”目的性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、回应设计评审与用户评价(2026-07-03 补充)
|
||||||
|
|
||||||
|
本节在保留原评审全部内容的基础上,逐条回应作者(用户)的评价,并补充研究支撑和新理解。保证前一版完整可回溯。
|
||||||
|
|
||||||
|
### 6.1 番茄钟:我纠正——强制休息是教条,宽松才是工具
|
||||||
|
|
||||||
|
**原文批评**:番茄钟应 25 分→5 分强制循环。
|
||||||
|
|
||||||
|
**用户评价**:”有效学习时间实际是学习时间(非暂停状态),25 分钟对一个大任务太短,应给予宽松选择。”
|
||||||
|
|
||||||
|
**新理解**:用户是对的。我之前的建议实质是把番茄钟教条化了。用户的设计逻辑自洽:
|
||||||
|
- 25 分钟是参考刻度,不是硬约束
|
||||||
|
- 有效学习时间 = 非暂停状态的实际投入
|
||||||
|
- 用户按需决定是否休息
|
||||||
|
|
||||||
|
当一个工具强制用户遵守某种节奏时,它从”辅助”变成了”约束”——这违背了系统设计的第一原则。需要休息时会自然产生意愿,工具应在这个时候提供支持(比如推荐填充残片),而不是提前打断。
|
||||||
|
|
||||||
|
**修订建议**:保留当前灵活模式。可增加一个可选的”节拍器”——用户开启后,每轮结束时轻声提示”已学完一轮,休息回顾?”,用户可忽略。这不是通知,是提醒。
|
||||||
|
|
||||||
|
### 6.2 优先级权重:我误解了目标——用户要的是重要不紧急
|
||||||
|
|
||||||
|
**原文批评**:维度和权重有重叠,客观性存疑。
|
||||||
|
|
||||||
|
**用户评价**:”如果一个课程最开始并不急迫,但其很重要,如果等到变为急迫后用户才开始学习,那就晚了。权重设计本就是可修改的,只是系统中没有来得及实现。”
|
||||||
|
|
||||||
|
**新理解**:用户实际运用的是 **Eisenhower Matrix 第二象限(重要不紧急)优先**原则。紧急性(35%)不是用来让紧急任务主宰排序的——它是**兜底**,防止真正紧急的任务被忽略。如果没有这个兜底,用户可能在”重要不紧急”上花太多时间,错过 deadline。
|
||||||
|
|
||||||
|
我之前的批评建立在错误前提上。真正缺失的不是设计思路,而是 **配置界面**——让用户直观滑动五个维度的权重滑块,即时看到排序变化。这个功能已在 PRD 和代码注释里明确列出,是未实现,不是未设计。
|
||||||
|
|
||||||
|
**修订建议**:P0 优先级——权重配置 UI。五个维度的滑块控件,调整后实时重新排序并展示。
|
||||||
|
|
||||||
|
### 6.3 学习预期:共识——闭环是必须的
|
||||||
|
|
||||||
|
双方看法一致:没有争议。
|
||||||
|
|
||||||
|
### 6.4 复习滚动:研究支撑与反面论证
|
||||||
|
|
||||||
|
这是最有意思的部分。我搜索了学术文献,验证用户的设计假设(”无意中看到的内容会停留更久”),同时寻找反面论证。
|
||||||
|
|
||||||
|
#### ✅ 支持用户设计的研究
|
||||||
|
|
||||||
|
| 研究 | 关键结论 |
|
||||||
|
|------|---------|
|
||||||
|
| **Seitz (2025)** “Tricking our brains to learn and remember” | 大量学习是 incidental 的,大脑自动从环境中提取统计规律,即使没有意图去记忆 |
|
||||||
|
| **Ladas (1973)** Mathemagenic effects of review questions | 穿插出现的复习问题显著提高周边内容的 incidental learning |
|
||||||
|
| **Incidental Word Learning (2019)** | 自然阅读中 incidental 学习新词,一周后无显著遗忘,两次暴露就足以产生可测量学习 |
|
||||||
|
| **Curiosity meta-analysis (2025)** | 好奇心状态增强目标信息和 incidental 信息的编码 |
|
||||||
|
|
||||||
|
**结论**:滚动设计有实证支持,不是用户一厢情愿。Incidental exposure 确实能促进记忆,特别是当用户处于好奇或放松状态时。
|
||||||
|
|
||||||
|
#### ❌ 反面论证(同样成立)
|
||||||
|
|
||||||
|
| 研究 | 关键结论 |
|
||||||
|
|------|---------|
|
||||||
|
| **Roediger & Karpicke (2006) / Adesope et al. (2017) meta-analysis** | 主动回忆(testing effect)的效果远强于被动重读,effect size g = 0.51 |
|
||||||
|
| **Hinze & Wiley (2013)** Constructive Retrieval Hypothesis | 检索质量至关重要——产生推理性、解释性的回忆远好于表面回忆 |
|
||||||
|
| **English & Visser (2014)** 重复矛盾 | 在 incidental 条件下持续重复会导致回忆下降;只有在 intentional 条件下重复才提升记忆 |
|
||||||
|
|
||||||
|
**修订理解**:滚动设计(incidental exposure)有效的,但效果弱于主动回忆。它真正的价值不是替代回忆,而是**播种**——用户瞥见自己写过但有陌生感的片段→感到认知失调→点进去→进入主动回忆通道。关键在”是否会点进去”。如果只是扫过,效果有限;如果点击触发回忆,效果显著。
|
||||||
|
|
||||||
|
**修订建议**:
|
||||||
|
1. **保留滚动条**不动核心设计
|
||||||
|
2. 在点击滚动条内容后,先弹出回忆卡片再展开原文——“你还记得多少?”→ 尝试回忆 → 展示原文。这样将 incidental exposure 和 active recall 结合起来
|
||||||
|
3. 你提出的”让更需要复习的内容优先展示”可以对接到 spaced repetition 的计算——按回忆覆盖率、时间衰减排序
|
||||||
|
|
||||||
|
### 6.5 学习报告 AI 聚合:理解
|
||||||
|
|
||||||
|
用户指出原设计就是”残片自动聚合为报告”,只是缺 AI 实现。32B 模型足够。这一认识一致。
|
||||||
|
|
||||||
|
**关于 AI 服务架构**(详见 6.7 节技术推荐)。
|
||||||
|
|
||||||
|
### 6.6 其他无争议项汇总
|
||||||
|
|
||||||
|
| 项目 | 第 1 版 | 用户评价 | 共识 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 断点续传 | 批评”长时间隔后恢复无意义” | session 是连续单元,长时间暂停会被自动终止 | 批评基于对状态机的错误理解,撤回 |
|
||||||
|
| 有效学习时间阈值 | 批评 10 分钟阈值太高 | 暂停按钮是显式信号,暂停前就是有效;10 分钟是 25 分钟周期的最低限 | 批评基于误解,撤回 |
|
||||||
|
| 回忆覆盖率 | 指标误导 | 希望直接在思维导图上着色展示 | 改进方向:可视化展示 |
|
||||||
|
| 多租户 | 建议去除 | 保留,未来必然需要 | 保留,无争议 |
|
||||||
|
|
||||||
|
### 6.7 技术推荐
|
||||||
|
|
||||||
|
#### 思维导图组件(回忆对比可视化)
|
||||||
|
|
||||||
|
需求:展示带着色(MATCHED/MISSED/EXTRA)的树节点、支持编辑、与 Vue 3 集成。
|
||||||
|
|
||||||
|
**首选:mind-elixir-core**([GitHub](https://github.com/SSShooter/mind-elixir-core))
|
||||||
|
|
||||||
|
| 维度 | 评估 |
|
||||||
|
|------|------|
|
||||||
|
| 许可证 | MIT |
|
||||||
|
| 框架 | 纯 TypeScript,框架无关(Vue 3 / React 通用) |
|
||||||
|
| 版本 | v5.13.0(2026 年 6 月 24 日,非常活跃) |
|
||||||
|
| Star | 3.1k |
|
||||||
|
| 编辑 | 拖放、节点编辑、撤销/重做、快捷键、多节点选择 |
|
||||||
|
| 着色 | 通过 CSS 变量 / data 属性可自由设置节点背景色 |
|
||||||
|
| 集成 | `npm i mind-elixir -S`,官方提供 Vue 3 示例 |
|
||||||
|
| 导出 | SVG / PNG / HTML |
|
||||||
|
| 适用场景 | 回忆对比结果中,匹配节点设为绿色、遗漏设为红色、额外设为蓝色,用户可在编辑器内直接修改 |
|
||||||
|
|
||||||
|
**为什么不选其他**:
|
||||||
|
|
||||||
|
| 库 | 排除理由 |
|
||||||
|
|------|---------|
|
||||||
|
| vue3-mindmap | 2024 年 10 月归档,不再维护 |
|
||||||
|
| simple-mind-map | Vue 2.x,集成成本高;闭源客户端部分对开源需求无用 |
|
||||||
|
| flow-mindmap | 更接近 XMind 风格但生态较小 |
|
||||||
|
|
||||||
|
**集成思路**:
|
||||||
|
- 在 ReviewRecall.vue 中嵌入 mind-elixir 实例
|
||||||
|
- 后端对比结果(CompareResult.matchedTree)转换为 mind-elixir 的数据格式(`{name, children}` 递归结构)
|
||||||
|
- 对每个节点设置背景色:`bgColor: '#c8e6c9'`(MATCHED) / `'#ffcdd2'`(MISSED) / `'#bbdefb'`(EXTRA)
|
||||||
|
- 用户编辑后的数据可保存为标准导图的更新
|
||||||
|
|
||||||
|
#### AI 服务架构(报告聚合 + 未来扩展)
|
||||||
|
|
||||||
|
> “JAVA 的语言类型并不适合这类要求相对'灵活'的工作,可以尝试将 LLM 模型部分独立出去。”
|
||||||
|
|
||||||
|
完全同意。Java 的强类型、编译周期、生态惯性在面对 LLM 的 prompt 调试、流式响应、快速迭代时确实不够顺手。推荐以下架构:
|
||||||
|
|
||||||
|
**推荐方案:独立 TypeScript 微服务**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ HTTP/SSE ┌──────────────────────┐
|
||||||
|
│ LPT 后端 (Java) │ ◄──────────────► │ LPT-AI 服务 (TS) │
|
||||||
|
│ Spring Boot │ REST API │ Fastify / Hono │
|
||||||
|
│ │ │ │
|
||||||
|
│ 业务逻辑 │ │ Prompt 模板管理 │
|
||||||
|
│ 数据持久化 │ │ LLM 调用(OpenAI 兼容)│
|
||||||
|
│ 权限认证 │ │ 流式响应 / SSE │
|
||||||
|
│ │ │ 沙箱 / 安全过滤 │
|
||||||
|
└──────────────────┘ └──────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ SiliconFlow API │
|
||||||
|
│ https://api.silicon │
|
||||||
|
│ flow.cn/v1/... │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计点**:
|
||||||
|
|
||||||
|
1. **技术选型**:TypeScript + Fastify(或 Hono),后者更轻量且支持 Bun/Deno 运行时
|
||||||
|
2. **API 设计**:Java 后端通过 HTTP 调用 AI 服务,不直接暴露 LLM API
|
||||||
|
3. **Prompt 管理**:Template 化——AI 服务内部管理 prompt 模板,支持版本回滚
|
||||||
|
4. **流式支持**:报告生成等长耗时任务使用 SSE 流式返回,Java 端通过 WebFlux 转发
|
||||||
|
5. **错误处理**:AI 服务降级(不可用时回退到内置规则)
|
||||||
|
6. **未来扩展**:
|
||||||
|
- 安全沙箱(用户输入过滤、输出审核)
|
||||||
|
- 多模型路由(按任务选择模型)
|
||||||
|
- 用量监控与成本控制
|
||||||
|
7. **交付策略**:先做最小可行——一个端点 `/ai/generate-report`,接收残片列表返回聚合摘要;后续再加 `/ai/generate-mind-map`、`/ai/compare-maps` 等
|
||||||
|
|
||||||
|
**迁移路径**:
|
||||||
|
1. 第一阶段(现在):Java 端用 BuiltinMindMapGenerator,AI 服务未就绪时完全可用
|
||||||
|
2. 第二阶段:搭建 TS 服务,接入 `/ai/generate-report`,prompt 调优
|
||||||
|
3. 第三阶段:AI 生成标准导图替代 BuiltinMindMapGenerator
|
||||||
|
4. 第四阶段:安全层、多模型、沙箱
|
||||||
|
|
||||||
|
### 6.8 修正后优先级
|
||||||
|
|
||||||
|
| 优先级 | 项目 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **P0** | 学习预期闭环 | 创建 session 时必填 → 结束时对比”预期 vs 实际” |
|
||||||
|
| **P0** | 权重配置 UI | 五个维度滑块,即时影响排序 |
|
||||||
|
| **P1** | 滚动条交互增强 | 点击内容后弹出回忆卡片”你还记得多少?”→ 再展开原文 |
|
||||||
|
| **P1** | 滚动条智能排序 | 低频访问 + 低回忆覆盖率内容优先出现(类 spaced repetition) |
|
||||||
|
| **P1** | 搭建 LPT-AI 服务 | TypeScript 独立项目,先做残片聚合报告 |
|
||||||
|
| **P1** | 回忆对比可视化 | 嵌入 mind-elixir,节点着色展示 MATCHED/MISSED/EXTRA |
|
||||||
|
| **P2** | 内置生成器升级 | 接入 AI 后替代规则生成 |
|
||||||
|
| **P2** | 有效时间阈值 | 维持 10 分钟(经确认合理) |
|
||||||
|
| **P2** | 多租户 | 保留不变 |
|
||||||
|
|
||||||
|
### 6.9 最终总结(修正版)
|
||||||
|
|
||||||
|
系统在基础设施层面(前后端分离、RESTful、Flyway、Sa-Token、MyBatis-Plus)相当规范。原评审中约 50% 的批评基于对设计意图和代码状态机的误解,经用户指正后已修正。
|
||||||
|
|
||||||
|
剩余真正有价值的问题:
|
||||||
|
1. 学习预期无闭环:P0,需要实施
|
||||||
|
2. 权重配置无 UI:P0,需要实施
|
||||||
|
3. 复习交互可深化:滚动条 + 点击触发回忆,而非纯被动浏览
|
||||||
|
4. AI 聚合报告:需要独立服务
|
||||||
|
|
||||||
|
> 修正后的判断:系统设计的大部分决策是有意为之且有合理性的,不是”把功能完整当有用”。
|
||||||
|
> 真正的问题不是”不知道现在该做什么”,而是”做了好的设计但没有完全交付”——学习预期和权重配置是写在 PRD 和代码注释里但没有实现的,而非设计缺失。
|
||||||
|
> 下一步重点是:交付未完成的功能 + 引入 AI 服务作为独立项目 + 深化复习交互。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、交付记录(2026-07-04)
|
||||||
|
|
||||||
|
6.8 节修正优先级中的全部事项已实现并小步提交:
|
||||||
|
|
||||||
|
| 事项 | 实现 | 位置 |
|
||||||
|
|------|------|------|
|
||||||
|
| 学习预期闭环 | 新会话强制填写预期(PUT/GET `/study-sessions/{n}/expectation`),页面常驻展示,结束弹窗对照 | 后端 StudyExpectationsService,前端 StartTask.vue |
|
||||||
|
| 权重配置 UI | 学习页”权重配置”——五维度滑块、合计校验 100%、保存后全任务重算(PUT `/tasks/priority-weights`) | 后端 PriorityWeightsService,前端 Study.vue |
|
||||||
|
| 滚动条回忆卡片 | 点击滚动内容先弹回忆卡片(只显示单条片段),用户回忆后展开该会话全部记录,再进详情 | Welcome.vue |
|
||||||
|
| feed 智能排序 | `/review/feed?mode=smart`:时间衰减 × 回忆掌握度加权随机采样 | ReviewServiceImpl.getSmartFeed |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
待用户操作:lpt-ai 服务的 `.env` 中填入 `LLM_API_KEY`,Java 端 `application.yml` 配置 `lpt.ai-service.url`。
|
||||||
@@ -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. **安全层**:多租户隔离、双重校验、权限控制
|
||||||
|
|
||||||
|
这些优化不是对设计的否定,而是在实现过程中针对实际场景的工程化改进。设计文档描述"做什么",本文档记录"怎么做得更好"。
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 复习模块设计说明
|
||||||
|
|
||||||
|
本文档是对复习功能当前实现边界的工程说明,不修改原始需求文档。
|
||||||
|
|
||||||
|
## 核心理解
|
||||||
|
|
||||||
|
复习模块包含两类不同层级的体验:碎片化提醒和围绕思维导图的主动回忆。二者都属于复习模块,但不是同一个业务流程。
|
||||||
|
|
||||||
|
## 碎片化提醒
|
||||||
|
|
||||||
|
碎片化提醒展示用户自己在学习过程中写下的学习报告和学习残片。
|
||||||
|
|
||||||
|
这些内容不是完整复习记录,而是日常滚动出现的记忆触发物。用户看到一段自己写过的内容时,如果能立刻回想起上下文,说明相关知识暂时不需要深入复习;如果想不起来,可以点击进入详情页回看,形成一次轻量提醒。
|
||||||
|
|
||||||
|
当前对应能力:
|
||||||
|
|
||||||
|
- `GET /review/feed`
|
||||||
|
- `GET /review/task/{taskNum}`
|
||||||
|
- `GET /review/report/{id}`
|
||||||
|
- `GET /review/fragment/{id}`
|
||||||
|
- 前端 `Review.vue`
|
||||||
|
- 前端 `ReviewDetail.vue`
|
||||||
|
|
||||||
|
## 当前阶段边界
|
||||||
|
|
||||||
|
当前阶段暂不追求完整的自动化复习分析,因为项目尚未引入 AI 来处理学习碎片和导图数据。
|
||||||
|
|
||||||
|
当前阶段复习模块只需要保留思维导图的基本结构能力:
|
||||||
|
|
||||||
|
- 保存任务对应的思维导图
|
||||||
|
- 支持用户上传外部导图文件
|
||||||
|
- 解析导图的基础节点结构
|
||||||
|
- 允许用户在碎片化提醒中回看学习报告和学习残片
|
||||||
|
|
||||||
|
这个阶段不应该强制用户逐条处理大量学习碎片。学习碎片很多时,逐条关联和对照会让复习过程变得漫长、枯燥,并违背复习模块“简单、不抵触、可随意围绕某个知识点复习”的初衷。
|
||||||
|
|
||||||
|
## 思维导图的意义
|
||||||
|
|
||||||
|
思维导图不是普通附件,也不是单纯的存储对象。它是复习模块的主要交互形式。
|
||||||
|
|
||||||
|
用户复习时,主要动作应该是围绕某个知识点进行回忆,并编写或重绘思维导图。导图的意义在于降低复习阻力:用户不需要按顺序处理所有碎片,而是可以从任意知识点出发,自由地把自己能想起来的内容组织成结构。
|
||||||
|
|
||||||
|
初版设计中已经提到:
|
||||||
|
|
||||||
|
- 后续复习时可以在脑子里重绘那张图
|
||||||
|
- 没有想起来的部分就是需要重新看的地方
|
||||||
|
- 如果顺利绘制出了好的思维导图,就替换原来的思维导图
|
||||||
|
- 如果能顺利用思维导图描述所学内容,复习效果就能体现在这个过程中
|
||||||
|
|
||||||
|
因此,导图应该是复习效果的表达方式,而不仅是文件存储。
|
||||||
|
|
||||||
|
## 应用场景的位置
|
||||||
|
|
||||||
|
应用场景不应归属复习模块。
|
||||||
|
|
||||||
|
应用场景更接近学习任务的最终目标:完成整个学习任务后,用户希望能做什么、产出什么、应用到哪里。它应该挂在学习任务模块下,而不是挂在复习流程或某条学习碎片下。
|
||||||
|
|
||||||
|
当前实现已将其迁移为任务级能力,由任务页面维护,并使用 `task_applications` 存储。
|
||||||
|
|
||||||
|
## 模块边界
|
||||||
|
|
||||||
|
学习执行模块产生学习报告和学习残片。
|
||||||
|
|
||||||
|
复习模块使用这些产物做两件事:
|
||||||
|
|
||||||
|
- 在碎片化提醒中滚动展示它们
|
||||||
|
- 在引入 AI 后,把它们整理进程序生成的知识网络
|
||||||
|
|
||||||
|
复习模块不要求用户手动逐条整理学习报告和学习残片。学习报告/残片是后续自动整理和对照分析的数据来源。
|
||||||
|
|
||||||
|
## 下一阶段设计:AI 生成导图与用户导图对比
|
||||||
|
|
||||||
|
下一阶段引入 AI 后,复习模块的目标应升级为:
|
||||||
|
|
||||||
|
1. 程序读取学习报告、学习残片、已有导图等数据。
|
||||||
|
2. 程序自动将碎片整理成一个具有关联关系的巨大思维导图。
|
||||||
|
3. 用户围绕某个知识点进行回忆,并绘制自己的导图。
|
||||||
|
4. 系统将用户导图与程序生成导图进行结构对比。
|
||||||
|
5. 系统定位用户忽略、遗漏、误解或尚未建立关联的内容。
|
||||||
|
6. 用户根据差异回看必要的学习报告或残片,而不是从头处理所有碎片。
|
||||||
|
|
||||||
|
这个设计可以解决当前手动复习流程的两个问题:
|
||||||
|
|
||||||
|
- 学习碎片很多时,不需要用户逐条筛选,避免复习过程漫长且枯燥。
|
||||||
|
- 思维导图不再只是存储对象,而是成为用户回忆结果和程序知识网络之间的对照媒介。
|
||||||
|
|
||||||
|
## 对初版设计的补充判断
|
||||||
|
|
||||||
|
初版设计没有忽略“思维导图用于复习”的方向,反而已经明确指出导图应承担脑内重绘、发现遗漏、体现复习效果的作用。
|
||||||
|
|
||||||
|
初版设计没有具体描述“AI 自动整理碎片为巨大导图,并与用户导图进行对比”的实现逻辑。这是当前讨论对初版设计的重要补充,适合放入下一阶段实现。
|
||||||
|
|
||||||
|
初版中提到”复习内容可以应用到什么地方”,但结合当前理解,应用场景应从复习模块中移出,归入学习任务的最终目标或验收目标。
|
||||||
|
|
||||||
|
## 第二阶段实现:标准思维导图与回忆对比(2026-07-03)
|
||||||
|
|
||||||
|
第二阶段将 AI 生成导图与用户导图对比的设计落地,引入以下能力:
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
- **标准思维导图(Standard Mind Map)**:每个学习任务对应一份”标准”导图,由系统从该任务的已有学习报告、学习残片、应用场景中提取整理而成。它是用户回忆的对照基准,也可由用户手动编辑修正。
|
||||||
|
- **回忆对比(Recall Review)**:用户凭记忆以缩进大纲格式写下对该任务知识点的回忆,系统将其与标准导图进行树结构对比,定位已掌握、遗漏和额外回忆的内容。
|
||||||
|
|
||||||
|
### 内置生成器(BuiltinMindMapGenerator)
|
||||||
|
|
||||||
|
当前阶段未接入外部 AI,由内置规则引擎生成标准导图:
|
||||||
|
|
||||||
|
- **根节点**:任务名称
|
||||||
|
- **一级分支**:按学习会话分组,以”日期 + 报告前60字摘要”为标题
|
||||||
|
- **子分支**:该会话下的每条报告、每条残片成为一个子节点
|
||||||
|
- **应用场景分支**:如有已创建的应用场景,附加为独立的”应用场景”分支
|
||||||
|
- **去重**:同级节点按标准化标题对比并合并
|
||||||
|
- **追溯**:每个节点携带 `sourceType`(REPORT/FRAGMENT/APPLICATION)和 `sourceId`,前端可点击回看原文
|
||||||
|
|
||||||
|
### AI 客户端抽象(MindMapAiClient)
|
||||||
|
|
||||||
|
预留了远程 AI 调用接口,供后续接入真实 AI 使用:
|
||||||
|
|
||||||
|
- 配置项 `lpt.ai.endpoint` / `lpt.ai.api-key`(当前均未配置)
|
||||||
|
- 未配置时自动回退到内置生成器
|
||||||
|
- 接口返回 `MindMapNode` 树结构,与内置生成器输出格式一致
|
||||||
|
|
||||||
|
### 回忆对比算法(StandardMindMapServiceImpl.compareTrees)
|
||||||
|
|
||||||
|
对比标准导图与用户回忆大纲的树结构:
|
||||||
|
|
||||||
|
1. **展平**:前序遍历将两颗树分别展开为节点列表
|
||||||
|
2. **精确匹配**:对每个标准节点标题做归一化(去空格标点 + 小写),在回忆节点中查找完全匹配
|
||||||
|
3. **模糊匹配**:未精确匹配时,计算字符 Bigram Jaccard 相似度(阈值 0.6),容忍中文同义表达
|
||||||
|
4. **标注**:匹配的节点标注 `MATCHED`,遗漏的节点标注 `MISSED`,用户多写的内容归为 `EXTRA`
|
||||||
|
5. **统计**:计算回忆覆盖率 `recall_ratio = matched / (matched + missed)`
|
||||||
|
|
||||||
|
### 用户交互
|
||||||
|
|
||||||
|
- **回忆复习页**(`/review/recall/:taskNum`):双栏布局。左栏为回忆大纲输入区(缩进文本)+ 对比结果展示,右栏为标准导图区(默认折叠防剧透)
|
||||||
|
- **标准导图编辑**:用户可展开查看、编辑大纲文本并保存,编辑后来源标记为 `USER`
|
||||||
|
- **重新生成**:如需从头生成新版本,可一键重新生成
|
||||||
|
- **遗漏项回溯**:对比后定位到的遗漏知识点自动列出,点击”查看原文”可直接跳转到对应的报告/残片详情页
|
||||||
|
- **回忆历史**:查阅历次回忆对比记录,查看覆盖率变化趋势
|
||||||
|
|
||||||
|
### 新增数据库表
|
||||||
|
|
||||||
|
- `review_standard_mind_maps`:每任务一行,存储标准导图的 JSON 树结构、大纲文本、生成元信息
|
||||||
|
- `review_recall_records`:存储用户每次回忆对比的原始大纲、对比结果 JSON、覆盖率等统计
|
||||||
|
|
||||||
|
未改动既有表的 schema,与原有 `review_mind_maps`(用户手动上传的导图文件)并存。
|
||||||
|
|
||||||
|
### 新增端点
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/review/standard-mind-map/{taskNum}` | 获取或自动生成标准导图 |
|
||||||
|
| POST | `/review/standard-mind-map/{taskNum}/regenerate` | 强制重新生成 |
|
||||||
|
| PUT | `/review/standard-mind-map/{taskNum}` | 用户编辑标准导图 |
|
||||||
|
| POST | `/review/standard-mind-map/{taskNum}/recall` | 用户提交回忆大纲,返回对比结果 |
|
||||||
|
| GET | `/review/standard-mind-map/{taskNum}/recall-records` | 回忆对比历史列表 |
|
||||||
|
| GET | `/review/standard-mind-map/recall-records/{recordId}` | 单条回忆记录详情 |
|
||||||
|
|
||||||
|
### 下一步展望
|
||||||
|
|
||||||
|
- 接入真实 AI API(配置 `lpt.ai.api-key` 后切换为 AI 生成器,内置生成器作为降级)
|
||||||
|
- 可视化思维导图渲染(而非纯缩进文本展示)
|
||||||
|
- 对比结果中显示更精确的路径定位
|
||||||
|
- AI 驱动的用户导图评分与改进建议
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: lpt-be
|
||||||
|
namespace: lpt-dev
|
||||||
|
labels:
|
||||||
|
app: lpt-be
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: lpt-be
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 25%
|
||||||
|
maxUnavailable: 25%
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: lpt-be
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
containers:
|
||||||
|
- name: lpt-be
|
||||||
|
image: 192.168.123.199:5000/lpt-be:dev
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 8888
|
||||||
|
env:
|
||||||
|
- name: SPRING_PROFILES_ACTIVE
|
||||||
|
value: dev
|
||||||
|
- name: SPRING_DATASOURCE_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: lpt-config
|
||||||
|
key: SPRING_DATASOURCE_URL
|
||||||
|
- name: SPRING_DATASOURCE_USERNAME
|
||||||
|
value: root
|
||||||
|
- name: SPRING_DATASOURCE_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: lpt-secrets
|
||||||
|
key: mysql-root-password
|
||||||
|
- name: LPT_AI-SERVICE_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: lpt-config
|
||||||
|
key: LPT_AI-SERVICE_URL
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 1Gi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8888
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8888
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: lpt-be
|
||||||
|
namespace: lpt-dev
|
||||||
|
labels:
|
||||||
|
app: lpt-be
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: lpt-be
|
||||||
|
ports:
|
||||||
|
- port: 8888
|
||||||
|
targetPort: 8888
|
||||||
|
nodePort: 30082
|
||||||
|
protocol: TCP
|
||||||
@@ -52,6 +52,11 @@
|
|||||||
<artifactId>mockito-junit-jupiter</artifactId>
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-core</artifactId>
|
<artifactId>flyway-core</artifactId>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.guo.learningprogresstracker.config;
|
|||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import net.sf.jsqlparser.expression.StringValue;
|
import net.sf.jsqlparser.expression.StringValue;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||||
import net.sf.jsqlparser.expression.Expression;
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
@@ -33,6 +34,8 @@ public class MybatisPlusConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
// 分页拦截器必须注册,否则 selectPage 不会生成 LIMIT / COUNT
|
||||||
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||||
@Override
|
@Override
|
||||||
public Expression getTenantId() {
|
public Expression getTenantId() {
|
||||||
|
|||||||
+18
-10
@@ -6,18 +6,16 @@ import cn.dev33.satoken.stp.StpUtil;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.CorsRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@Profile("dev")
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WebMvcDevConfig implements WebMvcConfigurer {
|
@Slf4j
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final CorsProperties corsProperties;
|
private final CorsProperties corsProperties;
|
||||||
|
|
||||||
@@ -35,11 +33,21 @@ public class WebMvcDevConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
|
String[] origins = corsProperties.getAllowedOrigins();
|
||||||
registry.addMapping("/**")
|
boolean hasWildcard = origins != null && Arrays.asList(origins).contains("*");
|
||||||
.allowedOrigins(corsProperties.getAllowedOrigins())
|
log.info("允许cors的地址配置:{}", Arrays.toString(origins));
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
if (hasWildcard) {
|
||||||
.allowedHeaders("*")
|
registry.addMapping("/**")
|
||||||
.allowCredentials(null == corsProperties.getAllowCredentials() || corsProperties.getAllowCredentials());
|
.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,40 +0,0 @@
|
|||||||
package com.guo.learningprogresstracker.config;
|
|
||||||
|
|
||||||
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 -> StpUtil.checkLogin()))
|
|
||||||
.addPathPatterns("/**")
|
|
||||||
.excludePathPatterns("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+1
-1
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
|
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() {
|
public LocalDateTimeSerializer() {
|
||||||
super(LocalDateTime.class);
|
super(LocalDateTime.class);
|
||||||
|
|||||||
@@ -2,16 +2,24 @@ package com.guo.learningprogresstracker.controller;
|
|||||||
|
|
||||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
|
||||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
import com.guo.learningprogresstracker.service.impl.ReviewServiceImpl;
|
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||||
|
import com.guo.learningprogresstracker.service.ReviewService;
|
||||||
|
import com.guo.learningprogresstracker.service.StandardMindMapService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复习模块控制层 — 复习内容滚动展示与交互
|
* 复习模块控制层 — 复习内容滚动展示与交互
|
||||||
@@ -22,15 +30,18 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReviewController {
|
public class ReviewController {
|
||||||
|
|
||||||
private final ReviewServiceImpl reviewService;
|
private final ReviewService reviewService;
|
||||||
|
|
||||||
|
private final StandardMindMapService standardMindMapService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取复习 feed,合并报告和残片按时间倒序
|
* 获取复习 feed,合并报告和残片按时间倒序
|
||||||
*/
|
*/
|
||||||
@GetMapping("/feed")
|
@GetMapping("/feed")
|
||||||
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
|
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
|
||||||
@RequestParam(defaultValue = "30") int limit) {
|
@RequestParam(defaultValue = "30") int limit,
|
||||||
return CommonResult.success(reviewService.getReviewFeed(limit));
|
@RequestParam(defaultValue = "recent") String mode) {
|
||||||
|
return CommonResult.success(reviewService.getReviewFeed(limit, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,4 +83,78 @@ public class ReviewController {
|
|||||||
public CommonResult<StudyReportFragmentsEntity> getFragmentDetail(@PathVariable int id) throws NotFindEntitiesException {
|
public CommonResult<StudyReportFragmentsEntity> getFragmentDetail(@PathVariable int id) throws NotFindEntitiesException {
|
||||||
return CommonResult.success(reviewService.getFragmentDetail(id));
|
return CommonResult.success(reviewService.getFragmentDetail(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 标准思维导图与回忆对比 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或自动生成任务的标准思维导图
|
||||||
|
*/
|
||||||
|
@GetMapping("/standard-mind-map/{taskNum}")
|
||||||
|
public CommonResult<ReviewStandardMindMapEntity> getStandardMindMap(
|
||||||
|
@PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
return CommonResult.success(standardMindMapService.getOrGenerate(taskNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制重新生成任务的标准思维导图
|
||||||
|
* @param mode 生成模式:full(全量覆盖,默认)或 incremental(增量合并)
|
||||||
|
*/
|
||||||
|
@PostMapping("/standard-mind-map/{taskNum}/regenerate")
|
||||||
|
public CommonResult<ReviewStandardMindMapEntity> regenerateStandardMindMap(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@RequestParam(defaultValue = "full") String mode) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
if ("incremental".equals(mode)) {
|
||||||
|
return CommonResult.success(standardMindMapService.incrementalGenerate(taskNum));
|
||||||
|
}
|
||||||
|
return CommonResult.success(standardMindMapService.regenerate(taskNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户编辑标准思维导图(大纲文本形式)
|
||||||
|
*/
|
||||||
|
@PutMapping("/standard-mind-map/{taskNum}")
|
||||||
|
public CommonResult<ReviewStandardMindMapEntity> updateStandardMindMap(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@Valid @RequestBody UpdateStandardMindMapRequest request) throws NotFindEntitiesException {
|
||||||
|
return CommonResult.success(standardMindMapService.updateByOutline(taskNum, request.getOutline()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户提交回忆大纲,与标准导图对比
|
||||||
|
*/
|
||||||
|
@PostMapping("/standard-mind-map/{taskNum}/recall")
|
||||||
|
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getFocusPath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在标准导图中查找与给定内容最匹配的节点
|
||||||
|
*/
|
||||||
|
@PostMapping("/standard-mind-map/{taskNum}/find-node")
|
||||||
|
public CommonResult<Map<String, Object>> findNode(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@RequestBody Map<String, String> body) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
String content = body != null ? body.getOrDefault("content", "") : "";
|
||||||
|
return CommonResult.success(standardMindMapService.findNode(taskNum, content));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取该任务的所有回忆对比记录
|
||||||
|
*/
|
||||||
|
@GetMapping("/standard-mind-map/{taskNum}/recall-records")
|
||||||
|
public CommonResult<List<ReviewRecallRecordEntity>> listRecallRecords(
|
||||||
|
@PathVariable String taskNum) throws NotFindEntitiesException {
|
||||||
|
return CommonResult.success(standardMindMapService.listRecallRecords(taskNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单条回忆对比记录详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/standard-mind-map/recall-records/{recordId}")
|
||||||
|
public CommonResult<ReviewRecallRecordEntity> getRecallRecord(
|
||||||
|
@PathVariable Integer recordId) throws NotFindEntitiesException {
|
||||||
|
return CommonResult.success(standardMindMapService.getRecallRecord(recordId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-2
@@ -2,9 +2,15 @@ package com.guo.learningprogresstracker.controller;
|
|||||||
|
|
||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||||
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
@@ -26,9 +32,25 @@ public class StudySessionController {
|
|||||||
|
|
||||||
private final StudySessionsServiceImpl studySessionsServiceImpl;
|
private final StudySessionsServiceImpl studySessionsServiceImpl;
|
||||||
|
|
||||||
@PostMapping
|
private final StudyExpectationsService studyExpectationsService;
|
||||||
public void studySessionList() {
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建/更新学习会话的学习预期
|
||||||
|
*/
|
||||||
|
@PutMapping("/{sessionNum}/expectation")
|
||||||
|
public CommonResult<StudyExpectationsEntity> upsertExpectation(
|
||||||
|
@PathVariable("sessionNum") String sessionNum,
|
||||||
|
@Valid @RequestBody UpsertExpectationRequest request) throws ErrorParameterException {
|
||||||
|
return CommonResult.success(studyExpectationsService.upsertExpectation(sessionNum, request.getDescription()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学习会话的学习预期
|
||||||
|
*/
|
||||||
|
@GetMapping("/{sessionNum}/expectation")
|
||||||
|
public CommonResult<StudyExpectationsEntity> getExpectation(
|
||||||
|
@PathVariable("sessionNum") String sessionNum) {
|
||||||
|
return CommonResult.success(studyExpectationsService.getBySessionNum(sessionNum));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,4 +102,39 @@ public class StudySessionController {
|
|||||||
return CommonResult.success(allFragments);
|
return CommonResult.success(allFragments);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成学习报告草稿:AI 聚合残片(不可用时降级为拼接),返回给前端作为编辑起点
|
||||||
|
*/
|
||||||
|
@GetMapping("/{sessionNum}/report-draft")
|
||||||
|
public CommonResult<String> getReportDraft(@PathVariable("sessionNum") String sessionNum) throws ErrorParameterException {
|
||||||
|
return CommonResult.success("请求成功", studySessionsServiceImpl.generateReportDraft(sessionNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询任务的残片历史 */
|
||||||
|
@GetMapping("/tasks/{taskNum}/fragments")
|
||||||
|
public CommonResult<Page<StudyReportFragmentsEntity>> getTaskFragments(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
return CommonResult.success(studySessionsServiceImpl.getTaskFragments(taskNum, page, size, keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询任务的报告历史 */
|
||||||
|
@GetMapping("/tasks/{taskNum}/reports")
|
||||||
|
public CommonResult<Page<StudyReportsEntity>> getTaskReports(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
return CommonResult.success(studySessionsServiceImpl.getTaskReports(taskNum, page, size, keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询当前是否有活跃会话(用于跨页面恢复 + 阻止多任务) */
|
||||||
|
@GetMapping("/active")
|
||||||
|
public CommonResult<StudySessionResponse> getActiveSession(
|
||||||
|
@RequestParam(required = false) String excludeTaskNum) {
|
||||||
|
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
import com.guo.learningprogresstracker.common.Ops;
|
import com.guo.learningprogresstracker.common.Ops;
|
||||||
import com.guo.learningprogresstracker.dto.TaskInfo;
|
import com.guo.learningprogresstracker.dto.TaskInfo;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||||
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
||||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||||
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
import com.guo.learningprogresstracker.service.PriorityWeightsService;
|
||||||
import com.guo.learningprogresstracker.service.TasksService;
|
import com.guo.learningprogresstracker.service.TasksService;
|
||||||
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -27,6 +33,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.rmi.ServerException;
|
import java.rmi.ServerException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务控制层
|
* 任务控制层
|
||||||
@@ -43,10 +50,25 @@ public class TaskController {
|
|||||||
|
|
||||||
private final StudySessionsServiceImpl studySessionsServiceImpl;
|
private final StudySessionsServiceImpl studySessionsServiceImpl;
|
||||||
|
|
||||||
|
private final PriorityWeightsService priorityWeightsService;
|
||||||
|
|
||||||
|
@GetMapping("/priority-weights")
|
||||||
|
@Operation(summary = "获取优先级维度权重配置")
|
||||||
|
public CommonResult<UserPriorityWeightsEntity> getPriorityWeights() {
|
||||||
|
return CommonResult.success(priorityWeightsService.getWeights());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/priority-weights")
|
||||||
|
@Operation(summary = "保存优先级维度权重配置并重算全部任务优先级")
|
||||||
|
public CommonResult<UserPriorityWeightsEntity> savePriorityWeights(
|
||||||
|
@RequestBody UserPriorityWeightsEntity weights) throws ErrorParameterException {
|
||||||
|
return CommonResult.success(priorityWeightsService.saveWeights(weights));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@Operation(summary = "添加新任务")
|
@Operation(summary = "添加新任务")
|
||||||
public CommonResult<String> addTask(@RequestBody @Validated({Ops.CreateG.class}) TaskRequest taskRequest) throws ErrorParameterException {
|
public CommonResult<String> addTask(@RequestBody @Validated({Ops.CreateG.class}) TaskRequest taskRequest) throws ErrorParameterException {
|
||||||
return CommonResult.success(tasksService.addTask(taskRequest));
|
return CommonResult.success("请求成功", tasksService.addTask(taskRequest));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "任务详情API")
|
@Operation(summary = "任务详情API")
|
||||||
@@ -69,6 +91,36 @@ public class TaskController {
|
|||||||
return CommonResult.success();
|
return CommonResult.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{taskNum}/applications")
|
||||||
|
@Operation(summary = "获取任务应用场景列表")
|
||||||
|
public CommonResult<List<TaskApplicationEntity>> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException {
|
||||||
|
return CommonResult.success(tasksService.getApplications(taskNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{taskNum}/applications")
|
||||||
|
@Operation(summary = "创建任务应用场景")
|
||||||
|
public CommonResult<TaskApplicationEntity> createApplication(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@Valid @RequestBody CreateTaskApplicationRequest request) throws NotFindEntitiesException {
|
||||||
|
request.setTaskNum(taskNum);
|
||||||
|
return CommonResult.success(tasksService.createApplication(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/applications/{id}")
|
||||||
|
@Operation(summary = "更新任务应用场景")
|
||||||
|
public CommonResult<TaskApplicationEntity> updateApplication(
|
||||||
|
@PathVariable Integer id,
|
||||||
|
@Valid @RequestBody UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
|
||||||
|
return CommonResult.success(tasksService.updateApplication(id, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/applications/{id}")
|
||||||
|
@Operation(summary = "删除任务应用场景")
|
||||||
|
public CommonResult<Void> deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException {
|
||||||
|
tasksService.deleteApplication(id);
|
||||||
|
return CommonResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "获取所有任务列表")
|
@Operation(summary = "获取所有任务列表")
|
||||||
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
|
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ public class TaskInfo {
|
|||||||
private String taskName;
|
private String taskName;
|
||||||
// 任务描述
|
// 任务描述
|
||||||
private String taskDescription;
|
private String taskDescription;
|
||||||
|
// 学习材料地址
|
||||||
|
private String materialUrl;
|
||||||
// 任务优先级
|
// 任务优先级
|
||||||
private Double taskPriority;
|
private Double taskPriority;
|
||||||
// 上次该任务学习情况
|
// 上次该任务学习情况
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CreateTaskApplicationRequest {
|
||||||
|
|
||||||
|
private String taskNum;
|
||||||
|
|
||||||
|
@NotBlank(message = "应用项目标题不可为空")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private String resourceUrl;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户回忆对比请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class RecallCompareRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "回忆大纲不可为空")
|
||||||
|
private String recallOutline;
|
||||||
|
|
||||||
|
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */
|
||||||
|
private String focusPath;
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新标准思维导图(大纲文本形式)请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UpdateStandardMindMapRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "思维导图大纲不可为空")
|
||||||
|
private String outline;
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UpdateTaskApplicationRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "应用项目标题不可为空")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private String resourceUrl;
|
||||||
|
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建/更新学习预期请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UpsertExpectationRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "学习预期不可为空")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ public class StudySessionResponse {
|
|||||||
@Schema(description = "任务编号")
|
@Schema(description = "任务编号")
|
||||||
private String taskNum;
|
private String taskNum;
|
||||||
|
|
||||||
|
@Schema(description = "学习材料(Markdown)")
|
||||||
|
private String materialUrl;
|
||||||
|
|
||||||
|
@Schema(description = "任务ID")
|
||||||
|
private Integer taskId;
|
||||||
|
|
||||||
@Schema(description = "学习开始时间")
|
@Schema(description = "学习开始时间")
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class TaskInfoResponse {
|
|||||||
*/
|
*/
|
||||||
private String materialUrl;
|
private String materialUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
/**
|
||||||
* 用户设置的任务紧急性
|
* 用户设置的任务紧急性
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复习回忆与标准导图的对比记录
|
||||||
|
*/
|
||||||
|
@TableName(value = "review_recall_records")
|
||||||
|
@Data
|
||||||
|
public class ReviewRecallRecordEntity extends BaseEntity implements Serializable {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@TableField(value = "task_num")
|
||||||
|
private String taskNum;
|
||||||
|
|
||||||
|
@TableField(value = "standard_map_id")
|
||||||
|
private Integer standardMapId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复习起点节点路径(以 / 分隔),null 表示任务维度复习
|
||||||
|
*/
|
||||||
|
@TableField(value = "focus_path")
|
||||||
|
private String focusPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户回忆绘制的导图大纲文本
|
||||||
|
*/
|
||||||
|
@TableField(value = "recall_content")
|
||||||
|
private String recallContent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与标准导图的结构对比结果 JSON
|
||||||
|
*/
|
||||||
|
@TableField(value = "compare_result")
|
||||||
|
private String compareResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回忆覆盖率(0-1)
|
||||||
|
*/
|
||||||
|
@TableField(value = "recall_ratio")
|
||||||
|
private Double recallRatio;
|
||||||
|
|
||||||
|
@TableField(value = "matched_count")
|
||||||
|
private Integer matchedCount;
|
||||||
|
|
||||||
|
@TableField(value = "missed_count")
|
||||||
|
private Integer missedCount;
|
||||||
|
|
||||||
|
@TableField(value = "extra_count")
|
||||||
|
private Integer extraCount;
|
||||||
|
|
||||||
|
@TableField(exist = false)
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改
|
||||||
|
*/
|
||||||
|
@TableName(value = "review_standard_mind_maps")
|
||||||
|
@Data
|
||||||
|
public class ReviewStandardMindMapEntity 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准导图统一树结构 JSON(根节点:{title, notes, children})
|
||||||
|
*/
|
||||||
|
@TableField(value = "content")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩进大纲文本,供前端展示与用户编辑
|
||||||
|
*/
|
||||||
|
@TableField(value = "outline")
|
||||||
|
private String outline;
|
||||||
|
|
||||||
|
@TableField(value = "summary")
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成来源:BUILTIN/AI/USER
|
||||||
|
*/
|
||||||
|
@TableField(value = "generator")
|
||||||
|
private String generator;
|
||||||
|
|
||||||
|
@TableField(value = "generator_version")
|
||||||
|
private String generatorVersion;
|
||||||
|
|
||||||
|
@TableField(value = "source_report_count")
|
||||||
|
private Integer sourceReportCount;
|
||||||
|
|
||||||
|
@TableField(value = "source_fragment_count")
|
||||||
|
private Integer sourceFragmentCount;
|
||||||
|
|
||||||
|
@TableField(value = "generated_time")
|
||||||
|
private LocalDateTime generatedTime;
|
||||||
|
|
||||||
|
@TableField(exist = false)
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储每次学习开始前的预期
|
||||||
|
*/
|
||||||
|
@TableName(value = "study_expectations")
|
||||||
|
@Data
|
||||||
|
public class StudyExpectationsEntity extends BaseEntity implements Serializable {
|
||||||
|
|
||||||
|
@TableId(value = "expectation_id", type = IdType.AUTO)
|
||||||
|
private Integer expectationId;
|
||||||
|
|
||||||
|
@TableField(value = "session_num")
|
||||||
|
private String sessionNum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学习预期的详细描述
|
||||||
|
*/
|
||||||
|
@TableField(value = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@TableField(exist = false)
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ public class StudyReportsEntity extends BaseEntity implements Serializable {
|
|||||||
@TableField(value = "content")
|
@TableField(value = "content")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对应学习会话的预期目标(非数据库字段,查询时填充)
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String sessionExpectation;
|
||||||
|
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@TableName(value = "task_applications")
|
||||||
|
@Data
|
||||||
|
public class TaskApplicationEntity 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 = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@TableField(value = "resource_url")
|
||||||
|
private String resourceUrl;
|
||||||
|
|
||||||
|
@TableField(value = "status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@TableField(exist = false)
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户自定义的任务优先级维度权重(每用户一行,依赖多租户拦截器隔离)
|
||||||
|
*/
|
||||||
|
@TableName(value = "user_priority_weights")
|
||||||
|
@Data
|
||||||
|
public class UserPriorityWeightsEntity extends BaseEntity implements Serializable {
|
||||||
|
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@TableField(value = "urgency_weight")
|
||||||
|
private Double urgencyWeight;
|
||||||
|
|
||||||
|
@TableField(value = "importance_weight")
|
||||||
|
private Double importanceWeight;
|
||||||
|
|
||||||
|
@TableField(value = "content_difficulty_weight")
|
||||||
|
private Double contentDifficultyWeight;
|
||||||
|
|
||||||
|
@TableField(value = "future_value_weight")
|
||||||
|
private Double futureValueWeight;
|
||||||
|
|
||||||
|
@TableField(value = "subjective_priority_weight")
|
||||||
|
private Double subjectivePriorityWeight;
|
||||||
|
|
||||||
|
@TableField(exist = false)
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 系统默认权重 */
|
||||||
|
public static UserPriorityWeightsEntity defaults() {
|
||||||
|
UserPriorityWeightsEntity entity = new UserPriorityWeightsEntity();
|
||||||
|
entity.setUrgencyWeight(0.35);
|
||||||
|
entity.setImportanceWeight(0.25);
|
||||||
|
entity.setContentDifficultyWeight(0.20);
|
||||||
|
entity.setFutureValueWeight(0.10);
|
||||||
|
entity.setSubjectivePriorityWeight(0.10);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.guo.learningprogresstracker.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum TaskApplicationStatusEnum {
|
||||||
|
TODO("TODO", "待应用"),
|
||||||
|
DOING("DOING", "应用中"),
|
||||||
|
DONE("DONE", "已完成");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TaskApplicationStatusEnum fromCodeOrDefault(String code) {
|
||||||
|
if (code == null || code.trim().isEmpty()) {
|
||||||
|
return TODO;
|
||||||
|
}
|
||||||
|
String normalized = code.trim().toUpperCase(Locale.ROOT);
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(status -> status.code.equals(normalized))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(TODO);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.guo.learningprogresstracker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ReviewRecallRecordMapper extends BaseMapper<ReviewRecallRecordEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.guo.learningprogresstracker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ReviewStandardMindMapMapper extends BaseMapper<ReviewStandardMindMapEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.guo.learningprogresstracker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface StudyExpectationsMapper extends BaseMapper<StudyExpectationsEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.guo.learningprogresstracker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface TaskApplicationMapper extends BaseMapper<TaskApplicationEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.guo.learningprogresstracker.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserPriorityWeightsMapper extends BaseMapper<UserPriorityWeightsEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 思维导图 AI 生成客户端抽象。
|
||||||
|
* <p>后续接入真实 AI 时实现此接口,通过 Spring {@code @Profile} / {@code @ConditionalOnProperty} 切换。
|
||||||
|
* 当前由内置规则生成器 {@code BuiltinMindMapGenerator} 充当。</p>
|
||||||
|
*/
|
||||||
|
public interface MindMapAiClient {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否可用(例如:API Key 已配置)
|
||||||
|
*/
|
||||||
|
boolean isAvailable();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据学习数据生成标准思维导图根节点
|
||||||
|
*
|
||||||
|
* @param task 学习任务
|
||||||
|
* @param reports 该任务的全部学习报告
|
||||||
|
* @param fragments 该任务的全部学习残片
|
||||||
|
* @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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取生成器标识(如 {@code BUILTIN} / {@code AI-4.5})
|
||||||
|
*/
|
||||||
|
String generatorName();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.guo.learningprogresstracker.service;
|
||||||
|
|
||||||
|
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||||
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先级权重配置服务
|
||||||
|
*/
|
||||||
|
public interface PriorityWeightsService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的权重配置,不存在时返回系统默认值
|
||||||
|
*/
|
||||||
|
UserPriorityWeightsEntity getWeights();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存权重配置并重算当前用户全部任务的优先级。
|
||||||
|
* 五项权重之和必须为 1(容差 0.001)。
|
||||||
|
*/
|
||||||
|
UserPriorityWeightsEntity saveWeights(UserPriorityWeightsEntity weights) throws ErrorParameterException;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ public interface ReviewService {
|
|||||||
/**
|
/**
|
||||||
* 获取复习 feed 列表,合并报告和残片按时间倒序
|
* 获取复习 feed 列表,合并报告和残片按时间倒序
|
||||||
*/
|
*/
|
||||||
List<ReviewFeedItem> getReviewFeed(int limit);
|
List<ReviewFeedItem> getReviewFeed(int limit, String mode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有任务的复习统计数据
|
* 获取所有任务的复习统计数据
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.guo.learningprogresstracker.service;
|
||||||
|
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
|
||||||
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准思维导图服务。
|
||||||
|
* <p>负责标准导图的按需生成、查询、用户编辑,以及用户回忆导图的对比。</p>
|
||||||
|
*/
|
||||||
|
public interface StandardMindMapService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定任务的标准思维导图,不存在时自动生成。
|
||||||
|
*/
|
||||||
|
ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制重新生成指定任务的标准思维导图。
|
||||||
|
*/
|
||||||
|
ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量更新:保留用户编辑过的节点,合并最新生成的节点。
|
||||||
|
*/
|
||||||
|
ReviewStandardMindMapEntity incrementalGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户手动编辑标准思维导图(用缩进大纲文本替换)。
|
||||||
|
*/
|
||||||
|
ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户提交回忆大纲,与标准导图对比并返回结果。
|
||||||
|
*
|
||||||
|
* @param recallOutline 用户回忆的缩进大纲文本
|
||||||
|
* @param focusPath 可选:标准导图中的节点路径(/ 分隔),指定后仅对比该子树
|
||||||
|
*/
|
||||||
|
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath) throws NotFindEntitiesException, OperationFailedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据内容文本在标准导图中查找最匹配的节点,返回节点路径。
|
||||||
|
*
|
||||||
|
* @return { "path": "节点A / 节点B", "nodeTitle": "节点B", "score": 0.85 }
|
||||||
|
*/
|
||||||
|
Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取该任务的所有回忆对比记录。
|
||||||
|
*/
|
||||||
|
List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单条对比记录详情。
|
||||||
|
*/
|
||||||
|
ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.guo.learningprogresstracker.service;
|
||||||
|
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||||
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学习预期服务
|
||||||
|
*/
|
||||||
|
public interface StudyExpectationsService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为学习会话创建学习预期(每个会话最多一条,重复创建则覆盖)
|
||||||
|
*/
|
||||||
|
StudyExpectationsEntity upsertExpectation(String sessionNum, String description) throws ErrorParameterException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询学习会话的学习预期,不存在时返回 null
|
||||||
|
*/
|
||||||
|
StudyExpectationsEntity getBySessionNum(String sessionNum);
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ package com.guo.learningprogresstracker.service;
|
|||||||
|
|
||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
|
||||||
@@ -25,4 +29,10 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
|
|||||||
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
|
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
|
||||||
|
|
||||||
void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException;
|
void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException;
|
||||||
|
|
||||||
|
Page<StudyReportFragmentsEntity> getTaskFragments(String taskNum, int page, int size, String keyword);
|
||||||
|
|
||||||
|
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
|
||||||
|
|
||||||
|
StudySessionResponse getActiveSession(String excludeTaskNum);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ package com.guo.learningprogresstracker.service;
|
|||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import com.guo.learningprogresstracker.dto.TaskInfo;
|
import com.guo.learningprogresstracker.dto.TaskInfo;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
|
||||||
import java.rmi.ServerException;
|
import java.rmi.ServerException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guo
|
* @author guo
|
||||||
@@ -26,4 +31,12 @@ public interface TasksService extends IService<TaskEntity> {
|
|||||||
void updateTask(String taskId, TaskRequest updatedTask);
|
void updateTask(String taskId, TaskRequest updatedTask);
|
||||||
|
|
||||||
void deleteTask(String taskId) throws ServerException;
|
void deleteTask(String taskId) throws ServerException;
|
||||||
|
|
||||||
|
List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException;
|
||||||
|
|
||||||
|
TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException;
|
||||||
|
|
||||||
|
TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException;
|
||||||
|
|
||||||
|
void deleteApplication(Integer id) throws NotFindEntitiesException;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lpt-ai 独立 AI 服务客户端。
|
||||||
|
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
|
||||||
|
* <p>
|
||||||
|
* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果,
|
||||||
|
* 避免同步等待 LLM 响应时阻塞 HTTP 连接。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "lpt.ai-service")
|
||||||
|
@Setter
|
||||||
|
public class AiServiceClient {
|
||||||
|
|
||||||
|
/** lpt-ai 服务地址,如 http://localhost:5199 */
|
||||||
|
private String url;
|
||||||
|
/** 超时秒数(等待 AI 任务完成的最长时间) */
|
||||||
|
private int timeoutSeconds = 600;
|
||||||
|
|
||||||
|
private HttpClient httpClient;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
if (isConfigured()) {
|
||||||
|
log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds);
|
||||||
|
} else {
|
||||||
|
log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfigured() {
|
||||||
|
return url != null && !url.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 公开 API(签名不变,内部改为 submit + poll)============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 lpt-ai 将残片聚合为学习报告草稿。
|
||||||
|
*/
|
||||||
|
public Optional<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
|
||||||
|
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Map<String, String>> fragmentBodies = fragments.stream()
|
||||||
|
.map(content -> Map.of("content", content))
|
||||||
|
.toList();
|
||||||
|
Map<String, Object> params = expectation == null || expectation.isBlank()
|
||||||
|
? Map.of("taskName", taskName, "fragments", fragmentBodies)
|
||||||
|
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
|
||||||
|
|
||||||
|
Optional<JsonNode> result = submitAndWait("aggregate-report", params);
|
||||||
|
return result.map(r -> r.path("report").asText(null)).filter(s -> s != null && !s.isBlank());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 lpt-ai 从学习数据生成思维导图大纲。
|
||||||
|
*/
|
||||||
|
public Optional<String> generateMindMap(String taskName, String taskDescription,
|
||||||
|
List<String> reports, List<String> fragments) {
|
||||||
|
if (!isConfigured()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> params = Map.of(
|
||||||
|
"taskName", taskName,
|
||||||
|
"taskDescription", taskDescription != null ? taskDescription : "",
|
||||||
|
"reports", reports != null ? reports : List.of(),
|
||||||
|
"fragments", fragments != null ? fragments : List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
|
||||||
|
return result.map(r -> r.path("outline").asText(null)).filter(s -> s != null && !s.isBlank());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 lpt-ai 进行语义化回忆对比。
|
||||||
|
*/
|
||||||
|
public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
|
||||||
|
if (!isConfigured()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> params = Map.of(
|
||||||
|
"taskName", taskName,
|
||||||
|
"standardOutline", standardOutline,
|
||||||
|
"recallOutline", recallOutline
|
||||||
|
);
|
||||||
|
|
||||||
|
return submitAndWait("compare-recall", params);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("lpt-ai 回忆对比异常,将降级为内置算法: {}", e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 内部异步 submit + poll ============
|
||||||
|
|
||||||
|
/** 提交任务并同步等待结果(内部轮询,调用方无感知) */
|
||||||
|
private Optional<JsonNode> submitAndWait(String type, Map<String, Object> params) {
|
||||||
|
String taskId = submitTask(type, params);
|
||||||
|
if (taskId == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return pollResult(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交任务到 lpt-ai /ai/tasks */
|
||||||
|
private String submitTask(String type, Map<String, Object> params) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = Map.of("type", type, "params", params);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url + "/ai/tasks"))
|
||||||
|
.timeout(Duration.ofSeconds(30)) // 提交本身不应超时
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 201) {
|
||||||
|
log.warn("lpt-ai 提交任务失败: type={}, status={}, body={}",
|
||||||
|
type, response.statusCode(),
|
||||||
|
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonNode json = objectMapper.readTree(response.body());
|
||||||
|
String taskId = json.path("taskId").asText(null);
|
||||||
|
if (taskId == null || taskId.isBlank()) {
|
||||||
|
log.warn("lpt-ai 提交任务成功但未返回 taskId");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("lpt-ai 任务已提交: type={}, taskId={}", type, taskId);
|
||||||
|
return taskId;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("lpt-ai 提交任务异常: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询等待任务完成 */
|
||||||
|
private Optional<JsonNode> pollResult(String taskId) {
|
||||||
|
Instant deadline = Instant.now().plusSeconds(timeoutSeconds);
|
||||||
|
int pollCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 给 worker 一点启动时间
|
||||||
|
Thread.sleep(500);
|
||||||
|
|
||||||
|
while (Instant.now().isBefore(deadline)) {
|
||||||
|
pollCount++;
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url + "/ai/tasks/" + taskId))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() == 404) {
|
||||||
|
log.warn("lpt-ai 任务 {} 不存在(可能已过期或被清理)", taskId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.warn("lpt-ai 查询任务失败: taskId={}, status={}", taskId, response.statusCode());
|
||||||
|
// 临时故障,继续轮询
|
||||||
|
Thread.sleep(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode json = objectMapper.readTree(response.body());
|
||||||
|
String status = json.path("status").asText("");
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "done":
|
||||||
|
log.info("lpt-ai 任务完成: taskId={}, 轮询次数={}", taskId, pollCount);
|
||||||
|
return Optional.ofNullable(json.path("result"));
|
||||||
|
case "failed":
|
||||||
|
String err = json.path("error").asText("未知错误");
|
||||||
|
log.warn("lpt-ai 任务失败: taskId={}, error={}", taskId, err);
|
||||||
|
return Optional.empty();
|
||||||
|
case "pending":
|
||||||
|
case "running":
|
||||||
|
// 还在处理,继续轮询
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.warn("lpt-ai 未知任务状态: taskId={}, status={}", taskId, status);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("lpt-ai 轮询异常 (第{}次): {}", pollCount, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退避:前 10 次 2s,之后 5s
|
||||||
|
long delay = pollCount <= 10 ? 2000 : 5000;
|
||||||
|
Thread.sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("lpt-ai 任务超时: taskId={}, timeout={}s", taskId, timeoutSeconds);
|
||||||
|
return Optional.empty();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.warn("lpt-ai 轮询被中断: taskId={}", taskId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
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;
|
||||||
|
import com.guo.learningprogresstracker.service.MindMapAiClient;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。
|
||||||
|
* <p>规则策略:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>根节点 = 任务名称</li>
|
||||||
|
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
|
||||||
|
* <li>二级分支 = 该会话下的残片标题</li>
|
||||||
|
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
|
||||||
|
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
|
||||||
|
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class BuiltinMindMapGenerator implements MindMapAiClient {
|
||||||
|
|
||||||
|
private static final int MAX_TITLE_LENGTH = 60;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return true; // 始终可用
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String generatorName() {
|
||||||
|
return "BUILTIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<MindMapNode> generate(TaskEntity task,
|
||||||
|
List<StudyReportsEntity> reports,
|
||||||
|
List<StudyReportFragmentsEntity> fragments,
|
||||||
|
List<TaskApplicationEntity> applications,
|
||||||
|
String clientHint) {
|
||||||
|
if (reports.isEmpty() && fragments.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
MindMapNode root = new MindMapNode(task.getTaskName() != null ? task.getTaskName() : "学习任务");
|
||||||
|
root.setNotes(task.getTaskDescription() != null ? task.getTaskDescription() : "");
|
||||||
|
|
||||||
|
// 按 session 分组
|
||||||
|
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
|
||||||
|
.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 (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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
||||||
|
|
||||||
|
// 报告作为子节点
|
||||||
|
for (StudyReportsEntity report : sessReports) {
|
||||||
|
String content = report.getContent();
|
||||||
|
if (content == null || content.isBlank()) continue;
|
||||||
|
MindMapNode reportNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
|
||||||
|
reportNode.setNotes(content);
|
||||||
|
reportNode.setSourceType("REPORT");
|
||||||
|
reportNode.setSourceId(report.getId());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用场景分支(如有)
|
||||||
|
if (applications != null && !applications.isEmpty()) {
|
||||||
|
MindMapNode appNode = new MindMapNode("应用场景");
|
||||||
|
for (TaskApplicationEntity app : applications) {
|
||||||
|
if (app.getTitle() == null || app.getTitle().isBlank()) continue;
|
||||||
|
MindMapNode child = new MindMapNode(app.getTitle());
|
||||||
|
child.setNotes(app.getDescription() != null ? app.getDescription() : "");
|
||||||
|
child.setSourceType("APPLICATION");
|
||||||
|
child.setSourceId(app.getId());
|
||||||
|
appNode.getChildren().add(child);
|
||||||
|
}
|
||||||
|
if (!appNode.getChildren().isEmpty()) {
|
||||||
|
root.getChildren().add(appNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重:同级 title 标准化后合并
|
||||||
|
deduplicateChildren(root);
|
||||||
|
|
||||||
|
log.info("BuiltinMindMapGenerator: 为任务[{}]生成导图,共 {} 个节点,{} 层",
|
||||||
|
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
|
||||||
|
return Optional.of(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同级节点按标准化标题去重 */
|
||||||
|
private void deduplicateChildren(MindMapNode node) {
|
||||||
|
if (node.getChildren() == null || node.getChildren().isEmpty()) return;
|
||||||
|
|
||||||
|
Map<String, MindMapNode> seen = new LinkedHashMap<>();
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
String key = normalize(child.getTitle());
|
||||||
|
seen.merge(key, child, (a, b) -> {
|
||||||
|
// 保留 notes 较长的那个
|
||||||
|
if (b.getNotes() != null && b.getNotes().length() > (a.getNotes() != null ? a.getNotes().length() : 0)) {
|
||||||
|
a.setNotes(b.getNotes());
|
||||||
|
}
|
||||||
|
// 合并子节点
|
||||||
|
if (b.getChildren() != null) {
|
||||||
|
a.getChildren().addAll(b.getChildren());
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
node.setChildren(new ArrayList<>(seen.values()));
|
||||||
|
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
deduplicateChildren(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
|
||||||
|
.toLowerCase(Locale.ROOT)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String s, int maxLen) {
|
||||||
|
if (s == null) return "";
|
||||||
|
if (s.length() <= maxLen) return s;
|
||||||
|
return s.substring(0, maxLen) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatDate(LocalDateTime dt) {
|
||||||
|
if (dt == null) return "";
|
||||||
|
return dt.format(DateTimeFormatter.ofPattern("MM-dd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.guo.learningprogresstracker.dto.PriorityDto;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||||
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.UserPriorityWeightsMapper;
|
||||||
|
import com.guo.learningprogresstracker.service.PriorityWeightsService;
|
||||||
|
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先级权重配置服务实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PriorityWeightsServiceImpl implements PriorityWeightsService {
|
||||||
|
|
||||||
|
private static final double SUM_TOLERANCE = 0.001;
|
||||||
|
|
||||||
|
private final UserPriorityWeightsMapper weightsMapper;
|
||||||
|
private final TasksMapper tasksMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserPriorityWeightsEntity getWeights() {
|
||||||
|
UserPriorityWeightsEntity existing = weightsMapper.selectOne(
|
||||||
|
Wrappers.<UserPriorityWeightsEntity>lambdaQuery().last("LIMIT 1"));
|
||||||
|
return existing != null ? existing : UserPriorityWeightsEntity.defaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public UserPriorityWeightsEntity saveWeights(UserPriorityWeightsEntity weights) throws ErrorParameterException {
|
||||||
|
validate(weights);
|
||||||
|
|
||||||
|
UserPriorityWeightsEntity existing = weightsMapper.selectOne(
|
||||||
|
Wrappers.<UserPriorityWeightsEntity>lambdaQuery().last("LIMIT 1"));
|
||||||
|
if (existing == null) {
|
||||||
|
weightsMapper.insert(weights);
|
||||||
|
} else {
|
||||||
|
weights.setId(existing.getId());
|
||||||
|
weightsMapper.updateById(weights);
|
||||||
|
}
|
||||||
|
|
||||||
|
recalculateAllTasks(weights);
|
||||||
|
return weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用新权重重算当前用户的全部任务优先级 */
|
||||||
|
private void recalculateAllTasks(UserPriorityWeightsEntity weights) {
|
||||||
|
List<TaskEntity> tasks = tasksMapper.selectList(Wrappers.lambdaQuery(TaskEntity.class));
|
||||||
|
for (TaskEntity task : tasks) {
|
||||||
|
PriorityDto dto = new PriorityDto();
|
||||||
|
dto.setUrgency(orZero(task.getUrgency()));
|
||||||
|
dto.setImportance(orZero(task.getImportance()));
|
||||||
|
dto.setContentDifficulty(orZero(task.getContentDifficulty()));
|
||||||
|
dto.setFutureValue(orZero(task.getFutureValue()));
|
||||||
|
dto.setSubjectivePriority(orZero(task.getSubjectivePriority()));
|
||||||
|
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(dto, weights));
|
||||||
|
tasksMapper.updateById(task);
|
||||||
|
}
|
||||||
|
log.info("权重更新,已重算 {} 个任务的优先级", tasks.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(UserPriorityWeightsEntity weights) throws ErrorParameterException {
|
||||||
|
double[] values = {
|
||||||
|
orZero(weights.getUrgencyWeight()),
|
||||||
|
orZero(weights.getImportanceWeight()),
|
||||||
|
orZero(weights.getContentDifficultyWeight()),
|
||||||
|
orZero(weights.getFutureValueWeight()),
|
||||||
|
orZero(weights.getSubjectivePriorityWeight())};
|
||||||
|
double sum = 0;
|
||||||
|
for (double v : values) {
|
||||||
|
if (v < 0 || v > 1) {
|
||||||
|
throw new ErrorParameterException("权重必须在 0 到 1 之间");
|
||||||
|
}
|
||||||
|
sum += v;
|
||||||
|
}
|
||||||
|
if (Math.abs(sum - 1.0) > SUM_TOLERANCE) {
|
||||||
|
throw new ErrorParameterException("五项权重之和必须为 1,当前为 " + sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int orZero(Integer v) {
|
||||||
|
return v == null ? 0 : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double orZero(Double v) {
|
||||||
|
return v == null ? 0 : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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;
|
||||||
|
import com.guo.learningprogresstracker.service.MindMapAiClient;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远程 AI 思维导图生成客户端。
|
||||||
|
* <p>通过 lpt-ai 服务({@code lpt.ai-service.url})调用大语言模型生成思维导图。
|
||||||
|
* 未配置 lpt-ai 时不可用,会回退到 {@link BuiltinMindMapGenerator}。</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RemoteAiMindMapClient implements MindMapAiClient {
|
||||||
|
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return aiServiceClient.isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String generatorName() {
|
||||||
|
return "AI";
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> optOutline = aiServiceClient.generateMindMap(
|
||||||
|
task.getTaskName(),
|
||||||
|
task.getTaskDescription(),
|
||||||
|
reportTexts,
|
||||||
|
fragmentTexts
|
||||||
|
);
|
||||||
|
|
||||||
|
if (optOutline.isEmpty() || optOutline.get().isBlank()) {
|
||||||
|
log.warn("RemoteAiMindMapClient: lpt-ai 返回空大纲,降级");
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String outline = optOutline.get();
|
||||||
|
MindMapNode root = MindMapTreeTool.parseOutline(outline);
|
||||||
|
log.info("RemoteAiMindMapClient: 为任务[{}]生成 AI 导图,共 {} 个节点,{} 层",
|
||||||
|
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
|
||||||
|
return Optional.of(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ package com.guo.learningprogresstracker.service.impl;
|
|||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||||
@@ -31,24 +33,136 @@ import java.util.stream.Stream;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReviewServiceImpl implements ReviewService {
|
public class ReviewServiceImpl implements ReviewService {
|
||||||
|
|
||||||
|
private static final int DEFAULT_LIMIT = 30;
|
||||||
|
private static final int MAX_LIMIT = 100;
|
||||||
|
private static final String RANDOM_MODE = "random";
|
||||||
|
private static final String SMART_MODE = "smart";
|
||||||
|
private static final int SMART_CANDIDATE_MULTIPLIER = 5;
|
||||||
|
|
||||||
private final StudyReportsMapper studyReportsMapper;
|
private final StudyReportsMapper studyReportsMapper;
|
||||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||||
private final StudySessionsMapper studySessionsMapper;
|
private final StudySessionsMapper studySessionsMapper;
|
||||||
private final TasksMapper tasksMapper;
|
private final TasksMapper tasksMapper;
|
||||||
|
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ReviewFeedItem> getReviewFeed(int limit) {
|
public List<ReviewFeedItem> getReviewFeed(int limit, String mode) {
|
||||||
|
int safeLimit = normalizeLimit(limit);
|
||||||
|
if (SMART_MODE.equalsIgnoreCase(mode)) {
|
||||||
|
return getSmartFeed(safeLimit);
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||||
|
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
|
||||||
|
.last("LIMIT " + safeLimit));
|
||||||
|
|
||||||
|
List<ReviewFeedItem> items = mergeAndConvert(reports, fragments);
|
||||||
|
if (random) {
|
||||||
|
Collections.shuffle(items);
|
||||||
|
}
|
||||||
|
return items.stream().limit(safeLimit).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能模式:加权随机采样,让更需要复习的内容有更高概率出现。
|
||||||
|
* 权重 = 时间衰减(越久未见权重越高) × 任务回忆掌握度(覆盖率越低权重越高)。
|
||||||
|
* 仍保持随机性,只是"随机得不那么均匀"——符合复习模块无压力、偶遇式的设计理念。
|
||||||
|
*/
|
||||||
|
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
|
||||||
|
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
|
||||||
|
|
||||||
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
|
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
|
||||||
Wrappers.<StudyReportsEntity>lambdaQuery()
|
Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||||
.orderByDesc(StudyReportsEntity::getCreatedTime)
|
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
||||||
.last("LIMIT " + limit));
|
|
||||||
|
|
||||||
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
||||||
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||||
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
|
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
||||||
.last("LIMIT " + limit));
|
|
||||||
|
|
||||||
return mergeAndConvert(reports, fragments);
|
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments);
|
||||||
|
if (candidates.size() <= safeLimit) {
|
||||||
|
Collections.shuffle(candidates);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习)
|
||||||
|
Map<String, Double> taskRecallRatio = latestRecallRatioByTask(
|
||||||
|
candidates.stream().map(ReviewFeedItem::getTaskNum)
|
||||||
|
.filter(Objects::nonNull).collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
List<Double> weights = new ArrayList<>(candidates.size());
|
||||||
|
for (ReviewFeedItem item : candidates) {
|
||||||
|
weights.add(reviewNeedWeight(item, taskRecallRatio, now));
|
||||||
|
}
|
||||||
|
return weightedSample(candidates, weights, safeLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条内容的复习需求权重 */
|
||||||
|
private double reviewNeedWeight(ReviewFeedItem item, Map<String, Double> taskRecallRatio, LocalDateTime now) {
|
||||||
|
// 时间衰减:7 天内权重较低,之后随天数增长,90 天封顶
|
||||||
|
double ageDays = item.getCreatedTime() == null ? 30
|
||||||
|
: Math.max(0, java.time.Duration.between(item.getCreatedTime(), now).toDays());
|
||||||
|
double ageFactor = 0.3 + Math.min(ageDays, 90) / 90.0 * 0.7;
|
||||||
|
|
||||||
|
// 掌握度:最近回忆覆盖率越低,权重越高
|
||||||
|
double ratio = item.getTaskNum() == null ? 0
|
||||||
|
: taskRecallRatio.getOrDefault(item.getTaskNum(), 0D);
|
||||||
|
double masteryFactor = 1.0 - ratio * 0.7; // 覆盖率 100% 时权重降至 0.3
|
||||||
|
|
||||||
|
return ageFactor * masteryFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询每个任务最近一次回忆对比的覆盖率 */
|
||||||
|
private Map<String, Double> latestRecallRatioByTask(Set<String> taskNums) {
|
||||||
|
if (taskNums.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
List<ReviewRecallRecordEntity> records = reviewRecallRecordMapper.selectList(
|
||||||
|
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
|
||||||
|
.in(ReviewRecallRecordEntity::getTaskNum, taskNums)
|
||||||
|
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
|
||||||
|
Map<String, Double> result = new HashMap<>();
|
||||||
|
for (ReviewRecallRecordEntity record : records) {
|
||||||
|
result.putIfAbsent(record.getTaskNum(),
|
||||||
|
record.getRecallRatio() == null ? 0D : record.getRecallRatio());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按权重不放回采样 */
|
||||||
|
private List<ReviewFeedItem> weightedSample(List<ReviewFeedItem> items, List<Double> weights, int count) {
|
||||||
|
List<ReviewFeedItem> pool = new ArrayList<>(items);
|
||||||
|
List<Double> poolWeights = new ArrayList<>(weights);
|
||||||
|
List<ReviewFeedItem> selected = new ArrayList<>(count);
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
while (selected.size() < count && !pool.isEmpty()) {
|
||||||
|
double total = poolWeights.stream().mapToDouble(Double::doubleValue).sum();
|
||||||
|
double r = random.nextDouble() * total;
|
||||||
|
double cumulative = 0;
|
||||||
|
int chosen = pool.size() - 1;
|
||||||
|
for (int i = 0; i < pool.size(); i++) {
|
||||||
|
cumulative += poolWeights.get(i);
|
||||||
|
if (r <= cumulative) {
|
||||||
|
chosen = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected.add(pool.remove(chosen));
|
||||||
|
poolWeights.remove(chosen);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -197,6 +311,20 @@ public class ReviewServiceImpl implements ReviewService {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int normalizeLimit(int limit) {
|
||||||
|
if (limit <= 0) {
|
||||||
|
return DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
return Math.min(limit, MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||||
|
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||||
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||||
|
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void fillReviewTaskStats(List<ReviewTaskStats> statsList) {
|
private void fillReviewTaskStats(List<ReviewTaskStats> statsList) {
|
||||||
Map<String, ReviewTaskStats> statsByTaskNum = statsList.stream()
|
Map<String, ReviewTaskStats> statsByTaskNum = statsList.stream()
|
||||||
.filter(item -> StringUtils.hasText(item.getTaskNum()))
|
.filter(item -> StringUtils.hasText(item.getTaskNum()))
|
||||||
|
|||||||
+556
@@ -0,0 +1,556 @@
|
|||||||
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.*;
|
||||||
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||||
|
import com.guo.learningprogresstracker.mapper.*;
|
||||||
|
import com.guo.learningprogresstracker.service.MindMapAiClient;
|
||||||
|
import com.guo.learningprogresstracker.service.StandardMindMapService;
|
||||||
|
import com.guo.learningprogresstracker.utils.CompareResult;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准思维导图服务实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StandardMindMapServiceImpl implements StandardMindMapService {
|
||||||
|
|
||||||
|
private final ReviewStandardMindMapMapper standardMindMapMapper;
|
||||||
|
private final ReviewRecallRecordMapper recallRecordMapper;
|
||||||
|
private final TasksMapper tasksMapper;
|
||||||
|
private final StudyReportsMapper studyReportsMapper;
|
||||||
|
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||||
|
private final TaskApplicationMapper taskApplicationMapper;
|
||||||
|
private final StudySessionsMapper studySessionsMapper;
|
||||||
|
|
||||||
|
private final List<MindMapAiClient> aiClients;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
|
||||||
|
private static final String GENERATOR_BUILTIN = "BUILTIN";
|
||||||
|
private static final String GENERATOR_USER = "USER";
|
||||||
|
private static final String GENERATOR_USER_MERGE = "USER_MERGE";
|
||||||
|
private static final String MATCH_STATUS_MATCHED = "MATCHED";
|
||||||
|
private static final String MATCH_STATUS_MISSED = "MISSED";
|
||||||
|
|
||||||
|
/** 防并发生成:taskNum → 是否正在生成中 */
|
||||||
|
private final ConcurrentHashMap<String, AtomicBoolean> generatingTasks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// ============ 查询与生成 ============
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
||||||
|
return existing != null ? existing : doGenerate(taskNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
// 防并发生成:同一任务正在生成时直接返回当前实体
|
||||||
|
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
||||||
|
if (!lock.compareAndSet(false, true)) {
|
||||||
|
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
||||||
|
log.warn("任务[{}]正在生成中,跳过重复请求", taskNum);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return doGenerate(taskNum);
|
||||||
|
} finally {
|
||||||
|
lock.set(false);
|
||||||
|
generatingTasks.remove(taskNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ReviewStandardMindMapEntity incrementalGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new NotFindEntitiesException("标准思维导图尚不存在,无法增量更新");
|
||||||
|
}
|
||||||
|
// 防并发生成
|
||||||
|
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
||||||
|
if (!lock.compareAndSet(false, true)) {
|
||||||
|
log.warn("任务[{}]正在生成中,跳过重复增量请求", taskNum);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ReviewStandardMindMapEntity fresh = doGenerate(taskNum);
|
||||||
|
// 合并新旧树:保留用户编辑过的节点,追加新节点
|
||||||
|
MindMapNode oldRoot = MindMapTreeTool.fromJson(existing.getContent(), objectMapper);
|
||||||
|
MindMapNode newRoot = MindMapTreeTool.fromJson(fresh.getContent(), objectMapper);
|
||||||
|
MindMapNode merged = MindMapTreeTool.mergeTrees(oldRoot, newRoot);
|
||||||
|
String mergedJson = MindMapTreeTool.toJson(merged, objectMapper);
|
||||||
|
String mergedOutline = MindMapTreeTool.toFullOutline(merged);
|
||||||
|
|
||||||
|
existing.setContent(mergedJson);
|
||||||
|
existing.setOutline(mergedOutline);
|
||||||
|
existing.setTitle(merged.getTitle() != null ? merged.getTitle() : "思维导图");
|
||||||
|
existing.setGenerator(GENERATOR_USER_MERGE);
|
||||||
|
existing.setGeneratorVersion(fresh.getGeneratorVersion());
|
||||||
|
existing.setSummary("增量更新,共 " + MindMapTreeTool.countNodes(merged) + " 个节点");
|
||||||
|
existing.setGeneratedTime(LocalDateTime.now());
|
||||||
|
standardMindMapMapper.updateById(existing);
|
||||||
|
return existing;
|
||||||
|
} finally {
|
||||||
|
lock.set(false);
|
||||||
|
generatingTasks.remove(taskNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
|
||||||
|
if (existing == null) {
|
||||||
|
existing = new ReviewStandardMindMapEntity();
|
||||||
|
existing.setTaskNum(taskNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析大纲为树节点
|
||||||
|
MindMapNode root = MindMapTreeTool.parseOutline(outline);
|
||||||
|
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
|
||||||
|
|
||||||
|
existing.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
|
||||||
|
existing.setContent(contentJson);
|
||||||
|
existing.setOutline(outline);
|
||||||
|
existing.setGenerator(GENERATOR_USER);
|
||||||
|
existing.setGeneratorVersion(null);
|
||||||
|
existing.setSummary("用户编辑,共 " + MindMapTreeTool.countNodes(root) + " 个节点");
|
||||||
|
existing.setGeneratedTime(LocalDateTime.now());
|
||||||
|
|
||||||
|
if (existing.getId() == null) {
|
||||||
|
standardMindMapMapper.insert(existing);
|
||||||
|
} else {
|
||||||
|
standardMindMapMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 回忆对比 ============
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath)
|
||||||
|
throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
|
||||||
|
// 1. 获取标准导图(自动生成)
|
||||||
|
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
|
||||||
|
|
||||||
|
// 2. 解析标准树和用户回忆树
|
||||||
|
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
|
||||||
|
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
|
||||||
|
|
||||||
|
// 2b. 若指定了起点节点路径,提取该子树作为对比基准
|
||||||
|
MindMapNode compareRoot = standardRoot;
|
||||||
|
if (focusPath != null && !focusPath.isBlank()) {
|
||||||
|
compareRoot = MindMapTreeTool.extractSubtree(standardRoot, focusPath);
|
||||||
|
if (compareRoot == null) {
|
||||||
|
log.warn("focusPath 未匹配到节点,使用全量标准导图: path={}", focusPath);
|
||||||
|
compareRoot = standardRoot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
|
||||||
|
CompareResult result;
|
||||||
|
if (aiServiceClient.isConfigured()) {
|
||||||
|
String taskName = tasksMapper.selectOne(
|
||||||
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"))
|
||||||
|
.getTaskName();
|
||||||
|
var optJson = aiServiceClient.compareRecall(
|
||||||
|
taskName,
|
||||||
|
MindMapTreeTool.toFullOutline(compareRoot),
|
||||||
|
recallOutline
|
||||||
|
);
|
||||||
|
if (optJson.isPresent()) {
|
||||||
|
result = buildCompareResultFromAI(optJson.get(), compareRoot);
|
||||||
|
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
|
||||||
|
taskNum, result.getRecallRatio(),
|
||||||
|
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
|
||||||
|
} else {
|
||||||
|
result = compareTrees(compareRoot, recallRoot);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = compareTrees(compareRoot, recallRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 序列化对比结果
|
||||||
|
String resultJson;
|
||||||
|
try {
|
||||||
|
resultJson = objectMapper.writeValueAsString(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new OperationFailedException("对比结果序列化失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 保存回忆记录
|
||||||
|
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
|
||||||
|
record.setTaskNum(taskNum);
|
||||||
|
record.setStandardMapId(standard.getId());
|
||||||
|
record.setFocusPath(focusPath != null && !focusPath.isBlank() ? focusPath : null);
|
||||||
|
record.setRecallContent(recallOutline);
|
||||||
|
record.setCompareResult(resultJson);
|
||||||
|
record.setRecallRatio(result.getRecallRatio());
|
||||||
|
record.setMatchedCount(result.getMatchedCount());
|
||||||
|
record.setMissedCount(result.getMissedCount());
|
||||||
|
record.setExtraCount(result.getExtraCount());
|
||||||
|
recallRecordMapper.insert(record);
|
||||||
|
|
||||||
|
log.info("回忆对比: taskNum={}, recallRatio={}, matched={}, missed={}, extra={}",
|
||||||
|
taskNum, result.getRecallRatio(), result.getMatchedCount(),
|
||||||
|
result.getMissedCount(), result.getExtraCount());
|
||||||
|
|
||||||
|
return standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 节点查找 ============
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
|
||||||
|
MindMapNode root = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
|
||||||
|
|
||||||
|
MindMapNode closest = MindMapTreeTool.findClosestNode(root, content);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
if (closest != null) {
|
||||||
|
String path = MindMapTreeTool.getPath(root, closest.getTitle());
|
||||||
|
result.put("path", path);
|
||||||
|
result.put("nodeTitle", closest.getTitle());
|
||||||
|
result.put("score", MindMapTreeTool.similarityScore(closest.getTitle(), content));
|
||||||
|
} else {
|
||||||
|
result.put("path", "");
|
||||||
|
result.put("nodeTitle", "");
|
||||||
|
result.put("score", 0.0);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 对比算法核心 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两颗树的节点级对比算法。
|
||||||
|
*/
|
||||||
|
CompareResult compareTrees(MindMapNode standardRoot, MindMapNode recallRoot) {
|
||||||
|
CompareResult result = new CompareResult();
|
||||||
|
|
||||||
|
// 展平标准树
|
||||||
|
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
|
||||||
|
// 展平回忆树(排除根节点本身)
|
||||||
|
List<MindMapNode> recallFlat = MindMapTreeTool.flatten(recallRoot).stream()
|
||||||
|
.filter(n -> n != recallRoot)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 构建回忆节点标题 → 节点映射(标准化后)
|
||||||
|
Map<String, MindMapNode> recallTitleMap = new LinkedHashMap<>();
|
||||||
|
for (MindMapNode node : recallFlat) {
|
||||||
|
recallTitleMap.merge(normalize(node.getTitle()), node, (a, b) -> a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标注标准树
|
||||||
|
int matched = 0, missed = 0;
|
||||||
|
for (MindMapNode node : standardFlat) {
|
||||||
|
if (node == standardRoot) continue; // 跳过根节点
|
||||||
|
String key = normalize(node.getTitle());
|
||||||
|
boolean found = recallTitleMap.containsKey(key);
|
||||||
|
if (found) {
|
||||||
|
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
|
||||||
|
matched++;
|
||||||
|
} else {
|
||||||
|
// 尝试模糊匹配
|
||||||
|
found = fuzzyMatch(node.getTitle(), recallTitleMap);
|
||||||
|
if (found) {
|
||||||
|
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
|
||||||
|
matched++;
|
||||||
|
} else {
|
||||||
|
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
|
||||||
|
missed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找出额外节点(用户在回忆中新增的、标准树中没有的)
|
||||||
|
Set<String> standardNormTitles = standardFlat.stream()
|
||||||
|
.map(n -> normalize(n.getTitle()))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
List<CompareResult.FlatNode> extraNodes = new ArrayList<>();
|
||||||
|
for (MindMapNode node : recallFlat) {
|
||||||
|
String key = normalize(node.getTitle());
|
||||||
|
if (!standardNormTitles.contains(key)) {
|
||||||
|
CompareResult.FlatNode flat = new CompareResult.FlatNode();
|
||||||
|
flat.setTitle(node.getTitle());
|
||||||
|
flat.setPath(node.getTitle()); // 简化路径
|
||||||
|
extraNodes.add(flat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = matched + missed;
|
||||||
|
result.setMatchedTree(standardRoot);
|
||||||
|
result.setExtraNodes(extraNodes);
|
||||||
|
result.setMatchedCount(matched);
|
||||||
|
result.setMissedCount(missed);
|
||||||
|
result.setExtraCount(extraNodes.size());
|
||||||
|
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 AI 返回的平铺匹配列表构建 CompareResult,并将 MATCHED/MISSED 标注回标准树。
|
||||||
|
* <p>AI 只输出哪些节点匹配/遗漏,树结构标注由本方法确定性完成,避免 LLM 输出不可靠的嵌套 JSON。</p>
|
||||||
|
*/
|
||||||
|
private CompareResult buildCompareResultFromAI(JsonNode aiJson, MindMapNode standardRoot) {
|
||||||
|
CompareResult result = new CompareResult();
|
||||||
|
|
||||||
|
// 1. 读取 AI 返回的匹配对 → 构建 standardNorm → matchFlag
|
||||||
|
Set<String> matchedStandardTitles = new HashSet<>();
|
||||||
|
JsonNode matchesArr = aiJson.path("matches");
|
||||||
|
if (matchesArr.isArray()) {
|
||||||
|
for (JsonNode m : matchesArr) {
|
||||||
|
String stdTitle = m.path("standardTitle").asText(null);
|
||||||
|
if (stdTitle != null) {
|
||||||
|
matchedStandardTitles.add(normalize(stdTitle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取遗漏列表
|
||||||
|
Set<String> missedTitles = new HashSet<>();
|
||||||
|
JsonNode missedArr = aiJson.path("missedTitles");
|
||||||
|
if (missedArr.isArray()) {
|
||||||
|
for (JsonNode t : missedArr) {
|
||||||
|
String title = t.asText(null);
|
||||||
|
if (title != null) missedTitles.add(normalize(title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 标注标准树的每个非根节点
|
||||||
|
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
|
||||||
|
int matched = 0, missed = 0;
|
||||||
|
for (MindMapNode node : standardFlat) {
|
||||||
|
if (node == standardRoot) continue;
|
||||||
|
String key = normalize(node.getTitle());
|
||||||
|
if (matchedStandardTitles.contains(key)) {
|
||||||
|
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
|
||||||
|
matched++;
|
||||||
|
} else {
|
||||||
|
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
|
||||||
|
missed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 读取 extraNodes(兼容字符串数组和对象数组两种格式)
|
||||||
|
List<CompareResult.FlatNode> extras = new ArrayList<>();
|
||||||
|
JsonNode extrasArr = aiJson.path("extraNodes");
|
||||||
|
if (extrasArr.isArray()) {
|
||||||
|
for (JsonNode e : extrasArr) {
|
||||||
|
CompareResult.FlatNode fn = new CompareResult.FlatNode();
|
||||||
|
if (e.isTextual()) {
|
||||||
|
// 字符串格式 ["知识点D1", "知识点D2"]
|
||||||
|
fn.setTitle(e.asText(""));
|
||||||
|
fn.setPath("");
|
||||||
|
} else {
|
||||||
|
// 对象格式 [{ title: "...", path: "..." }]
|
||||||
|
fn.setTitle(e.path("title").asText(""));
|
||||||
|
fn.setPath(e.path("path").asText(""));
|
||||||
|
}
|
||||||
|
extras.add(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 组装结果
|
||||||
|
result.setMatchedTree(standardRoot);
|
||||||
|
result.setExtraNodes(extras);
|
||||||
|
result.setMatchedCount(matched);
|
||||||
|
result.setMissedCount(missed);
|
||||||
|
result.setExtraCount(extras.size());
|
||||||
|
int total = matched + missed;
|
||||||
|
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
|
||||||
|
result.setEvaluation(aiJson.path("evaluation").asText(null));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 回忆记录查询 ============
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
return recallRecordMapper.selectList(
|
||||||
|
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
|
||||||
|
.eq(ReviewRecallRecordEntity::getTaskNum, taskNum)
|
||||||
|
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
|
||||||
|
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
|
||||||
|
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 内部方法 ============
|
||||||
|
|
||||||
|
private ReviewStandardMindMapEntity doGenerate(String taskNum) throws OperationFailedException {
|
||||||
|
TaskEntity task = tasksMapper.selectOne(
|
||||||
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
|
||||||
|
|
||||||
|
if (task == null) {
|
||||||
|
throw new OperationFailedException("任务[" + taskNum + "]不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集学习数据
|
||||||
|
List<String> sessionNums = studySessionsMapper.selectList(
|
||||||
|
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||||
|
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
||||||
|
.select(StudySessionsEntity::getSessionNum))
|
||||||
|
.stream().map(StudySessionsEntity::getSessionNum).collect(Collectors.toList());
|
||||||
|
|
||||||
|
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 + "]没有学习报告或残片,无法生成思维导图");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
|
||||||
|
MindMapAiClient client = aiClients.stream()
|
||||||
|
.filter(MindMapAiClient::isAvailable)
|
||||||
|
.min(Comparator.comparing(c -> "BUILTIN".equals(c.generatorName()) ? 1 : 0))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (client == null) {
|
||||||
|
throw new OperationFailedException("没有可用的思维导图生成器");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
|
||||||
|
// AI 生成失败时尝试降级到内置生成器
|
||||||
|
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
|
||||||
|
log.info("AI 思维导图生成失败,降级到内置生成器");
|
||||||
|
MindMapAiClient fallback = aiClients.stream()
|
||||||
|
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (fallback != null) {
|
||||||
|
optRoot = fallback.generate(task, reports, fragments, applications, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (optRoot.isEmpty()) {
|
||||||
|
throw new OperationFailedException("思维导图生成失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
MindMapNode root = optRoot.get();
|
||||||
|
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
|
||||||
|
String outline = MindMapTreeTool.toFullOutline(root);
|
||||||
|
int nodeCount = MindMapTreeTool.countNodes(root);
|
||||||
|
int depth = MindMapTreeTool.maxDepth(root);
|
||||||
|
|
||||||
|
ReviewStandardMindMapEntity entity = queryByTaskNum(taskNum);
|
||||||
|
boolean create = entity == null;
|
||||||
|
if (create) {
|
||||||
|
entity = new ReviewStandardMindMapEntity();
|
||||||
|
entity.setTaskNum(taskNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
|
||||||
|
entity.setContent(contentJson);
|
||||||
|
entity.setOutline(outline);
|
||||||
|
entity.setSummary("共 " + nodeCount + " 个节点,最大层级 " + depth);
|
||||||
|
entity.setGenerator(client.generatorName());
|
||||||
|
entity.setGeneratorVersion("1.0");
|
||||||
|
entity.setSourceReportCount(reports.size());
|
||||||
|
entity.setSourceFragmentCount(fragments.size());
|
||||||
|
entity.setGeneratedTime(LocalDateTime.now());
|
||||||
|
|
||||||
|
if (create) {
|
||||||
|
standardMindMapMapper.insert(entity);
|
||||||
|
} else {
|
||||||
|
standardMindMapMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||||
|
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||||
|
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||||
|
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReviewStandardMindMapEntity queryByTaskNum(String taskNum) {
|
||||||
|
return standardMindMapMapper.selectOne(
|
||||||
|
Wrappers.<ReviewStandardMindMapEntity>lambdaQuery()
|
||||||
|
.eq(ReviewStandardMindMapEntity::getTaskNum, taskNum)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 标题归一化 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化标题用于对比:去空格、去标点、转小写。
|
||||||
|
*/
|
||||||
|
static String normalize(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
// 先移除内部对比标记前缀,再清理标点
|
||||||
|
String result = s.replaceAll("^(?:MATCHED|MISSED)\\|", "");
|
||||||
|
result = result.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "");
|
||||||
|
return result.toLowerCase(Locale.ROOT).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模糊匹配:计算字符 bigram Jaccard 相似度
|
||||||
|
*/
|
||||||
|
static boolean fuzzyMatch(String title, Map<String, MindMapNode> recallTitleMap) {
|
||||||
|
if (title == null || title.isBlank()) return false;
|
||||||
|
String norm = normalize(title);
|
||||||
|
Set<String> bigrams = bigramSet(norm);
|
||||||
|
if (bigrams.isEmpty()) return false;
|
||||||
|
|
||||||
|
for (String recallKey : recallTitleMap.keySet()) {
|
||||||
|
Set<String> recallBigrams = bigramSet(recallKey);
|
||||||
|
if (recallBigrams.isEmpty()) continue;
|
||||||
|
// Jaccard
|
||||||
|
Set<String> intersection = new HashSet<>(bigrams);
|
||||||
|
intersection.retainAll(recallBigrams);
|
||||||
|
Set<String> union = new HashSet<>(bigrams);
|
||||||
|
union.addAll(recallBigrams);
|
||||||
|
double similarity = (double) intersection.size() / union.size();
|
||||||
|
if (similarity >= 0.6) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Set<String> bigramSet(String s) {
|
||||||
|
Set<String> set = new HashSet<>();
|
||||||
|
for (int i = 0; i < s.length() - 1; i++) {
|
||||||
|
set.add(s.substring(i, i + 2));
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||||
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||||
|
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学习预期服务实现
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StudyExpectationsServiceImpl implements StudyExpectationsService {
|
||||||
|
|
||||||
|
private final StudyExpectationsMapper studyExpectationsMapper;
|
||||||
|
private final StudySessionsMapper studySessionsMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StudyExpectationsEntity upsertExpectation(String sessionNum, String description) throws ErrorParameterException {
|
||||||
|
boolean sessionExists = studySessionsMapper.exists(
|
||||||
|
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||||
|
.eq(StudySessionsEntity::getSessionNum, sessionNum));
|
||||||
|
if (!sessionExists) {
|
||||||
|
throw new ErrorParameterException("学习会话[" + sessionNum + "]不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
StudyExpectationsEntity existing = getBySessionNum(sessionNum);
|
||||||
|
if (existing == null) {
|
||||||
|
StudyExpectationsEntity entity = new StudyExpectationsEntity();
|
||||||
|
entity.setSessionNum(sessionNum);
|
||||||
|
entity.setDescription(description);
|
||||||
|
studyExpectationsMapper.insert(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
existing.setDescription(description);
|
||||||
|
studyExpectationsMapper.updateById(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StudyExpectationsEntity getBySessionNum(String sessionNum) {
|
||||||
|
return studyExpectationsMapper.selectOne(
|
||||||
|
Wrappers.<StudyExpectationsEntity>lambdaQuery()
|
||||||
|
.eq(StudyExpectationsEntity::getSessionNum, sessionNum)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -13,6 +13,7 @@ import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
|
|||||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
|
|||||||
private final StudySessionsMapper studySessionsMapper;
|
private final StudySessionsMapper studySessionsMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException {
|
public void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException {
|
||||||
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
||||||
StudySessionsEntity session = studySessionsMapper.selectOne(
|
StudySessionsEntity session = studySessionsMapper.selectOne(
|
||||||
|
|||||||
+104
@@ -1,6 +1,7 @@
|
|||||||
package com.guo.learningprogresstracker.service.impl;
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
import com.guo.learningprogresstracker.dto.StudySessionsDto;
|
import com.guo.learningprogresstracker.dto.StudySessionsDto;
|
||||||
@@ -16,10 +17,12 @@ import com.guo.learningprogresstracker.mapStruct.StudySessionConvert;
|
|||||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||||
|
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||||
import com.guo.learningprogresstracker.service.StudySessionsService;
|
import com.guo.learningprogresstracker.service.StudySessionsService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -41,6 +44,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
||||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||||
private final StudyReportsMapper studyReportsMapper;
|
private final StudyReportsMapper studyReportsMapper;
|
||||||
|
private final AiServiceClient aiServiceClient;
|
||||||
|
private final StudyExpectationsService studyExpectationsService;
|
||||||
|
|
||||||
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
|
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||||
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
|
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
|
||||||
@@ -68,6 +73,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
|
|
||||||
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||||
response.setTaskName(taskEntity.getTaskName());
|
response.setTaskName(taskEntity.getTaskName());
|
||||||
|
response.setMaterialUrl(taskEntity.getMaterialUrl());
|
||||||
|
response.setTaskId(taskEntity.getId());
|
||||||
|
|
||||||
if (session.isOverTime()) {
|
if (session.isOverTime()) {
|
||||||
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
|
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
|
||||||
@@ -93,6 +100,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
||||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
@@ -133,6 +141,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
|
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
|
||||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
@@ -158,4 +167,99 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
return StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成学习报告草稿:优先调用 lpt-ai 聚合残片,服务不可用时降级为按序拼接。
|
||||||
|
* 草稿仅作为编辑起点返回,不落库——最终报告由用户确认后经 endedStudySession 保存。
|
||||||
|
*/
|
||||||
|
public String generateReportDraft(String sessionNum) throws ErrorParameterException {
|
||||||
|
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
|
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||||
|
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||||
|
|
||||||
|
ArrayList<String> fragments = getAllFragments(sessionNum);
|
||||||
|
if (fragments.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
String taskName = tasksServiceImpl.getOneOpt(
|
||||||
|
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, session.getTaskNum()))
|
||||||
|
.map(TaskEntity::getTaskName)
|
||||||
|
.orElse("学习任务");
|
||||||
|
String expectation = Optional.ofNullable(studyExpectationsService.getBySessionNum(sessionNum))
|
||||||
|
.map(e -> e.getDescription())
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
return aiServiceClient.aggregateReport(taskName, fragments, expectation)
|
||||||
|
.orElseGet(() -> String.join("\n", fragments));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 分页查询任务的历史残片/报告 ============
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<StudyReportFragmentsEntity> getTaskFragments(String taskNum, int page, int size, String keyword) {
|
||||||
|
Page<StudyReportFragmentsEntity> pg = new Page<>(page, size);
|
||||||
|
var wrapper = Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
|
||||||
|
.apply("session_num in (select session_num from study_sessions where task_num = {0})", taskNum)
|
||||||
|
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime);
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.like(StudyReportFragmentsEntity::getContent, keyword);
|
||||||
|
}
|
||||||
|
return studyReportFragmentsMapper.selectPage(pg, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword) {
|
||||||
|
Page<StudyReportsEntity> pg = new Page<>(page, size);
|
||||||
|
var wrapper = Wrappers.lambdaQuery(StudyReportsEntity.class)
|
||||||
|
.apply("session_num in (select session_num from study_sessions where task_num = {0})", taskNum)
|
||||||
|
.orderByDesc(StudyReportsEntity::getCreatedTime);
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
wrapper.like(StudyReportsEntity::getContent, keyword);
|
||||||
|
}
|
||||||
|
Page<StudyReportsEntity> result = studyReportsMapper.selectPage(pg, wrapper);
|
||||||
|
// 填充每个报告对应会话的预期目标
|
||||||
|
for (StudyReportsEntity report : result.getRecords()) {
|
||||||
|
if (report.getSessionNum() != null) {
|
||||||
|
Optional.ofNullable(studyExpectationsService.getBySessionNum(report.getSessionNum()))
|
||||||
|
.ifPresent(e -> report.setSessionExpectation(e.getDescription()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 活跃会话查询 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户是否有活跃会话(ONGOING 或 PAUSED)。
|
||||||
|
*
|
||||||
|
* @param excludeTaskNum 可选,排除指定任务(同一任务继续学习时不会视为冲突)
|
||||||
|
* @return 活跃会话响应;无活跃会话时返回 null
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public StudySessionResponse getActiveSession(String excludeTaskNum) {
|
||||||
|
var wrapper = Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
|
.in(StudySessionsEntity::getSessionState,
|
||||||
|
StudySessionStateEnum.ONGOING.name(),
|
||||||
|
StudySessionStateEnum.PAUSED.name())
|
||||||
|
.orderByDesc(StudySessionsEntity::getCreatedTime)
|
||||||
|
.last("LIMIT 1");
|
||||||
|
|
||||||
|
StudySessionsEntity session = this.getOne(wrapper);
|
||||||
|
if (session == null) return null;
|
||||||
|
|
||||||
|
// 如果活跃会话属于被排除的任务,视为无冲突
|
||||||
|
if (excludeTaskNum != null && excludeTaskNum.equals(session.getTaskNum())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String taskName = tasksServiceImpl.getOneOpt(
|
||||||
|
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, session.getTaskNum()))
|
||||||
|
.map(TaskEntity::getTaskName)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||||
|
response.setTaskName(taskName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.guo.learningprogresstracker.dto.PriorityDto;
|
import com.guo.learningprogresstracker.dto.PriorityDto;
|
||||||
import com.guo.learningprogresstracker.dto.TaskInfo;
|
import com.guo.learningprogresstracker.dto.TaskInfo;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
import com.guo.learningprogresstracker.dto.request.TaskRequest;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
|
||||||
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
|
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
|
||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
import com.guo.learningprogresstracker.mapStruct.RequestConvert;
|
import com.guo.learningprogresstracker.mapStruct.RequestConvert;
|
||||||
import com.guo.learningprogresstracker.mapStruct.TaskConvert;
|
import com.guo.learningprogresstracker.mapStruct.TaskConvert;
|
||||||
|
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
|
||||||
|
import com.guo.learningprogresstracker.service.PriorityWeightsService;
|
||||||
import com.guo.learningprogresstracker.service.TasksService;
|
import com.guo.learningprogresstracker.service.TasksService;
|
||||||
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||||
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
|
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
|
||||||
@@ -18,6 +25,10 @@ import com.guo.learningprogresstracker.utils.GenerateNumTool;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
|
* 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
|
||||||
@@ -30,6 +41,10 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
|
|
||||||
private final TasksMapper tasksMapper;
|
private final TasksMapper tasksMapper;
|
||||||
|
|
||||||
|
private final TaskApplicationMapper taskApplicationMapper;
|
||||||
|
|
||||||
|
private final PriorityWeightsService priorityWeightsService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) {
|
public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) {
|
||||||
Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize),
|
Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize),
|
||||||
@@ -50,7 +65,7 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
|
|
||||||
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
|
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
|
||||||
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(taskRequest);
|
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(taskRequest);
|
||||||
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto));
|
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
||||||
this.save(task);
|
this.save(task);
|
||||||
return task.getTaskNum();
|
return task.getTaskNum();
|
||||||
}
|
}
|
||||||
@@ -75,6 +90,10 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
|
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
|
||||||
}
|
}
|
||||||
taskEntity.setId(id);
|
taskEntity.setId(id);
|
||||||
|
taskEntity.setTaskNum(existingTask.getTaskNum());
|
||||||
|
// 维度数据可能变化,更新时重算优先级
|
||||||
|
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
|
||||||
|
taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
||||||
this.updateById(taskEntity);
|
this.updateById(taskEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +105,57 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
}
|
}
|
||||||
this.removeById(taskId);
|
this.removeById(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException {
|
||||||
|
ensureTaskExists(taskNum);
|
||||||
|
return taskApplicationMapper.selectList(
|
||||||
|
Wrappers.<TaskApplicationEntity>lambdaQuery()
|
||||||
|
.eq(TaskApplicationEntity::getTaskNum, taskNum)
|
||||||
|
.orderByDesc(TaskApplicationEntity::getLastModifiedTime)
|
||||||
|
.orderByDesc(TaskApplicationEntity::getCreatedTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException {
|
||||||
|
ensureTaskExists(request.getTaskNum());
|
||||||
|
TaskApplicationEntity entity = new TaskApplicationEntity();
|
||||||
|
entity.setTaskNum(request.getTaskNum());
|
||||||
|
entity.setTitle(request.getTitle());
|
||||||
|
entity.setDescription(request.getDescription());
|
||||||
|
entity.setResourceUrl(request.getResourceUrl());
|
||||||
|
entity.setStatus(normalizeApplicationStatus(request.getStatus()));
|
||||||
|
taskApplicationMapper.insert(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
|
||||||
|
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
||||||
|
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
|
||||||
|
existing.setTitle(request.getTitle());
|
||||||
|
existing.setDescription(request.getDescription());
|
||||||
|
existing.setResourceUrl(request.getResourceUrl());
|
||||||
|
existing.setStatus(normalizeApplicationStatus(request.getStatus()));
|
||||||
|
taskApplicationMapper.updateById(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteApplication(Integer id) throws NotFindEntitiesException {
|
||||||
|
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
|
||||||
|
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
|
||||||
|
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 + "]不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeApplicationStatus(String status) {
|
||||||
|
return TaskApplicationStatusEnum.fromCodeOrDefault(status).getCode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.guo.learningprogresstracker.utils;
|
package com.guo.learningprogresstracker.utils;
|
||||||
|
|
||||||
import com.guo.learningprogresstracker.dto.PriorityDto;
|
import com.guo.learningprogresstracker.dto.PriorityDto;
|
||||||
|
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加权优先级计算工具
|
* 加权优先级计算工具
|
||||||
@@ -8,31 +9,21 @@ import com.guo.learningprogresstracker.dto.PriorityDto;
|
|||||||
public class CalculatedPriorityTool {
|
public class CalculatedPriorityTool {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过用户选择的5个选项,结合定义的权重计算任务的加权优先级。
|
* 使用系统默认权重计算加权优先级。
|
||||||
* 急迫性 (Urgency)
|
* 急迫性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
|
||||||
* 急迫性代表任务的紧急程度。权重:0.35
|
|
||||||
* <p>
|
|
||||||
* 重要性 (Importance)
|
|
||||||
* 重要性指示任务的重要程度。权重:0.25
|
|
||||||
* <p>
|
|
||||||
* 内容难度 (Content Difficulty)
|
|
||||||
* 内容难度衡量任务内容的复杂性。权重:0.20
|
|
||||||
* <p>
|
|
||||||
* 未来价值 (Future Value)
|
|
||||||
* 未来价值估计完成任务的长期价值。权重:0.10
|
|
||||||
* <p>
|
|
||||||
* 主观优先级 (Subjective Priority)
|
|
||||||
* 主观优先级是用户对任务优先级的个人评估。权重:0.10
|
|
||||||
*
|
|
||||||
* @param priorityDto
|
|
||||||
* @return
|
|
||||||
*/
|
*/
|
||||||
public static Double calculatedPriority(PriorityDto priorityDto) {
|
public static Double calculatedPriority(PriorityDto priorityDto) {
|
||||||
// 暂时写死权重,也许可以考虑让用户自行配置权重
|
return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
|
||||||
return (priorityDto.getUrgency() * 0.35) +
|
}
|
||||||
(priorityDto.getImportance() * 0.25) +
|
|
||||||
(priorityDto.getContentDifficulty() * 0.20) +
|
/**
|
||||||
(priorityDto.getFutureValue() * 0.10) +
|
* 使用用户自定义权重计算加权优先级。
|
||||||
(priorityDto.getSubjectivePriority() * 0.10);
|
*/
|
||||||
|
public static Double calculatedPriority(PriorityDto priorityDto, UserPriorityWeightsEntity weights) {
|
||||||
|
return (priorityDto.getUrgency() * weights.getUrgencyWeight()) +
|
||||||
|
(priorityDto.getImportance() * weights.getImportanceWeight()) +
|
||||||
|
(priorityDto.getContentDifficulty() * weights.getContentDifficultyWeight()) +
|
||||||
|
(priorityDto.getFutureValue() * weights.getFutureValueWeight()) +
|
||||||
|
(priorityDto.getSubjectivePriority() * weights.getSubjectivePriorityWeight());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.guo.learningprogresstracker.utils;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 思维导图结构对比结果。
|
||||||
|
* 每个 {@link NodeDiff} 对应标准导图中的一个节点,标注其匹配状态及用户在回忆中撰写的标题。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CompareResult {
|
||||||
|
|
||||||
|
/** 对比后的完整树(带 MATCHED/MISSED 标注) */
|
||||||
|
private MindMapNode matchedTree;
|
||||||
|
|
||||||
|
/** 用户追加但标准导图中不存在的节点 */
|
||||||
|
private List<FlatNode> extraNodes = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 回忆覆盖率 0-1 */
|
||||||
|
private double recallRatio;
|
||||||
|
|
||||||
|
/** 命中节点数 */
|
||||||
|
private int matchedCount;
|
||||||
|
|
||||||
|
/** 遗漏节点数 */
|
||||||
|
private int missedCount;
|
||||||
|
|
||||||
|
/** 额外节点数 */
|
||||||
|
private int extraCount;
|
||||||
|
|
||||||
|
/** AI 评价文本(仅 AI 对比时有值,内置对比为 null) */
|
||||||
|
private String evaluation;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class FlatNode {
|
||||||
|
private String title;
|
||||||
|
private String path; // 以 / 分隔的路径
|
||||||
|
private String sourceType;
|
||||||
|
private Integer sourceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.guo.learningprogresstracker.utils;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.Accessors;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 思维导图树节点 DTO,与 MindMapFileParser 的输出结构兼容。
|
||||||
|
* 序列化后可在「nodes」和「children」两个 key 下放置子节点列表。
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "title": "根标题",
|
||||||
|
* "notes": "备注",
|
||||||
|
* "sourceType": "REPORT", // 可选:节点来源类型
|
||||||
|
* "sourceId": 1, // 可选:来源主键
|
||||||
|
* "children": [ ... ]
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Accessors(chain = true)
|
||||||
|
public class MindMapNode {
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
private String notes;
|
||||||
|
private String sourceType;
|
||||||
|
private Integer sourceId;
|
||||||
|
private List<MindMapNode> children;
|
||||||
|
|
||||||
|
public MindMapNode(String title) {
|
||||||
|
this.title = title;
|
||||||
|
this.notes = "";
|
||||||
|
this.children = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建 JSON 友好的 Map 结构(兼容 MindMapFileParser 输出风格) */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("title", title != null ? title : "");
|
||||||
|
map.put("notes", notes != null ? notes : "");
|
||||||
|
if (sourceType != null) map.put("sourceType", sourceType);
|
||||||
|
if (sourceId != null) map.put("sourceId", sourceId);
|
||||||
|
List<Object> childMaps = new ArrayList<>();
|
||||||
|
if (children != null) {
|
||||||
|
for (MindMapNode c : children) {
|
||||||
|
childMaps.add(c.toMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.put("children", childMaps);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Map 重建节点 */
|
||||||
|
public static MindMapNode fromMap(Map<String, Object> map) {
|
||||||
|
MindMapNode node = new MindMapNode();
|
||||||
|
node.setTitle((String) map.getOrDefault("title", ""));
|
||||||
|
node.setNotes((String) map.getOrDefault("notes", ""));
|
||||||
|
node.setSourceType((String) map.get("sourceType"));
|
||||||
|
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
|
||||||
|
node.setSourceId(((Number) map.get("sourceId")).intValue());
|
||||||
|
}
|
||||||
|
Object raw = map.getOrDefault("children", map.get("nodes"));
|
||||||
|
List<MindMapNode> children = new ArrayList<>();
|
||||||
|
if (raw instanceof List<?> list) {
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item instanceof Map<?, ?> m) {
|
||||||
|
children.add(fromMap((Map<String, Object>) m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.setChildren(children);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 反序列化 JSON 字符串为 MindMapNode */
|
||||||
|
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> map = mapper.readValue(json, LinkedHashMap.class);
|
||||||
|
return fromMap(map);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new MindMapNode("解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package com.guo.learningprogresstracker.utils;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 思维导图树结构与缩进大纲文本之间的双向转换工具。
|
||||||
|
* <p>大纲格式:每行一条节点,缩进表示层级(2 空格 / tab / # 前缀),行首 - * 数字. 等标记被自动剥离。</p>
|
||||||
|
*/
|
||||||
|
public class MindMapTreeTool {
|
||||||
|
|
||||||
|
private static final int INDENT_SPACES = 2;
|
||||||
|
|
||||||
|
private MindMapTreeTool() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 序列化:树 → 缩进大纲 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将根节点序列化为缩进大纲文本
|
||||||
|
*/
|
||||||
|
public static String toOutline(MindMapNode root) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
appendOutline(sb, root, 0);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendOutline(StringBuilder sb, MindMapNode node, int level) {
|
||||||
|
if (level > 0) {
|
||||||
|
sb.append(" ".repeat(level)).append("- ").append(node.getTitle() != null ? node.getTitle() : "").append('\n');
|
||||||
|
}
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
appendOutline(sb, child, level + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// level 0 是根节点标题本身不输出,但其子节点输出
|
||||||
|
if (level == 0 && node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
appendOutline(sb, child, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根节点序列化为完整大纲,第一行为根标题
|
||||||
|
*/
|
||||||
|
public static String toFullOutline(MindMapNode root) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(root.getTitle() != null ? root.getTitle() : "").append('\n');
|
||||||
|
if (root.getChildren() != null) {
|
||||||
|
for (MindMapNode child : root.getChildren()) {
|
||||||
|
appendOutline(sb, child, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 反序列化:缩进大纲 → 树 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将缩进大纲文本解析为根节点。
|
||||||
|
* 第一行非空文本作为根标题,后续行作为子节点。
|
||||||
|
*/
|
||||||
|
public static MindMapNode parseOutline(String outlineText) {
|
||||||
|
if (outlineText == null || outlineText.isBlank()) {
|
||||||
|
return new MindMapNode("未命名");
|
||||||
|
}
|
||||||
|
String[] lines = outlineText.split("\\R");
|
||||||
|
List<String> nonBlank = new ArrayList<>();
|
||||||
|
for (String line : lines) {
|
||||||
|
if (!line.trim().isBlank()) {
|
||||||
|
nonBlank.add(line.stripTrailing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nonBlank.isEmpty()) {
|
||||||
|
return new MindMapNode("未命名");
|
||||||
|
}
|
||||||
|
|
||||||
|
MindMapNode root = new MindMapNode(stripMarker(nonBlank.get(0).strip()));
|
||||||
|
List<MindMapNode> roots = new ArrayList<>();
|
||||||
|
|
||||||
|
Deque<StackEntry> stack = new ArrayDeque<>();
|
||||||
|
stack.push(new StackEntry(-1, roots));
|
||||||
|
|
||||||
|
for (int i = 1; i < nonBlank.size(); i++) {
|
||||||
|
String raw = nonBlank.get(i);
|
||||||
|
int level = detectLevel(raw);
|
||||||
|
String title = stripMarker(raw.strip());
|
||||||
|
MindMapNode child = new MindMapNode(title);
|
||||||
|
while (stack.peek().level >= level) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
stack.peek().children.add(child);
|
||||||
|
List<MindMapNode> children = new ArrayList<>();
|
||||||
|
child.setChildren(children);
|
||||||
|
stack.push(new StackEntry(level, children));
|
||||||
|
}
|
||||||
|
|
||||||
|
root.setChildren(roots);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int detectLevel(String line) {
|
||||||
|
String trimmed = line.stripLeading();
|
||||||
|
int indent = line.length() - trimmed.length();
|
||||||
|
int hashCount = 0;
|
||||||
|
while (hashCount < trimmed.length() && trimmed.charAt(hashCount) == '#') {
|
||||||
|
hashCount++;
|
||||||
|
}
|
||||||
|
if (hashCount > 0) return hashCount;
|
||||||
|
if (indent == 0) return 1;
|
||||||
|
return Math.max(1, indent / INDENT_SPACES + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripMarker(String s) {
|
||||||
|
return s.replaceFirst("^#{1,6}\\s*", "")
|
||||||
|
.replaceFirst("^[-*+]\\s*", "")
|
||||||
|
.replaceFirst("^\\d+\\.\\s*", "")
|
||||||
|
.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record StackEntry(int level, List<MindMapNode> children) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 工具方法 ============
|
||||||
|
|
||||||
|
/** 展开树为平铺列表(前序遍历) */
|
||||||
|
public static List<MindMapNode> flatten(MindMapNode root) {
|
||||||
|
List<MindMapNode> list = new ArrayList<>();
|
||||||
|
flattenRecursive(root, list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void flattenRecursive(MindMapNode node, List<MindMapNode> acc) {
|
||||||
|
acc.add(node);
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
flattenRecursive(child, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计节点总数 */
|
||||||
|
public static int countNodes(MindMapNode root) {
|
||||||
|
return flatten(root).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算最大深度 */
|
||||||
|
public static int maxDepth(MindMapNode node) {
|
||||||
|
if (node.getChildren() == null || node.getChildren().isEmpty()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */
|
||||||
|
private static MindMapNode copyNodeShallow(MindMapNode node) {
|
||||||
|
MindMapNode copy = new MindMapNode();
|
||||||
|
copy.setTitle(node.getTitle());
|
||||||
|
copy.setNotes(node.getNotes());
|
||||||
|
copy.setSourceType(node.getSourceType());
|
||||||
|
copy.setSourceId(node.getSourceId());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按路径提取子树:以 / 分隔的节点标题路径,提取目标节点为根的新树。
|
||||||
|
* @param root 完整树根
|
||||||
|
* @param path 节点路径,如 "根标题 / 分支A / 子节点B"
|
||||||
|
* @return 以路径末端节点为根的深拷贝子树,匹配失败时返回 null
|
||||||
|
*/
|
||||||
|
public static MindMapNode extractSubtree(MindMapNode root, String path) {
|
||||||
|
if (root == null || path == null || path.isBlank()) return null;
|
||||||
|
String[] segments = path.split("\\s*/\\s*");
|
||||||
|
MindMapNode current = root;
|
||||||
|
for (int i = 0; i < segments.length; i++) {
|
||||||
|
String seg = segments[i].trim();
|
||||||
|
if (seg.isEmpty()) continue;
|
||||||
|
if (current.getTitle() != null && normalizeForMerge(current.getTitle()).equals(normalizeForMerge(seg))) {
|
||||||
|
continue; // 当前节点已匹配,看下一段
|
||||||
|
}
|
||||||
|
MindMapNode found = null;
|
||||||
|
if (current.getChildren() != null) {
|
||||||
|
for (MindMapNode child : current.getChildren()) {
|
||||||
|
if (child.getTitle() != null && normalizeForMerge(child.getTitle()).equals(normalizeForMerge(seg))) {
|
||||||
|
found = child;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found != null) {
|
||||||
|
current = found;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deepCopy(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深拷贝节点及其子树 */
|
||||||
|
private static MindMapNode deepCopy(MindMapNode node) {
|
||||||
|
if (node == null) return null;
|
||||||
|
MindMapNode copy = new MindMapNode();
|
||||||
|
copy.setTitle(node.getTitle());
|
||||||
|
copy.setNotes(node.getNotes());
|
||||||
|
copy.setSourceType(node.getSourceType());
|
||||||
|
copy.setSourceId(node.getSourceId());
|
||||||
|
List<MindMapNode> children = new ArrayList<>();
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
children.add(deepCopy(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy.setChildren(children);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找与 content 最相似的节点。基于 title 的 bigram Jaccard 相似度。
|
||||||
|
* @return 得分最高的节点,若所有节点得分均低于 0.1 则返回 null
|
||||||
|
*/
|
||||||
|
public static MindMapNode findClosestNode(MindMapNode root, String content) {
|
||||||
|
if (root == null || content == null || content.isBlank()) return null;
|
||||||
|
List<MindMapNode> flat = flatten(root);
|
||||||
|
MindMapNode best = null;
|
||||||
|
double bestScore = 0.0;
|
||||||
|
for (MindMapNode node : flat) {
|
||||||
|
if (node.getTitle() == null || node.getTitle().isBlank()) continue;
|
||||||
|
double score = similarityScore(node.getTitle(), content);
|
||||||
|
// notes 也参与匹配,但权重减半
|
||||||
|
if (node.getNotes() != null && !node.getNotes().isBlank()) {
|
||||||
|
double noteScore = similarityScore(node.getNotes(), content);
|
||||||
|
score = Math.max(score, noteScore * 0.5);
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestScore > 0.1 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点在树中的路径(以 / 分隔的 title 序列)
|
||||||
|
*/
|
||||||
|
public static String getPath(MindMapNode root, String targetTitle) {
|
||||||
|
if (root == null || targetTitle == null) return "";
|
||||||
|
List<String> path = new ArrayList<>();
|
||||||
|
if (findPathRecursive(root, targetTitle, path)) {
|
||||||
|
return String.join(" / ", path);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean findPathRecursive(MindMapNode node, String targetTitle, List<String> path) {
|
||||||
|
if (node == null) return false;
|
||||||
|
path.add(node.getTitle() != null ? node.getTitle() : "");
|
||||||
|
if (normalizeForMerge(node.getTitle()).equals(normalizeForMerge(targetTitle))) return true;
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
if (findPathRecursive(child, targetTitle, path)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.remove(path.size() - 1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bigram Jaccard 相似度 */
|
||||||
|
public static double similarityScore(String a, String b) {
|
||||||
|
if (a == null || b == null) return 0;
|
||||||
|
String na = normalizeForMerge(a);
|
||||||
|
String nb = normalizeForMerge(b);
|
||||||
|
if (na.isEmpty() || nb.isEmpty()) return 0;
|
||||||
|
if (na.equals(nb)) return 1.0;
|
||||||
|
// 子串匹配
|
||||||
|
if (na.contains(nb) || nb.contains(na)) return 0.9;
|
||||||
|
Set<String> bigramsA = bigrams(na);
|
||||||
|
Set<String> bigramsB = bigrams(nb);
|
||||||
|
if (bigramsA.isEmpty() && bigramsB.isEmpty()) return 0;
|
||||||
|
Set<String> union = new HashSet<>(bigramsA);
|
||||||
|
union.addAll(bigramsB);
|
||||||
|
Set<String> intersect = new HashSet<>(bigramsA);
|
||||||
|
intersect.retainAll(bigramsB);
|
||||||
|
return (double) intersect.size() / union.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> bigrams(String s) {
|
||||||
|
Set<String> set = new LinkedHashSet<>();
|
||||||
|
for (int i = 0; i < s.length() - 1; i++) {
|
||||||
|
set.add(s.substring(i, i + 2));
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
|
||||||
|
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。
|
||||||
|
*
|
||||||
|
* @param oldRoot 旧树根(用户可能编辑过)
|
||||||
|
* @param newRoot 新树根(AI/BUILTIN 最新生成)
|
||||||
|
* @return 合并后的树
|
||||||
|
*/
|
||||||
|
public static MindMapNode mergeTrees(MindMapNode oldRoot, MindMapNode newRoot) {
|
||||||
|
if (oldRoot == null) return newRoot;
|
||||||
|
if (newRoot == null) return oldRoot;
|
||||||
|
MindMapNode result = copyNodeShallow(oldRoot);
|
||||||
|
result.setChildren(mergeChildren(oldRoot.getChildren(), newRoot.getChildren()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归合并子节点列表 */
|
||||||
|
private static List<MindMapNode> mergeChildren(List<MindMapNode> oldChildren, List<MindMapNode> newChildren) {
|
||||||
|
Map<String, MindMapNode> oldByNormalized = new LinkedHashMap<>();
|
||||||
|
if (oldChildren != null) {
|
||||||
|
for (MindMapNode child : oldChildren) {
|
||||||
|
oldByNormalized.put(normalizeForMerge(child.getTitle()), child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MindMapNode> merged = new ArrayList<>();
|
||||||
|
Set<String> usedKeys = new HashSet<>();
|
||||||
|
|
||||||
|
if (newChildren != null) {
|
||||||
|
for (MindMapNode newNode : newChildren) {
|
||||||
|
String key = normalizeForMerge(newNode.getTitle());
|
||||||
|
MindMapNode oldNode = oldByNormalized.get(key);
|
||||||
|
if (oldNode != null) {
|
||||||
|
// 旧节点存在:保留旧节点标题,递归合并子节点
|
||||||
|
MindMapNode kept = copyNodeShallow(oldNode);
|
||||||
|
kept.setChildren(mergeChildren(oldNode.getChildren(), newNode.getChildren()));
|
||||||
|
merged.add(kept);
|
||||||
|
usedKeys.add(key);
|
||||||
|
} else {
|
||||||
|
// 新节点:直接添加
|
||||||
|
merged.add(newNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加旧树中有但新树中没有的节点(用户添加的额外节点)
|
||||||
|
if (oldChildren != null) {
|
||||||
|
for (MindMapNode oldNode : oldChildren) {
|
||||||
|
if (!usedKeys.contains(normalizeForMerge(oldNode.getTitle()))) {
|
||||||
|
merged.add(oldNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeForMerge(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
|
||||||
|
.toLowerCase(Locale.ROOT)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列化整棵树为 JSON */
|
||||||
|
public static String toJson(MindMapNode root, ObjectMapper mapper) {
|
||||||
|
try {
|
||||||
|
return mapper.writeValueAsString(root.toMap());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "{\"title\":\"序列化失败\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 JSON 反序列化 */
|
||||||
|
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
|
||||||
|
return MindMapNode.fromJson(json, mapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,4 +50,10 @@ management:
|
|||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
exposure:
|
exposure:
|
||||||
include: health,info # 或 "*"
|
include: health,info # 或 "*"
|
||||||
|
|
||||||
|
# lpt-ai 独立 AI 服务(可选,未配置时降级为内置规则引擎)
|
||||||
|
lpt:
|
||||||
|
ai-service:
|
||||||
|
url: http://localhost:5199
|
||||||
|
timeout-seconds: 600
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
create table review_applications
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
task_num varchar(255) not null comment '任务编码',
|
||||||
|
title varchar(255) not null comment '应用项目标题',
|
||||||
|
description text null comment '应用项目描述',
|
||||||
|
resource_url varchar(1024) null comment '相关链接',
|
||||||
|
status varchar(30) not null default 'TODO' comment '状态:TODO/DOING/DONE',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符',
|
||||||
|
index idx_review_applications_task_num (task_num)
|
||||||
|
)
|
||||||
|
comment '复习模块:学习内容可应用项目';
|
||||||
|
|
||||||
|
create table review_mind_maps
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
task_num varchar(255) not null comment '任务编码',
|
||||||
|
title varchar(255) not null comment '思维导图标题',
|
||||||
|
content text not null comment '思维导图内容',
|
||||||
|
content_format varchar(30) not null default 'TEXT' comment '内容格式:TEXT/MERMAID/JSON',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符',
|
||||||
|
unique key uk_review_mind_maps_task_num (task_num)
|
||||||
|
)
|
||||||
|
comment '复习模块:任务思维导图';
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
alter table review_mind_maps
|
||||||
|
add column source_type varchar(30) not null default 'MANUAL' comment '来源类型:MANUAL/FILE';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column file_name varchar(255) null comment '原始文件名';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column file_path varchar(1024) null comment '服务端文件路径';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column file_format varchar(30) null comment '文件格式:XMIND/MARKDOWN/OPML/FREEMIND/TEXT';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column parsed_content mediumtext null comment '解析后的统一结构JSON';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column parse_status varchar(30) not null default 'SUCCESS' comment '解析状态:SUCCESS/FAILED';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column parse_error text null comment '解析错误';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column summary text null comment '解析摘要';
|
||||||
|
|
||||||
|
alter table review_mind_maps
|
||||||
|
add column last_parsed_time datetime null comment '最近解析时间';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
create table review_sessions
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
task_num varchar(255) not null comment '任务编码',
|
||||||
|
target_title varchar(255) not null comment '本次复习的具体知识目标',
|
||||||
|
recall_content text null comment '用户主动回忆内容',
|
||||||
|
knowledge_network text null comment '用户重构出的体系化知识网络',
|
||||||
|
reflection text null comment '复习后的反思与缺口',
|
||||||
|
status varchar(30) not null default 'ONGOING' comment '状态:ONGOING/COMPLETED',
|
||||||
|
started_time datetime not null comment '开始时间',
|
||||||
|
completed_time datetime null comment '完成时间',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符',
|
||||||
|
index idx_review_sessions_task_num (task_num)
|
||||||
|
)
|
||||||
|
comment '复习模块:完整复习会话';
|
||||||
|
|
||||||
|
create table review_records
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
review_session_id int not null comment '复习会话ID',
|
||||||
|
source_type varchar(30) not null comment '关联来源:REPORT/FRAGMENT',
|
||||||
|
source_id int not null comment '来源ID',
|
||||||
|
recall_level varchar(30) not null default 'UNCERTAIN' comment '回忆程度:REMEMBERED/UNCERTAIN/FORGOTTEN',
|
||||||
|
note text null comment '对照后的说明',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符',
|
||||||
|
index idx_review_records_session_id (review_session_id),
|
||||||
|
index idx_review_records_source (source_type, source_id)
|
||||||
|
)
|
||||||
|
comment '复习模块:完整复习完成后与学习报告/残片的对照关系';
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
alter table review_applications rename to task_applications;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
drop table if exists review_records;
|
||||||
|
drop table if exists review_sessions;
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
create table review_standard_mind_maps
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
task_num varchar(255) not null comment '任务编码',
|
||||||
|
title varchar(255) not null comment '标准思维导图标题',
|
||||||
|
content mediumtext not null comment '标准导图统一树结构JSON',
|
||||||
|
outline mediumtext null comment '标准导图缩进大纲文本(用于展示与编辑)',
|
||||||
|
summary text null comment '生成摘要',
|
||||||
|
generator varchar(30) not null default 'BUILTIN' comment '生成来源:BUILTIN/AI/USER',
|
||||||
|
generator_version varchar(64) null comment '生成器版本或AI模型名',
|
||||||
|
source_report_count int default 0 not null comment '生成时参考的学习报告数',
|
||||||
|
source_fragment_count int default 0 not null comment '生成时参考的学习残片数',
|
||||||
|
generated_time datetime null comment '最近生成时间',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符',
|
||||||
|
unique key uk_review_standard_mind_maps_task_num (task_num)
|
||||||
|
)
|
||||||
|
comment '复习模块:任务标准思维导图(由内置规则或AI从学习报告/残片生成,用户可修改)';
|
||||||
|
|
||||||
|
create table review_recall_records
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
task_num varchar(255) not null comment '任务编码',
|
||||||
|
standard_map_id int null comment '对比时使用的标准导图ID',
|
||||||
|
recall_content mediumtext not null comment '用户回忆绘制的导图大纲文本',
|
||||||
|
compare_result mediumtext null comment '与标准导图的结构对比结果JSON',
|
||||||
|
recall_ratio double null comment '回忆覆盖率(0-1)',
|
||||||
|
matched_count int default 0 not null comment '回忆命中的节点数',
|
||||||
|
missed_count int default 0 not null comment '遗漏的节点数',
|
||||||
|
extra_count int default 0 not null comment '标准导图之外的新增节点数',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符',
|
||||||
|
index idx_review_recall_records_task_num (task_num)
|
||||||
|
)
|
||||||
|
comment '复习模块:复习回忆与标准导图的对比记录';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table study_expectations
|
||||||
|
modify session_num varchar(255) not null comment '会话编码';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
create table user_priority_weights
|
||||||
|
(
|
||||||
|
id int auto_increment comment 'id无业务含义'
|
||||||
|
primary key,
|
||||||
|
urgency_weight double not null default 0.35 comment '紧急性权重',
|
||||||
|
importance_weight double not null default 0.25 comment '重要性权重',
|
||||||
|
content_difficulty_weight double not null default 0.20 comment '内容难度权重',
|
||||||
|
future_value_weight double not null default 0.10 comment '未来价值权重',
|
||||||
|
subjective_priority_weight double not null default 0.10 comment '主观优先级权重',
|
||||||
|
created_time datetime not null comment '创建时间',
|
||||||
|
created_by varchar(255) null,
|
||||||
|
last_modified_time datetime null,
|
||||||
|
last_modified_by varchar(255) null,
|
||||||
|
device_info varchar(50) null comment '操作者设备类型',
|
||||||
|
deleted int default 0 not null comment '逻辑删除符'
|
||||||
|
)
|
||||||
|
comment '用户自定义的任务优先级维度权重(每用户一行)';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE tasks MODIFY material_url TEXT null comment '学习材料链接(每行一个)';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 为回忆对比记录添加 session_num 字段,支持会话维度的复习过滤
|
||||||
|
ALTER TABLE review_recall_records
|
||||||
|
ADD COLUMN session_num VARCHAR(255) NULL AFTER standard_map_id;
|
||||||
|
|
||||||
|
CREATE INDEX idx_recall_records_session_num ON review_recall_records (session_num);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 为回忆对比记录添加 focus_path 字段,支持节点级复习范围
|
||||||
|
ALTER TABLE review_recall_records
|
||||||
|
ADD COLUMN focus_path VARCHAR(512) NULL AFTER session_num;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.guo.learningprogresstracker;
|
package com.guo.learningprogresstracker;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.guo.learningprogresstracker.entity.TestTableEntity;
|
import com.guo.learningprogresstracker.entity.TestTableEntity;
|
||||||
import com.guo.learningprogresstracker.service.impl.TestTableServiceImpl;
|
import com.guo.learningprogresstracker.service.impl.TestTableServiceImpl;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
@@ -19,6 +21,11 @@ public class DataSourceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
TestTableServiceImpl testTableService;
|
TestTableServiceImpl testTableService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
StpUtil.login("1", "test_driver");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testAdd(){
|
public void testAdd(){
|
||||||
TestTableEntity testTableEntity = new TestTableEntity();
|
TestTableEntity testTableEntity = new TestTableEntity();
|
||||||
@@ -30,8 +37,12 @@ public class DataSourceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testDelete(){
|
public void testDelete(){
|
||||||
|
TestTableEntity testTableEntity = new TestTableEntity();
|
||||||
|
testTableEntity.setIdName("guo_test_delete");
|
||||||
|
testTableService.save(testTableEntity);
|
||||||
|
|
||||||
HashMap<String, Object> stringStringHashMap = new HashMap<>();
|
HashMap<String, Object> stringStringHashMap = new HashMap<>();
|
||||||
stringStringHashMap.put("id_name", "guo_test");
|
stringStringHashMap.put("id_name", "guo_test_delete");
|
||||||
boolean delete = testTableService.removeByMap(stringStringHashMap);
|
boolean delete = testTableService.removeByMap(stringStringHashMap);
|
||||||
|
|
||||||
assertTrue(delete,"删除idName为guo_test的数据失败");
|
assertTrue(delete,"删除idName为guo_test的数据失败");
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
@@ -45,12 +43,12 @@ class TaskControllerTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
StpUtil.login("测试用户-创建任务", "test_driver");
|
StpUtil.login("1", "test_driver");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addTask() throws Exception {
|
void addTask() throws Exception {
|
||||||
TaskRequest taskRequest = getTaskRequest();
|
TaskRequest taskRequest = getTaskRequest("测试任务名称-" + System.nanoTime());
|
||||||
log.info("testInfo");
|
log.info("testInfo");
|
||||||
String s = jacksonObjectMapper.writeValueAsString(taskRequest);
|
String s = jacksonObjectMapper.writeValueAsString(taskRequest);
|
||||||
System.out.println("Token Value: " + StpUtil.getTokenValue());
|
System.out.println("Token Value: " + StpUtil.getTokenValue());
|
||||||
@@ -63,30 +61,16 @@ class TaskControllerTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TaskRequest getTaskRequest() {
|
private static TaskRequest getTaskRequest(String taskName) {
|
||||||
TaskRequest taskRequest = mock(TaskRequest.class);
|
TaskRequest taskRequest = new TaskRequest();
|
||||||
|
taskRequest.setTaskName(taskName);
|
||||||
when(taskRequest.getId()).thenReturn(null);
|
taskRequest.setTaskDescription("测试任务描述");
|
||||||
// 设置学习任务的名称
|
taskRequest.setMaterialUrl("http://example.com/material");
|
||||||
when(taskRequest.getTaskName()).thenReturn("测试任务名称");
|
taskRequest.setUrgency(5);
|
||||||
|
taskRequest.setImportance(3);
|
||||||
// 设置学习材料的存储URL
|
taskRequest.setContentDifficulty(4);
|
||||||
when(taskRequest.getMaterialUrl()).thenReturn("http://example.com/material");
|
taskRequest.setFutureValue(4);
|
||||||
|
taskRequest.setSubjectivePriority(1);
|
||||||
// 设置用户设置的任务紧急性
|
|
||||||
when(taskRequest.getUrgency()).thenReturn(5);
|
|
||||||
|
|
||||||
// 设置用户设置的任务重要性
|
|
||||||
when(taskRequest.getImportance()).thenReturn(3);
|
|
||||||
|
|
||||||
// 设置任务的内容难度
|
|
||||||
when(taskRequest.getContentDifficulty()).thenReturn(4);
|
|
||||||
|
|
||||||
// 设置任务的未来价值
|
|
||||||
when(taskRequest.getFutureValue()).thenReturn(4);
|
|
||||||
|
|
||||||
// 设置用户对任务的主观优先级
|
|
||||||
when(taskRequest.getSubjectivePriority()).thenReturn(1);
|
|
||||||
return taskRequest;
|
return taskRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +92,8 @@ class TaskControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void updateTask() throws Exception {
|
void updateTask() throws Exception {
|
||||||
|
|
||||||
TaskRequest taskRequest = mock(TaskRequest.class);
|
TaskRequest taskRequest = getTaskRequest("测试数据名称");
|
||||||
when(taskRequest.getTaskName()).thenReturn("测试数据名称");
|
taskRequest.setId(1);
|
||||||
when(taskRequest.getMaterialUrl()).thenReturn("http://example.com/material");
|
|
||||||
when(taskRequest.getId()).thenReturn(1);
|
|
||||||
String s = jacksonObjectMapper.writeValueAsString(taskRequest);
|
String s = jacksonObjectMapper.writeValueAsString(taskRequest);
|
||||||
|
|
||||||
mockMvc.perform(put("/tasks/"+taskRequest.getId())
|
mockMvc.perform(put("/tasks/"+taskRequest.getId())
|
||||||
@@ -126,9 +108,9 @@ class TaskControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteTask() throws Exception {
|
void deleteTask() throws Exception {
|
||||||
CommonResult commonResult = taskController.addTask(getTaskRequest());
|
CommonResult commonResult = taskController.addTask(getTaskRequest("待删除任务-" + System.nanoTime()));
|
||||||
assertEquals(commonResult.getCode(),200);
|
assertEquals(commonResult.getCode(),200);
|
||||||
TaskEntity taskEntity = tasksServiceImpl.getOne(Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, commonResult.getMessage()));
|
TaskEntity taskEntity = tasksServiceImpl.getOne(Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, commonResult.getData()));
|
||||||
assertNotNull(taskEntity,"未能找到新创建的任务实体");
|
assertNotNull(taskEntity,"未能找到新创建的任务实体");
|
||||||
mockMvc.perform(delete("/tasks/"+taskEntity.getId())
|
mockMvc.perform(delete("/tasks/"+taskEntity.getId())
|
||||||
.header("satoken",StpUtil.getTokenValue()))
|
.header("satoken",StpUtil.getTokenValue()))
|
||||||
@@ -145,4 +127,4 @@ class TaskControllerTest {
|
|||||||
.andExpect(jsonPath("$.code").value(200))
|
.andExpect(jsonPath("$.code").value(200))
|
||||||
.andExpect(jsonPath("$.data").exists());
|
.andExpect(jsonPath("$.data").exists());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
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.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.mapper.ReviewRecallRecordMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ReviewServiceImplTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initTableInfo() {
|
||||||
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudyReportsEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudyReportFragmentsEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudySessionsEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ReviewServiceImpl reviewService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private StudyReportsMapper studyReportsMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private StudySessionsMapper studySessionsMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TasksMapper tasksMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReviewRecallRecordMapper reviewRecallRecordMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getReviewFeed_smartMode_prefersLowRecallRatioContent() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
StudyReportsEntity oldLowMastery = new StudyReportsEntity();
|
||||||
|
oldLowMastery.setId(1);
|
||||||
|
oldLowMastery.setSessionNum("S_LOW");
|
||||||
|
oldLowMastery.setContent("很久没复习且掌握度低");
|
||||||
|
oldLowMastery.setCreatedTime(now.minusDays(60));
|
||||||
|
|
||||||
|
StudyReportsEntity freshHighMastery = new StudyReportsEntity();
|
||||||
|
freshHighMastery.setId(2);
|
||||||
|
freshHighMastery.setSessionNum("S_HIGH");
|
||||||
|
freshHighMastery.setContent("刚学完且掌握度高");
|
||||||
|
freshHighMastery.setCreatedTime(now);
|
||||||
|
|
||||||
|
StudySessionsEntity sessionLow = new StudySessionsEntity();
|
||||||
|
sessionLow.setSessionNum("S_LOW");
|
||||||
|
sessionLow.setTaskNum("T_LOW");
|
||||||
|
StudySessionsEntity sessionHigh = new StudySessionsEntity();
|
||||||
|
sessionHigh.setSessionNum("S_HIGH");
|
||||||
|
sessionHigh.setTaskNum("T_HIGH");
|
||||||
|
|
||||||
|
TaskEntity taskLow = new TaskEntity();
|
||||||
|
taskLow.setTaskNum("T_LOW");
|
||||||
|
taskLow.setTaskName("低掌握任务");
|
||||||
|
TaskEntity taskHigh = new TaskEntity();
|
||||||
|
taskHigh.setTaskNum("T_HIGH");
|
||||||
|
taskHigh.setTaskName("高掌握任务");
|
||||||
|
|
||||||
|
ReviewRecallRecordEntity highRatioRecord = new ReviewRecallRecordEntity();
|
||||||
|
highRatioRecord.setTaskNum("T_HIGH");
|
||||||
|
highRatioRecord.setRecallRatio(1.0);
|
||||||
|
highRatioRecord.setCreatedTime(now);
|
||||||
|
|
||||||
|
when(studyReportsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery));
|
||||||
|
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of());
|
||||||
|
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));
|
||||||
|
|
||||||
|
List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "smart");
|
||||||
|
|
||||||
|
// 采样是概率性的,多次运行统计低掌握内容被选中的频率应显著更高
|
||||||
|
int lowMasteryWins = 0;
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
List<ReviewFeedItem> sample = reviewService.getReviewFeed(1, "smart");
|
||||||
|
if (!sample.isEmpty() && "T_LOW".equals(sample.get(0).getTaskNum())) {
|
||||||
|
lowMasteryWins++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
// 低掌握内容权重约为高掌握内容的 5 倍以上,200 次中至少应赢 120 次
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(lowMasteryWins > 120,
|
||||||
|
"低掌握内容被选中次数应显著更多,实际: " + lowMasteryWins + "/200");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getReviewFeed_shouldApplyFinalLimitAfterMerge() {
|
||||||
|
StudyReportsEntity report = new StudyReportsEntity();
|
||||||
|
report.setId(1);
|
||||||
|
report.setSessionNum("S1");
|
||||||
|
report.setContent("report");
|
||||||
|
report.setCreatedTime(LocalDateTime.now());
|
||||||
|
|
||||||
|
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
|
||||||
|
fragment.setId(2);
|
||||||
|
fragment.setSessionNum("S1");
|
||||||
|
fragment.setContent("fragment");
|
||||||
|
fragment.setCreatedTime(LocalDateTime.now().minusMinutes(1));
|
||||||
|
|
||||||
|
StudySessionsEntity session = new StudySessionsEntity();
|
||||||
|
session.setSessionNum("S1");
|
||||||
|
session.setTaskNum("T1");
|
||||||
|
|
||||||
|
TaskEntity task = new TaskEntity();
|
||||||
|
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));
|
||||||
|
|
||||||
|
List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "recent");
|
||||||
|
|
||||||
|
assertEquals(1, items.size());
|
||||||
|
assertEquals("REPORT", items.get(0).getSourceType());
|
||||||
|
assertEquals("Task", items.get(0).getTaskName());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
|
||||||
|
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||||
|
import com.guo.learningprogresstracker.mapper.*;
|
||||||
|
import com.guo.learningprogresstracker.service.MindMapAiClient;
|
||||||
|
import com.guo.learningprogresstracker.utils.CompareResult;
|
||||||
|
import com.guo.learningprogresstracker.utils.MindMapNode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class StandardMindMapServiceImplTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private StandardMindMapServiceImpl service;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ReviewStandardMindMapMapper standardMindMapMapper;
|
||||||
|
@Mock
|
||||||
|
private ReviewRecallRecordMapper recallRecordMapper;
|
||||||
|
@Mock
|
||||||
|
private TasksMapper tasksMapper;
|
||||||
|
@Mock
|
||||||
|
private StudyReportsMapper studyReportsMapper;
|
||||||
|
@Mock
|
||||||
|
private StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||||
|
@Mock
|
||||||
|
private TaskApplicationMapper taskApplicationMapper;
|
||||||
|
@Mock
|
||||||
|
private StudySessionsMapper studySessionsMapper;
|
||||||
|
@Mock
|
||||||
|
private List<MindMapAiClient> aiClients;
|
||||||
|
@Mock
|
||||||
|
private MindMapAiClient mockAiClient;
|
||||||
|
@Mock
|
||||||
|
private AiServiceClient aiServiceClient;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// ============ normalize / fuzzyMatch / bigramSet ============
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_shouldStripPunctuationAndLowercase() {
|
||||||
|
assertEquals("javaconcurrency", StandardMindMapServiceImpl.normalize("Java Concurrency"));
|
||||||
|
assertEquals("测试abc", StandardMindMapServiceImpl.normalize("测试,ABC!?"));
|
||||||
|
assertEquals("helloworld", StandardMindMapServiceImpl.normalize(" Hello, World! "));
|
||||||
|
assertEquals("", StandardMindMapServiceImpl.normalize(null));
|
||||||
|
assertEquals("", StandardMindMapServiceImpl.normalize(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_shouldStripCompareMarkers() {
|
||||||
|
assertEquals("thread", StandardMindMapServiceImpl.normalize("MATCHED|Thread"));
|
||||||
|
assertEquals("thread", StandardMindMapServiceImpl.normalize("MISSED|Thread"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bigramSet_shouldExtractCharacterPairs() {
|
||||||
|
java.util.Set<String> set = StandardMindMapServiceImpl.bigramSet("hello");
|
||||||
|
assertTrue(set.contains("he"));
|
||||||
|
assertTrue(set.contains("el"));
|
||||||
|
assertTrue(set.contains("ll"));
|
||||||
|
assertTrue(set.contains("lo"));
|
||||||
|
assertEquals(4, set.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bigramSet_shouldReturnEmptyForShortStrings() {
|
||||||
|
assertTrue(StandardMindMapServiceImpl.bigramSet("a").isEmpty());
|
||||||
|
assertTrue(StandardMindMapServiceImpl.bigramSet("").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ compareTrees ============
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compareTrees_perfectMatch() {
|
||||||
|
service = createService();
|
||||||
|
|
||||||
|
MindMapNode standard = new MindMapNode("学习Java");
|
||||||
|
standard.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
|
||||||
|
|
||||||
|
MindMapNode recall = new MindMapNode("学习Java");
|
||||||
|
recall.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
|
||||||
|
|
||||||
|
CompareResult result = service.compareTrees(standard, recall);
|
||||||
|
|
||||||
|
assertEquals(2, result.getMatchedCount());
|
||||||
|
assertEquals(0, result.getMissedCount());
|
||||||
|
assertEquals(0, result.getExtraCount());
|
||||||
|
assertEquals(1.0, result.getRecallRatio(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compareTrees_partialMatch() {
|
||||||
|
service = createService();
|
||||||
|
|
||||||
|
MindMapNode standard = new MindMapNode("学习Java");
|
||||||
|
standard.setChildren(List.of(
|
||||||
|
new MindMapNode("线程基础"),
|
||||||
|
new MindMapNode("锁机制"),
|
||||||
|
new MindMapNode("JVM内存模型")
|
||||||
|
));
|
||||||
|
|
||||||
|
MindMapNode recall = new MindMapNode("学习Java");
|
||||||
|
recall.setChildren(List.of(
|
||||||
|
new MindMapNode("线程基础"),
|
||||||
|
new MindMapNode("SpringBoot")
|
||||||
|
));
|
||||||
|
|
||||||
|
CompareResult result = service.compareTrees(standard, recall);
|
||||||
|
|
||||||
|
assertEquals(1, result.getMatchedCount());
|
||||||
|
assertEquals(2, result.getMissedCount());
|
||||||
|
assertEquals(1, result.getExtraCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compareTrees_noMatch() {
|
||||||
|
service = createService();
|
||||||
|
|
||||||
|
MindMapNode standard = new MindMapNode("学习Java");
|
||||||
|
standard.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
|
||||||
|
|
||||||
|
MindMapNode recall = new MindMapNode("完全不同");
|
||||||
|
recall.setChildren(List.of(new MindMapNode("前端开发")));
|
||||||
|
|
||||||
|
CompareResult result = service.compareTrees(standard, recall);
|
||||||
|
|
||||||
|
assertEquals(0, result.getMatchedCount());
|
||||||
|
assertEquals(2, result.getMissedCount());
|
||||||
|
assertEquals(1, result.getExtraCount());
|
||||||
|
assertEquals(0.0, result.getRecallRatio(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 边缘情况 ============
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compareTrees_emptyStandardLeaves() {
|
||||||
|
service = createService();
|
||||||
|
|
||||||
|
MindMapNode standard = new MindMapNode("根"); // 无子节点
|
||||||
|
MindMapNode recall = new MindMapNode("根");
|
||||||
|
recall.setChildren(List.of(new MindMapNode("额外节点")));
|
||||||
|
|
||||||
|
CompareResult result = service.compareTrees(standard, recall);
|
||||||
|
|
||||||
|
assertEquals(0, result.getMatchedCount());
|
||||||
|
assertEquals(0, result.getMissedCount());
|
||||||
|
assertEquals(1, result.getExtraCount());
|
||||||
|
assertEquals(0.0, result.getRecallRatio(), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrGenerate_shouldThrowWhenTaskNotFound() {
|
||||||
|
when(tasksMapper.exists(any())).thenReturn(false);
|
||||||
|
assertThrows(NotFindEntitiesException.class, () -> service.getOrGenerate("NOT_EXIST"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrGenerate_shouldReturnExisting() throws Exception {
|
||||||
|
when(tasksMapper.exists(any())).thenReturn(true);
|
||||||
|
|
||||||
|
ReviewStandardMindMapEntity existing = new ReviewStandardMindMapEntity();
|
||||||
|
existing.setId(1);
|
||||||
|
existing.setTaskNum("T1");
|
||||||
|
existing.setTitle("已有导图");
|
||||||
|
when(standardMindMapMapper.selectOne(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
ReviewStandardMindMapEntity result = service.getOrGenerate("T1");
|
||||||
|
assertEquals("已有导图", result.getTitle());
|
||||||
|
verify(standardMindMapMapper, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 辅助 ============
|
||||||
|
|
||||||
|
private StandardMindMapServiceImpl createService() {
|
||||||
|
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
|
||||||
|
standardMindMapMapper, recallRecordMapper,
|
||||||
|
tasksMapper, studyReportsMapper, studyReportFragmentsMapper,
|
||||||
|
taskApplicationMapper, studySessionsMapper,
|
||||||
|
List.of(mockAiClient), objectMapper, aiServiceClient);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.guo.learningprogresstracker.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||||
|
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||||
|
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
|
||||||
|
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
|
||||||
|
import com.guo.learningprogresstracker.mapper.TasksMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TasksServiceImplTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initTableInfo() {
|
||||||
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskApplicationEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private TasksServiceImpl tasksService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TasksMapper tasksMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TaskApplicationMapper taskApplicationMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createApplication_shouldUseDefaultStatusWhenStatusInvalid() throws Exception {
|
||||||
|
when(tasksMapper.exists(any())).thenReturn(true);
|
||||||
|
when(taskApplicationMapper.insert(any())).thenAnswer(invocation -> {
|
||||||
|
TaskApplicationEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(7);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
CreateTaskApplicationRequest request = new CreateTaskApplicationRequest();
|
||||||
|
request.setTaskNum("T1");
|
||||||
|
request.setTitle("Build a demo");
|
||||||
|
request.setStatus("invalid");
|
||||||
|
|
||||||
|
TaskApplicationEntity entity = tasksService.createApplication(request);
|
||||||
|
|
||||||
|
assertEquals(7, entity.getId());
|
||||||
|
assertEquals(TaskApplicationStatusEnum.TODO.getCode(), entity.getStatus());
|
||||||
|
verify(taskApplicationMapper).insert(any(TaskApplicationEntity.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
url: jdbc:h2:mem:lpt_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER;DB_CLOSE_DELAY=-1
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
baseline-on-migrate: true
|
||||||
|
jackson:
|
||||||
|
time-zone: UTC
|
||||||
|
date-format: yyyy-MM-dd'T'HH:mm:ss'Z'
|
||||||
|
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||||
|
|
||||||
|
sa-token:
|
||||||
|
token-name: satoken
|
||||||
|
timeout: 2592000
|
||||||
|
active-timeout: -1
|
||||||
|
is-concurrent: true
|
||||||
|
is-share: true
|
||||||
|
token-style: uuid
|
||||||
|
is-log: false
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
insert into user (id, user_name, user_password, created_time, deleted)
|
||||||
|
values ('1', 'admin', '$2a$10$skdiSwut4R9pAvt2ZK5ct.6QNualPyJGiaxngFObdLrpAxRnzehBS', now(), 0);
|
||||||
|
|
||||||
|
insert into tasks (
|
||||||
|
id,
|
||||||
|
task_num,
|
||||||
|
task_name,
|
||||||
|
task_description,
|
||||||
|
urgency,
|
||||||
|
importance,
|
||||||
|
content_difficulty,
|
||||||
|
future_value,
|
||||||
|
subjective_priority,
|
||||||
|
calculated_priority,
|
||||||
|
material_url,
|
||||||
|
last_learning_status,
|
||||||
|
created_by,
|
||||||
|
created_time,
|
||||||
|
deleted
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
1,
|
||||||
|
'TASK_TEST',
|
||||||
|
'测试任务名称',
|
||||||
|
'测试任务描述',
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
'http://example.com/material',
|
||||||
|
'未开始',
|
||||||
|
'1',
|
||||||
|
now(),
|
||||||
|
0
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mock-maker-subclass
|
||||||
Reference in New Issue
Block a user