Author SHA1 Message Date
cat-shark 6678c2b8c0 refactor: 优化用户可见错误提示 2026-08-03 23:08:26 +08:00
cat-shark b434520eff refactor: 标准导图仅使用报告生成 2026-08-03 23:08:19 +08:00
cat-shark 5b6f7d6a2a refactor: 首页feed仅返回残片 2026-08-03 23:08:14 +08:00
cat-shark c8d0584f11 docs: 补充用户提示规范 2026-08-03 23:08:08 +08:00
cat-shark 8c272dc1dc fix: 流水线kubeconfig改用临时目录 2026-08-02 10:51:03 +08:00
cat-shark e42b15600a chore: 移除Jenkins配置 2026-08-02 10:21:05 +08:00
cat-shark e32226d7d2 docs: 合并CLAUDE规范 2026-08-01 00:15:56 +08:00
cat-shark b61736eb2b docs: migrate .pi/SYSTEM.md to AGENTS.md 2026-08-01 00:13:05 +08:00
cat-shark 8ed5631969 perf: multi-stage Dockerfile + remove Maven from CI — Docker layer caching instead 2026-07-26 23:07:56 +08:00
cat-shark 65ee61c6c9 fix: add shell: bash to CI steps — maven container uses sh by default 2026-07-26 22:06:33 +08:00
cat-shark 09e67cd834 chore: remove k8s/ — migrated to lpt-infra 2026-07-26 21:35:51 +08:00
cat-shark fd317b7932 docs: 补充Git提交规范 2026-07-23 23:03:21 +08:00
cat-shark 859dab9401 docs: add .pi/SYSTEM.md with project overview 2026-07-23 23:01:47 +08:00
cat-sharkandClaude Opus 4.8 4f99f31475 fix(ci): 绑定分支与部署环境并加固 IMAGE_TAG 传递
- dev 仅允许 dev 分支,prod 仅允许 master/main
- IMAGE_TAG 写 env 文件 + image_tag.txt 兜底
- checkout 时确保 maven 容器内安装 git

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:46:17 +08:00
cat-sharkandClaude Opus 4.8 43580dd1fd Add Kubernetes deployment configurations
- Add deployment.yaml with imagePullSecrets and env configuration
- Add service.yaml with NodePort configuration
- Configure Spring Boot environment variables from ConfigMap and Secrets
- Ensures proper image pull authentication from private registry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:23:59 +08:00
cat-shark d5d9b7aa99 feat(ci): 添加 imagePullSecret 自动创建步骤 2026-07-19 22:45:59 +08:00
cat-shark 6eca6de249 feat(ci): 添加部署失败时的调试输出 2026-07-19 22:27:12 +08:00
cat-shark a42df9d521 feat(ci): 使用时间戳标签确保每次部署都触发更新 2026-07-19 21:56:14 +08:00
cat-shark 015ecd13d8 fix(ci): 使用命名 volume 避免挂载冲突 2026-07-19 21:31:18 +08:00
cat-shark 8abd750dfb debug(ci): 添加 Docker 安装调试信息 2026-07-19 21:24:03 +08:00
cat-shark f8e24ffdf0 fix(ci): 挂载 Docker socket 并安装 Docker CLI 2026-07-19 21:18:47 +08:00
cat-shark 688be6b65f feat(ci): 缓存 Maven 本地仓库,避免重复下载依赖 2026-07-19 21:17:45 +08:00
cat-shark a6e75f6e97 fix(ci): 使用容器自带的 mvn 替代 mvnw 2026-07-19 21:14:01 +08:00
cat-shark 6cc5212eba fix(ci): 使用 maven 容器镜像,移除手动安装依赖 2026-07-19 20:56:30 +08:00
cat-shark 2d87b96a09 fix(ci): 使用绝对路径调用 apt-get (host 模式) 2026-07-19 20:49:55 +08:00
cat-shark eb2869a71d fix(ci): 移除 sudo - runner 以 root 运行 2026-07-19 20:41:58 +08:00
cat-shark 1789141a7b fix: 解决 deploy.yml 冲突 2026-07-19 20:13:05 +08:00
cat-shark b80e4c01f5 fix: Alpine compatibility - apk, docker login, /tmp/kubeconfig 2026-07-19 19:27:44 +08:00
cat-sharkandClaude Opus 4.8 b70d90d18a fix(ci): 修复部署流水线配置
- 使用 Git commit hash 作为镜像标签
- 修复 Docker 网络配置(先连接 traefik-public,再附加 mysql 网络)
- 增加健康检查超时到 120 秒
- 添加镜像清理阶段(保留 7 天)
- 修复 Gitea Actions: github.sha → gitea.sha
- 添加 Docker Registry 认证
- kubectl 版本修正为 v1.30.0
- JDK 安装添加 sudo 权限
- 添加部署失败自动回滚机制

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:12:21 +08:00
cat-shark 73fdaf82e7 fix: add docker.io install for step container (no docker CLI in runner image) 2026-07-19 19:02:23 +08:00
cat-shark 5418f23111 ci: replace actions/checkout with git clone (no Node.js in runner) 2026-07-19 18:56:02 +08:00
cat-shark 58e9e4db22 ci: add kubectl setup step with secret 2026-07-19 18:50:39 +08:00
cat-shark a6dab438d5 ci: use unique image tags (git sha) 2026-07-19 18:49:23 +08:00
cat-shark 174b4265d7 Switch to manual trigger (workflow_dispatch) 2026-07-19 18:14:06 +08:00
cat-shark 3d157ed30c Add CI/CD pipeline
lpt-be Build & Deploy / build-and-deploy (push) Failing after 4s
2026-07-19 18:07:50 +08:00
34 changed files with 455 additions and 387 deletions
+152
View File
@@ -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
+130 -1
View File
@@ -1,4 +1,133 @@
# 项目规范 # LPT 后端服务(lpt-be
> Java Spring Boot 后端,提供 REST API、数据库访问、认证鉴权。
## Git 提交规范
- 提交信息必须简短且使用中文,不要使用英文长句。
- 格式:`类型: 简述`,例如 `feat: 新增用户登录接口``fix: 修复多租户拦截器空指针``docs: 补充API文档`
## 技术栈
- Java 17
- 框架:Spring Boot 3.2.5
- ORMMyBatis-Plus 3.5.5
- 数据库:MySQL 8.0
- 认证:Sa-Token 1.38.0Cookie 名 `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=400HTTP 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=...` 代理端点。
## 运行环境
- 默认 profilelocal`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 ## 数据库迁移(Flyway Migration
-63
View File
@@ -1,63 +0,0 @@
# LPT 后端规范
## 技术栈
- Java 17, Spring Boot 3.2.5
- MyBatis-Plus 3.5.5 + MySQL
- MapStruct 1.5.5(编译期生成代码,DTO 增删字段后需重新编译)
- Sa-Token 1.38.0(认证/鉴权)
- Flyway(数据库迁移)
- Lombok@Data, @Slf4j
## 项目结构
```
controller/ → REST 接口,只做参数校验和路由
service/ → 业务逻辑
impl/
mapper/ → MyBatis-Plus BaseMapper
entity/ → 数据库实体(@TableName, @TableField
dto/ → 请求/响应 DTO
request/
response/
config/ → Spring 配置(WebMvcConfig, JacksonConfig 等)
utils/ → 工具类
common/ → GlobalExceptionHandler, Ops 等
mapStruct/ → MapStruct Converter 接口
```
## 响应规范
- 统一使用 `CommonResult<T>` 包装:`{ code, message, data }`
- 成功:`CommonResult.success(data)` → code=200
- 业务错误:`CommonResult.error(msg)` → code=400, HTTP 200
- 未登录:`GlobalExceptionHandler.handleNotLogin()` → HTTP 401 + code=401
- 参数校验失败走 `MethodArgumentNotValidException` → code=400
## 认证
- SaInterceptor 注册在 `WebMvcConfig`(无 @Profile 限制,所有环境生效)
- 拦截 `/**`,排除 `/login`
- `StpUtil.checkLogin()` 失败 → NotLoginException → GlobalExceptionHandler → 401
- Cookie 名 `satoken`,前端 axios 需 `withCredentials: true`
## 数据库变更
- Flyway 迁移文件:`src/main/resources/db/migration/V{日期}_{序号}__{描述}.sql`
- 文件名日期格式:`yyyyMMdd`
## DTO 转换
- MapStruct 编译期生成 `*ConvertImpl.java`target/generated-sources/
- 同名属性自动映射,`unmappedTargetPolicy = IGNORE`
- 如需自定义映射用 `@Mapping(source, target)`
- **增删 DTO 字段后必须重新编译**,否则 MapStruct 生成代码不含新字段
## CORS
-`CorsProperties` 读取各 profile 的 `cors.allowed-origins` 配置
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`(兼容 allowCredentials
- local profile`allow-credentials: true`, `allowed-origins: '*'`
## 标题抓取
- `TitleFetcher`:静态工具类,支持手动跟随 HTTP→HTTPS 重定向、宽松 SSL
- `UtilsController``GET /utils/fetch-title?url=...` 代理端点
## 运行
- 默认 profilelocalapplication.yml 中 `spring.profiles.active: local`
- IDEA JDK`C:/Users/cat-win/.jdks/ms-17.0.19`
- MavenIDEA 内置 `C:/Program Files/JetBrains/IntelliJ IDEA 2026.1.3/plugins/maven/lib/maven3/bin/mvn`
- 编译:`mvn clean compile -DskipTests`(须用 JDK 17
+17 -10
View File
@@ -1,17 +1,24 @@
# 使用官方 OpenJDK 作为基础镜像 # Stage 1: Maven 构建(Docker 层缓存自动缓存依赖)
FROM eclipse-temurin:17-jre FROM maven:3.9-eclipse-temurin-17 AS builder
# 设置工作目录
WORKDIR /app WORKDIR /app
# 拷贝打包好的 JAR 文件到容器 # 先复制 pom.xml,单独一层 —— 只要依赖不变,这层就被缓存
COPY target/LPT.jar LPT.jar 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 EXPOSE 8888
# 启动应用
ENTRYPOINT ["java", "-jar", "LPT.jar"] ENTRYPOINT ["java", "-jar", "LPT.jar"]
# 健康检查 HEALTHCHECK --interval=10s --timeout=5s \
HEALTHCHECK --interval=10s --timeout=5s CMD curl --fail http://localhost:8888/actuator/health || exit 1 CMD curl --fail http://localhost:8888/actuator/health || exit 1
Vendored
-78
View File
@@ -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')
}
}
}
}
}
}
}
-78
View File
@@ -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')
}
}
}
}
}
}
}
@@ -5,6 +5,7 @@ import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.exception.AppException; import com.guo.learningprogresstracker.exception.AppException;
import com.guo.learningprogresstracker.exception.ErrorParameterException; import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -21,16 +22,25 @@ public class GlobalExceptionHandler {
@ExceptionHandler({ErrorParameterException.class}) @ExceptionHandler({ErrorParameterException.class})
public CommonResult errorParameterException(ErrorParameterException ex) { public CommonResult errorParameterException(ErrorParameterException ex) {
log.warn("参数异常: {}", ex.getMessage(), ex);
return CommonResult.error(ex.getMessage()); return CommonResult.error(ex.getMessage());
} }
@ExceptionHandler({NotFindEntitiesException.class}) @ExceptionHandler({NotFindEntitiesException.class})
public CommonResult notFindEntitiesException(NotFindEntitiesException ex) { public CommonResult notFindEntitiesException(NotFindEntitiesException ex) {
log.warn("数据不存在: {}", ex.getMessage(), ex);
return CommonResult.error(ex.getMessage());
}
@ExceptionHandler({OperationFailedException.class})
public CommonResult operationFailedException(OperationFailedException ex) {
log.warn("操作失败: {}", ex.getMessage(), ex);
return CommonResult.error(ex.getMessage()); return CommonResult.error(ex.getMessage());
} }
@ExceptionHandler({AppException.class}) @ExceptionHandler({AppException.class})
public CommonResult AppException(AppException ex) { public CommonResult AppException(AppException ex) {
log.error("业务异常: {}", ex.getMessage(), ex);
return CommonResult.serverError(ex.getMessage()); return CommonResult.serverError(ex.getMessage());
} }
@@ -49,7 +59,7 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public CommonResult Exception(Exception ex) { public CommonResult Exception(Exception ex) {
log.error("系统异常", ex); log.error("系统异常", ex);
return CommonResult.error(ex.getMessage()); return CommonResult.error("操作没有成功,请稍后再试");
} }
} }
@@ -35,7 +35,7 @@ public class ReviewController {
private final StandardMindMapService standardMindMapService; private final StandardMindMapService standardMindMapService;
/** /**
* 获取复习 feed合并报告和残片按时间倒序 * 获取复习 feed仅返回学习残片(首页滚动条使用)
*/ */
@GetMapping("/feed") @GetMapping("/feed")
public CommonResult<List<ReviewFeedItem>> getReviewFeed( public CommonResult<List<ReviewFeedItem>> getReviewFeed(
@@ -57,7 +57,7 @@ public class StudySessionController {
* 通过sessionNum获取一个【学习会话】 * 通过sessionNum获取一个【学习会话】
*/ */
@GetMapping("/{sessionNum}") @GetMapping("/{sessionNum}")
public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "sessionNum不可为空") @PathVariable String sessionNum) throws ErrorParameterException { public CommonResult<StudySessionResponse> getStudySessionBySessionNum(@NotEmpty(message = "请提供学习会话编号") @PathVariable String sessionNum) throws ErrorParameterException {
// 通过 service 层获取,包含归属校验 // 通过 service 层获取,包含归属校验
StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum); StudySessionResponse response = studySessionsServiceImpl.getStudySessionBySessionNum(sessionNum);
return CommonResult.success(response); return CommonResult.success(response);
@@ -132,7 +132,7 @@ public class TaskController {
* 开始或继续一个【学习会话】 * 开始或继续一个【学习会话】
*/ */
@GetMapping("/{taskNum}/study-sessions/start-or-continue") @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 { @PathVariable String taskNum) throws NotFindEntitiesException, ServiceException {
StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum); StudySessionResponse response = studySessionsServiceImpl.startOrContinueStudySession(taskNum);
return CommonResult.success(response); return CommonResult.success(response);
@@ -143,7 +143,7 @@ public class TaskController {
* 通过学习任务num获取该任务的未结束会话 * 通过学习任务num获取该任务的未结束会话
*/ */
@GetMapping("/{taskNum}/not-ended-study-session") @GetMapping("/{taskNum}/not-ended-study-session")
public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "taskNum不可为空") public CommonResult<StudySessionResponse> getNotEndedStudySessionByTaskNum(@NotEmpty(message = "请提供任务编号")
@PathVariable String taskNum) throws ErrorParameterException { @PathVariable String taskNum) throws ErrorParameterException {
StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum); StudySessionResponse response = studySessionsServiceImpl.getNotEndedStudySessionByTaskNum(taskNum);
return CommonResult.success(response); return CommonResult.success(response);
@@ -5,7 +5,7 @@ import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 复习滚动 feed 条目,合并展示学习报告和残片 * 复习滚动 feed 条目;首页仅使用残片,任务详情仍可同时包含报告和残片
*/ */
@Data @Data
public class ReviewFeedItem { public class ReviewFeedItem {
@@ -16,6 +16,6 @@ public class CreateFragmentsRequest {
/** /**
* 残片内容,学习内容的描述 * 残片内容,学习内容的描述
*/ */
@NotBlank(message = "啊?无字天书?") @NotBlank(message = "请填写学习内容")
private String content; private String content;
} }
@@ -8,7 +8,7 @@ public class CreateTaskApplicationRequest {
private String taskNum; private String taskNum;
@NotBlank(message = "应用项目标题不可为空") @NotBlank(message = "请填写应用项目标题")
private String title; private String title;
private String description; private String description;
@@ -8,6 +8,6 @@ import lombok.Data;
*/ */
@Data @Data
public class EndedStudySessionRequest { public class EndedStudySessionRequest {
@NotBlank(message = "啊?搞无字天书是吧?报告内容不可为空") @NotBlank(message = "请填写学习报告内容")
private String content; private String content;
} }
@@ -9,7 +9,7 @@ import lombok.Data;
@Data @Data
public class RecallCompareRequest { public class RecallCompareRequest {
@NotBlank(message = "回忆大纲不可为空") @NotBlank(message = "请先填写回忆大纲")
private String recallOutline; private String recallOutline;
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */ /** 复习起点节点路径(以 / 分隔),null 为任务维度 */
@@ -15,16 +15,16 @@ import lombok.Data;
public class TaskRequest { public class TaskRequest {
public interface Create{} public interface Create{}
@Null(message = "创建时不可指定任务ID",groups = Ops.CreateG.class) @Null(message = "创建任务时不需要填写任务编号",groups = Ops.CreateG.class)
@NotNull(message = "更新时必须指定任务ID",groups = Ops.UpdateG.class) @NotNull(message = "缺少要更新的任务信息,请刷新后重试",groups = Ops.UpdateG.class)
private Integer id; private Integer id;
/** /**
* 学习任务的名称 * 学习任务的名称
*/ */
@NotEmpty(message = "必须指定非空的任务名称", groups = Ops.CreateG.class) @NotEmpty(message = "请填写任务名称", groups = Ops.CreateG.class)
private String taskName; private String taskName;
@NotEmpty(message = "必须指定非空的任务描述", groups = Ops.CreateG.class) @NotEmpty(message = "请填写任务描述", groups = Ops.CreateG.class)
private String taskDescription; private String taskDescription;
/** /**
@@ -35,41 +35,41 @@ public class TaskRequest {
/** /**
* 用户设置的任务紧急性 * 用户设置的任务紧急性
*/ */
@NotNull(message = "必须指定【任务紧急性】指标", groups = Ops.CreateG.class) @NotNull(message = "请设置任务紧急性", groups = Ops.CreateG.class)
@Min(value = 0,message = "urgency参数值必须大于等于0") @Min(value = 0,message = "任务紧急性不能小于 0")
@Max(value = 5,message = "urgency参数值必须小于等于5") @Max(value = 5,message = "任务紧急性不能大于 5")
private Integer urgency; private Integer urgency;
/** /**
* 用户设置的任务重要性 * 用户设置的任务重要性
*/ */
@NotNull(message = "必须指定【任务重要性】指标", groups = Ops.CreateG.class) @NotNull(message = "请设置任务重要性", groups = Ops.CreateG.class)
@Min(value = 0,message = "importance参数值必须大于等于0") @Min(value = 0,message = "任务重要性不能小于 0")
@Max(value = 5,message = "importance参数值必须小于等于5") @Max(value = 5,message = "任务重要性不能大于 5")
private Integer importance; private Integer importance;
/** /**
* 任务的内容难度 * 任务的内容难度
*/ */
@NotNull(message = "必须指定【内容难度】指标", groups = Ops.CreateG.class) @NotNull(message = "请设置内容难度", groups = Ops.CreateG.class)
@Min(value = 0,message = "contentDifficulty参数值必须大于等于0") @Min(value = 0,message = "内容难度不能小于 0")
@Max(value = 5,message = "contentDifficulty参数值必须小于等于5") @Max(value = 5,message = "内容难度不能大于 5")
private Integer contentDifficulty; private Integer contentDifficulty;
/** /**
* 任务的未来价值 * 任务的未来价值
*/ */
@NotNull(message = "必须指定【未来价值】指标", groups = Ops.CreateG.class) @NotNull(message = "请设置未来价值", groups = Ops.CreateG.class)
@Min(value = 0,message = "futureValue参数值必须大于等于0") @Min(value = 0,message = "未来价值不能小于 0")
@Max(value = 5,message = "futureValue参数值必须小于等于5") @Max(value = 5,message = "未来价值不能大于 5")
private Integer futureValue; private Integer futureValue;
/** /**
* 用户对任务的主观优先级 * 用户对任务的主观优先级
*/ */
@NotNull(message = "必须指定【主观优先级】指标", groups = Ops.CreateG.class) @NotNull(message = "请设置主观优先级", groups = Ops.CreateG.class)
@Min(value = 0,message = "subjectivePriority参数值必须大于等于0") @Min(value = 0,message = "主观优先级不能小于 0")
@Max(value = 5,message = "subjectivePriority参数值必须小于等于5") @Max(value = 5,message = "主观优先级不能大于 5")
private Integer subjectivePriority; private Integer subjectivePriority;
} }
@@ -11,6 +11,6 @@ public class UpdateFragmentsRequest {
/** /**
* 残片内容,学习内容的描述 * 残片内容,学习内容的描述
*/ */
@NotBlank(message = "啊?无字天书?") @NotBlank(message = "请填写学习内容")
private String content; private String content;
} }
@@ -9,6 +9,6 @@ import lombok.Data;
@Data @Data
public class UpdateStandardMindMapRequest { public class UpdateStandardMindMapRequest {
@NotBlank(message = "思维导图大纲不可为空") @NotBlank(message = "请填写思维导图大纲")
private String outline; private String outline;
} }
@@ -6,7 +6,7 @@ import lombok.Data;
@Data @Data
public class UpdateTaskApplicationRequest { public class UpdateTaskApplicationRequest {
@NotBlank(message = "应用项目标题不可为空") @NotBlank(message = "请填写应用项目标题")
private String title; private String title;
private String description; private String description;
@@ -9,6 +9,6 @@ import lombok.Data;
@Data @Data
public class UpsertExpectationRequest { public class UpsertExpectationRequest {
@NotBlank(message = "学习预期不可为空") @NotBlank(message = "请填写学习预期")
private String description; private String description;
} }
@@ -10,7 +10,7 @@ import java.io.Serializable;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改 * 任务标准思维导图:由内置规则或 AI 从学习报告生成,用户可修改
*/ */
@TableName(value = "review_standard_mind_maps") @TableName(value = "review_standard_mind_maps")
@Data @Data
@@ -1,6 +1,5 @@
package com.guo.learningprogresstracker.service; package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity; import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.entity.TaskEntity;
@@ -25,15 +24,13 @@ public interface MindMapAiClient {
* 根据学习数据生成标准思维导图根节点 * 根据学习数据生成标准思维导图根节点
* *
* @param task 学习任务 * @param task 学习任务
* @param reports 该任务的全部学习报告 * @param reports 该任务的全部学习报告
* @param fragments 该任务的全部学习残片
* @param applications 该任务的应用场景(可选) * @param applications 该任务的应用场景(可选)
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成) * @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()} * @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
*/ */
Optional<MindMapNode> generate(TaskEntity task, Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports, List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications, List<TaskApplicationEntity> applications,
String clientHint); String clientHint);
@@ -14,7 +14,7 @@ import java.util.List;
public interface ReviewService { public interface ReviewService {
/** /**
* 获取复习 feed 列表,合并报告和残片按时间倒序 * 获取复习 feed 列表,仅返回学习残片按时间倒序
*/ */
List<ReviewFeedItem> getReviewFeed(int limit, String mode); List<ReviewFeedItem> getReviewFeed(int limit, String mode);
@@ -82,10 +82,10 @@ public class AiServiceClient {
} }
/** /**
* 调用 lpt-ai 从学习数据生成思维导图大纲。 * 调用 lpt-ai 从学习报告生成思维导图大纲。
*/ */
public Optional<String> generateMindMap(String taskName, String taskDescription, public Optional<String> generateMindMap(String taskName, String taskDescription,
List<String> reports, List<String> fragments) { List<String> reports) {
if (!isConfigured()) { if (!isConfigured()) {
return Optional.empty(); return Optional.empty();
} }
@@ -93,8 +93,7 @@ public class AiServiceClient {
Map<String, Object> params = Map.of( Map<String, Object> params = Map.of(
"taskName", taskName, "taskName", taskName,
"taskDescription", taskDescription != null ? taskDescription : "", "taskDescription", taskDescription != null ? taskDescription : "",
"reports", reports != null ? reports : List.of(), "reports", reports != null ? reports : List.of()
"fragments", fragments != null ? fragments : List.of()
); );
Optional<JsonNode> result = submitAndWait("generate-mind-map", params); Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
@@ -1,6 +1,5 @@
package com.guo.learningprogresstracker.service.impl; package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity; import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.entity.TaskEntity;
@@ -16,12 +15,12 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。 * 内置规则引擎:从学习报告提取内容并组织为树形思维导图。
* <p>规则策略:</p> * <p>规则策略:</p>
* <ol> * <ol>
* <li>根节点 = 任务名称</li> * <li>根节点 = 任务名称</li>
* <li>一级分支 = 按会话(日期+报告摘要)分组</li> * <li>一级分支 = 按会话(日期+报告摘要)分组</li>
* <li>二级分支 = 该会话下的残片标题</li> * <li>二级分支 = 该会话下的报告</li>
* <li>附加分支"应用场景" = 任务应用场景(如有)</li> * <li>附加分支"应用场景" = 任务应用场景(如有)</li>
* <li>去重:标准化后标题对比,合并内容相似的节点</li> * <li>去重:标准化后标题对比,合并内容相似的节点</li>
* <li>引用追溯:每个节点携带 sourceType / sourceId</li> * <li>引用追溯:每个节点携带 sourceType / sourceId</li>
@@ -46,10 +45,9 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
@Override @Override
public Optional<MindMapNode> generate(TaskEntity task, public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports, List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications, List<TaskApplicationEntity> applications,
String clientHint) { String clientHint) {
if (reports.isEmpty() && fragments.isEmpty()) { if (reports == null || reports.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
@@ -58,34 +56,21 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
// 按 session 分组 // 按 session 分组
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream() Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
.filter(r -> r.getSessionNum() != null)
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum)); .collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
Map<String, List<StudyReportFragmentsEntity>> fragmentsBySession = fragments.stream()
.collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum));
// 合并所有 session for (Map.Entry<String, List<StudyReportsEntity>> entry : reportsBySession.entrySet()) {
Set<String> allSessions = new LinkedHashSet<>(); List<StudyReportsEntity> sessReports = entry.getValue();
allSessions.addAll(reportsBySession.keySet());
allSessions.addAll(fragmentsBySession.keySet());
for (String sessionNum : allSessions) { // 会话分支标题:取第一条报告的前 60 字作为摘要
List<StudyReportsEntity> sessReports = reportsBySession.getOrDefault(sessionNum, List.of()); StudyReportsEntity firstReport = sessReports.get(0);
List<StudyReportFragmentsEntity> sessFragments = fragmentsBySession.getOrDefault(sessionNum, List.of()); String sessionTitle = truncate(firstReport.getContent(), MAX_TITLE_LENGTH);
if (firstReport.getCreatedTime() != null) {
// 会话分支标题:取第一条报告的前 60 字作为摘要,或直接写"学习记录" sessionTitle = formatDate(firstReport.getCreatedTime()) + " " + sessionTitle;
String sessionTitle;
if (!sessReports.isEmpty()) {
String firstReport = sessReports.get(0).getContent();
sessionTitle = truncate(firstReport, MAX_TITLE_LENGTH);
if (sessReports.get(0).getCreatedTime() != null) {
sessionTitle = formatDate(sessReports.get(0).getCreatedTime()) + " " + sessionTitle;
}
} else {
sessionTitle = "学习记录 " + (sessFragments.isEmpty() ? "" : formatDate(sessFragments.get(0).getCreatedTime()));
} }
MindMapNode sessionNode = new MindMapNode(sessionTitle); MindMapNode sessionNode = new MindMapNode(sessionTitle);
// 报告作为子节点
for (StudyReportsEntity report : sessReports) { for (StudyReportsEntity report : sessReports) {
String content = report.getContent(); String content = report.getContent();
if (content == null || content.isBlank()) continue; if (content == null || content.isBlank()) continue;
@@ -96,17 +81,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
sessionNode.getChildren().add(reportNode); sessionNode.getChildren().add(reportNode);
} }
// 残片作为子节点
for (StudyReportFragmentsEntity frag : sessFragments) {
String content = frag.getContent();
if (content == null || content.isBlank()) continue;
MindMapNode fragNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
fragNode.setNotes(content);
fragNode.setSourceType("FRAGMENT");
fragNode.setSourceId(frag.getId());
sessionNode.getChildren().add(fragNode);
}
root.getChildren().add(sessionNode); root.getChildren().add(sessionNode);
} }
@@ -1,6 +1,5 @@
package com.guo.learningprogresstracker.service.impl; package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity; import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.entity.TaskEntity;
@@ -40,32 +39,26 @@ public class RemoteAiMindMapClient implements MindMapAiClient {
@Override @Override
public Optional<MindMapNode> generate(TaskEntity task, public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports, List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications, List<TaskApplicationEntity> applications,
String clientHint) { String clientHint) {
if (!isAvailable()) { if (!isAvailable()) {
return Optional.empty(); return Optional.empty();
} }
// 提取报告和残片的内容文本 // 提取学习报告的内容文本
List<String> reportTexts = reports.stream() List<String> reportTexts = reports.stream()
.map(StudyReportsEntity::getContent) .map(StudyReportsEntity::getContent)
.filter(c -> c != null && !c.isBlank()) .filter(c -> c != null && !c.isBlank())
.collect(Collectors.toList()); .collect(Collectors.toList());
List<String> fragmentTexts = fragments.stream()
.map(StudyReportFragmentsEntity::getContent)
.filter(c -> c != null && !c.isBlank())
.collect(Collectors.toList());
if (reportTexts.isEmpty() && fragmentTexts.isEmpty()) { if (reportTexts.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
Optional<String> optOutline = aiServiceClient.generateMindMap( Optional<String> optOutline = aiServiceClient.generateMindMap(
task.getTaskName(), task.getTaskName(),
task.getTaskDescription(), task.getTaskDescription(),
reportTexts, reportTexts
fragmentTexts
); );
if (optOutline.isEmpty() || optOutline.get().isBlank()) { if (optOutline.isEmpty() || optOutline.get().isBlank()) {
@@ -16,6 +16,7 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.mapper.TasksMapper; import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.service.ReviewService; import com.guo.learningprogresstracker.service.ReviewService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -29,6 +30,7 @@ import java.util.stream.Stream;
/** /**
* 复习模块 Service 实现 * 复习模块 Service 实现
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class ReviewServiceImpl implements ReviewService { public class ReviewServiceImpl implements ReviewService {
@@ -53,13 +55,6 @@ public class ReviewServiceImpl implements ReviewService {
} }
boolean random = RANDOM_MODE.equalsIgnoreCase(mode); boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
List<StudyReportsEntity> reports = random
? studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + safeLimit))
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.orderByDesc(StudyReportsEntity::getCreatedTime)
.last("LIMIT " + safeLimit));
List<StudyReportFragmentsEntity> fragments = random List<StudyReportFragmentsEntity> fragments = random
? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery() ? studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + safeLimit)) .last("ORDER BY RAND() LIMIT " + safeLimit))
@@ -67,7 +62,7 @@ public class ReviewServiceImpl implements ReviewService {
.orderByDesc(StudyReportFragmentsEntity::getCreatedTime) .orderByDesc(StudyReportFragmentsEntity::getCreatedTime)
.last("LIMIT " + safeLimit)); .last("LIMIT " + safeLimit));
List<ReviewFeedItem> items = mergeAndConvert(reports, fragments); List<ReviewFeedItem> items = mergeAndConvert(List.of(), fragments);
if (random) { if (random) {
Collections.shuffle(items); Collections.shuffle(items);
} }
@@ -82,14 +77,11 @@ public class ReviewServiceImpl implements ReviewService {
private List<ReviewFeedItem> getSmartFeed(int safeLimit) { private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500); int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList( List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery() Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit)); .last("ORDER BY RAND() LIMIT " + candidateLimit));
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments); List<ReviewFeedItem> candidates = mergeAndConvert(List.of(), fragments);
if (candidates.size() <= safeLimit) { if (candidates.size() <= safeLimit) {
Collections.shuffle(candidates); Collections.shuffle(candidates);
return candidates; return candidates;
@@ -224,13 +216,19 @@ public class ReviewServiceImpl implements ReviewService {
@Override @Override
public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException { public StudyReportsEntity getReportDetail(int id) throws NotFindEntitiesException {
return Optional.ofNullable(studyReportsMapper.selectById(id)) return Optional.ofNullable(studyReportsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习报告[" + id + "]不存在")); .orElseThrow(() -> {
log.warn("学习报告[{}]不存在", id);
return new NotFindEntitiesException("这份学习报告不存在或已被删除");
});
} }
@Override @Override
public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException { public StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException {
return Optional.ofNullable(studyReportFragmentsMapper.selectById(id)) return Optional.ofNullable(studyReportFragmentsMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在")); .orElseThrow(() -> {
log.warn("学习残片[{}]不存在", id);
return new NotFindEntitiesException("这条学习残片不存在或已被删除");
});
} }
/** /**
@@ -321,7 +319,8 @@ public class ReviewServiceImpl implements ReviewService {
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException { private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists( if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) { Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在"); log.warn("任务[{}]不存在", taskNum);
throw new NotFindEntitiesException("这个任务不存在或已被删除");
} }
} }
@@ -36,7 +36,6 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
private final ReviewRecallRecordMapper recallRecordMapper; private final ReviewRecallRecordMapper recallRecordMapper;
private final TasksMapper tasksMapper; private final TasksMapper tasksMapper;
private final StudyReportsMapper studyReportsMapper; private final StudyReportsMapper studyReportsMapper;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final TaskApplicationMapper taskApplicationMapper; private final TaskApplicationMapper taskApplicationMapper;
private final StudySessionsMapper studySessionsMapper; private final StudySessionsMapper studySessionsMapper;
@@ -86,7 +85,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
ensureTaskExists(taskNum); ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum); ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
if (existing == null) { if (existing == null) {
throw new NotFindEntitiesException("标准思维导图尚不存在,无法增量更新"); log.warn("任务[{}]标准思维导图尚不存在,无法增量更新", taskNum);
throw new NotFindEntitiesException("还没有生成过标准思维导图,请先完整生成一次哦");
} }
// 防并发生成 // 防并发生成
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false)); AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
@@ -201,7 +201,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
try { try {
resultJson = objectMapper.writeValueAsString(result); resultJson = objectMapper.writeValueAsString(result);
} catch (Exception e) { } catch (Exception e) {
throw new OperationFailedException("对比结果序列化失败"); log.error("任务[{}]对比结果序列化失败", taskNum, e);
throw new OperationFailedException("对比结果解析失败了,请稍后再试");
} }
// 5. 保存回忆记录 // 5. 保存回忆记录
@@ -405,7 +406,10 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
@Override @Override
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException { public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
return Optional.ofNullable(recallRecordMapper.selectById(recordId)) return Optional.ofNullable(recallRecordMapper.selectById(recordId))
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在")); .orElseThrow(() -> {
log.warn("回忆记录[{}]不存在", recordId);
return new NotFindEntitiesException("这条回忆记录不存在或已被删除");
});
} }
// ============ 内部方法 ============ // ============ 内部方法 ============
@@ -415,7 +419,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1")); Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
if (task == null) { if (task == null) {
throw new OperationFailedException("任务[" + taskNum + "]不存在"); log.warn("任务[{}]不存在,无法生成思维导图", taskNum);
throw new OperationFailedException("这个任务不存在或已被删除");
} }
// 收集学习数据 // 收集学习数据
@@ -428,14 +433,12 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of() List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery() : studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums)); .in(StudyReportsEntity::getSessionNum, sessionNums));
List<StudyReportFragmentsEntity> fragments = sessionNums.isEmpty() ? List.of()
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums));
List<TaskApplicationEntity> applications = taskApplicationMapper.selectList( List<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum)); Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
if (reports.isEmpty() && fragments.isEmpty()) { if (reports.isEmpty()) {
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图"); log.warn("任务[{}]没有学习报告,无法生成思维导图", taskNum);
throw new OperationFailedException("这个任务还没开始学习哦,学习后产生学习报告后再来吧");
} }
// 优先选 AI 客户端(非 BUILTIN),其次内置生成器 // 优先选 AI 客户端(非 BUILTIN),其次内置生成器
@@ -445,10 +448,11 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
.orElse(null); .orElse(null);
if (client == null) { if (client == null) {
throw new OperationFailedException("没有可用的思维导图生成器"); log.warn("任务[{}]没有可用的思维导图生成器", taskNum);
throw new OperationFailedException("思维导图暂时生成不了,请稍后再试");
} }
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null); Optional<MindMapNode> optRoot = client.generate(task, reports, applications, null);
// AI 生成失败时尝试降级到内置生成器 // AI 生成失败时尝试降级到内置生成器
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) { if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
log.info("AI 思维导图生成失败,降级到内置生成器"); log.info("AI 思维导图生成失败,降级到内置生成器");
@@ -456,11 +460,12 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable()) .filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
.findFirst().orElse(null); .findFirst().orElse(null);
if (fallback != null) { if (fallback != null) {
optRoot = fallback.generate(task, reports, fragments, applications, null); optRoot = fallback.generate(task, reports, applications, null);
} }
} }
if (optRoot.isEmpty()) { if (optRoot.isEmpty()) {
throw new OperationFailedException("思维导图生成失败"); log.warn("任务[{}]思维导图生成失败,已尝试全部生成器", taskNum);
throw new OperationFailedException("思维导图生成失败了,请稍后再试");
} }
MindMapNode root = optRoot.get(); MindMapNode root = optRoot.get();
@@ -483,7 +488,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
entity.setGenerator(client.generatorName()); entity.setGenerator(client.generatorName());
entity.setGeneratorVersion("1.0"); entity.setGeneratorVersion("1.0");
entity.setSourceReportCount(reports.size()); entity.setSourceReportCount(reports.size());
entity.setSourceFragmentCount(fragments.size()); entity.setSourceFragmentCount(0);
entity.setGeneratedTime(LocalDateTime.now()); entity.setGeneratedTime(LocalDateTime.now());
if (create) { if (create) {
@@ -497,7 +502,8 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException { private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists( if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) { Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在"); log.warn("任务[{}]不存在", taskNum);
throw new NotFindEntitiesException("这个任务不存在或已被删除");
} }
} }
@@ -8,11 +8,13 @@ import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper; import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyExpectationsService; import com.guo.learningprogresstracker.service.StudyExpectationsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
* 学习预期服务实现 * 学习预期服务实现
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class StudyExpectationsServiceImpl implements StudyExpectationsService { public class StudyExpectationsServiceImpl implements StudyExpectationsService {
@@ -26,7 +28,8 @@ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
Wrappers.<StudySessionsEntity>lambdaQuery() Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getSessionNum, sessionNum)); .eq(StudySessionsEntity::getSessionNum, sessionNum));
if (!sessionExists) { if (!sessionExists) {
throw new ErrorParameterException("学习会话[" + sessionNum + "]不存在"); log.warn("学习会话[{}]不存在", sessionNum);
throw new ErrorParameterException("这次学习会话不存在或已结束");
} }
StudyExpectationsEntity existing = getBySessionNum(sessionNum); StudyExpectationsEntity existing = getBySessionNum(sessionNum);
@@ -12,6 +12,7 @@ import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyReportFragmentsService; import com.guo.learningprogresstracker.service.StudyReportFragmentsService;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper; import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -20,6 +21,7 @@ import java.util.List;
/** /**
* 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现 * 针对表【study_report_fragments(记录学习过程中的学习内容报告残片)】的数据库操作Service实现
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity> public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFragmentsMapper, StudyReportFragmentsEntity>
@@ -35,7 +37,8 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
Wrappers.lambdaQuery(StudySessionsEntity.class) Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, entity.getSessionNum())); .eq(StudySessionsEntity::getSessionNum, entity.getSessionNum()));
if (session == null) { if (session == null) {
throw new NotFindEntitiesException(String.format("未能找到学习会话sessionNum[%s]", entity.getSessionNum())); log.warn("未能找到学习会话sessionNum[{}]", entity.getSessionNum());
throw new NotFindEntitiesException("这次学习会话不存在或已结束");
} }
this.save(entity); this.save(entity);
} }
@@ -44,7 +47,8 @@ public class StudyReportFragmentsServiceImpl extends ServiceImpl<StudyReportFrag
public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException { public void updateFragments(Integer id, UpdateFragmentsRequest request) throws NotFindEntitiesException {
StudyReportFragmentsEntity existing = this.getById(id); StudyReportFragmentsEntity existing = this.getById(id);
if (existing == null) { if (existing == null) {
throw new NotFindEntitiesException(String.format("未能找到学习残片id[%d]", id)); log.warn("未能找到学习残片id[{}]", id);
throw new NotFindEntitiesException("这条学习残片不存在或已被删除");
} }
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request); StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
entity.setId(id); entity.setId(id);
@@ -50,7 +50,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException { public StudySessionResponse startOrContinueStudySession(String taskNum) throws NotFindEntitiesException, ServiceException {
TaskEntity taskEntity = tasksServiceImpl.getOneOpt( TaskEntity taskEntity = tasksServiceImpl.getOneOpt(
Wrappers.lambdaQuery(TaskEntity.class).eq(TaskEntity::getTaskNum, taskNum)) 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() StudySessionsEntity session = this.getOneOpt(Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum) .eq(StudySessionsEntity::getTaskNum, taskNum)
@@ -88,10 +91,10 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException { public void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class) StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum)) .eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在")); .orElseThrow(() -> sessionNotFound(sessionNum));
if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) { if (StudySessionStateEnum.PAUSED.name().equals(studySessionsEntity.getSessionState())) {
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停"); log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复暂停");
throw new ErrorParameterException("会话[" + sessionNum + "]已经暂停,请勿重复暂停"); throw new ErrorParameterException("这次学习会话已经暂停了,不用重复暂停");
} }
studySessionsEntity.calculatePointerPosition(); studySessionsEntity.calculatePointerPosition();
studySessionsEntity.pausedStudySession(endTime); studySessionsEntity.pausedStudySession(endTime);
@@ -104,7 +107,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public String endedStudySession(String sessionNum, String content) throws ErrorParameterException { public String endedStudySession(String sessionNum, String content) throws ErrorParameterException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class) StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum)) .eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在")); .orElseThrow(() -> sessionNotFound(sessionNum));
boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState()); boolean wasOngoing = StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState());
studySessionsEntity.endedStudySession(); studySessionsEntity.endedStudySession();
this.updateById(studySessionsEntity); this.updateById(studySessionsEntity);
@@ -122,9 +125,12 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException { public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class) TaskEntity task = tasksServiceImpl.getOneOpt(Wrappers.lambdaQuery(TaskEntity.class)
.eq(TaskEntity::getTaskNum, taskNum)) .eq(TaskEntity::getTaskNum, taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在")); .orElseThrow(() -> taskNotFound(taskNum));
StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum)) StudySessionsDto dto = Optional.ofNullable(studyReportsMapper.getNotEndedStudySessionDtoByTaskNum(taskNum))
.orElseThrow(() -> new ErrorParameterException("任务[" + taskNum + "]不存在进行中或暂停中的会话")); .orElseThrow(() -> {
log.warn("任务[{}]不存在进行中或暂停中的会话", taskNum);
return new ErrorParameterException("这个任务当前没有进行中或已暂停的学习会话");
});
return StudySessionConvert.MAPPER.toStudySessionResponse(dto); return StudySessionConvert.MAPPER.toStudySessionResponse(dto);
} }
@@ -136,7 +142,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum)) .eq(StudyReportFragmentsEntity::getSessionNum, sessionNum))
.stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new)); .stream().map(StudyReportFragmentsEntity::getContent).collect(Collectors.toCollection(ArrayList::new));
} else { } else {
throw new ErrorParameterException("会话[" + sessionNum + "]不存在"); throw sessionNotFound(sessionNum);
} }
} }
@@ -145,13 +151,13 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException { public void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class) StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum)) .eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在")); .orElseThrow(() -> sessionNotFound(sessionNum));
if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) { if (StudySessionStateEnum.ONGOING.name().equals(studySessionsEntity.getSessionState())) {
log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始"); log.error("会话[" + studySessionsEntity.getSessionNum() + "]重复开始");
throw new ErrorParameterException("会话[" + sessionNum + "]已经开始,请勿重复开始"); throw new ErrorParameterException("这次学习会话已经开始了,不用重复开始");
} else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) { } else if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!"); log.error("会话[" + studySessionsEntity.getSessionNum() + "]处于结束状态,不可开始该会话!");
throw new ErrorParameterException("会话[" + sessionNum + "]处于结束状态,不可开始该会话!"); throw new ErrorParameterException("这次学习会话已经结束,无法再次开始");
} }
studySessionsEntity.continueStudySession(); studySessionsEntity.continueStudySession();
this.updateById(studySessionsEntity); this.updateById(studySessionsEntity);
@@ -163,7 +169,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException { public StudySessionResponse getStudySessionBySessionNum(String sessionNum) throws ErrorParameterException {
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class) StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum)) .eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在")); .orElseThrow(() -> sessionNotFound(sessionNum));
return StudySessionConvert.MAPPER.toStudySessionResponse(session); return StudySessionConvert.MAPPER.toStudySessionResponse(session);
} }
@@ -174,7 +180,7 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public String generateReportDraft(String sessionNum) throws ErrorParameterException { public String generateReportDraft(String sessionNum) throws ErrorParameterException {
StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class) StudySessionsEntity session = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum)) .eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> new ErrorParameterException("会话[" + sessionNum + "]不存在")); .orElseThrow(() -> sessionNotFound(sessionNum));
ArrayList<String> fragments = getAllFragments(sessionNum); ArrayList<String> fragments = getAllFragments(sessionNum);
if (fragments.isEmpty()) { if (fragments.isEmpty()) {
@@ -262,4 +268,14 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
return response; return response;
} }
private ErrorParameterException sessionNotFound(String sessionNum) {
log.warn("会话[{}]不存在", sessionNum);
return new ErrorParameterException("这次学习会话不存在或已结束");
}
private ErrorParameterException taskNotFound(String taskNum) {
log.warn("任务[{}]不存在", taskNum);
return new ErrorParameterException("这个任务不存在或已被删除");
}
} }
@@ -60,7 +60,8 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
if (this.exists(Wrappers.lambdaQuery(TaskEntity.class) if (this.exists(Wrappers.lambdaQuery(TaskEntity.class)
.eq(TaskEntity::getTaskName, task.getTaskName()))) { .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")); task.setTaskNum(GenerateNumTool.generateNum("TASK"));
@@ -132,7 +133,10 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
@Override @Override
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException { public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id)) TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在")); .orElseThrow(() -> {
log.warn("应用场景[{}]不存在", id);
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
});
existing.setTitle(request.getTitle()); existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription()); existing.setDescription(request.getDescription());
existing.setResourceUrl(request.getResourceUrl()); existing.setResourceUrl(request.getResourceUrl());
@@ -144,14 +148,18 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
@Override @Override
public void deleteApplication(Integer id) throws NotFindEntitiesException { public void deleteApplication(Integer id) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id)) TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在")); .orElseThrow(() -> {
log.warn("应用场景[{}]不存在", id);
return new NotFindEntitiesException("这个应用场景不存在或已被删除");
});
taskApplicationMapper.deleteById(existing.getId()); taskApplicationMapper.deleteById(existing.getId());
} }
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException { private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists( if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) { Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在"); log.warn("任务[{}]不存在", taskNum);
throw new NotFindEntitiesException("这个任务不存在或已被删除");
} }
} }
@@ -62,13 +62,13 @@ class ReviewServiceImplTest {
void getReviewFeed_smartMode_prefersLowRecallRatioContent() { void getReviewFeed_smartMode_prefersLowRecallRatioContent() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
StudyReportsEntity oldLowMastery = new StudyReportsEntity(); StudyReportFragmentsEntity oldLowMastery = new StudyReportFragmentsEntity();
oldLowMastery.setId(1); oldLowMastery.setId(1);
oldLowMastery.setSessionNum("S_LOW"); oldLowMastery.setSessionNum("S_LOW");
oldLowMastery.setContent("很久没复习且掌握度低"); oldLowMastery.setContent("很久没复习且掌握度低");
oldLowMastery.setCreatedTime(now.minusDays(60)); oldLowMastery.setCreatedTime(now.minusDays(60));
StudyReportsEntity freshHighMastery = new StudyReportsEntity(); StudyReportFragmentsEntity freshHighMastery = new StudyReportFragmentsEntity();
freshHighMastery.setId(2); freshHighMastery.setId(2);
freshHighMastery.setSessionNum("S_HIGH"); freshHighMastery.setSessionNum("S_HIGH");
freshHighMastery.setContent("刚学完且掌握度高"); freshHighMastery.setContent("刚学完且掌握度高");
@@ -93,8 +93,7 @@ class ReviewServiceImplTest {
highRatioRecord.setRecallRatio(1.0); highRatioRecord.setRecallRatio(1.0);
highRatioRecord.setCreatedTime(now); highRatioRecord.setCreatedTime(now);
when(studyReportsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery)); when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery));
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of());
when(studySessionsMapper.selectList(any())).thenReturn(List.of(sessionLow, sessionHigh)); when(studySessionsMapper.selectList(any())).thenReturn(List.of(sessionLow, sessionHigh));
when(tasksMapper.selectList(any())).thenReturn(List.of(taskLow, taskHigh)); when(tasksMapper.selectList(any())).thenReturn(List.of(taskLow, taskHigh));
when(reviewRecallRecordMapper.selectList(any())).thenReturn(List.of(highRatioRecord)); when(reviewRecallRecordMapper.selectList(any())).thenReturn(List.of(highRatioRecord));
@@ -116,15 +115,9 @@ class ReviewServiceImplTest {
} }
@Test @Test
void getReviewFeed_shouldApplyFinalLimitAfterMerge() { void getReviewFeed_shouldReturnFragmentsOnly() {
StudyReportsEntity report = new StudyReportsEntity();
report.setId(1);
report.setSessionNum("S1");
report.setContent("report");
report.setCreatedTime(LocalDateTime.now());
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity(); StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
fragment.setId(2); fragment.setId(1);
fragment.setSessionNum("S1"); fragment.setSessionNum("S1");
fragment.setContent("fragment"); fragment.setContent("fragment");
fragment.setCreatedTime(LocalDateTime.now().minusMinutes(1)); fragment.setCreatedTime(LocalDateTime.now().minusMinutes(1));
@@ -137,7 +130,6 @@ class ReviewServiceImplTest {
task.setTaskNum("T1"); task.setTaskNum("T1");
task.setTaskName("Task"); task.setTaskName("Task");
when(studyReportsMapper.selectList(any())).thenReturn(List.of(report));
when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(fragment)); when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of(fragment));
when(studySessionsMapper.selectList(any())).thenReturn(List.of(session)); when(studySessionsMapper.selectList(any())).thenReturn(List.of(session));
when(tasksMapper.selectList(any())).thenReturn(List.of(task)); when(tasksMapper.selectList(any())).thenReturn(List.of(task));
@@ -145,7 +137,7 @@ class ReviewServiceImplTest {
List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "recent"); List<ReviewFeedItem> items = reviewService.getReviewFeed(1, "recent");
assertEquals(1, items.size()); assertEquals(1, items.size());
assertEquals("REPORT", items.get(0).getSourceType()); assertEquals("FRAGMENT", items.get(0).getSourceType());
assertEquals("Task", items.get(0).getTaskName()); assertEquals("Task", items.get(0).getTaskName());
} }
@@ -36,8 +36,6 @@ class StandardMindMapServiceImplTest {
@Mock @Mock
private StudyReportsMapper studyReportsMapper; private StudyReportsMapper studyReportsMapper;
@Mock @Mock
private StudyReportFragmentsMapper studyReportFragmentsMapper;
@Mock
private TaskApplicationMapper taskApplicationMapper; private TaskApplicationMapper taskApplicationMapper;
@Mock @Mock
private StudySessionsMapper studySessionsMapper; private StudySessionsMapper studySessionsMapper;
@@ -189,7 +187,7 @@ class StandardMindMapServiceImplTest {
private StandardMindMapServiceImpl createService() { private StandardMindMapServiceImpl createService() {
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl( StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
standardMindMapMapper, recallRecordMapper, standardMindMapMapper, recallRecordMapper,
tasksMapper, studyReportsMapper, studyReportFragmentsMapper, tasksMapper, studyReportsMapper,
taskApplicationMapper, studySessionsMapper, taskApplicationMapper, studySessionsMapper,
List.of(mockAiClient), objectMapper, aiServiceClient); List.of(mockAiClient), objectMapper, aiServiceClient);
return s; return s;