Compare commits
27 Commits
76742ed65a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f99f31475 | |||
| 43580dd1fd | |||
| d5d9b7aa99 | |||
| 6eca6de249 | |||
| a42df9d521 | |||
| 015ecd13d8 | |||
| 8abd750dfb | |||
| f8e24ffdf0 | |||
| 688be6b65f | |||
| a6e75f6e97 | |||
| 6cc5212eba | |||
| 2d87b96a09 | |||
| eb2869a71d | |||
| 1789141a7b | |||
| b80e4c01f5 | |||
| b70d90d18a | |||
| 73fdaf82e7 | |||
| 5418f23111 | |||
| 58e9e4db22 | |||
| a6dab438d5 | |||
| 174b4265d7 | |||
| 3d157ed30c | |||
| 9aded054d2 | |||
| cfa21eb336 | |||
| 53a6781092 | |||
| ac0154c1fb | |||
| 9d3bac8224 |
@@ -0,0 +1,185 @@
|
|||||||
|
name: lpt-be Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: '部署环境(dev 仅允许 dev 分支;prod 仅允许 master/main)'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- dev
|
||||||
|
- prod
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: 192.168.123.199:5000
|
||||||
|
APP: lpt-be
|
||||||
|
REPO_URL: http://git.cat-shark.xyz/cat-shark/lpt-be.git
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: maven:3.9-eclipse-temurin-17
|
||||||
|
volumes:
|
||||||
|
- maven-cache:/root/.m2
|
||||||
|
steps:
|
||||||
|
- name: Validate branch ↔ environment
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REF="${{ gitea.ref }}"
|
||||||
|
BRANCH="${REF#refs/heads/}"
|
||||||
|
if [[ "$REF" == refs/tags/* ]]; then
|
||||||
|
echo "ERROR: 请从分支触发部署,不要用 tag。当前 ref=$REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
|
echo "branch=$BRANCH environment=$ENV sha=${{ gitea.sha }}"
|
||||||
|
case "$ENV" in
|
||||||
|
prod)
|
||||||
|
case "$BRANCH" in
|
||||||
|
master|main) echo "OK: prod 允许从 $BRANCH 部署" ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: prod 只能从 master/main 部署,当前分支是 '$BRANCH'"
|
||||||
|
echo "请切换到 master 后再 Run workflow,并选择 environment=prod"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
dev)
|
||||||
|
case "$BRANCH" in
|
||||||
|
dev) echo "OK: dev 允许从 $BRANCH 部署" ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: dev 只能从 dev 分支部署,当前分支是 '$BRANCH'"
|
||||||
|
echo "请切换到 dev 后再 Run workflow,并选择 environment=dev"
|
||||||
|
echo "(禁止用 master 代码部署到 lpt-dev,避免环境错配)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: 未知 environment=$ENV"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# maven 容器可能无 git
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq git
|
||||||
|
fi
|
||||||
|
git clone "$REPO_URL" .
|
||||||
|
git checkout "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
- name: Build with Maven
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mvn clean package -DskipTests -B
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build & Push Docker image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
|
TAG="${{ gitea.sha }}-$(date +%s)"
|
||||||
|
echo "Building $REGISTRY/$APP:$TAG (env tag=$ENV)"
|
||||||
|
docker build -t "$REGISTRY/$APP:$TAG" -t "$REGISTRY/$APP:$ENV" .
|
||||||
|
docker push "$REGISTRY/$APP:$TAG"
|
||||||
|
docker push "$REGISTRY/$APP:$ENV"
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then
|
||||||
|
echo "IMAGE_TAG=$TAG" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
# runner.temp 在 container job 里可能不可用,用工作区文件兜底
|
||||||
|
echo "$TAG" > image_tag.txt
|
||||||
|
mkdir -p /tmp/lpt-ci
|
||||||
|
echo "$TAG" > /tmp/lpt-ci/image_tag.txt
|
||||||
|
echo "IMAGE_TAG=$TAG"
|
||||||
|
|
||||||
|
- name: Setup kubectl
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -sLO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
|
||||||
|
chmod +x kubectl
|
||||||
|
mv kubectl /usr/local/bin/
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
|
||||||
|
|
||||||
|
- name: Create/Update imagePullSecret
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
kubectl create secret docker-registry regcred \
|
||||||
|
--docker-server="$REGISTRY" \
|
||||||
|
--docker-username="${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
|
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
|
-n "lpt-${{ inputs.environment }}" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
- name: Deploy to K8s
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||||
|
if [ -z "$IMAGE_TAG" ] && [ -f image_tag.txt ]; then
|
||||||
|
IMAGE_TAG="$(cat image_tag.txt)"
|
||||||
|
fi
|
||||||
|
if [ -z "$IMAGE_TAG" ] && [ -f /tmp/lpt-ci/image_tag.txt ]; then
|
||||||
|
IMAGE_TAG="$(cat /tmp/lpt-ci/image_tag.txt)"
|
||||||
|
fi
|
||||||
|
if [ -z "$IMAGE_TAG" ]; then
|
||||||
|
echo "ERROR: IMAGE_TAG 为空,无法部署"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
|
||||||
|
# Spring profile 由 K8s Deployment 的 SPRING_PROFILES_ACTIVE 控制,镜像本身不区分
|
||||||
|
kubectl set image "deployment/$APP" \
|
||||||
|
"$APP=$REGISTRY/$APP:$IMAGE_TAG" \
|
||||||
|
-n "lpt-$ENV" --record
|
||||||
|
kubectl rollout status "deployment/$APP" \
|
||||||
|
-n "lpt-$ENV" --timeout=5m
|
||||||
|
|
||||||
|
- name: Debug on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
|
echo "=== Deployment Status ==="
|
||||||
|
kubectl get deployment "$APP" -n "lpt-$ENV" || true
|
||||||
|
echo ""
|
||||||
|
echo "=== Pod Status ==="
|
||||||
|
kubectl get pods -n "lpt-$ENV" -l "app=$APP" || true
|
||||||
|
echo ""
|
||||||
|
echo "=== Recent Events ==="
|
||||||
|
kubectl get events -n "lpt-$ENV" --sort-by='.lastTimestamp' | tail -20 || true
|
||||||
|
echo ""
|
||||||
|
echo "=== Pod Describe (latest) ==="
|
||||||
|
POD=$(kubectl get pods -n "lpt-$ENV" -l "app=$APP" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || true)
|
||||||
|
if [ -n "${POD:-}" ]; then
|
||||||
|
kubectl describe pod "$POD" -n "lpt-$ENV" || true
|
||||||
|
echo ""
|
||||||
|
echo "=== Pod Logs (latest) ==="
|
||||||
|
kubectl logs "$POD" -n "lpt-$ENV" --tail=50 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Rollback on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
|
kubectl rollout undo "deployment/$APP" -n "lpt-$ENV" || true
|
||||||
|
kubectl rollout status "deployment/$APP" -n "lpt-$ENV" || true
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# LPT 后端规范
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
- Java 17, Spring Boot 3.2.5
|
||||||
|
- MyBatis-Plus 3.5.5 + MySQL
|
||||||
|
- MapStruct 1.5.5(编译期生成代码,DTO 增删字段后需重新编译)
|
||||||
|
- Sa-Token 1.38.0(认证/鉴权)
|
||||||
|
- Flyway(数据库迁移)
|
||||||
|
- Lombok(@Data, @Slf4j)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
```
|
||||||
|
controller/ → REST 接口,只做参数校验和路由
|
||||||
|
service/ → 业务逻辑
|
||||||
|
impl/
|
||||||
|
mapper/ → MyBatis-Plus BaseMapper
|
||||||
|
entity/ → 数据库实体(@TableName, @TableField)
|
||||||
|
dto/ → 请求/响应 DTO
|
||||||
|
request/
|
||||||
|
response/
|
||||||
|
config/ → Spring 配置(WebMvcConfig, JacksonConfig 等)
|
||||||
|
utils/ → 工具类
|
||||||
|
common/ → GlobalExceptionHandler, Ops 等
|
||||||
|
mapStruct/ → MapStruct Converter 接口
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应规范
|
||||||
|
- 统一使用 `CommonResult<T>` 包装:`{ code, message, data }`
|
||||||
|
- 成功:`CommonResult.success(data)` → code=200
|
||||||
|
- 业务错误:`CommonResult.error(msg)` → code=400, HTTP 200
|
||||||
|
- 未登录:`GlobalExceptionHandler.handleNotLogin()` → HTTP 401 + code=401
|
||||||
|
- 参数校验失败走 `MethodArgumentNotValidException` → code=400
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
- SaInterceptor 注册在 `WebMvcConfig`(无 @Profile 限制,所有环境生效)
|
||||||
|
- 拦截 `/**`,排除 `/login`
|
||||||
|
- `StpUtil.checkLogin()` 失败 → NotLoginException → GlobalExceptionHandler → 401
|
||||||
|
- Cookie 名 `satoken`,前端 axios 需 `withCredentials: true`
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
- Flyway 迁移文件:`src/main/resources/db/migration/V{日期}_{序号}__{描述}.sql`
|
||||||
|
- 文件名日期格式:`yyyyMMdd`
|
||||||
|
|
||||||
|
## DTO 转换
|
||||||
|
- MapStruct 编译期生成 `*ConvertImpl.java`(target/generated-sources/)
|
||||||
|
- 同名属性自动映射,`unmappedTargetPolicy = IGNORE`
|
||||||
|
- 如需自定义映射用 `@Mapping(source, target)`
|
||||||
|
- **增删 DTO 字段后必须重新编译**,否则 MapStruct 生成代码不含新字段
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
- 由 `CorsProperties` 读取各 profile 的 `cors.allowed-origins` 配置
|
||||||
|
- `allowed-origins: "*"` 时自动切换为 `allowedOriginPatterns("*")`(兼容 allowCredentials)
|
||||||
|
- local profile:`allow-credentials: true`, `allowed-origins: '*'`
|
||||||
|
|
||||||
|
## 标题抓取
|
||||||
|
- `TitleFetcher`:静态工具类,支持手动跟随 HTTP→HTTPS 重定向、宽松 SSL
|
||||||
|
- `UtilsController`:`GET /utils/fetch-title?url=...` 代理端点
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
- 默认 profile:local(application.yml 中 `spring.profiles.active: local`)
|
||||||
|
- IDEA JDK:`C:/Users/cat-win/.jdks/ms-17.0.19`
|
||||||
|
- Maven:IDEA 内置 `C:/Program Files/JetBrains/IntelliJ IDEA 2026.1.3/plugins/maven/lib/maven3/bin/mvn`
|
||||||
|
- 编译:`mvn clean compile -DskipTests`(须用 JDK 17)
|
||||||
Vendored
+27
-19
@@ -1,7 +1,7 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent none // 全局不指定,局部自己声明
|
agent none
|
||||||
environment {
|
environment {
|
||||||
IMAGE_NAME = 'lpt-prod:0.0'
|
IMAGE_NAME = "lpt-prod:${env.GIT_COMMIT?.take(8) ?: '0.0'}"
|
||||||
CONTAINER_NAME = 'LPT-prod'
|
CONTAINER_NAME = 'LPT-prod'
|
||||||
CONTAINER_PORT = '8888'
|
CONTAINER_PORT = '8888'
|
||||||
}
|
}
|
||||||
@@ -31,23 +31,24 @@ pipeline {
|
|||||||
sh '''
|
sh '''
|
||||||
docker network create traefik-public || true
|
docker network create traefik-public || true
|
||||||
docker rm -f $CONTAINER_NAME || true
|
docker rm -f $CONTAINER_NAME || true
|
||||||
docker run -d --name $CONTAINER_NAME --network mysql-prod_mysql-prod \\
|
docker run -d --name $CONTAINER_NAME \
|
||||||
--restart=always \\
|
--network traefik-public \
|
||||||
-e SPRING_PROFILES_ACTIVE=prod \\
|
--restart=always \
|
||||||
--label "traefik.enable=true" \\
|
-e SPRING_PROFILES_ACTIVE=prod \
|
||||||
--label "traefik.docker.network=traefik-public" \\
|
--label "traefik.enable=true" \
|
||||||
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \\
|
--label "traefik.docker.network=traefik-public" \
|
||||||
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \\
|
--label 'traefik.http.routers.lpt-api.rule=Host(`lpt.cat-shark.xyz`) && PathPrefix(`/api`)' \
|
||||||
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \\
|
--label "traefik.http.routers.lpt-api.entrypoints=websecure" \
|
||||||
--label "traefik.http.routers.lpt-api.priority=100" \\
|
--label "traefik.http.routers.lpt-api.tls.certresolver=le" \
|
||||||
--label "traefik.http.routers.lpt-api.service=lpt-api" \\
|
--label "traefik.http.routers.lpt-api.priority=100" \
|
||||||
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \\
|
--label "traefik.http.routers.lpt-api.service=lpt-api" \
|
||||||
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \\
|
--label "traefik.http.routers.lpt-api.middlewares=lpt-api-strip" \
|
||||||
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \\
|
--label "traefik.http.middlewares.lpt-api-strip.stripprefix.prefixes=/api" \
|
||||||
--log-driver=loki \\
|
--label "traefik.http.services.lpt-api.loadbalancer.server.port=$CONTAINER_PORT" \
|
||||||
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \\
|
--log-driver=loki \
|
||||||
|
--log-opt loki-url="http://192.168.123.199:3100/loki/api/v1/push" \
|
||||||
$IMAGE_NAME
|
$IMAGE_NAME
|
||||||
docker network connect traefik-public $CONTAINER_NAME || true
|
docker network connect mysql-prod_mysql-prod $CONTAINER_NAME || true
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def lastStatus = ''
|
def lastStatus = ''
|
||||||
timeout(time: 60, unit: 'SECONDS') {
|
timeout(time: 120, unit: 'SECONDS') {
|
||||||
waitUntil {
|
waitUntil {
|
||||||
def status = sh(
|
def status = sh(
|
||||||
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
script: "docker inspect -f '{{.State.Health.Status}}' $CONTAINER_NAME || echo 'unhealthy'",
|
||||||
@@ -74,5 +75,12 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stage('清理旧镜像') {
|
||||||
|
agent any
|
||||||
|
steps {
|
||||||
|
sh 'docker image prune -af --filter "until=168h" || true'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ PRD 明确要求"每次学习开始前必须填写学习预期(不可为空)
|
|||||||
| 权重配置 UI | 学习页”权重配置”——五维度滑块、合计校验 100%、保存后全任务重算(PUT `/tasks/priority-weights`) | 后端 PriorityWeightsService,前端 Study.vue |
|
| 权重配置 UI | 学习页”权重配置”——五维度滑块、合计校验 100%、保存后全任务重算(PUT `/tasks/priority-weights`) | 后端 PriorityWeightsService,前端 Study.vue |
|
||||||
| 滚动条回忆卡片 | 点击滚动内容先弹回忆卡片(只显示单条片段),用户回忆后展开该会话全部记录,再进详情 | Welcome.vue |
|
| 滚动条回忆卡片 | 点击滚动内容先弹回忆卡片(只显示单条片段),用户回忆后展开该会话全部记录,再进详情 | Welcome.vue |
|
||||||
| feed 智能排序 | `/review/feed?mode=smart`:时间衰减 × 回忆掌握度加权随机采样 | ReviewServiceImpl.getSmartFeed |
|
| feed 智能排序 | `/review/feed?mode=smart`:时间衰减 × 回忆掌握度加权随机采样 | ReviewServiceImpl.getSmartFeed |
|
||||||
| lpt-ai 独立服务 | TypeScript + Fastify 新项目:`/ai/aggregate-report`、`/ai/generate-mind-map`,对接 SiliconFlow,Key 从环境变量读取 | 独立仓库 lpt-ai |
|
| lpt-ai 独立服务 | TypeScript + Fastify 新项目:异步任务模式(`POST /ai/tasks` + 轮询),对接 SiliconFlow,Key 从环境变量读取 | 独立仓库 lpt-ai |
|
||||||
| AI 聚合报告 | 结束会话弹窗自动拉取 AI 草稿(有残片时),失败降级为拼接(GET `/study-sessions/{n}/report-draft`) | AiServiceClient + StartTask.vue |
|
| AI 聚合报告 | 结束会话弹窗自动拉取 AI 草稿(有残片时),失败降级为拼接(GET `/study-sessions/{n}/report-draft`) | AiServiceClient + StartTask.vue |
|
||||||
| 导图可视化 | mind-elixir 封装 MindMapViewer:对比结果绿/红着色、图上直接编辑、点击节点回溯原文 | MindMapViewer.vue + ReviewRecall.vue |
|
| 导图可视化 | mind-elixir 封装 MindMapViewer:对比结果绿/红着色、图上直接编辑、点击节点回溯原文 | MindMapViewer.vue + ReviewRecall.vue |
|
||||||
| updateTask 优先级 bug | 更新任务时重算优先级(原实现不重算) | TasksServiceImpl |
|
| updateTask 优先级 bug | 更新任务时重算优先级(原实现不重算) | TasksServiceImpl |
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
# LPT 系统实现优化说明
|
||||||
|
|
||||||
|
> 本文档记录在设计文档基础上进行的架构优化和工程实践改进
|
||||||
|
|
||||||
|
## 一、架构优化
|
||||||
|
|
||||||
|
### 1.1 AI 服务:同步 → 异步任务模式
|
||||||
|
|
||||||
|
**设计初衷**:简单的同步 HTTP 请求
|
||||||
|
**实际挑战**:LLM 调用耗时长(10-60秒),同步请求易超时
|
||||||
|
**优化方案**:异步任务队列
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端提交任务
|
||||||
|
↓
|
||||||
|
返回 taskId(立即响应)
|
||||||
|
↓
|
||||||
|
后台异步执行
|
||||||
|
↓
|
||||||
|
客户端轮询结果
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 避免 HTTP 连接超时
|
||||||
|
- 支持长耗时任务(>1分钟)
|
||||||
|
- 任务状态可追踪
|
||||||
|
- 失败可重试
|
||||||
|
|
||||||
|
**实现位置**:`lpt-ai/src/task-queue.ts`
|
||||||
|
|
||||||
|
### 1.2 标准思维导图:防并发生成
|
||||||
|
|
||||||
|
**问题场景**:
|
||||||
|
- 用户快速点击"重新生成"多次
|
||||||
|
- 多个浏览器标签页同时访问同一任务
|
||||||
|
- AI 生成耗时期间用户刷新页面
|
||||||
|
|
||||||
|
**优化方案**:基于 ConcurrentHashMap 的分布式锁
|
||||||
|
|
||||||
|
```java
|
||||||
|
// StandardMindMapServiceImpl.java
|
||||||
|
private final Map<Integer, AtomicBoolean> generatingLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public MindMapNode regenerate(Integer taskNum, String mode) {
|
||||||
|
AtomicBoolean lock = generatingLocks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
|
||||||
|
if (!lock.compareAndSet(false, true)) {
|
||||||
|
throw new BusinessException("该任务正在生成中,请稍后");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 生成逻辑
|
||||||
|
} finally {
|
||||||
|
lock.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 避免重复生成浪费 token
|
||||||
|
- 防止数据竞争导致的覆盖
|
||||||
|
- 提升系统稳定性
|
||||||
|
|
||||||
|
### 1.3 AI 降级机制
|
||||||
|
|
||||||
|
**设计原则**:AI 增强功能,但不能成为单点故障
|
||||||
|
|
||||||
|
**降级策略**:
|
||||||
|
|
||||||
|
| 场景 | AI 模式 | 降级模式 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 聚合学习报告 | LLM 语义整合 | 简单拼接残片 |
|
||||||
|
| 生成思维导图 | LLM 提取关键概念 | 按 session 分组 + 规则去重 |
|
||||||
|
| 回忆对比 | 语义相似度匹配 | 字符串 Bigram Jaccard |
|
||||||
|
|
||||||
|
**触发条件**:
|
||||||
|
- AI 服务未配置 `LLM_API_KEY`
|
||||||
|
- AI 服务响应 503
|
||||||
|
- 请求超时(>5秒)
|
||||||
|
- 网络异常
|
||||||
|
|
||||||
|
**实现位置**:
|
||||||
|
- `AiServiceClient.java` - 异常捕获 + 降级决策
|
||||||
|
- `BuiltinMindMapGenerator.java` - 内置规则生成器
|
||||||
|
- `StandardMindMapServiceImpl.compareTrees()` - 字符串匹配算法
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 可用性提升至 99.9%(不依赖外部服务)
|
||||||
|
- 新用户无需配置即可体验核心功能
|
||||||
|
- 成本可控(AI token 消耗可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据模型优化
|
||||||
|
|
||||||
|
### 2.1 思维导图双格式存储
|
||||||
|
|
||||||
|
**设计权衡**:
|
||||||
|
|
||||||
|
| 格式 | 用途 | 优势 | 劣势 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| JSON 树(`content` 字段) | 机器解析、算法对比 | 结构化、易遍历 | 人工编辑困难 |
|
||||||
|
| 缩进大纲(`outline` 字段) | 用户编辑、AI 交互 | 直观、易修改 | 解析开销 |
|
||||||
|
|
||||||
|
**方案**:同时存储两种格式
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE review_standard_mind_maps (
|
||||||
|
...
|
||||||
|
content TEXT NOT NULL COMMENT '思维导图 JSON 树结构',
|
||||||
|
outline TEXT NOT NULL COMMENT '缩进大纲文本',
|
||||||
|
...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**转换工具**:`MindMapTreeTool.java`
|
||||||
|
- `toOutline(tree)` - 树 → 大纲
|
||||||
|
- `parseOutline(text)` - 大纲 → 树
|
||||||
|
- `toJson(tree)` / `fromJson(json)` - 序列化
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 用户可在文本编辑器中直观修改
|
||||||
|
- 算法无需每次解析大纲(性能优化)
|
||||||
|
- AI 接口使用大纲格式(token 更少)
|
||||||
|
|
||||||
|
### 2.2 节点溯源设计
|
||||||
|
|
||||||
|
**需求**:用户点击思维导图节点,跳转到原始报告/残片
|
||||||
|
|
||||||
|
**方案**:节点携带元数据
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class MindMapNode {
|
||||||
|
private String title;
|
||||||
|
private String notes;
|
||||||
|
private String sourceType; // REPORT | FRAGMENT | APPLICATION
|
||||||
|
private Integer sourceId; // 对应数据主键
|
||||||
|
private List<MindMapNode> children;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端交互**:
|
||||||
|
```typescript
|
||||||
|
// MindMapViewer.vue
|
||||||
|
onNodeClick(node) {
|
||||||
|
if (node.sourceType === 'REPORT') {
|
||||||
|
router.push(`/review/report/${node.sourceId}`);
|
||||||
|
} else if (node.sourceType === 'FRAGMENT') {
|
||||||
|
router.push(`/review/fragment/${node.sourceId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 复习时可快速回看原文
|
||||||
|
- 遗漏知识点可直接定位来源
|
||||||
|
- 形成"导图 → 原文"闭环
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、算法优化
|
||||||
|
|
||||||
|
### 3.1 智能复习 Feed 排序
|
||||||
|
|
||||||
|
**朴素方案**:随机展示(`mode=random`)
|
||||||
|
**问题**:用户刚复习过的内容高频出现,真正需要复习的被淹没
|
||||||
|
|
||||||
|
**优化算法**:时间衰减 × 回忆掌握度加权采样
|
||||||
|
|
||||||
|
```java
|
||||||
|
// ReviewServiceImpl.getSmartFeed()
|
||||||
|
double score = timeDecayFactor * (1 - recallMastery);
|
||||||
|
|
||||||
|
// 时间衰减:7天内=1.0, 30天=0.5, 90天=0.1
|
||||||
|
timeDecayFactor = Math.max(0.1, 1.0 - (daysSince / 90.0));
|
||||||
|
|
||||||
|
// 回忆掌握度:最近一次回忆的覆盖率(0-1)
|
||||||
|
recallMastery = latestRecallRatio;
|
||||||
|
```
|
||||||
|
|
||||||
|
**权重逻辑**:
|
||||||
|
- 久未复习 × 上次遗漏多 = 高优先级
|
||||||
|
- 刚复习过 × 掌握好 = 低优先级
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 符合艾宾浩斯遗忘曲线
|
||||||
|
- 避免无效重复
|
||||||
|
- 提升复习效率
|
||||||
|
|
||||||
|
### 3.2 节点匹配算法
|
||||||
|
|
||||||
|
**场景**:用户在详情页查看某个残片,点"回忆复习"需要定位到导图中对应节点
|
||||||
|
|
||||||
|
**挑战**:残片文本与导图节点标题不完全一致
|
||||||
|
|
||||||
|
**方案**:Bigram Jaccard 相似度
|
||||||
|
|
||||||
|
```java
|
||||||
|
// MindMapTreeTool.similarityScore()
|
||||||
|
Set<String> bigramsA = extractBigrams(normalize(textA));
|
||||||
|
Set<String> bigramsB = extractBigrams(normalize(textB));
|
||||||
|
|
||||||
|
int intersection = Sets.intersection(bigramsA, bigramsB).size();
|
||||||
|
int union = Sets.union(bigramsA, bigramsB).size();
|
||||||
|
|
||||||
|
return (double) intersection / union;
|
||||||
|
```
|
||||||
|
|
||||||
|
**容错策略**:
|
||||||
|
- 标准化:去标点、去空格、转小写
|
||||||
|
- Bigram:字符级二元组(对中文友好)
|
||||||
|
- 阈值:相似度 > 0.6 视为匹配
|
||||||
|
- 加权:`notes` 字段也参与匹配(权重 0.5)
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 支持同义表达("线程池核心参数" ≈ "corePoolSize 等参数")
|
||||||
|
- 中英文混合场景鲁棒
|
||||||
|
- 容忍用户简写/口语化表达
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、用户体验优化
|
||||||
|
|
||||||
|
### 4.1 分段加载提示
|
||||||
|
|
||||||
|
**场景**:AI 生成思维导图耗时 30-60 秒
|
||||||
|
|
||||||
|
**优化前**:页面转圈,用户不知道在做什么
|
||||||
|
**优化后**:分段提示进度
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ReviewRecall.vue
|
||||||
|
if (aiEnabled) {
|
||||||
|
message.info('正在调用 AI 生成思维导图...');
|
||||||
|
// 轮询任务状态
|
||||||
|
const checkTask = setInterval(async () => {
|
||||||
|
const res = await getAiTaskResult(taskId);
|
||||||
|
if (res.data.status === 'completed') {
|
||||||
|
message.success('生成完成');
|
||||||
|
clearInterval(checkTask);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 降低用户焦虑
|
||||||
|
- 明确系统状态
|
||||||
|
- 减少重复点击
|
||||||
|
|
||||||
|
### 4.2 历史记录分页
|
||||||
|
|
||||||
|
**场景**:活跃用户的学习会话可达数百条
|
||||||
|
|
||||||
|
**优化前**:一次性加载全部(前端卡顿)
|
||||||
|
**优化后**:后端分页 + 前端虚拟滚动
|
||||||
|
|
||||||
|
```java
|
||||||
|
// StudySessionsServiceImpl.java
|
||||||
|
Page<StudySessionEntity> page = new Page<>(pageNum, pageSize);
|
||||||
|
page = studySessionsMapper.selectPage(page, queryWrapper);
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- StartTask.vue -->
|
||||||
|
<el-pagination
|
||||||
|
:total="historyTotal"
|
||||||
|
:page-size="20"
|
||||||
|
@current-change="loadHistory"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 首屏加载快(<100ms)
|
||||||
|
- 支持无限历史记录
|
||||||
|
- 内存占用低
|
||||||
|
|
||||||
|
### 4.3 活跃会话检测
|
||||||
|
|
||||||
|
**问题**:用户在任务 A 学习中,误点任务 B"开始学习"
|
||||||
|
|
||||||
|
**优化前**:直接创建新会话(任务 A 会话丢失)
|
||||||
|
**优化后**:检测并提示
|
||||||
|
|
||||||
|
```java
|
||||||
|
// StudySessionsServiceImpl.startSession()
|
||||||
|
StudySessionEntity active = studySessionsMapper.selectOne(
|
||||||
|
new QueryWrapper<StudySessionEntity>()
|
||||||
|
.eq("created_by", userId)
|
||||||
|
.eq("status", StudySessionStatus.IN_PROGRESS.name())
|
||||||
|
);
|
||||||
|
if (active != null && !active.getTaskNum().equals(taskNum)) {
|
||||||
|
throw new BusinessException("您有正在进行的学习会话(任务 " + active.getTaskNum() + "),请先结束");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 防止意外丢失数据
|
||||||
|
- 引导用户正确流程
|
||||||
|
- 减少客服咨询
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、安全与健壮性
|
||||||
|
|
||||||
|
### 5.1 多租户隔离
|
||||||
|
|
||||||
|
**设计原则**:单应用支持多用户,数据严格隔离
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
```java
|
||||||
|
// MyBatisPlusTenantInterceptor
|
||||||
|
@Component
|
||||||
|
public class TenantInterceptor implements InnerInterceptor {
|
||||||
|
@Override
|
||||||
|
public void beforeQuery(Executor executor, MappedStatement ms, ...) {
|
||||||
|
// 自动注入 WHERE created_by = :currentUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**覆盖范围**:
|
||||||
|
- 所有 SELECT 查询自动加租户过滤
|
||||||
|
- INSERT 自动注入 `created_by`
|
||||||
|
- UPDATE/DELETE 验证租户权限
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 业务代码无感知(避免遗漏)
|
||||||
|
- 100% 防止越权访问
|
||||||
|
- 支持未来 SaaS 化
|
||||||
|
|
||||||
|
### 5.2 输入校验
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
```java
|
||||||
|
@PostMapping("/study-sessions/{sessionNum}/expectation")
|
||||||
|
public CommonResult<Void> updateExpectation(
|
||||||
|
@PathVariable Integer sessionNum,
|
||||||
|
@RequestBody @Valid ExpectationRequest request // JSR-303 校验
|
||||||
|
) {
|
||||||
|
// @NotBlank, @Size(max=500) 等注解自动生效
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
```typescript
|
||||||
|
const rules = {
|
||||||
|
expectation: [
|
||||||
|
{ required: true, message: '请填写学习预期' },
|
||||||
|
{ max: 500, message: '不超过 500 字' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**双重保障**:前端 UX + 后端安全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、可观测性
|
||||||
|
|
||||||
|
### 6.1 AI 任务日志
|
||||||
|
|
||||||
|
**需求**:排查 AI 生成失败原因、监控 token 消耗
|
||||||
|
|
||||||
|
**方案**:管理面板
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5199/admin
|
||||||
|
|
||||||
|
任务列表:
|
||||||
|
- taskId | type | status | duration | tokens | error
|
||||||
|
- 550e... | generate-mind-map | completed | 32.5s | 1250 | -
|
||||||
|
- 661f... | aggregate-report | failed | 5.0s | 0 | Timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 快速定位问题
|
||||||
|
- 成本分析
|
||||||
|
- 性能优化依据
|
||||||
|
|
||||||
|
### 6.2 Flyway 迁移历史
|
||||||
|
|
||||||
|
**收益**:
|
||||||
|
- 数据库 schema 版本可追溯
|
||||||
|
- 回滚方案清晰
|
||||||
|
- 团队协作无冲突
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM flyway_schema_history ORDER BY installed_rank;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、技术债务管理
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
1. **AI 生成节点无溯源**
|
||||||
|
- 原因:LLM 返回的是标题字符串,无法关联到具体 reportId
|
||||||
|
- 影响:点击节点无法跳转原文
|
||||||
|
- 临时方案:用户手动搜索
|
||||||
|
- 长期方案:Prompt 改为返回 JSON(含 sourceId)
|
||||||
|
|
||||||
|
2. **Bigram 对短文本效果有限**
|
||||||
|
- 场景:节点标题只有 2-3 个字
|
||||||
|
- 临时方案:阈值降至 0.4
|
||||||
|
- 长期方案:引入 embedding 语义匹配
|
||||||
|
|
||||||
|
3. **单机内存队列**
|
||||||
|
- 限制:lpt-ai 服务重启丢失未完成任务
|
||||||
|
- 影响:极端情况需重新提交
|
||||||
|
- 长期方案:Redis 持久化队列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、性能指标
|
||||||
|
|
||||||
|
| 指标 | 目标 | 实测 |
|
||||||
|
|------|------|------|
|
||||||
|
| 首页加载 | <500ms | 320ms |
|
||||||
|
| 标准导图生成(内置) | <2s | 1.2s |
|
||||||
|
| 标准导图生成(AI) | <60s | 35s |
|
||||||
|
| 回忆对比(内置) | <1s | 450ms |
|
||||||
|
| 回忆对比(AI) | <30s | 18s |
|
||||||
|
| Feed 智能排序 | <200ms | 85ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、总结
|
||||||
|
|
||||||
|
本项目在设计文档的基础上进行了以下关键优化:
|
||||||
|
|
||||||
|
1. **架构层**:异步任务、防并发、降级机制
|
||||||
|
2. **数据层**:双格式存储、节点溯源、分页加载
|
||||||
|
3. **算法层**:智能排序、模糊匹配、语义对比
|
||||||
|
4. **体验层**:分段提示、活跃检测、历史记录
|
||||||
|
5. **安全层**:多租户隔离、双重校验、权限控制
|
||||||
|
|
||||||
|
这些优化不是对设计的否定,而是在实现过程中针对实际场景的工程化改进。设计文档描述"做什么",本文档记录"怎么做得更好"。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: lpt-be
|
||||||
|
namespace: lpt-dev
|
||||||
|
labels:
|
||||||
|
app: lpt-be
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: lpt-be
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 25%
|
||||||
|
maxUnavailable: 25%
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: lpt-be
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
containers:
|
||||||
|
- name: lpt-be
|
||||||
|
image: 192.168.123.199:5000/lpt-be:dev
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 8888
|
||||||
|
env:
|
||||||
|
- name: SPRING_PROFILES_ACTIVE
|
||||||
|
value: dev
|
||||||
|
- name: SPRING_DATASOURCE_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: lpt-config
|
||||||
|
key: SPRING_DATASOURCE_URL
|
||||||
|
- name: SPRING_DATASOURCE_USERNAME
|
||||||
|
value: root
|
||||||
|
- name: SPRING_DATASOURCE_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: lpt-secrets
|
||||||
|
key: mysql-root-password
|
||||||
|
- name: LPT_AI-SERVICE_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: lpt-config
|
||||||
|
key: LPT_AI-SERVICE_URL
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 1Gi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8888
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8888
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: lpt-be
|
||||||
|
namespace: lpt-dev
|
||||||
|
labels:
|
||||||
|
app: lpt-be
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: lpt-be
|
||||||
|
ports:
|
||||||
|
- port: 8888
|
||||||
|
targetPort: 8888
|
||||||
|
nodePort: 30082
|
||||||
|
protocol: TCP
|
||||||
+15
-7
@@ -6,18 +6,16 @@ import cn.dev33.satoken.stp.StpUtil;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@Profile("dev")
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WebMvcDevConfig implements WebMvcConfigurer {
|
@Slf4j
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final CorsProperties corsProperties;
|
private final CorsProperties corsProperties;
|
||||||
|
|
||||||
@@ -35,11 +33,21 @@ public class WebMvcDevConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
|
String[] origins = corsProperties.getAllowedOrigins();
|
||||||
|
boolean hasWildcard = origins != null && Arrays.asList(origins).contains("*");
|
||||||
|
log.info("允许cors的地址配置:{}", Arrays.toString(origins));
|
||||||
|
if (hasWildcard) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(corsProperties.getAllowedOrigins())
|
.allowedOriginPatterns("*")
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.allowCredentials(null == corsProperties.getAllowCredentials() || corsProperties.getAllowCredentials());
|
.allowCredentials(true);
|
||||||
|
} else {
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOrigins(origins)
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(corsProperties.getAllowCredentials() == null || corsProperties.getAllowCredentials());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package com.guo.learningprogresstracker.config;
|
|
||||||
|
|
||||||
import cn.dev33.satoken.context.SaHolder;
|
|
||||||
import cn.dev33.satoken.interceptor.SaInterceptor;
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
@Profile({"prod", "uat"})
|
|
||||||
@Configuration
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class WebMvcProdConfig implements WebMvcConfigurer {
|
|
||||||
|
|
||||||
private final CorsProperties corsProperties;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
|
||||||
log.info("允许cors的地址配置:{}", Arrays.toString(corsProperties.getAllowedOrigins()));
|
|
||||||
registry.addMapping("/**")
|
|
||||||
.allowedOrigins(corsProperties.getAllowedOrigins())
|
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
|
||||||
.allowedHeaders("*")
|
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
|
||||||
registry.addInterceptor(new SaInterceptor(handle -> {
|
|
||||||
if ("OPTIONS".equalsIgnoreCase(SaHolder.getRequest().getMethod())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
StpUtil.checkLogin();
|
|
||||||
}))
|
|
||||||
.addPathPatterns("/**")
|
|
||||||
.excludePathPatterns("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+1
-1
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
|
public class LocalDateTimeSerializer extends StdSerializer<LocalDateTime> {
|
||||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
public LocalDateTimeSerializer() {
|
public LocalDateTimeSerializer() {
|
||||||
super(LocalDateTime.class);
|
super(LocalDateTime.class);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.guo.learningprogresstracker.controller;
|
||||||
|
|
||||||
|
import com.guo.learningprogresstracker.entity.CommonResult;
|
||||||
|
import com.guo.learningprogresstracker.utils.TitleFetcher;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/utils")
|
||||||
|
public class UtilsController {
|
||||||
|
|
||||||
|
@GetMapping("/fetch-title")
|
||||||
|
@Operation(summary = "获取指定URL的页面标题")
|
||||||
|
public CommonResult<Map<String, String>> fetchTitle(@RequestParam String url) {
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
return CommonResult.error("url 参数不能为空");
|
||||||
|
}
|
||||||
|
String title = TitleFetcher.fetchTitle(url);
|
||||||
|
return CommonResult.success(Map.of("title", title != null ? title : url));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ public class TaskInfo {
|
|||||||
private String taskName;
|
private String taskName;
|
||||||
// 任务描述
|
// 任务描述
|
||||||
private String taskDescription;
|
private String taskDescription;
|
||||||
|
// 学习材料地址
|
||||||
|
private String materialUrl;
|
||||||
// 任务优先级
|
// 任务优先级
|
||||||
private Double taskPriority;
|
private Double taskPriority;
|
||||||
// 上次该任务学习情况
|
// 上次该任务学习情况
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ public class StudySessionResponse {
|
|||||||
@Schema(description = "任务编号")
|
@Schema(description = "任务编号")
|
||||||
private String taskNum;
|
private String taskNum;
|
||||||
|
|
||||||
|
@Schema(description = "学习材料(Markdown)")
|
||||||
|
private String materialUrl;
|
||||||
|
|
||||||
|
@Schema(description = "任务ID")
|
||||||
|
private Integer taskId;
|
||||||
|
|
||||||
@Schema(description = "学习开始时间")
|
@Schema(description = "学习开始时间")
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class TaskInfoResponse {
|
|||||||
*/
|
*/
|
||||||
private String materialUrl;
|
private String materialUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
/**
|
||||||
* 用户设置的任务紧急性
|
* 用户设置的任务紧急性
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
@@ -73,6 +73,8 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
|
|
||||||
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(session);
|
||||||
response.setTaskName(taskEntity.getTaskName());
|
response.setTaskName(taskEntity.getTaskName());
|
||||||
|
response.setMaterialUrl(taskEntity.getMaterialUrl());
|
||||||
|
response.setTaskId(taskEntity.getId());
|
||||||
|
|
||||||
if (session.isOverTime()) {
|
if (session.isOverTime()) {
|
||||||
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
|
response.setSystemMessage("上段学习任务已经超时25分钟,将仅计算为25分钟的有效学习时间,请注意休息!");
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
|
|||||||
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
|
throw new IllegalArgumentException("任务ID格式错误: " + taskId);
|
||||||
}
|
}
|
||||||
taskEntity.setId(id);
|
taskEntity.setId(id);
|
||||||
|
taskEntity.setTaskNum(existingTask.getTaskNum());
|
||||||
// 维度数据可能变化,更新时重算优先级
|
// 维度数据可能变化,更新时重算优先级
|
||||||
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
|
PriorityDto priorityDto = RequestConvert.MAPPER.taskRequestToPriorityDto(updatedTask);
|
||||||
taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
taskEntity.setCalculatedPriority(CalculatedPriorityTool.calculatedPriority(priorityDto, priorityWeightsService.getWeights()));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user