Compare commits
100
Commits
task-1
...
0413013f24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0413013f24 | ||
|
|
9595ad92ee | ||
|
|
6678c2b8c0 | ||
|
|
b434520eff | ||
|
|
5b6f7d6a2a | ||
|
|
c8d0584f11 | ||
|
|
8c272dc1dc | ||
|
|
e42b15600a | ||
|
|
e32226d7d2 | ||
|
|
b61736eb2b | ||
|
|
8ed5631969 | ||
|
|
65ee61c6c9 | ||
|
|
09e67cd834 | ||
|
|
fd317b7932 | ||
|
|
859dab9401 | ||
|
|
4f99f31475 | ||
|
|
43580dd1fd | ||
|
|
d5d9b7aa99 | ||
|
|
6eca6de249 | ||
|
|
a42df9d521 | ||
|
|
015ecd13d8 | ||
|
|
8abd750dfb | ||
|
|
f8e24ffdf0 | ||
|
|
688be6b65f | ||
|
|
a6e75f6e97 | ||
|
|
6cc5212eba | ||
|
|
2d87b96a09 | ||
|
|
eb2869a71d | ||
|
|
1789141a7b | ||
|
|
b80e4c01f5 | ||
|
|
b70d90d18a | ||
|
|
73fdaf82e7 | ||
|
|
5418f23111 | ||
|
|
58e9e4db22 | ||
|
|
a6dab438d5 | ||
|
|
174b4265d7 | ||
|
|
3d157ed30c | ||
|
|
9aded054d2 | ||
|
|
cfa21eb336 | ||
|
|
53a6781092 | ||
|
|
ac0154c1fb | ||
|
|
9d3bac8224 | ||
|
|
76742ed65a | ||
|
|
c8c0773a6a | ||
|
|
5794bddc6c | ||
|
|
b3c7d52174 | ||
|
|
a9a5c888e3 | ||
|
|
4cf2827a74 | ||
|
|
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 | ||
|
|
81543969d8 | ||
|
|
2cf3533141 | ||
|
|
d0c78ceb28 | ||
|
|
d7e1d9a10c | ||
|
|
a64daacc71 | ||
|
|
591dc89e53 | ||
|
|
c7eb448e33 | ||
|
|
d5835cdf55 | ||
|
|
487b38e8b0 | ||
|
|
e1cd5d5b82 | ||
|
|
6bd6b00518 | ||
|
|
aadf1911ce | ||
|
|
82948e3ff9 | ||
|
|
fe6983cb69 | ||
|
|
27cb1b21fb | ||
|
|
4b2bd5a47a | ||
|
|
b6bc587ea2 | ||
|
|
a92465dfc0 | ||
|
|
78a24e8394 | ||
|
|
1fc41f1443 | ||
|
|
24d871b430 | ||
|
|
950c91791f | ||
|
|
f797a712db | ||
|
|
a932bf52ca | ||
|
|
4b26ed46ec | ||
|
|
8409dc58ab | ||
|
|
0beaed8121 |
@@ -0,0 +1,152 @@
|
||||
name: lpt-be Build & Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: '部署环境(dev 仅允许 dev 分支;prod 仅允许 master/main)'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.123.199:5000
|
||||
APP: lpt-be
|
||||
REPO_URL: http://git.cat-shark.xyz/cat-shark/lpt-be.git
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
KUBECONFIG: /tmp/kubeconfig
|
||||
steps:
|
||||
- name: Validate branch ↔ environment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REF="${{ gitea.ref }}"
|
||||
BRANCH="${REF#refs/heads/}"
|
||||
if [[ "$REF" == refs/tags/* ]]; then
|
||||
echo "ERROR: 请从分支触发部署,不要用 tag。当前 ref=$REF"
|
||||
exit 1
|
||||
fi
|
||||
ENV="${{ inputs.environment }}"
|
||||
echo "branch=$BRANCH environment=$ENV sha=${{ gitea.sha }}"
|
||||
case "$ENV" in
|
||||
prod)
|
||||
case "$BRANCH" in
|
||||
master|main) echo "OK: prod 允许从 $BRANCH 部署" ;;
|
||||
*)
|
||||
echo "ERROR: prod 只能从 master/main 部署,当前分支是 '$BRANCH'"
|
||||
echo "请切换到 master 后再 Run workflow,并选择 environment=prod"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
dev)
|
||||
case "$BRANCH" in
|
||||
dev) echo "OK: dev 允许从 $BRANCH 部署" ;;
|
||||
*)
|
||||
echo "ERROR: dev 只能从 dev 分支部署,当前分支是 '$BRANCH'"
|
||||
echo "请切换到 dev 后再 Run workflow,并选择 environment=dev"
|
||||
echo "(禁止用 master 代码部署到 lpt-dev,避免环境错配)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: 未知 environment=$ENV"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "$REPO_URL" .
|
||||
git checkout "${{ gitea.sha }}"
|
||||
|
||||
- name: Login to Docker Registry
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build & Push Docker image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENV="${{ inputs.environment }}"
|
||||
TAG="${{ gitea.sha }}-$(date +%s)"
|
||||
echo "Building $REGISTRY/$APP:$TAG (env tag=$ENV)"
|
||||
docker build -t "$REGISTRY/$APP:$TAG" -t "$REGISTRY/$APP:$ENV" .
|
||||
docker push "$REGISTRY/$APP:$TAG"
|
||||
docker push "$REGISTRY/$APP:$ENV"
|
||||
echo "IMAGE_TAG=$TAG"
|
||||
echo "$TAG" > image_tag.txt
|
||||
mkdir -p /tmp/lpt-ci
|
||||
echo "$TAG" > /tmp/lpt-ci/image_tag.txt
|
||||
|
||||
- name: Setup kubectl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sLO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > /tmp/kubeconfig
|
||||
chmod 600 /tmp/kubeconfig
|
||||
|
||||
- name: Create/Update imagePullSecret
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl create secret docker-registry regcred \
|
||||
--docker-server="$REGISTRY" \
|
||||
--docker-username="${{ secrets.REGISTRY_USERNAME }}" \
|
||||
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
|
||||
-n "lpt-${{ inputs.environment }}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Deploy to K8s
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENV="${{ inputs.environment }}"
|
||||
IMAGE_TAG="$(cat image_tag.txt 2>/dev/null || cat /tmp/lpt-ci/image_tag.txt 2>/dev/null || echo '')"
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG 为空,无法部署"
|
||||
exit 1
|
||||
fi
|
||||
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
|
||||
kubectl set image "deployment/$APP" \
|
||||
"$APP=$REGISTRY/$APP:$IMAGE_TAG" \
|
||||
-n "lpt-$ENV" --record
|
||||
kubectl rollout status "deployment/$APP" \
|
||||
-n "lpt-$ENV" --timeout=5m
|
||||
|
||||
- name: Debug on failure
|
||||
if: failure()
|
||||
run: |
|
||||
ENV="${{ inputs.environment }}"
|
||||
echo "=== Deployment Status ==="
|
||||
kubectl get deployment "$APP" -n "lpt-$ENV" || true
|
||||
echo ""
|
||||
echo "=== Pod Status ==="
|
||||
kubectl get pods -n "lpt-$ENV" -l "app=$APP" || true
|
||||
echo ""
|
||||
echo "=== Recent Events ==="
|
||||
kubectl get events -n "lpt-$ENV" --sort-by='.lastTimestamp' | tail -20 || true
|
||||
echo ""
|
||||
echo "=== Pod Describe (latest) ==="
|
||||
POD=$(kubectl get pods -n "lpt-$ENV" -l "app=$APP" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || true)
|
||||
if [ -n "${POD:-}" ]; then
|
||||
kubectl describe pod "$POD" -n "lpt-$ENV" || true
|
||||
echo ""
|
||||
echo "=== Pod Logs (latest) ==="
|
||||
kubectl logs "$POD" -n "lpt-$ENV" --tail=50 || true
|
||||
fi
|
||||
|
||||
- name: Rollback on failure
|
||||
if: failure()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENV="${{ inputs.environment }}"
|
||||
kubectl rollout undo "deployment/$APP" -n "lpt-$ENV" || true
|
||||
kubectl rollout status "deployment/$APP" -n "lpt-$ENV" || true
|
||||
@@ -0,0 +1,176 @@
|
||||
# LPT 后端服务(lpt-be)
|
||||
|
||||
> Java Spring Boot 后端,提供 REST API、数据库访问、认证鉴权。
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
- 提交信息必须简短且使用中文,不要使用英文长句。
|
||||
- 格式:`类型: 简述`,例如 `feat: 新增用户登录接口`、`fix: 修复多租户拦截器空指针`、`docs: 补充API文档`。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Java 17
|
||||
- 框架:Spring Boot 3.2.5
|
||||
- ORM:MyBatis-Plus 3.5.5
|
||||
- 数据库:MySQL 8.0
|
||||
- 认证:Sa-Token 1.38.0,Cookie 名 `satoken`
|
||||
- 数据库迁移:Flyway
|
||||
- 对象映射:MapStruct 1.5.5
|
||||
- 连接池:Druid 1.2.8
|
||||
- 密码加密:jBCrypt 0.4
|
||||
- API 文档:Knife4j (OpenAPI 3)
|
||||
- Lombok(@Data, @Slf4j)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
lpt-be/src/main/java/com/guo/learningprogresstracker/
|
||||
├── controller/ → REST 接口层
|
||||
├── service/ → 业务逻辑层(接口 + impl)
|
||||
├── mapper/ → MyBatis-Plus BaseMapper
|
||||
├── entity/ → 数据库实体
|
||||
├── dto/ → 请求/响应 DTO
|
||||
├── config/ → Spring 配置
|
||||
├── common/ → 全局异常处理、Ops
|
||||
├── mapStruct/ → MapStruct Converter
|
||||
├── enums/ → 枚举类
|
||||
├── exception/ → 自定义异常
|
||||
└── utils/ → 工具类
|
||||
```
|
||||
|
||||
- controller 只做参数校验和路由。
|
||||
- service/impl 承载业务逻辑。
|
||||
- mapper 使用 MyBatis-Plus BaseMapper。
|
||||
- entity 使用 `@TableName`、`@TableField` 映射数据库字段。
|
||||
- mapStruct 承载 DTO 转换。
|
||||
|
||||
## API 端点总览(32 个)
|
||||
|
||||
| 模块 | 端点数 | 端点 |
|
||||
|------|--------|------|
|
||||
| 学习会话 | 9 | `POST /study-sessions/{taskNum}/start`, `PUT .../pause`, `PUT .../resume`, `PUT .../end`, `POST .../fragments`, `GET .../history`, `PUT .../expectation`, `GET .../expectation`, `GET .../report-draft` |
|
||||
| 标准思维导图 | 6 | `GET /review/standard-mind-map/{taskNum}`, `POST .../regenerate`, `PUT ...`, `POST .../recall`, `POST .../find-node`, `GET .../recall-records` |
|
||||
| 复习模块 | 6 | `GET /review/feed`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/recall-records/{recordId}`, `GET /review/tasks` |
|
||||
| 任务管理 | 6 | `GET /tasks`, `POST /tasks`, `PUT /tasks/{taskNum}`, `DELETE /tasks/{taskNum}`, `GET /tasks/priority-weights`, `PUT /tasks/priority-weights` |
|
||||
| 应用场景 | 4 | `GET/POST/PUT/DELETE /tasks/{taskNum}/applications[/{id}]` |
|
||||
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
|
||||
|
||||
## 数据库(10 个核心表)
|
||||
|
||||
`users`, `tasks`, `study_sessions`, `study_reports`, `study_report_fragments`, `study_expectations`, `task_applications`, `review_standard_mind_maps`, `review_recall_records`, `user_priority_weights`
|
||||
|
||||
## 关键机制
|
||||
|
||||
- 多租户隔离:`TenantLineInnerInterceptor` 自动注入 `WHERE created_by = #{当前用户}`,排除 `user`/`flyway_schema_history` 表。
|
||||
- 自动填充:`MetaObjectHandler` 自动填充 `created_by`/`updated_by`/`created_time`/`updated_time`。
|
||||
- 认证鉴权:Sa-Token 拦截 `/**` 排除 `/login`,Cookie `satoken`,支持 `@SaCheckPermission` 注解式权限。
|
||||
- 响应格式:`CommonResult<T>` 统一包装 `{ code, message, data }`。
|
||||
- CORS:可配置,local profile 允许所有来源。
|
||||
|
||||
## 响应规范
|
||||
|
||||
- 成功:`CommonResult.success(data)`,code=200。
|
||||
- 业务错误:`CommonResult.error(msg)`,code=400,HTTP 200。
|
||||
- 未登录:`GlobalExceptionHandler.handleNotLogin()`,HTTP 401 + code=401。
|
||||
- 参数校验失败:`MethodArgumentNotValidException`,code=400。
|
||||
- 用户可见错误文案必须口语化、可理解,避免“无法生成”“不存在”等技术化表述;技术细节写入日志。例如无学习报告时应提示“这个任务还没开始学习哦,学习后产生学习报告后再来吧”,而不是“没有学习报告,无法生成思维导图”。
|
||||
|
||||
## DTO 转换
|
||||
|
||||
- MapStruct 编译期生成 `*ConvertImpl.java`,同名属性自动映射。
|
||||
- 默认 `unmappedTargetPolicy = IGNORE`。
|
||||
- 自定义映射使用 `@Mapping(source, target)`。
|
||||
- 增删 DTO 字段后必须重新编译,否则生成代码不含新字段。
|
||||
|
||||
## CORS
|
||||
|
||||
- 由 `CorsProperties` 读取各 profile 的 `cors.allowed-origins`。
|
||||
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`,兼容 `allowCredentials`。
|
||||
|
||||
## 标题抓取
|
||||
|
||||
- `TitleFetcher`:静态工具类,支持 HTTP→HTTPS 重定向和宽松 SSL。
|
||||
- `UtilsController`:`GET /utils/fetch-title?url=...` 代理端点。
|
||||
|
||||
## 运行环境
|
||||
|
||||
- 默认 profile:local(`application.yml` 中 `spring.profiles.active: local`)。
|
||||
- 编译命令:`mvn clean compile -DskipTests`,需要 JDK 17。
|
||||
|
||||
## 配置文件
|
||||
|
||||
| Profile | 文件 |
|
||||
|---------|------|
|
||||
| local | `application-local.yml` |
|
||||
| dev | `application-dev.yml` |
|
||||
| uat | `application-uat.yml` |
|
||||
| prod | `application-prod.yml` |
|
||||
| 公共 | `application.yml` |
|
||||
|
||||
## AI 服务依赖
|
||||
|
||||
- 配置:`lpt.ai-service.url=http://localhost:5199`
|
||||
- 超时:600s
|
||||
- AI 服务不可用时自动降级到内置规则引擎(`BuiltinMindMapGenerator`)
|
||||
|
||||
## 启动命令
|
||||
|
||||
```bash
|
||||
mvn clean compile -DskipTests # 编译(需要 JDK 17)
|
||||
mvn spring-boot:run # 启动(默认 profile: local)
|
||||
```
|
||||
|
||||
## 关联项目
|
||||
|
||||
| 项目 | 路径 | 端口 | 说明 |
|
||||
|------|------|------|------|
|
||||
| lpt-fe | `../lpt-fe/` | 5158 | Vue 3 前端,通过 `/api` 代理调用本服务 |
|
||||
| lpt-ai | `../lpt-ai/` | 5199 | AI 服务,本服务通过 HTTP 调用其异步任务接口 |
|
||||
|
||||
## 项目规范
|
||||
|
||||
## 数据库迁移(Flyway Migration)
|
||||
|
||||
### 命名规则
|
||||
- 脚本格式:`V{YYYYMMDD}_{序号}__{描述}.sql`
|
||||
- 日期必须使用**实际编写日期**,不得使用过去的日期
|
||||
- 序号从 1 开始,同一天多个脚本递增
|
||||
- 描述使用下划线分隔的英文短语
|
||||
|
||||
### 核心原则
|
||||
- **不可变性**:已执行的迁移脚本永远不得修改
|
||||
- **只增不减**:数据库变更只能通过新增迁移脚本实现
|
||||
- **向后兼容**:新脚本应兼容已有数据
|
||||
|
||||
### 操作规范
|
||||
- 删除表:创建新迁移脚本,使用 `DROP TABLE IF EXISTS`
|
||||
- 修改表结构:使用 `ALTER TABLE` 语句
|
||||
- 新增表:创建新迁移脚本,使用 `CREATE TABLE`
|
||||
|
||||
## 框架机制
|
||||
|
||||
### 行级数据隔离(多租户拦截器)
|
||||
|
||||
- **配置类:** `MybatisPlusConfig` 注册 `TenantLineInnerInterceptor`
|
||||
- **租户字段:** `created_by`(每个业务表的创建人字段)
|
||||
- **租户值来源:** `StpUtil.getLoginIdAsString()`(当前登录用户)
|
||||
- **自动注入:** 所有 SELECT/UPDATE/DELETE 语句自动追加 `WHERE created_by = #{当前用户}`
|
||||
- **归属校验:** 查询单条记录时拦截器自动校验 `created_by`,非本人数据直接返回空
|
||||
- **排除表:** `user`、`flyway_schema_history`、`databasechangelog`、`databasechangeloglock`
|
||||
|
||||
### 自动填充(MetaObjectHandler)
|
||||
|
||||
- **created_by / updated_by:** 插入/更新时自动填充为当前登录用户
|
||||
- **created_time / updated_time:** 插入/更新时自动填充当前时间
|
||||
|
||||
### 认证鉴权(Sa-Token)
|
||||
|
||||
- **会话管理:** 基于 `StpUtil` 的登录/登出/会话查询
|
||||
- **权限校验:** `@SaCheckPermission` 注解式权限控制
|
||||
- **未登录处理:** 全局异常处理器捕获 `NotLoginException` 返回 401
|
||||
|
||||
## 编码规范
|
||||
|
||||
### 数据访问层
|
||||
- **优先使用纯 MyBatis-Plus Java API**(`Wrappers.<T>lambdaQuery()`、`selectList`、`selectById` 等),避免手写 XML SQL
|
||||
- 复杂查询通过 `Wrappers` 链式构建条件,必要时使用 `.apply()` 拼接原生 SQL 片段
|
||||
+17
-10
@@ -1,17 +1,24 @@
|
||||
# 使用官方 OpenJDK 作为基础镜像
|
||||
FROM eclipse-temurin:17-jre
|
||||
|
||||
# 设置工作目录
|
||||
# Stage 1: Maven 构建(Docker 层缓存自动缓存依赖)
|
||||
FROM maven:3.9-eclipse-temurin-17 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# 拷贝打包好的 JAR 文件到容器
|
||||
COPY target/LPT.jar LPT.jar
|
||||
# 先复制 pom.xml,单独一层 —— 只要依赖不变,这层就被缓存
|
||||
COPY pom.xml .
|
||||
RUN mvn dependency:go-offline -B -q
|
||||
|
||||
# 再复制源码构建
|
||||
COPY src/ ./src/
|
||||
RUN mvn package -DskipTests -B -q
|
||||
|
||||
# Stage 2: 运行时(精简 JRE)
|
||||
FROM eclipse-temurin:17-jre
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/target/LPT.jar LPT.jar
|
||||
|
||||
# 暴露应用运行的端口
|
||||
EXPOSE 8888
|
||||
|
||||
# 启动应用
|
||||
ENTRYPOINT ["java", "-jar", "LPT.jar"]
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=10s --timeout=5s CMD curl --fail http://localhost:8888/actuator/health || exit 1
|
||||
HEALTHCHECK --interval=10s --timeout=5s \
|
||||
CMD curl --fail http://localhost:8888/actuator/health || exit 1
|
||||
|
||||
Vendored
-78
@@ -1,78 +0,0 @@
|
||||
pipeline {
|
||||
agent none // 全局不指定,局部自己声明
|
||||
environment {
|
||||
IMAGE_NAME = 'lpt-prod:0.0'
|
||||
CONTAINER_NAME = 'LPT-prod'
|
||||
CONTAINER_PORT = '8888'
|
||||
}
|
||||
stages {
|
||||
stage('Build') {
|
||||
agent {
|
||||
docker {
|
||||
image 'maven:3.9.6-eclipse-temurin-17'
|
||||
args '-v /root/.m2:/root/.m2'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'mvn -B -DskipTests clean package'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Image') {
|
||||
agent any // 使用 Jenkins 默认节点(宿主机),前提是宿主有 docker 命令
|
||||
steps {
|
||||
sh 'docker build -t $IMAGE_NAME .'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Docker Container') {
|
||||
agent any
|
||||
steps {
|
||||
sh '''
|
||||
docker network create traefik-public || true
|
||||
docker rm -f $CONTAINER_NAME || true
|
||||
docker run -d --name $CONTAINER_NAME --network mysql-prod_mysql-prod \\
|
||||
--restart=always \\
|
||||
-e SPRING_PROFILES_ACTIVE=prod \\
|
||||
--label "traefik.enable=true" \\
|
||||
--label "traefik.docker.network=traefik-public" \\
|
||||
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \\
|
||||
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \\
|
||||
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \\
|
||||
--label "traefik.http.routers.lpt-api.priority=100" \\
|
||||
--label "traefik.http.routers.lpt-api.service=lpt-api" \\
|
||||
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \\
|
||||
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \\
|
||||
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \\
|
||||
--log-driver=loki \\
|
||||
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
|
||||
$IMAGE_NAME
|
||||
docker network connect traefik-public $CONTAINER_NAME || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Check Docker Status') {
|
||||
agent any
|
||||
steps {
|
||||
script {
|
||||
def lastStatus = ''
|
||||
timeout(time: 60, unit: 'SECONDS') {
|
||||
waitUntil {
|
||||
def status = sh(
|
||||
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (status != lastStatus) {
|
||||
echo "Container health: ${status}"
|
||||
lastStatus = status
|
||||
}
|
||||
return (status == 'healthy')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
pipeline {
|
||||
agent none // 全局不指定,局部自己声明
|
||||
environment {
|
||||
IMAGE_NAME = 'lpt-dev:0.0'
|
||||
CONTAINER_NAME = 'LPT-dev'
|
||||
CONTAINER_PORT = '8888'
|
||||
}
|
||||
stages {
|
||||
stage('Build') {
|
||||
agent {
|
||||
docker {
|
||||
image 'maven:3.9.6-eclipse-temurin-17'
|
||||
args '-v /root/.m2:/root/.m2'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'mvn -B -DskipTests clean package'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Image') {
|
||||
agent any // 使用 Jenkins 默认节点(宿主机),前提是宿主有 docker 命令
|
||||
steps {
|
||||
sh 'docker build -t $IMAGE_NAME .'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Docker Container') {
|
||||
agent any
|
||||
steps {
|
||||
sh '''
|
||||
docker network create traefik-public || true
|
||||
docker rm -f $CONTAINER_NAME || true
|
||||
docker run -d --name $CONTAINER_NAME --network mysql_default \\
|
||||
--restart=always \\
|
||||
-e SPRING_PROFILES_ACTIVE=dev \\
|
||||
--label "traefik.enable=true" \\
|
||||
--label "traefik.docker.network=traefik-public" \\
|
||||
--label 'traefik.http.routers.lpt-api-dev.rule=Host(`lpt-dev.cat-shark.xyz`) && PathPrefix(`/api`)' \\
|
||||
--label "traefik.http.routers.lpt-api-dev.entrypoints=websecure" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.tls.certresolver=le" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.priority=100" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.service=lpt-api-dev" \\
|
||||
--label "traefik.http.routers.lpt-api-dev.middlewares=lpt-api-dev-strip" \\
|
||||
--label "traefik.http.middlewares.lpt-api-dev-strip.stripprefix.prefixes=/api" \\
|
||||
--label "traefik.http.services.lpt-api-dev.loadbalancer.server.port=$CONTAINER_PORT" \\
|
||||
--log-driver=loki \\
|
||||
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
|
||||
$IMAGE_NAME
|
||||
docker network connect traefik-public $CONTAINER_NAME || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Check Docker Status') {
|
||||
agent any
|
||||
steps {
|
||||
script {
|
||||
def lastStatus = ''
|
||||
timeout(time: 60, unit: 'SECONDS') {
|
||||
waitUntil {
|
||||
def status = sh(
|
||||
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (status != lastStatus) {
|
||||
echo "Container health: ${status}"
|
||||
lastStatus = status
|
||||
}
|
||||
return (status == 'healthy')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
@@ -129,6 +134,13 @@
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- jBCrypt (独立 BCrypt 实现,无 Spring Security 依赖) -->
|
||||
<dependency>
|
||||
<groupId>org.mindrot</groupId>
|
||||
<artifactId>jbcrypt</artifactId>
|
||||
<version>0.4</version>
|
||||
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -142,7 +154,7 @@
|
||||
<forceJavacCompilerUse>true</forceJavacCompilerUse>
|
||||
<source>17</source>
|
||||
<target>17</target>
|
||||
<compilerArgs>--enable-preview</compilerArgs>
|
||||
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: comment-cleanup
|
||||
description: 提交代码前优化 Java 注释,移除冗余描述性注释,保留功能性注释和业务上下文注释。
|
||||
metadata:
|
||||
short-description: 清理 Java 代码中的冗余注释
|
||||
---
|
||||
|
||||
# Comment Cleanup
|
||||
|
||||
在代码提交前,扫描变更的 Java 文件,清理冗余注释,保留有价值的注释。
|
||||
|
||||
## 触发条件
|
||||
|
||||
用户要求清理注释、优化注释、提交前检查注释时使用。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 定位待清理的 Java 文件(通常是 `git diff` 中变更的文件)
|
||||
2. 逐文件扫描注释,按规则分类处理
|
||||
3. 执行清理(删除或改造)
|
||||
4. 输出清理报告
|
||||
|
||||
## 注释分类规则
|
||||
|
||||
### 移除:类型 A — 代码语义重复注释
|
||||
|
||||
代码本身已经清晰表达意图时,删除多余的行内注释。
|
||||
|
||||
```java
|
||||
// ❌ 移除:代码已经说清楚了
|
||||
// 转换报告
|
||||
List<ReviewFeedItem> reportItems = reports.stream().map(r -> toFeedItem(r)).collect(Collectors.toList());
|
||||
|
||||
// ❌ 移除:合并逻辑一眼就能看出
|
||||
// 合并并按创建时间倒序
|
||||
return Stream.concat(reportItems.stream(), fragmentItems.stream())
|
||||
.sorted(Comparator.comparing(ReviewFeedItem::getCreatedTime).reversed())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// ❌ 移除:查询目的从变量名已可知
|
||||
// 先查该任务下的所有 sessionNum
|
||||
List<String> sessionNums = studySessionsMapper.selectList(...)
|
||||
```
|
||||
|
||||
**判断标准:** 如果删掉注释后,一个熟悉 Java/Spring 的开发者看代码没有任何困惑,就该移除。
|
||||
|
||||
**例外:** 方法级的 Javadoc(`/** ... */`)即使与代码重复,也保留,因为它服务于 IDE 提示和文档生成。
|
||||
|
||||
### 移除:类型 B — 框架机制注释
|
||||
|
||||
框架隐式行为(拦截器、MetaObjectHandler、AOP 等)的说明不在代码中重复标注,而是在 `AGENTS.md` 的"框架机制"章节集中描述。代码中的此类注释一律移除。
|
||||
|
||||
```java
|
||||
// ❌ 移除:框架机制已在 AGENTS.md 中说明
|
||||
// created_by 条件由 TenantLineInnerInterceptor 自动注入
|
||||
List<StudyReportsEntity> reports = studyReportsMapper.selectList(...)
|
||||
|
||||
// ❌ 移除
|
||||
// 拦截器自动校验归属
|
||||
return Optional.ofNullable(studyReportsMapper.selectById(id)) ...
|
||||
|
||||
// ❌ 移除
|
||||
// created_by 由 MetaObjectHandler 自动填充
|
||||
studySessionsServiceImpl.save(...)
|
||||
```
|
||||
|
||||
### 移除:类型 D — 空注释 / 无信息量注释
|
||||
|
||||
```java
|
||||
// ❌ 空 Javadoc
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@TableField(value = "created_time")
|
||||
private LocalDateTime createdTime;
|
||||
|
||||
// ❌ 纯标注作者(无版本/日期等有价值信息时)
|
||||
/**
|
||||
* @author guo
|
||||
*/
|
||||
public class GlobalExceptionHandler { ... }
|
||||
|
||||
// ❌ 重复 HTTP 状态码(CommonResult.error 已隐含 400)
|
||||
//code:400
|
||||
return CommonResult.error(ex.getMessage());
|
||||
```
|
||||
|
||||
### 保留:类型 C — 字段/类 Javadoc
|
||||
|
||||
字段 Javadoc 描述数据含义,类 Javadoc 描述模块职能。
|
||||
|
||||
**字段 Javadoc:** 保留,说明字段的业务含义。
|
||||
|
||||
```java
|
||||
// ✅ 保留:字段含义对理解数据模型有帮助
|
||||
/**
|
||||
* 账号-登录用
|
||||
*/
|
||||
@TableField(value = "user_name")
|
||||
private String userName;
|
||||
```
|
||||
|
||||
**类 Javadoc:** 保留,但只描述类的职能,不包含实现技术细节。
|
||||
|
||||
```java
|
||||
// ❌ 移除:实现技术属于实现细节,不属于类描述
|
||||
/** 复习模块 Service 实现(纯 MyBatis-Plus Java API) */
|
||||
|
||||
// ✅ 保留:只描述职能
|
||||
/** 复习模块 Service 实现 */
|
||||
```
|
||||
|
||||
### 改造:类型 E — 枚举注释
|
||||
|
||||
枚举中的中文含义注释应迁移到枚举类的专用字段中,而非用注释标注。
|
||||
|
||||
```java
|
||||
// ❌ 改造前:用注释标注含义
|
||||
public enum Strategy {
|
||||
// 创建组
|
||||
CREATE("C"),
|
||||
// 更新组
|
||||
UPDATE("U");
|
||||
}
|
||||
|
||||
// ✅ 改造后:用字段存储含义
|
||||
public enum Strategy {
|
||||
CREATE("C", "创建组"),
|
||||
UPDATE("U", "更新组");
|
||||
|
||||
private final String code;
|
||||
private final String label;
|
||||
|
||||
Strategy(String code, String label) {
|
||||
this.code = code;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getCode() { return code; }
|
||||
public String getLabel() { return label; }
|
||||
}
|
||||
```
|
||||
|
||||
如果枚举已有 `label`/`desc` 等字段,则直接删除注释。
|
||||
|
||||
**常量注释保留不变:**
|
||||
```java
|
||||
// ✅ 常量注释保留
|
||||
// 最大重试次数
|
||||
private static final int MAX_RETRY = 3;
|
||||
```
|
||||
|
||||
### 保留:类型 F — TODO / FIXME 注释
|
||||
|
||||
```java
|
||||
// ✅ 保留
|
||||
//todo 参数传递未加密
|
||||
```
|
||||
|
||||
## 执行命令
|
||||
|
||||
```bash
|
||||
# 获取变更的 Java 文件
|
||||
git diff --cached --name-only --diff-filter=ACMR -- '*.java'
|
||||
|
||||
# 对每个文件逐行扫描,按上述规则处理
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
清理完成后,输出简要报告:
|
||||
|
||||
```
|
||||
✅ 清理完成,共处理 N 个文件:
|
||||
- 移除冗余注释 X 处
|
||||
- 改造枚举注释 Y 处(需人工确认新增字段)
|
||||
- 保留注释 Z 处(字段Javadoc/TODO)
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
schema_version: v1
|
||||
interface:
|
||||
display_name: Java 注释清理
|
||||
short_description: 清理 Java 代码中的冗余注释,保留功能性注释
|
||||
default_prompt: |
|
||||
使用 $comment-cleanup 扫描变更的 Java 文件,移除冗余的描述性注释,保留框架机制注释、字段 Javadoc 和 TODO 注释。
|
||||
@@ -5,6 +5,7 @@ import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.exception.AppException;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -15,41 +16,42 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler({ErrorParameterException.class})
|
||||
public CommonResult errorParameterException(ErrorParameterException ex) {
|
||||
//code:400
|
||||
log.warn("参数异常: {}", ex.getMessage(), ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({NotFindEntitiesException.class})
|
||||
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
|
||||
//code:400
|
||||
log.warn("数据不存在: {}", ex.getMessage(), ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({OperationFailedException.class})
|
||||
public CommonResult operationFailedException(OperationFailedException ex) {
|
||||
log.warn("操作失败: {}", ex.getMessage(), ex);
|
||||
return CommonResult.error(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({AppException.class})
|
||||
public CommonResult AppException(AppException ex) {
|
||||
//code:500
|
||||
log.error("业务异常: {}", ex.getMessage(), ex);
|
||||
return CommonResult.serverError(ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public CommonResult MyMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
|
||||
BindingResult bindingResult = ex.getBindingResult();
|
||||
//code:400
|
||||
return CommonResult.error(bindingResult.getFieldError().getDefaultMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotLoginException.class)
|
||||
public CommonResult handleNotLogin(NotLoginException e, HttpServletRequest req, HttpServletResponse res) {
|
||||
//code:401
|
||||
res.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
return new CommonResult<>(HttpStatus.UNAUTHORIZED.value(), "未登录,请重新登录", null);
|
||||
}
|
||||
@@ -57,8 +59,7 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(Exception.class)
|
||||
public CommonResult Exception(Exception ex) {
|
||||
log.error("系统异常", ex);
|
||||
//code:400
|
||||
return CommonResult.error(ex.getMessage());
|
||||
return CommonResult.error("操作没有成功,请稍后再试");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import jakarta.validation.groups.Default;
|
||||
|
||||
/**
|
||||
* 全局数据验证分组
|
||||
* GlobalValidationGroup -> OpenGroups ->ops
|
||||
* @author guo
|
||||
*/
|
||||
public interface Ops {
|
||||
// 创建组
|
||||
interface CreateG extends Default {}
|
||||
interface CreateG extends Default {
|
||||
}
|
||||
|
||||
// 更新组
|
||||
interface UpdateG {}
|
||||
interface UpdateG {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,16 @@ import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
@Bean
|
||||
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
|
||||
ObjectMapper mapper = builder.build();
|
||||
|
||||
// 注册自定义序列化器
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
|
||||
mapper.registerModule(module);
|
||||
|
||||
// 关闭序列化为时间戳(使用字符串格式)
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
return mapper;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.guo.learningprogresstracker.config;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import net.sf.jsqlparser.expression.StringValue;
|
||||
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.handler.TenantLineHandler;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 多租户配置
|
||||
* 利用 TenantLineInnerInterceptor 自动在 SELECT/UPDATE/DELETE 语句中
|
||||
* 注入 WHERE created_by = #{当前登录用户},实现行级数据隔离。
|
||||
*/
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
/**
|
||||
* 不需要租户过滤的表(仅排除不含 created_by 的系统表)
|
||||
*/
|
||||
private static final List<String> EXCLUDE_TABLES = Arrays.asList(
|
||||
"user",
|
||||
"flyway_schema_history",
|
||||
"databasechangelog",
|
||||
"databasechangeloglock"
|
||||
);
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
// 分页拦截器必须注册,否则 selectPage 不会生成 LIMIT / COUNT
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
return new StringValue(StpUtil.getLoginIdAsString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTenantIdColumn() {
|
||||
return "created_by";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
return EXCLUDE_TABLES.contains(tableName.toLowerCase());
|
||||
}
|
||||
}));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
+18
-15
@@ -6,29 +6,22 @@ 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;
|
||||
|
||||
/**
|
||||
* @author Administrator
|
||||
*/
|
||||
@Profile("dev")
|
||||
@Configuration
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcDevConfig implements WebMvcConfigurer {
|
||||
@Slf4j
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final CorsProperties corsProperties;
|
||||
|
||||
// 注册拦截器
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new SaInterceptor(handle -> {
|
||||
// OPTIONS 请求直接放行
|
||||
if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) {
|
||||
return;
|
||||
}
|
||||
@@ -40,11 +33,21 @@ public class WebMvcDevConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(corsProperties.getAllowedOrigins())
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(null == corsProperties.getAllowCredentials() || corsProperties.getAllowCredentials());
|
||||
String[] origins = corsProperties.getAllowedOrigins();
|
||||
boolean hasWildcard = origins != null && Arrays.asList(origins).contains("*");
|
||||
log.info("允许cors的地址配置:{}", Arrays.toString(origins));
|
||||
if (hasWildcard) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
} else {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(origins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(corsProperties.getAllowCredentials() == null || corsProperties.getAllowCredentials());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +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) {
|
||||
// 注册 Sa-Token 拦截器,校验规则为 StpUtil.checkLogin() 登录校验。
|
||||
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/login");
|
||||
}
|
||||
|
||||
}
|
||||
+1
-4
@@ -9,9 +9,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
|
||||
|
||||
// 修改为ISO 8601格式,末尾带Z,表示UTC时区
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
public LocalDateTimeSerializer() {
|
||||
super(LocalDateTime.class);
|
||||
@@ -22,7 +20,6 @@ public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
|
||||
if (value == null) {
|
||||
gen.writeNull();
|
||||
} else {
|
||||
// 这里假设LocalDateTime是UTC时间,直接格式化并加Z
|
||||
String formattedDate = value.format(formatter);
|
||||
gen.writeString(formattedDate);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
*/
|
||||
@CrossOrigin
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
|
||||
import com.guo.learningprogresstracker.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.StudyReportsEntity;
|
||||
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 org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 复习模块控制层 — 复习内容滚动展示与交互
|
||||
@@ -21,15 +30,34 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class ReviewController {
|
||||
|
||||
private final ReviewServiceImpl reviewService;
|
||||
private final ReviewService reviewService;
|
||||
|
||||
private final StandardMindMapService standardMindMapService;
|
||||
|
||||
/**
|
||||
* 获取复习 feed,合并报告和残片按时间倒序
|
||||
* 获取复习 feed,仅返回学习残片(首页滚动条使用)
|
||||
*/
|
||||
@GetMapping("/feed")
|
||||
public CommonResult<List<ReviewFeedItem>> getReviewFeed(
|
||||
@RequestParam(defaultValue = "30") int limit) {
|
||||
return CommonResult.success(reviewService.getReviewFeed(limit));
|
||||
@RequestParam(defaultValue = "30") int limit,
|
||||
@RequestParam(defaultValue = "recent") String mode) {
|
||||
return CommonResult.success(reviewService.getReviewFeed(limit, mode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务维度的复习统计汇总
|
||||
*/
|
||||
@GetMapping("/tasks")
|
||||
public CommonResult<List<ReviewTaskStats>> getReviewTaskStats() {
|
||||
return CommonResult.success(reviewService.getReviewTaskStats());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定任务的复习统计汇总
|
||||
*/
|
||||
@GetMapping("/tasks/{taskNum}")
|
||||
public CommonResult<ReviewTaskStats> getReviewTaskStats(@PathVariable String taskNum) {
|
||||
return CommonResult.success(reviewService.getReviewTaskStats(taskNum));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,4 +83,78 @@ public class ReviewController {
|
||||
public CommonResult<StudyReportFragmentsEntity> getFragmentDetail(@PathVariable int id) throws NotFindEntitiesException {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+66
-14
@@ -1,13 +1,16 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.google.protobuf.ServiceException;
|
||||
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
|
||||
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.StudySessionsEntity;
|
||||
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.mapStruct.StudySessionConvert;
|
||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -15,8 +18,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 学习会话控制层
|
||||
@@ -29,21 +32,35 @@ public class StudySessionController {
|
||||
|
||||
private final StudySessionsServiceImpl studySessionsServiceImpl;
|
||||
|
||||
@PostMapping
|
||||
public void studySessionList() {
|
||||
private final StudyExpectationsService studyExpectationsService;
|
||||
|
||||
/**
|
||||
* 创建/更新学习会话的学习预期
|
||||
*/
|
||||
@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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过sessionNum获取一个【学习会话】
|
||||
*/
|
||||
@GetMapping("/{sessionNum}")
|
||||
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException {
|
||||
StudySessionsEntity studySessionsEntity = studySessionsServiceImpl.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
StudySessionResponse studySessionResponse = StudySessionConvert.MAPPER.toStudySessionResponse(studySessionsEntity);
|
||||
return CommonResult.success(studySessionResponse);
|
||||
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "请提供学习会话编号") @PathVariable String sessionNum) throws ErrorParameterException {
|
||||
// 通过 service 层获取,包含归属校验
|
||||
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
|
||||
return CommonResult.success(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,8 +89,8 @@ public class StudySessionController {
|
||||
@PostMapping("/{sessionNum}/study-sessions/ended")
|
||||
public CommonResult<Void> endedStudySession(@PathVariable("sessionNum") String sessionNum,
|
||||
@Valid @RequestBody EndedStudySessionRequest request) throws ErrorParameterException {
|
||||
studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent());
|
||||
return CommonResult.success();
|
||||
String message = studySessionsServiceImpl.endedStudySession(sessionNum, request.getContent());
|
||||
return message != null ? CommonResult.success(message) : CommonResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,4 +102,39 @@ public class StudySessionController {
|
||||
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.guo.learningprogresstracker.common.Ops;
|
||||
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.TaskInfoResponse;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
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.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.service.PriorityWeightsService;
|
||||
import com.guo.learningprogresstracker.service.TasksService;
|
||||
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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 java.rmi.ServerException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 任务控制层
|
||||
@@ -43,10 +50,25 @@ public class TaskController {
|
||||
|
||||
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
|
||||
@Operation(summary = "添加新任务")
|
||||
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")
|
||||
@@ -69,6 +91,36 @@ public class TaskController {
|
||||
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
|
||||
@Operation(summary = "获取所有任务列表")
|
||||
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
|
||||
@@ -80,7 +132,7 @@ public class TaskController {
|
||||
* 开始或继续一个【学习会话】
|
||||
*/
|
||||
@GetMapping("/{taskNum}/study-sessions/start-or-continue")
|
||||
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "taskNum不可为空")
|
||||
public CommonResult<StudySessionResponse> startOrContinueStudySession(@NotEmpty(message = "请提供任务编号")
|
||||
@PathVariable String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||
StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum);
|
||||
return CommonResult.success(response);
|
||||
@@ -91,7 +143,7 @@ public class TaskController {
|
||||
* 通过学习任务num获取该任务的未结束会话
|
||||
*/
|
||||
@GetMapping("/{taskNum}/not-ended-study-session")
|
||||
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "taskNum不可为空")
|
||||
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "请提供任务编号")
|
||||
@PathVariable String taskNum) throws ErrorParameterException {
|
||||
StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum);
|
||||
return CommonResult.success(response);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.utils.TitleFetcher;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/utils")
|
||||
public class UtilsController {
|
||||
|
||||
@GetMapping("/fetch-title")
|
||||
@Operation(summary = "获取指定URL的页面标题")
|
||||
public CommonResult<Map<String, String>> fetchTitle(@RequestParam String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
return CommonResult.error("url 参数不能为空");
|
||||
}
|
||||
String title = TitleFetcher.fetchTitle(url);
|
||||
return CommonResult.success(Map.of("title", title != null ? title : url));
|
||||
}
|
||||
}
|
||||
+24
-6
@@ -1,16 +1,17 @@
|
||||
package com.guo.learningprogresstracker.controller;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学习残片控制层
|
||||
@@ -24,12 +25,29 @@ public class reportFragmentsController {
|
||||
|
||||
/**
|
||||
* 创建学习残片
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
@PostMapping
|
||||
public CommonResult<Void> createFragments(@Valid @RequestBody CreateFragmentsRequest request) throws NotFindEntitiesException {
|
||||
studyReportFragmentsServiceImpl.createFragments(request);
|
||||
return CommonResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新学习残片
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public CommonResult<Void> updateFragments(@PathVariable Integer id,
|
||||
@Valid @RequestBody UpdateFragmentsRequest request) throws NotFindEntitiesException {
|
||||
studyReportFragmentsServiceImpl.updateFragments(id, request);
|
||||
return CommonResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定会话的所有学习残片
|
||||
*/
|
||||
@GetMapping("/session/{sessionNum}")
|
||||
public CommonResult<List<StudyReportFragmentsEntity>> getFragmentsBySession(@PathVariable String sessionNum) {
|
||||
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsServiceImpl.getFragmentsBySession(sessionNum);
|
||||
return CommonResult.success(fragments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 复习滚动 feed 条目,合并展示学习报告和残片
|
||||
* 复习滚动 feed 条目;首页仅使用残片,任务详情仍可同时包含报告和残片
|
||||
*/
|
||||
@Data
|
||||
public class ReviewFeedItem {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.guo.learningprogresstracker.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 任务维度的复习统计数据。
|
||||
*/
|
||||
@Data
|
||||
public class ReviewTaskStats {
|
||||
private String taskNum;
|
||||
private String taskName;
|
||||
private long reportCount;
|
||||
private long fragmentCount;
|
||||
private double effectiveTime;
|
||||
private long sessionCount;
|
||||
private double todayEffectiveTime;
|
||||
private double weekEffectiveTime;
|
||||
private double avgEffectiveTime;
|
||||
private double avgEffectivenessRatio;
|
||||
}
|
||||
@@ -2,9 +2,6 @@ package com.guo.learningprogresstracker.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
*/
|
||||
@Data
|
||||
public class TaskInfo {
|
||||
|
||||
@@ -16,6 +13,8 @@ public class TaskInfo {
|
||||
private String taskName;
|
||||
// 任务描述
|
||||
private String taskDescription;
|
||||
// 学习材料地址
|
||||
private String materialUrl;
|
||||
// 任务优先级
|
||||
private Double taskPriority;
|
||||
// 上次该任务学习情况
|
||||
|
||||
+1
-1
@@ -16,6 +16,6 @@ public class CreateFragmentsRequest {
|
||||
/**
|
||||
* 残片内容,学习内容的描述
|
||||
*/
|
||||
@NotBlank(message = "啊?无字天书?")
|
||||
@NotBlank(message = "请填写学习内容")
|
||||
private String content;
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@ import lombok.Data;
|
||||
*/
|
||||
@Data
|
||||
public class EndedStudySessionRequest {
|
||||
@NotBlank(message = "啊?搞无字天书是吧?报告内容不可为空")
|
||||
@NotBlank(message = "请填写学习报告内容")
|
||||
private String content;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,16 +15,16 @@ import lombok.Data;
|
||||
public class TaskRequest {
|
||||
|
||||
public interface Create{}
|
||||
@Null(message = "创建时不可指定任务ID",groups = Ops.CreateG.class)
|
||||
@NotNull(message = "更新时必须指定任务ID",groups = Ops.UpdateG.class)
|
||||
@Null(message = "创建任务时不需要填写任务编号",groups = Ops.CreateG.class)
|
||||
@NotNull(message = "缺少要更新的任务信息,请刷新后重试",groups = Ops.UpdateG.class)
|
||||
private Integer id;
|
||||
/**
|
||||
* 学习任务的名称
|
||||
*/
|
||||
@NotEmpty(message = "必须指定非空的任务名称", groups = Ops.CreateG.class)
|
||||
@NotEmpty(message = "请填写任务名称", groups = Ops.CreateG.class)
|
||||
private String taskName;
|
||||
|
||||
@NotEmpty(message = "必须指定非空的任务描述", groups = Ops.CreateG.class)
|
||||
@NotEmpty(message = "请填写任务描述", groups = Ops.CreateG.class)
|
||||
private String taskDescription;
|
||||
|
||||
/**
|
||||
@@ -35,41 +35,41 @@ public class TaskRequest {
|
||||
/**
|
||||
* 用户设置的任务紧急性
|
||||
*/
|
||||
@NotNull(message = "必须指定【任务紧急性】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "urgency参数值必须大于等于0")
|
||||
@Max(value = 5,message = "urgency参数值必须小于等于5")
|
||||
@NotNull(message = "请设置任务紧急性", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "任务紧急性不能小于 0")
|
||||
@Max(value = 5,message = "任务紧急性不能大于 5")
|
||||
private Integer urgency;
|
||||
|
||||
/**
|
||||
* 用户设置的任务重要性
|
||||
*/
|
||||
@NotNull(message = "必须指定【任务重要性】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "importance参数值必须大于等于0")
|
||||
@Max(value = 5,message = "importance参数值必须小于等于5")
|
||||
@NotNull(message = "请设置任务重要性", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "任务重要性不能小于 0")
|
||||
@Max(value = 5,message = "任务重要性不能大于 5")
|
||||
private Integer importance;
|
||||
|
||||
/**
|
||||
* 任务的内容难度
|
||||
*/
|
||||
@NotNull(message = "必须指定【内容难度】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "contentDifficulty参数值必须大于等于0")
|
||||
@Max(value = 5,message = "contentDifficulty参数值必须小于等于5")
|
||||
@NotNull(message = "请设置内容难度", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "内容难度不能小于 0")
|
||||
@Max(value = 5,message = "内容难度不能大于 5")
|
||||
private Integer contentDifficulty;
|
||||
|
||||
/**
|
||||
* 任务的未来价值
|
||||
*/
|
||||
@NotNull(message = "必须指定【未来价值】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "futureValue参数值必须大于等于0")
|
||||
@Max(value = 5,message = "futureValue参数值必须小于等于5")
|
||||
@NotNull(message = "请设置未来价值", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "未来价值不能小于 0")
|
||||
@Max(value = 5,message = "未来价值不能大于 5")
|
||||
private Integer futureValue;
|
||||
|
||||
/**
|
||||
* 用户对任务的主观优先级
|
||||
*/
|
||||
@NotNull(message = "必须指定【主观优先级】指标", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "subjectivePriority参数值必须大于等于0")
|
||||
@Max(value = 5,message = "subjectivePriority参数值必须小于等于5")
|
||||
@NotNull(message = "请设置主观优先级", groups = Ops.CreateG.class)
|
||||
@Min(value = 0,message = "主观优先级不能小于 0")
|
||||
@Max(value = 5,message = "主观优先级不能大于 5")
|
||||
private Integer subjectivePriority;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.guo.learningprogresstracker.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
*/
|
||||
@Data
|
||||
public class UpdateFragmentsRequest {
|
||||
/**
|
||||
* 残片内容,学习内容的描述
|
||||
*/
|
||||
@NotBlank(message = "请填写学习内容")
|
||||
private String content;
|
||||
}
|
||||
+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 = "任务编号")
|
||||
private String taskNum;
|
||||
|
||||
@Schema(description = "学习材料(Markdown)")
|
||||
private String materialUrl;
|
||||
|
||||
@Schema(description = "任务ID")
|
||||
private Integer taskId;
|
||||
|
||||
@Schema(description = "学习开始时间")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public class TaskInfoResponse {
|
||||
*/
|
||||
private String materialUrl;
|
||||
|
||||
/**
|
||||
/**
|
||||
* 用户设置的任务紧急性
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,28 +4,22 @@ 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 java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 存储每次学习开始前的预期
|
||||
* @TableName study_expectations
|
||||
*/
|
||||
@TableName(value ="study_expectations")
|
||||
@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_id")
|
||||
private Integer sessionId;
|
||||
@TableField(value = "session_num")
|
||||
private String sessionNum;
|
||||
|
||||
/**
|
||||
* 学习预期的详细描述
|
||||
@@ -33,7 +27,6 @@ public class StudyExpectationsEntity extends BaseEntity implements Serializable
|
||||
@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")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 对应学习会话的预期目标(非数据库字段,查询时填充)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String sessionExpectation;
|
||||
|
||||
@TableField(exist = false)
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -123,7 +123,6 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
|
||||
public void pausedStudySession(LocalDateTime endTime){
|
||||
if (this.getSessionState().equals(StudySessionStateEnum.PAUSED.name())) {
|
||||
log.warn("不应出现的情况:暂停了一个状态为【{}】的学习会话({})", this.getSessionState(), this.getSessionNum());
|
||||
//
|
||||
} else {
|
||||
this.setEndTime(ObjectUtils.isEmpty(endTime) ? LocalDateTime.now() : endTime);
|
||||
this.setActualTime(Duration.between(this.startTime, this.endTime).toSeconds());
|
||||
@@ -133,6 +132,11 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效学习时间的最小阈值(秒),低于此值不计入总学习时间
|
||||
*/
|
||||
private static final double MIN_EFFECTIVE_TIME_SECONDS = 10 * 60;
|
||||
|
||||
/**
|
||||
* 结束会话
|
||||
*/
|
||||
@@ -141,7 +145,12 @@ public class StudySessionsEntity extends BaseEntity implements Serializable {
|
||||
log.warn("不应出现的情况:结束了一个状态为【{}】的学习会话",this.getSessionState());
|
||||
}else {
|
||||
if (this.getSessionState().equals(StudySessionStateEnum.ONGOING.name())) {
|
||||
this.pausedStudySession(endTime);
|
||||
this.pausedStudySession(LocalDateTime.now());
|
||||
}
|
||||
if (this.getEffectiveTime() < MIN_EFFECTIVE_TIME_SECONDS) {
|
||||
log.info("会话[{}]有效学习时间({}秒)不足10分钟,不计入总学习时间", this.getSessionNum(), this.getEffectiveTime());
|
||||
this.setEffectiveTime(0);
|
||||
this.setEffectivenessRatio(0);
|
||||
}
|
||||
this.setSessionState(StudySessionStateEnum.ENDED.name());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.guo.learningprogresstracker.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.io.Serializable;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 存储用户-任务之间的对应关系
|
||||
* @TableName user_task
|
||||
*/
|
||||
@TableName(value ="user_task")
|
||||
@Data
|
||||
public class UserTaskEntity extends BaseEntity implements Serializable {
|
||||
|
||||
@TableId
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 用户id
|
||||
*/
|
||||
@TableField(value = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 任务id
|
||||
*/
|
||||
@TableField(value = "task_id")
|
||||
private Integer taskId;
|
||||
|
||||
|
||||
@TableField(exist = false)
|
||||
private static final long serialVersionUID = 1L;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.guo.learningprogresstracker.mapStruct;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
@@ -11,4 +12,6 @@ public interface FragmentsConvert {
|
||||
FragmentsConvert MAPPER = Mappers.getMapper(FragmentsConvert.class);
|
||||
|
||||
StudyReportFragmentsEntity toFragmentsEntity(CreateFragmentsRequest request);
|
||||
|
||||
StudyReportFragmentsEntity toFragmentsEntity(UpdateFragmentsRequest request);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.guo.learningprogresstracker.mapper;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 复习模块自定义查询 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReviewMapper {
|
||||
|
||||
/**
|
||||
* 获取最近的学习报告和残片,合并排序后返回
|
||||
*/
|
||||
List<ReviewFeedItem> selectReviewFeed(@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 获取指定任务下的所有报告和残片,按时间倒序
|
||||
*/
|
||||
List<ReviewFeedItem> selectTaskReview(@Param("taskNum") String taskNum);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
package com.guo.learningprogresstracker.mapper;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Mapper
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
* @Entity com.guo.learningprogresstracker.entity.StudyExpectationsEntity
|
||||
*/
|
||||
@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> {
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.guo.learningprogresstracker.mapper;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.UserTaskEntity;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【user_task(存储用户-任务之间的对应关系)】的数据库操作Mapper
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
* @Entity com.guo.learningprogresstracker.entity.UserTaskEntity
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserTaskMapper extends BaseMapper<UserTaskEntity> {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.guo.learningprogresstracker.service;
|
||||
|
||||
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 applications 该任务的应用场景(可选)
|
||||
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
|
||||
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
|
||||
*/
|
||||
Optional<MindMapNode> generate(TaskEntity task,
|
||||
List<StudyReportsEntity> reports,
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
@@ -13,9 +14,19 @@ import java.util.List;
|
||||
public interface ReviewService {
|
||||
|
||||
/**
|
||||
* 获取复习 feed 列表,合并报告和残片按时间倒序
|
||||
* 获取复习 feed 列表,仅返回学习残片并按时间倒序
|
||||
*/
|
||||
List<ReviewFeedItem> getReviewFeed(int limit);
|
||||
List<ReviewFeedItem> getReviewFeed(int limit, String mode);
|
||||
|
||||
/**
|
||||
* 获取所有任务的复习统计数据
|
||||
*/
|
||||
List<ReviewTaskStats> getReviewTaskStats();
|
||||
|
||||
/**
|
||||
* 获取指定任务的复习统计数据
|
||||
*/
|
||||
ReviewTaskStats getReviewTaskStats(String taskNum);
|
||||
|
||||
/**
|
||||
* 获取指定任务下的所有报告和残片
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+13
-6
@@ -1,13 +1,20 @@
|
||||
package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
*/
|
||||
public interface StudyExpectationsService extends IService<StudyExpectationsEntity> {
|
||||
* 学习预期服务
|
||||
*/
|
||||
public interface StudyExpectationsService {
|
||||
|
||||
/**
|
||||
* 为学习会话创建学习预期(每个会话最多一条,重复创建则覆盖)
|
||||
*/
|
||||
StudyExpectationsEntity upsertExpectation(String sessionNum, String description) throws ErrorParameterException;
|
||||
|
||||
/**
|
||||
* 查询学习会话的学习预期,不存在时返回 null
|
||||
*/
|
||||
StudyExpectationsEntity getBySessionNum(String sessionNum);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service
|
||||
@@ -13,4 +16,8 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
public interface StudyReportFragmentsService extends IService<StudyReportFragmentsEntity> {
|
||||
|
||||
void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException;
|
||||
|
||||
void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException;
|
||||
|
||||
List<StudyReportFragmentsEntity> getFragmentsBySession(String sessionNum);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.google.protobuf.ServiceException;
|
||||
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.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
|
||||
@@ -20,9 +24,15 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
|
||||
|
||||
ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException;
|
||||
|
||||
void endedStudySession(String sessionNum, String content) throws ErrorParameterException;
|
||||
String endedStudySession(String sessionNum, String content) throws ErrorParameterException;
|
||||
|
||||
void pauseStudySession(String sessionNum, LocalDateTime endTime) 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.service.IService;
|
||||
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.UpdateTaskApplicationRequest;
|
||||
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
|
||||
import java.rmi.ServerException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
@@ -26,4 +31,12 @@ public interface TasksService extends IService<TaskEntity> {
|
||||
void updateTask(String taskId, TaskRequest updatedTask);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.guo.learningprogresstracker.service;
|
||||
|
||||
import com.guo.learningprogresstracker.entity.UserTaskEntity;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【user_task(存储用户-任务之间的对应关系)】的数据库操作Service
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
*/
|
||||
public interface UserTaskService extends IService<UserTaskEntity> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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) {
|
||||
if (!isConfigured()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> params = Map.of(
|
||||
"taskName", taskName,
|
||||
"taskDescription", taskDescription != null ? taskDescription : "",
|
||||
"reports", reports != null ? reports : 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
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<TaskApplicationEntity> applications,
|
||||
String clientHint) {
|
||||
if (reports == null || reports.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()
|
||||
.filter(r -> r.getSessionNum() != null)
|
||||
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
|
||||
|
||||
for (Map.Entry<String, List<StudyReportsEntity>> entry : reportsBySession.entrySet()) {
|
||||
List<StudyReportsEntity> sessReports = entry.getValue();
|
||||
|
||||
// 会话分支标题:取第一条报告的前 60 字作为摘要
|
||||
StudyReportsEntity firstReport = sessReports.get(0);
|
||||
String sessionTitle = truncate(firstReport.getContent(), MAX_TITLE_LENGTH);
|
||||
if (firstReport.getCreatedTime() != null) {
|
||||
sessionTitle = formatDate(firstReport.getCreatedTime()) + " " + sessionTitle;
|
||||
}
|
||||
|
||||
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
||||
|
||||
for (StudyReportsEntity report : sessReports) {
|
||||
String content = report.getContent();
|
||||
if (content == null || content.isBlank()) continue;
|
||||
MindMapNode reportNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
|
||||
reportNode.setNotes(content);
|
||||
reportNode.setSourceType("REPORT");
|
||||
reportNode.setSourceId(report.getId());
|
||||
sessionNode.getChildren().add(reportNode);
|
||||
}
|
||||
|
||||
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,75 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
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<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());
|
||||
|
||||
if (reportTexts.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<String> optOutline = aiServiceClient.generateMindMap(
|
||||
task.getTaskName(),
|
||||
task.getTaskDescription(),
|
||||
reportTexts
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,412 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
|
||||
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
|
||||
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.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.mapper.ReviewMapper;
|
||||
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 com.guo.learningprogresstracker.service.ReviewService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 复习模块 Service 实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReviewServiceImpl implements ReviewService {
|
||||
|
||||
private final ReviewMapper reviewMapper;
|
||||
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 StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||
private final StudySessionsMapper studySessionsMapper;
|
||||
private final TasksMapper tasksMapper;
|
||||
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
|
||||
|
||||
@Override
|
||||
public List<ReviewFeedItem> getReviewFeed(int limit) {
|
||||
return reviewMapper.selectReviewFeed(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<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(List.of(), 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<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
||||
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||
.last("ORDER BY RAND() LIMIT " + candidateLimit));
|
||||
|
||||
List<ReviewFeedItem> candidates = mergeAndConvert(List.of(), 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
|
||||
public List<ReviewTaskStats> getReviewTaskStats() {
|
||||
List<TaskEntity> tasks = tasksMapper.selectList(
|
||||
Wrappers.<TaskEntity>lambdaQuery()
|
||||
.orderByDesc(TaskEntity::getCalculatedPriority));
|
||||
|
||||
List<ReviewTaskStats> stats = tasks.stream()
|
||||
.map(this::newTaskStats)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
fillReviewTaskStats(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReviewTaskStats getReviewTaskStats(String taskNum) {
|
||||
TaskEntity task = tasksMapper.selectList(
|
||||
Wrappers.<TaskEntity>lambdaQuery()
|
||||
.eq(TaskEntity::getTaskNum, taskNum)
|
||||
.last("LIMIT 1"))
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
ReviewTaskStats stats = newTaskStats(taskNum, task == null ? null : task.getTaskName());
|
||||
fillReviewTaskStats(Collections.singletonList(stats));
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ReviewFeedItem> getTaskReview(String taskNum) {
|
||||
return reviewMapper.selectTaskReview(taskNum);
|
||||
List<String> sessionNums = studySessionsMapper.selectList(
|
||||
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
||||
.select(StudySessionsEntity::getSessionNum))
|
||||
.stream()
|
||||
.map(StudySessionsEntity::getSessionNum)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (sessionNums.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
|
||||
Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||
.in(StudyReportsEntity::getSessionNum, sessionNums)
|
||||
.orderByDesc(StudyReportsEntity::getCreatedTime));
|
||||
|
||||
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
||||
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums)
|
||||
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime));
|
||||
|
||||
return mergeAndConvert(reports, fragments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
|
||||
return Optional.ofNullable(studyReportsMapper.selectById(id))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("学习报告[" + id + "]不存在"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("学习报告[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这份学习报告不存在或已被删除");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
|
||||
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
|
||||
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("学习残片[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这条学习残片不存在或已被删除");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将报告和残片合并转换为 ReviewFeedItem 列表,按创建时间倒序排列
|
||||
*/
|
||||
private List<ReviewFeedItem> mergeAndConvert(List<StudyReportsEntity> reports,
|
||||
List<StudyReportFragmentsEntity> fragments) {
|
||||
Set<String> allSessionNums = Stream.concat(
|
||||
reports.stream().map(StudyReportsEntity::getSessionNum),
|
||||
fragments.stream().map(StudyReportFragmentsEntity::getSessionNum)
|
||||
).collect(Collectors.toSet());
|
||||
|
||||
if (allSessionNums.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<String, String> sessionToTaskMap = studySessionsMapper.selectList(
|
||||
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||
.in(StudySessionsEntity::getSessionNum, allSessionNums)
|
||||
.select(StudySessionsEntity::getSessionNum, StudySessionsEntity::getTaskNum))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(
|
||||
StudySessionsEntity::getSessionNum,
|
||||
StudySessionsEntity::getTaskNum,
|
||||
(a, b) -> a));
|
||||
|
||||
Set<String> taskNums = new HashSet<>(sessionToTaskMap.values());
|
||||
Map<String, String> taskNameMap = tasksMapper.selectList(
|
||||
Wrappers.<TaskEntity>lambdaQuery()
|
||||
.in(TaskEntity::getTaskNum, taskNums)
|
||||
.select(TaskEntity::getTaskNum, TaskEntity::getTaskName))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(
|
||||
TaskEntity::getTaskNum,
|
||||
TaskEntity::getTaskName,
|
||||
(a, b) -> a));
|
||||
|
||||
List<ReviewFeedItem> reportItems = reports.stream().map(r -> {
|
||||
ReviewFeedItem item = new ReviewFeedItem();
|
||||
item.setId(r.getId());
|
||||
item.setSessionNum(r.getSessionNum());
|
||||
item.setSourceType("REPORT");
|
||||
item.setContent(r.getContent());
|
||||
item.setCreatedTime(r.getCreatedTime());
|
||||
String taskNum = sessionToTaskMap.get(r.getSessionNum());
|
||||
item.setTaskNum(taskNum);
|
||||
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
List<ReviewFeedItem> fragmentItems = fragments.stream().map(f -> {
|
||||
ReviewFeedItem item = new ReviewFeedItem();
|
||||
item.setId(f.getId());
|
||||
item.setSessionNum(f.getSessionNum());
|
||||
item.setSourceType("FRAGMENT");
|
||||
item.setContent(f.getContent());
|
||||
item.setCreatedTime(f.getCreatedTime());
|
||||
String taskNum = sessionToTaskMap.get(f.getSessionNum());
|
||||
item.setTaskNum(taskNum);
|
||||
item.setTaskName(taskNum != null ? taskNameMap.get(taskNum) : null);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
return Stream.concat(reportItems.stream(), fragmentItems.stream())
|
||||
.sorted(Comparator.comparing(ReviewFeedItem::getCreatedTime,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ReviewTaskStats newTaskStats(TaskEntity task) {
|
||||
return newTaskStats(task.getTaskNum(), task.getTaskName());
|
||||
}
|
||||
|
||||
private ReviewTaskStats newTaskStats(String taskNum, String taskName) {
|
||||
ReviewTaskStats stats = new ReviewTaskStats();
|
||||
stats.setTaskNum(taskNum);
|
||||
stats.setTaskName(taskName);
|
||||
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))) {
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
}
|
||||
}
|
||||
|
||||
private void fillReviewTaskStats(List<ReviewTaskStats> statsList) {
|
||||
Map<String, ReviewTaskStats> statsByTaskNum = statsList.stream()
|
||||
.filter(item -> StringUtils.hasText(item.getTaskNum()))
|
||||
.collect(Collectors.toMap(
|
||||
ReviewTaskStats::getTaskNum,
|
||||
item -> item,
|
||||
(a, b) -> a,
|
||||
LinkedHashMap::new));
|
||||
|
||||
if (statsByTaskNum.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<StudySessionsEntity> sessions = studySessionsMapper.selectList(
|
||||
Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||
.in(StudySessionsEntity::getTaskNum, statsByTaskNum.keySet()));
|
||||
|
||||
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
|
||||
LocalDateTime weekStart = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay();
|
||||
Map<String, String> sessionToTaskNum = new HashMap<>();
|
||||
Map<String, Double> effectivenessRatioSum = new HashMap<>();
|
||||
|
||||
for (StudySessionsEntity session : sessions) {
|
||||
ReviewTaskStats stats = statsByTaskNum.get(session.getTaskNum());
|
||||
if (stats == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(session.getSessionNum())) {
|
||||
sessionToTaskNum.put(session.getSessionNum(), session.getTaskNum());
|
||||
}
|
||||
|
||||
double effectiveTime = session.getEffectiveTime();
|
||||
stats.setSessionCount(stats.getSessionCount() + 1);
|
||||
stats.setEffectiveTime(stats.getEffectiveTime() + effectiveTime);
|
||||
effectivenessRatioSum.merge(session.getTaskNum(), session.getEffectivenessRatio(), Double::sum);
|
||||
|
||||
LocalDateTime startTime = session.getStartTime();
|
||||
if (startTime != null && !startTime.isBefore(todayStart)) {
|
||||
stats.setTodayEffectiveTime(stats.getTodayEffectiveTime() + effectiveTime);
|
||||
}
|
||||
if (startTime != null && !startTime.isBefore(weekStart)) {
|
||||
stats.setWeekEffectiveTime(stats.getWeekEffectiveTime() + effectiveTime);
|
||||
}
|
||||
}
|
||||
|
||||
statsByTaskNum.forEach((taskNum, stats) -> {
|
||||
if (stats.getSessionCount() > 0) {
|
||||
stats.setAvgEffectiveTime(stats.getEffectiveTime() / stats.getSessionCount());
|
||||
stats.setAvgEffectivenessRatio(effectivenessRatioSum.getOrDefault(taskNum, 0D) / stats.getSessionCount());
|
||||
}
|
||||
});
|
||||
|
||||
fillReportAndFragmentCount(sessionToTaskNum, statsByTaskNum);
|
||||
}
|
||||
|
||||
private void fillReportAndFragmentCount(Map<String, String> sessionToTaskNum,
|
||||
Map<String, ReviewTaskStats> statsByTaskNum) {
|
||||
if (sessionToTaskNum.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> sessionNums = sessionToTaskNum.keySet();
|
||||
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
|
||||
Wrappers.<StudyReportsEntity>lambdaQuery()
|
||||
.in(StudyReportsEntity::getSessionNum, sessionNums)
|
||||
.select(StudyReportsEntity::getSessionNum));
|
||||
for (StudyReportsEntity report : reports) {
|
||||
ReviewTaskStats stats = statsByTaskNum.get(sessionToTaskNum.get(report.getSessionNum()));
|
||||
if (stats != null) {
|
||||
stats.setReportCount(stats.getReportCount() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
|
||||
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
|
||||
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums)
|
||||
.select(StudyReportFragmentsEntity::getSessionNum));
|
||||
for (StudyReportFragmentsEntity fragment : fragments) {
|
||||
ReviewTaskStats stats = statsByTaskNum.get(sessionToTaskNum.get(fragment.getSessionNum()));
|
||||
if (stats != null) {
|
||||
stats.setFragmentCount(stats.getFragmentCount() + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
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 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) {
|
||||
log.warn("任务[{}]标准思维导图尚不存在,无法增量更新", taskNum);
|
||||
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) {
|
||||
log.error("任务[{}]对比结果序列化失败", taskNum, 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(() -> {
|
||||
log.warn("回忆记录[{}]不存在", recordId);
|
||||
return new NotFindEntitiesException("这条回忆记录不存在或已被删除");
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 内部方法 ============
|
||||
|
||||
private ReviewStandardMindMapEntity doGenerate(String taskNum) throws OperationFailedException {
|
||||
TaskEntity task = tasksMapper.selectOne(
|
||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
|
||||
|
||||
if (task == null) {
|
||||
log.warn("任务[{}]不存在,无法生成思维导图", taskNum);
|
||||
throw new OperationFailedException("这个任务不存在或已被删除");
|
||||
}
|
||||
|
||||
// 收集学习数据
|
||||
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<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
|
||||
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
|
||||
|
||||
if (reports.isEmpty()) {
|
||||
log.warn("任务[{}]没有学习报告,无法生成思维导图", taskNum);
|
||||
throw new OperationFailedException("这个任务还没开始学习哦,学习后产生学习报告后再来吧");
|
||||
}
|
||||
|
||||
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器
|
||||
MindMapAiClient client = aiClients.stream()
|
||||
.filter(MindMapAiClient::isAvailable)
|
||||
.min(Comparator.comparing(c -> "BUILTIN".equals(c.generatorName()) ? 1 : 0))
|
||||
.orElse(null);
|
||||
|
||||
if (client == null) {
|
||||
log.warn("任务[{}]没有可用的思维导图生成器", taskNum);
|
||||
throw new OperationFailedException("思维导图暂时生成不了,请稍后再试");
|
||||
}
|
||||
|
||||
Optional<MindMapNode> optRoot = client.generate(task, reports, 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, applications, null);
|
||||
}
|
||||
}
|
||||
if (optRoot.isEmpty()) {
|
||||
log.warn("任务[{}]思维导图生成失败,已尝试全部生成器", taskNum);
|
||||
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(0);
|
||||
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))) {
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+45
-12
@@ -1,22 +1,55 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
|
||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||
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 lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【study_expectations(存储每次学习开始前的预期)】的数据库操作Service实现
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
*/
|
||||
* 学习预期服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class StudyExpectationsServiceImpl extends ServiceImpl<StudyExpectationsMapper, StudyExpectationsEntity>
|
||||
implements StudyExpectationsService{
|
||||
@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) {
|
||||
log.warn("学习会话[{}]不存在", sessionNum);
|
||||
throw new ErrorParameterException("这次学习会话不存在或已结束");
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+37
-14
@@ -3,6 +3,7 @@ package com.guo.learningprogresstracker.service.impl;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
|
||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
@@ -11,32 +12,54 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
|
||||
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
*/
|
||||
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
|
||||
implements StudyReportFragmentsService{
|
||||
implements StudyReportFragmentsService {
|
||||
|
||||
private final StudySessionsMapper studySessionsMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void createFragments(CreateFragmentsRequest request) throws NotFindEntitiesException {
|
||||
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
||||
if (studySessionsMapper.exists(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum,entity.getSessionNum()))) {
|
||||
this.save(entity);
|
||||
}else {
|
||||
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]",entity.getSessionNum()));
|
||||
StudySessionsEntity session = studySessionsMapper.selectOne(
|
||||
Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
|
||||
if (session == null) {
|
||||
log.warn("未能找到学习会话sessionNum[{}]", entity.getSessionNum());
|
||||
throw new NotFindEntitiesException("这次学习会话不存在或已结束");
|
||||
}
|
||||
this.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
|
||||
StudyReportFragmentsEntity existing = this.getById(id);
|
||||
if (existing == null) {
|
||||
log.warn("未能找到学习残片id[{}]", id);
|
||||
throw new NotFindEntitiesException("这条学习残片不存在或已被删除");
|
||||
}
|
||||
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
|
||||
entity.setId(id);
|
||||
this.updateById(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StudyReportFragmentsEntity> getFragmentsBySession(String sessionNum) {
|
||||
return this.list(
|
||||
Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
|
||||
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum)
|
||||
.orderByAsc(StudyReportFragmentsEntity::getCreatedTime));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+151
-19
@@ -1,6 +1,7 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
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.google.protobuf.ServiceException;
|
||||
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.StudyReportsMapper;
|
||||
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
|
||||
import com.guo.learningprogresstracker.service.StudyExpectationsService;
|
||||
import com.guo.learningprogresstracker.service.StudySessionsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -27,9 +30,7 @@ import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【study_sessions(记录每次学习会话的具体数据)】的数据库操作Service实现
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
* 针对表【study_sessions(记录每次学习会话的具体数据)】的数据库操作Service实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -38,15 +39,21 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
implements StudySessionsService {
|
||||
|
||||
public static final int WORK_DURATION = 25;
|
||||
|
||||
private final TasksServiceImpl tasksServiceImpl;
|
||||
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
|
||||
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
|
||||
private final StudyReportsMapper studyReportsMapper;
|
||||
private final AiServiceClient aiServiceClient;
|
||||
private final StudyExpectationsService studyExpectationsService;
|
||||
|
||||
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
|
||||
TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
|
||||
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, taskNum))
|
||||
.orElseThrow(() -> new NotFindEntitiesException(String.format("[%s]不存在", taskNum)));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("[{}]不存在", taskNum);
|
||||
return new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
});
|
||||
|
||||
StudySessionsEntity session = this.getOneOpt(Wrappers.<StudySessionsEntity>lambdaQuery()
|
||||
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
||||
@@ -69,6 +76,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
|
||||
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||
response.setTaskName(taskEntity.getTaskName());
|
||||
response.setMaterialUrl(taskEntity.getMaterialUrl());
|
||||
response.setTaskId(taskEntity.getId());
|
||||
|
||||
if (session.isOverTime()) {
|
||||
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
|
||||
@@ -82,10 +91,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停");
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]已经暂停,请勿重复暂停!");
|
||||
throw new ErrorParameterException("这次学习会话已经暂停了,不用重复暂停哦");
|
||||
}
|
||||
studySessionsEntity.calculatePointerPosition();
|
||||
studySessionsEntity.pausedStudySession(endTime);
|
||||
@@ -94,23 +103,34 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
|
||||
|
||||
@Override
|
||||
public void endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
||||
@Transactional
|
||||
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
|
||||
studySessionsEntity.endedStudySession();
|
||||
this.updateById(studySessionsEntity);
|
||||
// 创建学习报告
|
||||
StudyReportsEntity studyReportsEntity = new StudyReportsEntity();
|
||||
studyReportsEntity.setSessionNum(sessionNum);
|
||||
studyReportsEntity.setContent(content);
|
||||
studyReportsMapper.insert(studyReportsEntity);
|
||||
if (wasOngoing && studySessionsEntity.getEffectiveTime() == 0) {
|
||||
return "本次有效学习时间不足10分钟,不计入总学习时间";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
|
||||
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
|
||||
.eq(TaskEntity::getTaskNum, taskNum))
|
||||
.orElseThrow(() -> taskNotFound(taskNum));
|
||||
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话"));
|
||||
.orElseThrow(() -> {
|
||||
log.warn("任务[{}]不存在进行中或暂停中的会话", taskNum);
|
||||
return new ErrorParameterException("这个任务当前没有进行中或已暂停的学习会话");
|
||||
});
|
||||
return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
|
||||
}
|
||||
|
||||
@@ -118,32 +138,144 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
||||
public ArrayList<String> getAllFragments(String sessionNum) throws ErrorParameterException {
|
||||
if (this.exists(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))) {
|
||||
// todo guo 后续可在此补充更高级的整理方式
|
||||
return studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
|
||||
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum))
|
||||
.stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new));
|
||||
} else {
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]不存在");
|
||||
throw sessionNotFound(sessionNum);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
|
||||
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在"));
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始");
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]已经开始,请勿重复开始!");
|
||||
throw new ErrorParameterException("这次学习会话已经开始了,不用重复开始哦");
|
||||
} else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
|
||||
log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!");
|
||||
throw new ErrorParameterException("会话[" + sessionNum + "]处于结束状态,不可开始该会话!");
|
||||
throw new ErrorParameterException("这次学习会话已经结束,无法再次开始");
|
||||
}
|
||||
studySessionsEntity.continueStudySession();
|
||||
this.updateById(studySessionsEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 sessionNum 获取学习会话
|
||||
*/
|
||||
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
|
||||
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||
.eq(StudySessionsEntity::getSessionNum, sessionNum))
|
||||
.orElseThrow(() -> sessionNotFound(sessionNum));
|
||||
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(() -> sessionNotFound(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;
|
||||
}
|
||||
|
||||
private ErrorParameterException sessionNotFound(String sessionNum) {
|
||||
log.warn("会话[{}]不存在", sessionNum);
|
||||
return new ErrorParameterException("这次学习会话不存在或已结束");
|
||||
}
|
||||
|
||||
private ErrorParameterException taskNotFound(String taskNum) {
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
return new ErrorParameterException("这个任务不存在或已被删除");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,12 +5,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.guo.learningprogresstracker.dto.PriorityDto;
|
||||
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.UpdateTaskApplicationRequest;
|
||||
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
|
||||
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
|
||||
import com.guo.learningprogresstracker.entity.TaskEntity;
|
||||
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
|
||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
||||
import com.guo.learningprogresstracker.mapStruct.RequestConvert;
|
||||
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.mapper.TasksMapper;
|
||||
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
|
||||
@@ -18,44 +25,48 @@ import com.guo.learningprogresstracker.utils.GenerateNumTool;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.rmi.ServerException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
*/
|
||||
* 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
implements TasksService{
|
||||
|
||||
implements TasksService {
|
||||
|
||||
private final TasksMapper tasksMapper;
|
||||
|
||||
private final TaskApplicationMapper taskApplicationMapper;
|
||||
|
||||
private final PriorityWeightsService priorityWeightsService;
|
||||
|
||||
@Override
|
||||
public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) {
|
||||
Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize),
|
||||
Wrappers.lambdaQuery(TaskEntity.class).orderByDesc(TaskEntity::getCalculatedPriority));
|
||||
Wrappers.lambdaQuery(TaskEntity.class)
|
||||
.orderByDesc(TaskEntity::getCalculatedPriority));
|
||||
Page<TaskInfo> response = TaskConvert.MAPPER.toTaskInfoPage(page);
|
||||
// todo 需要补充TaskInfo中的lastLearningStatus字段信息
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String addTask(TaskRequest taskRequest) throws ErrorParameterException {
|
||||
TaskEntity task= TaskConvert.MAPPER.taskRequestToTaskEntity(taskRequest);
|
||||
TaskEntity task = TaskConvert.MAPPER.taskRequestToTaskEntity(taskRequest);
|
||||
|
||||
if (this.exists(Wrappers.lambdaQuery(TaskEntity.class)
|
||||
.eq(TaskEntity::getTaskName, task.getTaskName()))) {
|
||||
throw new ErrorParameterException(String.format("任务名[%s]重复", task.getTaskName()));
|
||||
log.warn("任务名[{}]重复", task.getTaskName());
|
||||
throw new ErrorParameterException("已经有同名任务了,换个任务名称吧");
|
||||
}
|
||||
|
||||
task.setTaskNum(GenerateNumTool.generateNum("TASK"));
|
||||
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(taskRequest);
|
||||
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto));
|
||||
task.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
||||
this.save(task);
|
||||
return task.getTaskNum();
|
||||
}
|
||||
@@ -63,13 +74,15 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
@Override
|
||||
public TaskInfoResponse getTask(String taskId) {
|
||||
TaskEntity taskEntity = this.getById(taskId);
|
||||
TaskInfoResponse taskInfoResponse = TaskConvert.MAPPER.taskEntityToTaskInfoResponse(taskEntity);
|
||||
|
||||
return taskInfoResponse;
|
||||
return TaskConvert.MAPPER.taskEntityToTaskInfoResponse(taskEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTask(String taskId, TaskRequest updatedTask) {
|
||||
TaskEntity existingTask = this.getById(taskId);
|
||||
if (existingTask == null) {
|
||||
throw new IllegalArgumentException("任务不存在: " + taskId);
|
||||
}
|
||||
TaskEntity taskEntity = TaskConvert.MAPPER.taskRequestToTaskEntity(updatedTask);
|
||||
int id;
|
||||
try {
|
||||
@@ -78,21 +91,79 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
||||
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
|
||||
}
|
||||
taskEntity.setId(id);
|
||||
boolean updated = this.updateById(taskEntity);
|
||||
if (!updated) {
|
||||
throw new IllegalArgumentException("任务不存在或更新失败: " + taskId);
|
||||
}
|
||||
|
||||
taskEntity.setTaskNum(existingTask.getTaskNum());
|
||||
// 维度数据可能变化,更新时重算优先级
|
||||
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
|
||||
taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
||||
this.updateById(taskEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteTask(String taskId) throws ServerException {
|
||||
boolean b = this.removeById(taskId);
|
||||
if(!b){
|
||||
throw new ServerException("未能正常删除任务");
|
||||
public void deleteTask(String taskId) {
|
||||
TaskEntity existingTask = this.getById(taskId);
|
||||
if (existingTask == null) {
|
||||
throw new IllegalArgumentException("任务不存在: " + 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(() -> {
|
||||
log.warn("应用场景[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
|
||||
});
|
||||
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(() -> {
|
||||
log.warn("应用场景[{}]不存在", id);
|
||||
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
|
||||
});
|
||||
taskApplicationMapper.deleteById(existing.getId());
|
||||
}
|
||||
|
||||
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
|
||||
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
|
||||
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
|
||||
log.warn("任务[{}]不存在", taskNum);
|
||||
throw new NotFindEntitiesException("这个任务不存在或已被删除");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeApplicationStatus(String status) {
|
||||
return TaskApplicationStatusEnum.fromCodeOrDefault(status).getCode();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.guo.learningprogresstracker.exception.AppException;
|
||||
import com.guo.learningprogresstracker.service.UserService;
|
||||
import com.guo.learningprogresstracker.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.mindrot.jbcrypt.BCrypt;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
@@ -23,12 +24,13 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity>
|
||||
public String authenticate(String username, String password) throws AppException {
|
||||
|
||||
UserEntity userEntity = this.getOneOpt(Wrappers.<UserEntity>lambdaQuery()
|
||||
.eq(UserEntity::getUserName, username)
|
||||
.eq(UserEntity::getUserPassword, password)).orElseThrow(() -> new AppException("账号或密码错误!"));
|
||||
return userEntity.getId();
|
||||
.eq(UserEntity::getUserName, username))
|
||||
.orElseThrow(() -> new AppException("账号或密码错误!"));
|
||||
|
||||
if (!BCrypt.checkpw(password, userEntity.getUserPassword())) {
|
||||
throw new AppException("账号或密码错误!");
|
||||
}
|
||||
|
||||
return userEntity.getId();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.guo.learningprogresstracker.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.guo.learningprogresstracker.entity.UserTaskEntity;
|
||||
import com.guo.learningprogresstracker.service.UserTaskService;
|
||||
import com.guo.learningprogresstracker.mapper.UserTaskMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @author guo
|
||||
* @description 针对表【user_task(存储用户-任务之间的对应关系)】的数据库操作Service实现
|
||||
* @createDate 2024-06-09 15:28:48
|
||||
*/
|
||||
@Service
|
||||
public class UserTaskServiceImpl extends ServiceImpl<UserTaskMapper, UserTaskEntity>
|
||||
implements UserTaskService{
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
package com.guo.learningprogresstracker.utils;
|
||||
|
||||
import com.guo.learningprogresstracker.dto.PriorityDto;
|
||||
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
|
||||
|
||||
/**
|
||||
* 加权优先级计算工具
|
||||
*
|
||||
* @author guo
|
||||
*/
|
||||
public class CalculatedPriorityTool {
|
||||
|
||||
/**
|
||||
* 通过用户选择的5个选项,结合定义的权重计算任务的加权优先级。
|
||||
* 急迫性 (Urgency)
|
||||
* 急迫性代表任务的紧急程度。权重:0.35
|
||||
* <p>
|
||||
* 重要性 (Importance)
|
||||
* 重要性指示任务的重要程度。权重:0.25
|
||||
* <p>
|
||||
* 内容难度 (Content Difficulty)
|
||||
* 内容难度衡量任务内容的复杂性。权重:0.20
|
||||
* <p>
|
||||
* 未来价值 (Future Value)
|
||||
* 未来价值估计完成任务的长期价值。权重:0.10
|
||||
* <p>
|
||||
* 主观优先级 (Subjective Priority)
|
||||
* 主观优先级是用户对任务优先级的个人评估。权重:0.10
|
||||
*
|
||||
* @param priorityDto
|
||||
* @return
|
||||
* 使用系统默认权重计算加权优先级。
|
||||
* 急迫性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
|
||||
*/
|
||||
public static Double calculatedPriority(PriorityDto priorityDto) {
|
||||
// 暂时写死权重,也许可以考虑让用户自行配置权重
|
||||
return (priorityDto.getUrgency() * 0.35) +
|
||||
(priorityDto.getImportance() * 0.25) +
|
||||
(priorityDto.getContentDifficulty() * 0.20) +
|
||||
(priorityDto.getFutureValue() * 0.10) +
|
||||
(priorityDto.getSubjectivePriority() * 0.10);
|
||||
return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用用户自定义权重计算加权优先级。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,11 @@ import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用于生成编码的工具
|
||||
*
|
||||
* @author guo
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class GenerateNumTool {
|
||||
private static int getNextSequence() {
|
||||
// 获取当前时间的毫秒级时间戳
|
||||
long timestamp = System.currentTimeMillis();
|
||||
|
||||
// 使用时间戳的最后6位作为序列号
|
||||
return (int) (timestamp % 1_000_000);
|
||||
}
|
||||
|
||||
@@ -29,22 +24,14 @@ public class GenerateNumTool {
|
||||
*/
|
||||
public static String generateNum(String prefix, String separator) {
|
||||
String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
|
||||
// 唯一的后缀
|
||||
int sequence = getNextSequence();
|
||||
|
||||
// 返回完整的 taskNum
|
||||
return prefix + separator + currentDate + separator + sequence;
|
||||
}
|
||||
|
||||
public static String generateNum(String prefix) {
|
||||
String separator = "-";
|
||||
String currentDate = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
|
||||
// 唯一的后缀
|
||||
int sequence = getNextSequence();
|
||||
|
||||
// 返回完整的 taskNum
|
||||
return prefix + separator + currentDate + separator + sequence;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info # 或 "*"
|
||||
include: health,info # 或 "*"
|
||||
|
||||
# lpt-ai 独立 AI 服务(可选,未配置时降级为内置规则引擎)
|
||||
lpt:
|
||||
ai-service:
|
||||
url: http://localhost:5199
|
||||
timeout-seconds: 600
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 删除不再需要的 user_task 表
|
||||
DROP TABLE IF EXISTS user_task;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` MODIFY COLUMN `user_password` varchar(60) NOT NULL COMMENT '账号密码(BCrypt哈希)';
|
||||
+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,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis-org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.guo.learningprogresstracker.mapper.ReviewMapper">
|
||||
|
||||
<select id="selectReviewFeed" resultType="com.guo.learningprogresstracker.dto.ReviewFeedItem">
|
||||
SELECT
|
||||
combined.id,
|
||||
combined.session_num AS sessionNum,
|
||||
combined.source_type AS sourceType,
|
||||
combined.content,
|
||||
combined.created_time AS createdTime,
|
||||
t.task_num AS taskNum,
|
||||
t.task_name AS taskName
|
||||
FROM (
|
||||
SELECT r.id, r.session_num, r.content, r.created_time, 'REPORT' AS source_type
|
||||
FROM study_reports r
|
||||
WHERE r.deleted = 0
|
||||
UNION ALL
|
||||
SELECT f.id, f.session_num, f.content, f.created_time, 'FRAGMENT' AS source_type
|
||||
FROM study_report_fragments f
|
||||
WHERE f.deleted = 0
|
||||
) combined
|
||||
JOIN study_sessions ss ON combined.session_num = ss.session_num AND ss.deleted = 0
|
||||
JOIN tasks t ON ss.task_num = t.task_num AND t.deleted = 0
|
||||
ORDER BY combined.created_time DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectTaskReview" resultType="com.guo.learningprogresstracker.dto.ReviewFeedItem">
|
||||
SELECT
|
||||
combined.id,
|
||||
combined.session_num AS sessionNum,
|
||||
combined.source_type AS sourceType,
|
||||
combined.content,
|
||||
combined.created_time AS createdTime,
|
||||
t.task_num AS taskNum,
|
||||
t.task_name AS taskName
|
||||
FROM (
|
||||
SELECT r.id, r.session_num, r.content, r.created_time, 'REPORT' AS source_type
|
||||
FROM study_reports r
|
||||
WHERE r.deleted = 0
|
||||
UNION ALL
|
||||
SELECT f.id, f.session_num, f.content, f.created_time, 'FRAGMENT' AS source_type
|
||||
FROM study_report_fragments f
|
||||
WHERE f.deleted = 0
|
||||
) combined
|
||||
JOIN study_sessions ss ON combined.session_num = ss.session_num AND ss.deleted = 0
|
||||
JOIN tasks t ON ss.task_num = t.task_num AND t.deleted = 0
|
||||
WHERE t.task_num = #{taskNum}
|
||||
ORDER BY combined.created_time DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.guo.learningprogresstracker.mapper.StudyExpectationsMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.guo.learningprogresstracker.entity.StudyExpectationsEntity">
|
||||
<id property="expectationId" column="expectation_id" jdbcType="INTEGER"/>
|
||||
<result property="sessionId" column="session_id" jdbcType="INTEGER"/>
|
||||
<result property="description" column="description" jdbcType="VARCHAR"/>
|
||||
<result property="createdTime" column="created_time" jdbcType="TIMESTAMP"/>
|
||||
<result property="createdBy" column="created_by" jdbcType="VARCHAR"/>
|
||||
<result property="lastModifiedTime" column="last_modified_time" jdbcType="TIMESTAMP"/>
|
||||
<result property="lastModifiedBy" column="last_modified_by" jdbcType="VARCHAR"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
expectation_id,session_id,description,
|
||||
created_time,created_by,last_modified_time,
|
||||
last_modified_by
|
||||
</sql>
|
||||
</mapper>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.guo.learningprogresstracker.mapper.UserTaskMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.guo.learningprogresstracker.entity.UserTaskEntity">
|
||||
<result property="id" column="id" jdbcType="VARCHAR"/>
|
||||
<result property="userId" column="user_id" jdbcType="INTEGER"/>
|
||||
<result property="taskId" column="task_id" jdbcType="INTEGER"/>
|
||||
<result property="createdTime" column="created_time" jdbcType="TIMESTAMP"/>
|
||||
<result property="createdBy" column="created_by" jdbcType="VARCHAR"/>
|
||||
<result property="lastModifiedTime" column="last_modified_time" jdbcType="TIMESTAMP"/>
|
||||
<result property="lastModifiedBy" column="last_modified_by" jdbcType="VARCHAR"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
id,user_id,task_id,
|
||||
created_time,created_by,last_modified_time,
|
||||
last_modified_by
|
||||
</sql>
|
||||
</mapper>
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.guo.learningprogresstracker;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.guo.learningprogresstracker.entity.TestTableEntity;
|
||||
import com.guo.learningprogresstracker.service.impl.TestTableServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -19,6 +21,11 @@ public class DataSourceTest {
|
||||
@Autowired
|
||||
TestTableServiceImpl testTableService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
StpUtil.login("1", "test_driver");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAdd(){
|
||||
TestTableEntity testTableEntity = new TestTableEntity();
|
||||
@@ -30,8 +37,12 @@ public class DataSourceTest {
|
||||
|
||||
@Test
|
||||
public void testDelete(){
|
||||
TestTableEntity testTableEntity = new TestTableEntity();
|
||||
testTableEntity.setIdName("guo_test_delete");
|
||||
testTableService.save(testTableEntity);
|
||||
|
||||
HashMap<String, Object> stringStringHashMap = new HashMap<>();
|
||||
stringStringHashMap.put("id_name", "guo_test");
|
||||
stringStringHashMap.put("id_name", "guo_test_delete");
|
||||
boolean delete = testTableService.removeByMap(stringStringHashMap);
|
||||
|
||||
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.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.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
@@ -45,12 +43,12 @@ class TaskControllerTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
StpUtil.login("测试用户-创建任务", "test_driver");
|
||||
StpUtil.login("1", "test_driver");
|
||||
}
|
||||
|
||||
@Test
|
||||
void addTask() throws Exception {
|
||||
TaskRequest taskRequest = getTaskRequest();
|
||||
TaskRequest taskRequest = getTaskRequest("测试任务名称-" + System.nanoTime());
|
||||
log.info("testInfo");
|
||||
String s = jacksonObjectMapper.writeValueAsString(taskRequest);
|
||||
System.out.println("Token Value: " + StpUtil.getTokenValue());
|
||||
@@ -63,30 +61,16 @@ class TaskControllerTest {
|
||||
|
||||
}
|
||||
|
||||
private static TaskRequest getTaskRequest() {
|
||||
TaskRequest taskRequest = mock(TaskRequest.class);
|
||||
|
||||
when(taskRequest.getId()).thenReturn(null);
|
||||
// 设置学习任务的名称
|
||||
when(taskRequest.getTaskName()).thenReturn("测试任务名称");
|
||||
|
||||
// 设置学习材料的存储URL
|
||||
when(taskRequest.getMaterialUrl()).thenReturn("http://example.com/material");
|
||||
|
||||
// 设置用户设置的任务紧急性
|
||||
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);
|
||||
private static TaskRequest getTaskRequest(String taskName) {
|
||||
TaskRequest taskRequest = new TaskRequest();
|
||||
taskRequest.setTaskName(taskName);
|
||||
taskRequest.setTaskDescription("测试任务描述");
|
||||
taskRequest.setMaterialUrl("http://example.com/material");
|
||||
taskRequest.setUrgency(5);
|
||||
taskRequest.setImportance(3);
|
||||
taskRequest.setContentDifficulty(4);
|
||||
taskRequest.setFutureValue(4);
|
||||
taskRequest.setSubjectivePriority(1);
|
||||
return taskRequest;
|
||||
}
|
||||
|
||||
@@ -108,10 +92,8 @@ class TaskControllerTest {
|
||||
@Test
|
||||
void updateTask() throws Exception {
|
||||
|
||||
TaskRequest taskRequest = mock(TaskRequest.class);
|
||||
when(taskRequest.getTaskName()).thenReturn("测试数据名称");
|
||||
when(taskRequest.getMaterialUrl()).thenReturn("http://example.com/material");
|
||||
when(taskRequest.getId()).thenReturn(1);
|
||||
TaskRequest taskRequest = getTaskRequest("测试数据名称");
|
||||
taskRequest.setId(1);
|
||||
String s = jacksonObjectMapper.writeValueAsString(taskRequest);
|
||||
|
||||
mockMvc.perform(put("/tasks/"+taskRequest.getId())
|
||||
@@ -126,9 +108,9 @@ class TaskControllerTest {
|
||||
|
||||
@Test
|
||||
void deleteTask() throws Exception {
|
||||
CommonResult commonResult = taskController.addTask(getTaskRequest());
|
||||
CommonResult commonResult = taskController.addTask(getTaskRequest("待删除任务-" + System.nanoTime()));
|
||||
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,"未能找到新创建的任务实体");
|
||||
mockMvc.perform(delete("/tasks/"+taskEntity.getId())
|
||||
.header("satoken",StpUtil.getTokenValue()))
|
||||
@@ -145,4 +127,4 @@ class TaskControllerTest {
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.guo.learningprogresstracker.entity;
|
||||
|
||||
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StudySessionsEntityTest {
|
||||
|
||||
private StudySessionsEntity createOngoingSession(int minutesAgo) {
|
||||
StudySessionsEntity session = new StudySessionsEntity();
|
||||
session.setSessionNum("SESSION_TEST");
|
||||
session.setStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(minutesAgo));
|
||||
session.setEffectiveTime(0);
|
||||
session.setActualTime(0);
|
||||
session.setSessionState(StudySessionStateEnum.ONGOING.name());
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景1:直接结束,有效时间 < 10分钟 → effectiveTime 应被清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_effectiveTimeLessThan10Min_shouldZeroOut() {
|
||||
StudySessionsEntity session = createOngoingSession(5);
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(0, session.getEffectiveTime(), "有效时间不足10分钟应被清零");
|
||||
assertEquals(0, session.getEffectivenessRatio(), "有效时间比应被清零");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景2:直接结束,有效时间 = 10分钟(边界值)→ 保留(600 不 < 600)
|
||||
*/
|
||||
@Test
|
||||
void endedSession_effectiveTimeExactly10Min_shouldKeep() {
|
||||
StudySessionsEntity session = createOngoingSession(10);
|
||||
session.endedStudySession();
|
||||
|
||||
assertTrue(session.getEffectiveTime() >= 10 * 60, "有效时间等于10分钟应保留(阈值为 < 600)");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景3:直接结束,有效时间 > 10分钟 → effectiveTime 应保留
|
||||
*/
|
||||
@Test
|
||||
void endedSession_effectiveTimeMoreThan10Min_shouldKeep() {
|
||||
StudySessionsEntity session = createOngoingSession(15);
|
||||
session.endedStudySession();
|
||||
|
||||
assertTrue(session.getEffectiveTime() >= 15 * 60, "有效时间超过10分钟应保留");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景4:暂停后直接结束(未继续),暂停段 < 10分钟 → 清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_pausedThenEnded_lessThan10Min_shouldZeroOut() {
|
||||
StudySessionsEntity session = createOngoingSession(5);
|
||||
session.pausedStudySession(LocalDateTime.now());
|
||||
assertEquals(StudySessionStateEnum.PAUSED.name(), session.getSessionState());
|
||||
assertTrue(session.getEffectiveTime() > 0, "暂停后应有有效时间");
|
||||
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(0, session.getEffectiveTime(), "暂停后结束,有效时间不足10分钟应清零");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景5:暂停后继续,再结束,总有效时间 < 10分钟 → 清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_pausedContinuedThenEnded_lessThan10Min_shouldZeroOut() throws Exception {
|
||||
StudySessionsEntity session = createOngoingSession(8);
|
||||
// 暂停(累加3分钟:从8分钟前到5分钟前)
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(5));
|
||||
|
||||
// 继续学习,模拟又学习了2分钟
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(2));
|
||||
|
||||
session.endedStudySession();
|
||||
|
||||
// 总有效时间 ≈ 3+2 = 5分钟 < 10分钟 → 清零
|
||||
assertEquals(0, session.getEffectiveTime(), "总有效时间不足10分钟应清零");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景6:暂停后继续,再结束,总有效时间 > 10分钟 → 保留
|
||||
*/
|
||||
@Test
|
||||
void endedSession_pausedContinuedThenEnded_moreThan10Min_shouldKeep() throws Exception {
|
||||
StudySessionsEntity session = createOngoingSession(20);
|
||||
// 暂停(累加8分钟:从20分钟前到12分钟前)
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(12));
|
||||
|
||||
// 继续学习,模拟又学习了12分钟
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(12));
|
||||
|
||||
session.endedStudySession();
|
||||
|
||||
// 总有效时间 ≈ 8+12 = 20分钟 > 10分钟 → 保留
|
||||
assertTrue(session.getEffectiveTime() > 0, "总有效时间超过10分钟应保留");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景7:多次暂停-继续,总有效时间 < 10分钟 → 清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_multiplePauseContinue_lessThan10Min_shouldZeroOut() throws Exception {
|
||||
StudySessionsEntity session = createOngoingSession(15);
|
||||
// 第一段:3分钟(15分钟前 → 12分钟前)
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(12));
|
||||
// 继续,模拟第二段学习3分钟(12分钟前 → 9分钟前)
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(9));
|
||||
|
||||
// 第二段暂停,累加3分钟
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(6));
|
||||
// 继续,模拟第三段学习3分钟(6分钟前 → 3分钟前)
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(3));
|
||||
|
||||
// 总计 3+3+3 = 9分钟 < 10分钟
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(0, session.getEffectiveTime(), "多次暂停继续,总有效时间不足10分钟应清零");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景8:多次暂停-继续,总有效时间 > 10分钟 → 保留
|
||||
*/
|
||||
@Test
|
||||
void endedSession_multiplePauseContinue_moreThan10Min_shouldKeep() throws Exception {
|
||||
StudySessionsEntity session = createOngoingSession(25);
|
||||
// 第一段:5分钟(25分钟前 → 20分钟前)
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(20));
|
||||
// 继续,模拟第二段学习5分钟
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(15));
|
||||
|
||||
// 第二段暂停,累加5分钟
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(10));
|
||||
// 继续,模拟第三段学习5分钟
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(5));
|
||||
|
||||
// 总计 5+5+5 = 15分钟 > 10分钟
|
||||
session.endedStudySession();
|
||||
|
||||
assertTrue(session.getEffectiveTime() > 0, "多次暂停继续,总有效时间超过10分钟应保留");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景9:已结束的会话再次结束 → 不应重复处理
|
||||
*/
|
||||
@Test
|
||||
void endedSession_alreadyEnded_shouldNotChange() {
|
||||
StudySessionsEntity session = createOngoingSession(5);
|
||||
session.endedStudySession();
|
||||
double effectiveTimeAfterFirstEnd = session.getEffectiveTime();
|
||||
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(effectiveTimeAfterFirstEnd, session.getEffectiveTime(), "已结束的会话不应重复处理");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景10:有效时间非常短(1分钟) → 清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_veryShortSession_shouldZeroOut() {
|
||||
StudySessionsEntity session = createOngoingSession(1);
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(0, session.getEffectiveTime(), "极短会话的有效时间应被清零");
|
||||
assertEquals(0, session.getEffectivenessRatio());
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景11:有效时间恰好超过阈值(11分钟) → 保留
|
||||
*/
|
||||
@Test
|
||||
void endedSession_justOverThreshold_shouldKeep() {
|
||||
StudySessionsEntity session = createOngoingSession(11);
|
||||
session.endedStudySession();
|
||||
|
||||
assertTrue(session.getEffectiveTime() > 0, "超过阈值的有效时间应保留");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景12:暂停期间不应计入有效时间,只计实际学习的段
|
||||
*/
|
||||
@Test
|
||||
void endedSession_pauseTimeNotCounted_shouldCalcCorrectly() throws Exception {
|
||||
StudySessionsEntity session = createOngoingSession(30);
|
||||
// 学习5分钟(30分钟前 → 25分钟前)
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(25));
|
||||
double firstSegment = session.getEffectiveTime();
|
||||
assertTrue(firstSegment >= 5 * 60 - 1 && firstSegment <= 5 * 60 + 1,
|
||||
"第一段有效时间应约5分钟,实际: " + firstSegment);
|
||||
|
||||
// 暂停20分钟(不应计入有效时间)
|
||||
|
||||
// 继续学习4分钟
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(4));
|
||||
|
||||
session.endedStudySession();
|
||||
|
||||
// 有效时间应约为 5+4=9分钟 < 10分钟 → 清零
|
||||
assertEquals(0, session.getEffectiveTime(), "暂停期间不应计入有效时间,9分钟 < 10分钟阈值应清零");
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景13:暂停后继续,有效时间恰好在阈值边界 → 验证边界精度
|
||||
*/
|
||||
@Test
|
||||
void endedSession_borderlineWithPause_shouldHandleCorrectly() throws Exception {
|
||||
StudySessionsEntity session = createOngoingSession(20);
|
||||
// 第一段:6分钟(20分钟前 → 14分钟前)
|
||||
session.pausedStudySession(LocalDateTime.now().minusMinutes(14));
|
||||
|
||||
// 继续,模拟第二段学习5分钟
|
||||
session.continueStudySession();
|
||||
session.setLastStartTime(LocalDateTime.now().minusMinutes(5));
|
||||
|
||||
// 总计 6+5 = 11分钟 > 10分钟 → 保留
|
||||
session.endedStudySession();
|
||||
|
||||
assertTrue(session.getEffectiveTime() > 0, "总计11分钟应超过阈值被保留");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景14:ONGOING 状态直接结束,effectiveTime 为 0(刚开始就结束) → 0 < 600 → 清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_immediatelyEnded_shouldZeroOut() {
|
||||
StudySessionsEntity session = new StudySessionsEntity();
|
||||
session.setSessionNum("SESSION_INSTANT");
|
||||
session.setStartTime(LocalDateTime.now());
|
||||
session.setLastStartTime(LocalDateTime.now());
|
||||
session.setEffectiveTime(0);
|
||||
session.setActualTime(0);
|
||||
session.setSessionState(StudySessionStateEnum.ONGOING.name());
|
||||
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(0, session.getEffectiveTime(), "刚开始就结束,有效时间应为0");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景15:暂停后直接结束(PAUSED 状态),有效时间 < 10分钟 → 清零
|
||||
*/
|
||||
@Test
|
||||
void endedSession_pausedThenDirectlyEnded_lessThan10Min_shouldZeroOut() {
|
||||
StudySessionsEntity session = createOngoingSession(7);
|
||||
// 暂停(累加7分钟有效时间)
|
||||
session.pausedStudySession(LocalDateTime.now());
|
||||
session.setEffectiveTime(7 * 60); // 精确设置为7分钟
|
||||
|
||||
// PAUSED 状态直接结束(不经过 ONGOING)
|
||||
session.endedStudySession();
|
||||
|
||||
assertEquals(0, session.getEffectiveTime(), "PAUSED 状态结束,7分钟 < 10分钟应清零");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景16:暂停后直接结束(PAUSED 状态),有效时间 > 10分钟 → 保留
|
||||
*/
|
||||
@Test
|
||||
void endedSession_pausedThenDirectlyEnded_moreThan10Min_shouldKeep() {
|
||||
StudySessionsEntity session = createOngoingSession(15);
|
||||
// 暂停(累加15分钟有效时间)
|
||||
session.pausedStudySession(LocalDateTime.now());
|
||||
|
||||
// PAUSED 状态直接结束
|
||||
session.endedStudySession();
|
||||
|
||||
assertTrue(session.getEffectiveTime() > 0, "PAUSED 状态结束,15分钟 > 10分钟应保留");
|
||||
assertEquals(StudySessionStateEnum.ENDED.name(), session.getSessionState());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user