diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..e042d515b --- /dev/null +++ b/.env.example @@ -0,0 +1,118 @@ +# === Core Service Ports === +SERVER_PORT=8080 +FRONTEND_PORT=3000 +WEBSOCKET_PORT=8082 +MYSQL_PORT=3306 +REDIS_PORT=6379 +RABBITMQ_PORT=5672 +RABBITMQ_MANAGEMENT_PORT=15672 + +# === OpenSearch Configuration === +OPENSEARCH_PORT=9200 +OPENSEARCH_METRICS_PORT=9600 +OPENSEARCH_DASHBOARDS_PORT=5601 +OPENSEARCH_ENABLED=true +OPENSEARCH_SCHEME=http +OPENSEARCH_USERNAME= +OPENSEARCH_PASSWORD= +OPENSEARCH_HOST=opensearch + +# === Database Configuration === +MYSQL_DATABASE=openisle +MYSQL_ROOT_PASSWORD=openisle +MYSQL_USER=openisle +MYSQL_PASSWORD=openisle +MYSQL_HOST=mysql + +# === Redis Configuration === +REDIS_HOST=redis +REDIS_DATABASE=0 + +# === RabbitMQ Configuration === +RABBITMQ_HOST=rabbitmq +RABBITMQ_USERNAME=nagisa +RABBITMQ_PASSWORD=nagisa + +# === Backend Application Secrets === +JWT_SECRET=change-me-jwt-secret +JWT_REASON_SECRET=change-me-jwt-reason-secret +JWT_RESET_SECRET=change-me-jwt-reset-secret +JWT_INVITE_SECRET=change-me-jwt-invite-secret +JWT_EXPIRATION=2592000000 +PASSWORD_STRENGTH=LOW +POST_PUBLISH_MODE=DIRECT +REGISTER_MODE=WHITELIST +UPLOAD_CHECK_TYPE=true +UPLOAD_MAX_SIZE=5242880 +AVATAR_STYLE=pixel-art-neutral +AVATAR_SIZE=128 +AVATAR_BASE_URL=https://api.dicebear.com/6.x +USER_POSTS_LIMIT=10 +USER_REPLIES_LIMIT=50 +SNIPPET_LENGTH=200 +SEARCH_INDEX_PREFIX=openisle +SEARCH_HIGHLIGHT_FRAGMENT_SIZE=200 +SEARCH_REINDEX_ON_STARTUP=true +SEARCH_REINDEX_BATCH_SIZE=500 +CAPTCHA_ENABLED=false +RECAPTCHA_SECRET_KEY= +CAPTCHA_REGISTER_ENABLED=false +CAPTCHA_LOGIN_ENABLED=false +CAPTCHA_POST_ENABLED=false +CAPTCHA_COMMENT_ENABLED=false +RESEND_API_KEY= +RESEND_FROM_EMAIL= +COS_BASE_URL=https://<你的cos>.cos.accelerate.myqcloud.com +COS_SECRET_ID= +COS_SECRET_KEY= +COS_REGION=ap-guangzhou +COS_BUCKET_NAME= +GITHUB_CLIENT_SECRET= +DISCORD_CLIENT_SECRET= +TWITTER_CLIENT_SECRET= +TELEGRAM_BOT_TOKEN= +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o +AI_FORMAT_LIMIT=3 +WEBSITE_URL=http://localhost:3000 +WEBPUSH_PUBLIC_KEY= +WEBPUSH_PRIVATE_KEY= +LOG_LEVEL=INFO + +# === Frontend (Nuxt) === +# 本地开发 +NUXT_PUBLIC_API_BASE_URL=http://localhost:8080 +# 线上环境 +# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +# 测试环境 +# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com + +# 本地开发 +NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082 +# 线上环境 +# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket +# 测试环境 +# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com/websocket + +# 本地开发 +NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000 +# 线上 & 测试 (www.staging.open-isle.com) & 本地均可使用 +NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com +# 线上 +NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ +# 测试环境 (www.staging.open-isle.com) +# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li6GHPxx4MwipWnM +# 本地 +# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN + +# 线上 & 本地均可使用 +NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 + +# 线上 & 本地均可使用 +NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ + +# 线上 +NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135 +# 测试环境 (www.staging.open-isle.com) +# NUXT_PUBLIC_TELEGRAM_BOT_ID=7832207011 + diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 2f1dac164..a60f63b00 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -11,12 +11,17 @@ on: permissions: contents: write +# 文档发布自己的排队锁,不影响服务器部署 +concurrency: + group: openisle-docs + cancel-in-progress: false + jobs: build-docs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 1 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index dde70459b..e4d92a7f8 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -2,28 +2,33 @@ name: Staging CI & CD on: push: - branches: [main] + branches: [ "main" ] workflow_dispatch: permissions: contents: write +# 与生产部署共用同一把锁,确保服务器上始终串行(跨工作流也互斥) +concurrency: + group: openisle-server + cancel-in-progress: false + jobs: build-and-deploy: runs-on: ubuntu-latest environment: Deploy - if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行 + if: ${{ !github.event.repository.fork }} steps: - uses: actions/checkout@v4 - - name: Deploy to Server + - name: Deploy to Server (staging) uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SSH_HOST }} username: root key: ${{ secrets.SSH_KEY }} - script: bash /opt/openisle/deploy-staging.sh + script: bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh deploy-docs: needs: build-and-deploy diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 28c013027..dca1c942e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,8 +2,13 @@ name: CI & CD on: workflow_dispatch: - # schedule: - # - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点 + schedule: + - cron: "0 19 * * *" # 每天 UTC 19:00(北京 03:00) + +# 与 Staging 共用同一把锁,避免两边同时在 8G 服务器上跑 +concurrency: + group: openisle-server + cancel-in-progress: false jobs: build-and-deploy: @@ -13,10 +18,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Deploy to Server + - name: Deploy to Server (prod) uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SSH_HOST }} username: root key: ${{ secrets.SSH_KEY }} - script: bash /opt/openisle/deploy.sh + script: bash /opt/openisle/OpenIsle/deploy/deploy.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe16759fa..c0bf059c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,19 @@ - [前置工作](#前置工作) +- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境) - [启动后端服务](#启动后端服务) - [本地 IDEA](#本地-idea) - [配置环境变量](#配置环境变量) - [配置 IDEA 参数](#配置-idea-参数) - - [配置 MySQL](#配置-mysql) - - [配置 Redis](#配置-redis) - - [配置 RabbitMQ](#配置-rabbitmq) - - [Docker 环境](#docker-环境) - - [配置环境变量](#配置环境变量-1) - - [构建并启动镜像](#构建并启动镜像) - [启动前端服务](#启动前端服务) - - [配置环境变量](#配置环境变量-2) - - [安装依赖和运行](#安装依赖和运行) + - [连接预发或正式环境](#连接预发或正式环境) - [其他配置](#其他配置) - - [配置第三方登录以GitHub为例](#配置第三方登录以GitHub为例) - - [配置Resend邮箱服务](#配置Resend邮箱服务) + - [配置第三方登录以GitHub为例](#配置第三方登录以github为例) + - [配置Resend邮箱服务](#配置resend邮箱服务) - [API文档](#api文档) - [OpenAPI文档](#openapi文档) - [部署时间线以及文档时效性](#部署时间线以及文档时效性) - - [OpenAPI文档使用](#OpenAPI文档使用) - - [OpenAPI文档应用场景](#OpenAPI文档应用场景) + - [OpenAPI文档使用](#openapi文档使用) + - [OpenAPI文档应用场景](#openapi文档应用场景) ## 前置工作 @@ -35,6 +29,58 @@ cd OpenIsle - 前端开发环境 - Node.JS 20+ +## 前端极速调试(Docker 全量环境) + +想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server,适合需要全链路联调的场景。 + +1. 准备环境变量文件: + ```shell + cp .env.example .env + ``` + `.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。 +2. 启动 Dev Profile: + ```shell + docker compose \ + -f docker/docker-compose.yaml \ + --env-file .env \ + --profile dev build + ``` + + ```shell + docker compose \ + -f docker/docker-compose.yaml \ + --env-file .env \ + --profile dev up -d + ``` + 该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。 + 修改前端代码,页面会热更新。 + 如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA,采用IDEA编译运行也可以哦。 + + ```shell + docker compose \ + -f docker/docker-compose.yaml \ + --env-file .env \ + --profile dev up -d --force-recreate + ``` + +3. 查看服务状态: + ```shell + docker compose -f docker/docker-compose.yaml --env-file .env ps + docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev + ``` +4. 停止所有容器: + ```shell + docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down + ``` + +5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行: + ```shell + docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v + ``` + `-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。 + +如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。 + ## 启动后端服务 启动后端服务有多种方式,选择一种即可。 @@ -52,37 +98,26 @@ IDEA 打开 `backend/` 文件夹。 #### 配置环境变量 -1. 生成环境变量文件 - +1. 生成环境变量文件: ```shell cp open-isle.env.example open-isle.env ``` + `open-isle.env` 才是实际被读取的文件。可在其中补充数据库、第三方服务等配置,`open-isle.env` 已被 Git 忽略,放心修改。 +2. 在 IDEA 中配置「Environment file」:将 `Run/Debug Configuration` 的 `Environment variables` 指向刚刚复制的 `open-isle.env`,即可让 IDE 读取该文件。 +3. 需要调整端口或功能开关时,优先修改 `open-isle.env`,例如: + ```ini + SERVER_PORT=8081 + LOG_LEVEL=DEBUG + ``` - `open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容 - -2. 修改环境变量,留下需要的,比如你要开发 Google 登录业务,就需要谷歌相关的变量,数据库是一定要的 - - ![环境变量](assets/contributing/backend_img_7.png) - -3. 应用环境文件,选择刚刚的 `open-isle.env` - -可以在 `open-isle.env` 按需填写个性化的配置,该文件不会被 Git 追踪。比如你想把服务跑在 `8082`(默认为 `8080`),那么直接改 `open-isle.env` 即可: - -```ini -SERVER_PORT=8082 -``` - -另一种方式是修改 `.properities` 文件(但不建议),位于 `src/main/application.properties`,该配置同样来源于 `open-isle.env`,但修改 `.properties` 文件会被 Git 追踪。 +也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。 ![配置数据库](assets/contributing/backend_img_5.png) #### 配置 IDEA 参数 -- 设置 JDK 版本为 java 17 - -- 设置 VM Option,最好运行在其他端口,非 `8080`,这里设置 `8081` - 若上面在环境变量中设置了端口,那这里就不需要再额外设置 - +- 设置 JDK 版本为 Java 17。 +- 设置 VM Option,最好运行在其他端口(例如 `8081`)。若已经在 `open-isle.env` 中调整端口,可省略此步骤。 ```shell -Dserver.port=8081 ``` @@ -91,191 +126,22 @@ SERVER_PORT=8082 ![配置2](assets/contributing/backend_img_2.png) -#### 配置 MySQL - -> [!TIP] -> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节 - -1. 本机配置 MySQL 服务(网上很多教程,忽略) - - 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence - - [下载地址](https://github.com/leokhoa/laragon/releases) - -2. 填写环境变量 - - ![环境变量](assets/contributing/backend_img_6.png) - - ```ini - MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC - MYSQL_USER=<数据库用户名> - MYSQL_PASSWORD=<数据库密码> - ``` - -3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据 - 管理员:**admin/123456** - 普通用户1:**user1/123456** - 普通用户2:**user2/123456** - - ![初始化脚本](assets/contributing/resources_img.png) - -#### 配置 Redis - -后端的登录态缓存、访问频控等都依赖 Redis,请确保本地有可用的 Redis 实例。 - -1. **启动 Redis 服务**(已有服务可跳过) - - ```bash - docker run --name openisle-redis -p 6379:6379 -d redis:7-alpine - ``` - - 该命令会在本机暴露 `6379` 端口。若你已有其他端口的 Redis,可以根据实际情况调整映射关系。 - -2. **在 `backend/open-isle.env` 中填写连接信息** - - ```ini - REDIS_HOST=127.0.0.1 - REDIS_PORT=6379 - # 可选:若需要切换逻辑库,可新增此变量,默认使用 0 号库 - REDIS_DATABASE=0 - ``` - - `application.properties` 中的默认值为 `localhost:6379`、数据库 `0`,如果你的环境恰好一致,也可以不额外填写;显式声明可以避免 IDE/运行时读取到意外配置。 - -3. **验证连接** - - ```bash - redis-cli -h 127.0.0.1 -p 6379 ping - ``` - - 启动后端后,日志中会出现 `Redis connection established ...`(来自 `RedisConnectionLogger`),说明已成功连通。 - -#### 配置 RabbitMQ - -消息通知和 WebSocket 推送链路依赖 RabbitMQ。后端会自动声明交换机与队列,确保本地 RabbitMQ 可用即可。 - -1. **启动 RabbitMQ 服务**(推荐包含管理界面) - - ```bash - docker run --name openisle-rabbitmq \ - -e RABBITMQ_DEFAULT_USER=openisle \ - -e RABBITMQ_DEFAULT_PASS=openisle \ - -p 5672:5672 -p 15672:15672 \ - -d rabbitmq:3.13-management - ``` - - 管理界面位于 http://127.0.0.1:15672 ,可用于查看队列、交换机等资源。 - -2. **同步填写后端与 WebSocket 服务的环境变量** - - ```ini - # backend/open-isle.env - RABBITMQ_HOST=127.0.0.1 - RABBITMQ_PORT=5672 - RABBITMQ_USERNAME=openisle - RABBITMQ_PASSWORD=openisle - - # 如果需要启动 websocket_service,也需要在 websocket_service.env 中保持一致 - ``` - - 如果沿用 RabbitMQ 默认的 `guest/guest`,可以不显式设置,Spring Boot 会回退到 `application.properties` 中的默认值 (`localhost:5672`、`guest/guest`、虚拟主机 `/`)。 - -3. **确认自动声明的资源** - - - 交换机:`openisle-exchange` - - 旧版兼容队列:`notifications-queue` - - 分片队列:`notifications-queue-0` ~ `notifications-queue-f`(共 16 个,对应路由键 `notifications.shard.0` ~ `notifications.shard.f`) - - 队列持久化默认开启,来自 `rabbitmq.queue.durable=true`,如需仅在本地短暂测试,可在 `application.properties` 中调整该配置。 - - 启动后端时可在日志中看到 `=== 开始主动声明 RabbitMQ 组件 ===` 与后续的声明结果,也可以在管理界面中查看是否创建成功。 - -完成 Redis 与 RabbitMQ 配置后,即可继续启动后端服务。 +完成环境变量和运行参数设置后,即可启动 Spring Boot 应用。 ![运行画面](assets/contributing/backend_img_4.png) -### Docker 环境 +## 前端连接预发或正式环境 -#### 配置环境变量 +前端默认读取 `.env` 中的接口地址,可通过修改以下变量快速切换到预发或正式环境: -```shell -cd docker/ -``` +1. 按需覆盖关键变量: -主要配置两个 `.env` 文件 + ```ini + NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com + NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com + ``` + 将 `staging` 替换为 `www` 即可连接正式环境。其他变量(如 OAuth Client ID、站点地址等)可根据需求调整。 -- `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。 -- `docker/.env`:Docker Compose 环境变量,主要配置 MySQL 相关 - ```shell - cp .env.example .env - ``` - -> [!TIP] -> 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。 - -在指定 `docker/.env` 后,`backend/open-isle.env` 中以下配置会被覆盖,这样就确保使用了同一份配置。 - -```ini -MYSQL_URL= -MYSQL_USER= -MYSQL_PASSWORD= -``` - -#### 构建并启动镜像 - -```shell -docker compose up -d -``` - -如果想了解启动过程发生了什么可以查看日志 - -```shell -docker compose logs -``` - -## 启动前端服务 - -> [!IMPORTANT] -> **⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)** - -```shell -cd frontend_nuxt/ -``` - -### 配置环境变量 - -前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。 - -- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)** - - ```shell - cp .env.staging.example .env - ``` - -- 利用生产环境 - - ```shell - cp .env.production.example .env - ``` - -- 利用本地环境 - - ```shell - cp .env.dev.example .env - ``` - -若依赖本机部署的后端,需要修改 `.env` 中的 `NUXT_PUBLIC_API_BASE_URL` 值与后端服务端口一致 - -### 安装依赖和运行 - -前端安装依赖并启动服务。 - -```shell -# 安装依赖 -npm install --verbose - -# 运行前端服务 -npm run dev -``` - -如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面。 ## 其他配置 @@ -334,7 +200,7 @@ https://docs.open-isle.com ### OpenAPI文档使用 -- 预发环境/正式环境切换,可以通过如下位置切换API环境 +- 预发环境/正式环境切换,以通过如下位置切换API环境 ![CleanShot 2025-09-10 at 12 .08.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f9fb7a0f020d4a0e94159d7820783224.png) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..bfeab3158 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,176 @@ +# Security Policy + +## Supported Versions + +We take the security of OpenIsle seriously. The following versions are currently being supported with security updates: + +| Version | Supported | +| ------- | ------------------ | +| 0.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions. + +### How to Report a Security Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via one of the following methods: + +1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information) +2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new + +### What to Include in Your Report + +To help us better understand the nature and scope of the issue, please include as much of the following information as possible: + +- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit it + +### Response Timeline + +- **Initial Response**: We will acknowledge your report within 48 hours +- **Status Updates**: We will provide status updates at least every 5 business days +- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure + +### What to Expect + +After you submit a report: + +1. We will confirm receipt of your vulnerability report and may ask for additional information +2. We will investigate the issue and determine its impact and severity +3. We will work on a fix and coordinate disclosure timing with you +4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous) + +## Security Considerations for Deployment + +### Authentication & Authorization + +- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits) +- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control +- **Session Management**: Configure appropriate session timeout values + +### Database Security + +- Use strong database passwords +- Never expose database ports publicly +- Use database connection encryption when available +- Regularly backup your database + +### API Security + +- Enable rate limiting to prevent abuse +- Validate all user inputs on both client and server side +- Use HTTPS in production environments +- Configure CORS properly to restrict origins + +### Environment Variables + +The following sensitive environment variables should be kept secure: + +- `JWT_SECRET` - JWT signing key +- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials +- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials +- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials +- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials +- `WEBPUSH_PRIVATE_KEY` - Web push notification private key +- Database connection strings and credentials +- Cloud storage credentials (Tencent COS) + +**Never commit these values to version control or expose them in logs.** + +### File Upload Security + +- Validate file types and sizes +- Scan uploaded files for malware +- Store uploaded files outside the web root +- Use cloud storage with proper access controls + +### Password Security + +- Configure password strength requirements via environment variables +- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security) +- Implement account lockout after failed login attempts + +### Web Push Notifications + +- Keep `WEBPUSH_PRIVATE_KEY` secret and secure +- Only send notifications to users who have explicitly opted in +- Validate notification payloads + +### Dependency Management + +- Regularly update dependencies to patch known vulnerabilities +- Run `mvn dependency-check:check` to scan for vulnerable dependencies +- Monitor GitHub security advisories for this project + +### Production Deployment Checklist + +- [ ] Use HTTPS/TLS for all connections +- [ ] Set strong, unique secrets for all environment variables +- [ ] Enable CSRF protection +- [ ] Configure secure headers (CSP, X-Frame-Options, etc.) +- [ ] Disable debug mode and verbose error messages +- [ ] Set up proper logging and monitoring +- [ ] Implement rate limiting and DDoS protection +- [ ] Regular security updates and patches +- [ ] Database backups and disaster recovery plan +- [ ] Restrict admin access to trusted IPs when possible + +## Known Security Features + +OpenIsle includes the following security features: + +- JWT-based authentication with configurable expiration +- OAuth 2.0 integration with major providers +- Password strength validation +- Protection codes for sensitive operations +- Input validation and sanitization +- SQL injection prevention through ORM (JPA/Hibernate) +- XSS protection in Vue.js templates +- CSRF protection (Spring Security) + +## Security Best Practices for Contributors + +- Never commit credentials, API keys, or secrets +- Follow secure coding practices (OWASP Top 10) +- Validate and sanitize all user inputs +- Use parameterized queries for database operations +- Implement proper error handling without exposing sensitive information +- Write security tests for new features +- Review code for security issues before submitting PRs + +## Disclosure Policy + +When we receive a security bug report, we will: + +1. Confirm the problem and determine affected versions +2. Audit code to find any similar problems +3. Prepare fixes for all supported versions +4. Release patches as soon as possible + +We appreciate your help in keeping OpenIsle and its users safe! + +## Attribution + +We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in: + +- Security advisory +- Release notes +- A security hall of fame (if established) + +If you prefer to remain anonymous, we will respect your wishes. + +## Contact + +For any security-related questions or concerns, please reach out through the channels mentioned above. + +--- + +Thank you for helping keep OpenIsle secure! diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index a9b0ccc8b..aad682124 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -1,3 +1,6 @@ +# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。 +# 此文件保留作参考用途,如需在 Docker 之外手动配置,可按需复制。 + # === Spring Boot === SERVER_PORT=8080 diff --git a/backend/pom.xml b/backend/pom.xml index a0f1c9b7e..1908a5c1b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -132,6 +132,10 @@ springdoc-openapi-starter-webmvc-api 2.2.0 + + org.springframework.boot + spring-boot-starter-actuator + org.opensearch.client diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 4bd8e735f..6a5dc8fb2 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -97,6 +97,8 @@ public class SecurityConfig { "http://localhost:8081", "http://localhost:8082", "http://localhost:3000", + "http://frontend_dev:3000", + "http://frontend_service:3000", "http://localhost:3001", "http://localhost", "http://30.211.97.238:3000", @@ -177,6 +179,8 @@ public class SecurityConfig { .permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods") .permitAll() + .requestMatchers("/actuator/**") + .permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**") .hasAuthority("ADMIN") .requestMatchers(HttpMethod.POST, "/api/tags/**") @@ -230,6 +234,7 @@ public class SecurityConfig { uri.startsWith("/api/channels") || uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || + uri.startsWith("/actuator") || uri.startsWith("/api/rss")); if (authHeader != null && authHeader.startsWith("Bearer ")) { diff --git a/backend/src/main/java/com/openisle/controller/CommentController.java b/backend/src/main/java/com/openisle/controller/CommentController.java index d8fbe17ea..5de15888d 100644 --- a/backend/src/main/java/com/openisle/controller/CommentController.java +++ b/backend/src/main/java/com/openisle/controller/CommentController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -131,6 +132,7 @@ public class CommentController { c.getId(), "comment", c.getCreatedAt(), + c.getPinnedAt(), c // payload 是 CommentDto ) ) @@ -145,17 +147,39 @@ public class CommentController { l.getId(), "log", l.getTime(), // 注意字段名不一样 + null, l // payload 是 PostChangeLogDto ) ) .toList() ); // 排序 - Comparator> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt); + Comparator> pinnedOrderComparator = (a, b) -> { + LocalDateTime aPinned = a.getPinnedAt(); + LocalDateTime bPinned = b.getPinnedAt(); + if (aPinned == null && bPinned == null) { + return 0; + } + if (aPinned == null) { + return 1; + } + if (bPinned == null) { + return -1; + } + return bPinned.compareTo(aPinned); + }; + + Comparator> comparator = Comparator., Boolean>comparing( + item -> item.getPinnedAt() == null + ).thenComparing(pinnedOrderComparator); + + Comparator> createdAtComparator = Comparator.comparing( + TimelineItemDto::getCreatedAt + ); if (CommentSort.NEWEST.equals(sort)) { - comparator = comparator.reversed(); + createdAtComparator = createdAtComparator.reversed(); } - itemDtoList.sort(comparator); + itemDtoList.sort(comparator.thenComparing(createdAtComparator)); log.debug("listComments returning {} comments", itemDtoList.size()); return itemDtoList; } diff --git a/backend/src/main/java/com/openisle/controller/PostDonationController.java b/backend/src/main/java/com/openisle/controller/PostDonationController.java new file mode 100644 index 000000000..b74239416 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/PostDonationController.java @@ -0,0 +1,43 @@ +package com.openisle.controller; + +import com.openisle.dto.DonationRequest; +import com.openisle.dto.DonationResponse; +import com.openisle.service.PointService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/posts/{postId}/donations") +@RequiredArgsConstructor +public class PostDonationController { + + private final PointService pointService; + + @GetMapping + @Operation(summary = "List donations", description = "Get recent donations for a post") + @ApiResponse(responseCode = "200", description = "Donation summary") + public DonationResponse list(@PathVariable Long postId) { + return pointService.getPostDonations(postId); + } + + @PostMapping + @SecurityRequirement(name = "JWT") + @Operation(summary = "Donate", description = "Donate points to the post author") + @ApiResponse(responseCode = "200", description = "Donation result") + public DonationResponse donate( + @PathVariable Long postId, + @RequestBody DonationRequest req, + Authentication auth + ) { + return pointService.donateToPost(auth.getName(), postId, req.getAmount()); + } +} diff --git a/backend/src/main/java/com/openisle/dto/DonationDto.java b/backend/src/main/java/com/openisle/dto/DonationDto.java new file mode 100644 index 000000000..460cec56b --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/DonationDto.java @@ -0,0 +1,16 @@ +package com.openisle.dto; + +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DonationDto { + + private Long userId; + private String username; + private String avatar; + private int amount; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/openisle/dto/DonationRequest.java b/backend/src/main/java/com/openisle/dto/DonationRequest.java new file mode 100644 index 000000000..14421e1e5 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/DonationRequest.java @@ -0,0 +1,11 @@ +package com.openisle.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DonationRequest { + + private int amount; +} diff --git a/backend/src/main/java/com/openisle/dto/DonationResponse.java b/backend/src/main/java/com/openisle/dto/DonationResponse.java new file mode 100644 index 000000000..1a83be807 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/DonationResponse.java @@ -0,0 +1,15 @@ +package com.openisle.dto; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DonationResponse { + + private int totalAmount; + private List donations = new ArrayList<>(); + private Integer balance; +} diff --git a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java index ce5d55201..296287f0a 100644 --- a/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java +++ b/backend/src/main/java/com/openisle/dto/PostChangeLogDto.java @@ -29,4 +29,5 @@ public class PostChangeLogDto { private LocalDateTime newPinnedAt; private Boolean oldFeatured; private Boolean newFeatured; + private Integer amount; } diff --git a/backend/src/main/java/com/openisle/dto/TimelineItemDto.java b/backend/src/main/java/com/openisle/dto/TimelineItemDto.java index d492e3181..52daaf5fa 100644 --- a/backend/src/main/java/com/openisle/dto/TimelineItemDto.java +++ b/backend/src/main/java/com/openisle/dto/TimelineItemDto.java @@ -15,5 +15,6 @@ public class TimelineItemDto { private Long id; private String kind; // "comment" | "log" private LocalDateTime createdAt; + private LocalDateTime pinnedAt; private T payload; // 泛型,具体类型由外部决定 } diff --git a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java index 52611c5d7..cb0d1a9f1 100644 --- a/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostChangeLogMapper.java @@ -52,6 +52,8 @@ public class PostChangeLogMapper { } else if (log instanceof PostFeaturedChangeLog f) { dto.setOldFeatured(f.isOldFeatured()); dto.setNewFeatured(f.isNewFeatured()); + } else if (log instanceof PostDonateChangeLog d) { + dto.setAmount(d.getAmount()); } return dto; } diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index 0e96cfc20..74262c9b2 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -48,6 +48,8 @@ public enum NotificationType { POLL_RESULT_PARTICIPANT, /** Your post was featured */ POST_FEATURED, + /** Someone donated to your post */ + DONATION, /** You were mentioned in a post or comment */ MENTION, } diff --git a/backend/src/main/java/com/openisle/model/PointHistoryType.java b/backend/src/main/java/com/openisle/model/PointHistoryType.java index 689f73c9c..776827f0a 100644 --- a/backend/src/main/java/com/openisle/model/PointHistoryType.java +++ b/backend/src/main/java/com/openisle/model/PointHistoryType.java @@ -13,4 +13,6 @@ public enum PointHistoryType { REDEEM, LOTTERY_JOIN, LOTTERY_REWARD, + DONATE_SENT, + DONATE_RECEIVED, } diff --git a/backend/src/main/java/com/openisle/model/PostChangeType.java b/backend/src/main/java/com/openisle/model/PostChangeType.java index c09a5555a..eeee99cbb 100644 --- a/backend/src/main/java/com/openisle/model/PostChangeType.java +++ b/backend/src/main/java/com/openisle/model/PostChangeType.java @@ -10,4 +10,5 @@ public enum PostChangeType { FEATURED, VOTE_RESULT, LOTTERY_RESULT, + DONATE, } diff --git a/backend/src/main/java/com/openisle/model/PostDonateChangeLog.java b/backend/src/main/java/com/openisle/model/PostDonateChangeLog.java new file mode 100644 index 000000000..50eed96eb --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostDonateChangeLog.java @@ -0,0 +1,19 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "post_donate_change_logs") +public class PostDonateChangeLog extends PostChangeLog { + + @Column(nullable = false) + private int amount; +} diff --git a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java index 197ac1a45..3436466a3 100644 --- a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java +++ b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java @@ -2,11 +2,14 @@ package com.openisle.repository; import com.openisle.model.Comment; import com.openisle.model.PointHistory; +import com.openisle.model.PointHistoryType; import com.openisle.model.Post; import com.openisle.model.User; import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PointHistoryRepository extends JpaRepository { List findByUserOrderByIdDesc(User user); @@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository findByComment(Comment comment); List findByPost(Post post); + + List findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type); + + @Query( + "SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type" + ) + Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type); } diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index 0a8349a53..7b4af0f96 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -1,5 +1,7 @@ package com.openisle.service; +import com.openisle.dto.DonationDto; +import com.openisle.dto.DonationResponse; import com.openisle.exception.FieldException; import com.openisle.model.*; import com.openisle.repository.*; @@ -8,8 +10,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -20,6 +24,8 @@ public class PointService { private final PostRepository postRepository; private final CommentRepository commentRepository; private final PointHistoryRepository pointHistoryRepository; + private final NotificationService notificationService; + private final PostChangeLogService postChangeLogService; public int awardForPost(String userName, Long postId) { User user = userRepository.findByUsername(userName).orElseThrow(); @@ -272,4 +278,95 @@ public class PointService { User user = userRepository.findByUsername(userName).orElseThrow(); return recalculateUserPoints(user); } + + @Transactional + public DonationResponse donateToPost(String donorName, Long postId, int amount) { + if (amount <= 0) { + throw new FieldException("amount", "打赏积分必须大于0"); + } + User donor = userRepository.findByUsername(donorName).orElseThrow(); + Post post = postRepository.findById(postId).orElseThrow(); + User author = post.getAuthor(); + if (author.getId().equals(donor.getId())) { + throw new FieldException("post", "不能给自己打赏"); + } + if (donor.getPoint() < amount) { + throw new FieldException("point", "积分不足"); + } + addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author); + addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor); + notificationService.createNotification( + author, + NotificationType.DONATION, + post, + null, + null, + donor, + null, + String.valueOf(amount) + ); + postChangeLogService.recordDonation(post, donor, amount); + DonationResponse response = buildDonationResponse(post); + response.setBalance(donor.getPoint()); + return response; + } + + public DonationResponse getPostDonations(Long postId) { + Post post = postRepository.findById(postId).orElseThrow(); + return buildDonationResponse(post); + } + + private DonationResponse buildDonationResponse(Post post) { + List histories = + pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc( + post, + PointHistoryType.DONATE_RECEIVED + ); + List donations = histories + .stream() + .collect(Collectors.collectingAndThen(Collectors.toMap( + history -> { + User donor = history.getFromUser(); + if (donor != null && donor.getId() != null) { + return "user:" + donor.getId(); + } + return "history:" + history.getId(); + }, + history -> { + DonationDto dto = new DonationDto(); + User donor = history.getFromUser(); + if (donor != null) { + dto.setUserId(donor.getId()); + dto.setUsername(donor.getUsername()); + dto.setAvatar(donor.getAvatar()); + } + dto.setAmount(history.getAmount()); + dto.setCreatedAt(history.getCreatedAt()); + return dto; + }, + (left, right) -> { + left.setAmount(left.getAmount() + right.getAmount()); + if ( + left.getCreatedAt() == null || + (right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt())) + ) { + left.setCreatedAt(right.getCreatedAt()); + } + return left; + }, + java.util.LinkedHashMap::new + ), map -> new java.util.ArrayList<>(map.values()))); + Long total = pointHistoryRepository.sumAmountByPostAndType( + post, + PointHistoryType.DONATE_RECEIVED + ); + int safeTotal = 0; + if (total != null) { + safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue(); + } + DonationResponse response = new DonationResponse(); + response.setDonations(donations); + response.setTotalAmount(safeTotal); + return response; + } } diff --git a/backend/src/main/java/com/openisle/service/PostChangeLogService.java b/backend/src/main/java/com/openisle/service/PostChangeLogService.java index df78a266d..0d3fd28ad 100644 --- a/backend/src/main/java/com/openisle/service/PostChangeLogService.java +++ b/backend/src/main/java/com/openisle/service/PostChangeLogService.java @@ -115,6 +115,15 @@ public class PostChangeLogService { logRepository.save(log); } + public void recordDonation(Post post, User donor, int amount) { + PostDonateChangeLog log = new PostDonateChangeLog(); + log.setPost(post); + log.setUser(donor); + log.setType(PostChangeType.DONATE); + log.setAmount(amount); + logRepository.save(log); + } + public void deleteLogsForPost(Post post) { logRepository.deleteByPost(post); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 8a68ccf90..ee734cd29 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -4,7 +4,7 @@ server.port=${SERVER_PORT:8080} # for mysql logging.level.root=${LOG_LEVEL:INFO} logging.level.com.openisle.service.CosImageUploader=DEBUG -spring.datasource.url=${MYSQL_URL:jdbc:mysql://localhost:3306/openisle} +spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true spring.datasource.username=${MYSQL_USER:root} spring.datasource.password=${MYSQL_PASSWORD:password} spring.jpa.hibernate.ddl-auto=update @@ -48,11 +48,11 @@ app.snippet-length=${SNIPPET_LENGTH:200} # OpenSearch integration app.search.enabled=${SEARCH_ENABLED:true} -app.search.host=${SEARCH_HOST:localhost} -app.search.port=${SEARCH_PORT:9200} -app.search.scheme=${SEARCH_SCHEME:http} -app.search.username=${SEARCH_USERNAME:} -app.search.password=${SEARCH_PASSWORD:} +app.search.host=${OPENSEARCH_HOST:opensearch} +app.search.port=${OPENSEARCH_PORT:9200} +app.search.scheme=${OPENSEARCH_SCHEME:http} +app.search.username=${OPENSEARCH_USERNAME:} +app.search.password=${OPENSEARCH_PASSWORD:} app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle} app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}} app.search.reindex-on-startup=${SEARCH_REINDEX_ON_STARTUP:true} @@ -82,15 +82,15 @@ cos.bucket-name=${COS_BUCKET_NAME:} # your image upload services: ... # Google OAuth configuration -google.client-id=${GOOGLE_CLIENT_ID:} +google.client-id=${NUXT_PUBLIC_GOOGLE_CLIENT_ID:} # GitHub OAuth configuration -github.client-id=${GITHUB_CLIENT_ID:} +github.client-id=${NUXT_PUBLIC_GITHUB_CLIENT_ID:} github.client-secret=${GITHUB_CLIENT_SECRET:} # Discord OAuth configuration -discord.client-id=${DISCORD_CLIENT_ID:} +discord.client-id=${NUXT_PUBLIC_DISCORD_CLIENT_ID:} discord.client-secret=${DISCORD_CLIENT_SECRET:} # Twitter OAuth configuration -twitter.client-id=${TWITTER_CLIENT_ID:} +twitter.client-id=${NUXT_PUBLIC_TWITTER_CLIENT_ID:} twitter.client-secret=${TWITTER_CLIENT_SECRET:} # Telegram login configuration telegram.bot-token=${TELEGRAM_BOT_TOKEN:} @@ -130,3 +130,6 @@ springdoc.info.description=OpenIsle Open API Documentation springdoc.info.version=0.0.1 springdoc.info.scheme=Bearer springdoc.info.header=Authorization + +management.endpoints.web.exposure.include=health,info +management.endpoint.health.probes.enabled=true \ No newline at end of file diff --git a/backend/src/main/resources/db/init/00_init_db_and_user.sql b/backend/src/main/resources/db/init/00_init_db_and_user.sql new file mode 100644 index 000000000..d8cf198ef --- /dev/null +++ b/backend/src/main/resources/db/init/00_init_db_and_user.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4; +SET CHARACTER SET utf8mb4; +SET collation_connection = utf8mb4_0900_ai_ci; + +CREATE DATABASE IF NOT EXISTS `openisle` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci; + +CREATE USER IF NOT EXISTS 'openisle'@'%' IDENTIFIED BY 'openisle'; +GRANT ALL PRIVILEGES ON `openisle`.* TO 'openisle'@'%'; +FLUSH PRIVILEGES; + +USE `openisle`; diff --git a/backend/src/main/resources/db/init/01_schema.sql b/backend/src/main/resources/db/init/01_schema.sql new file mode 100644 index 000000000..87e1e22da --- /dev/null +++ b/backend/src/main/resources/db/init/01_schema.sql @@ -0,0 +1,54 @@ +USE `openisle`; +SET NAMES utf8mb4; + +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `users` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `approved` bit(1) DEFAULT NULL, + `avatar` varchar(255) DEFAULT NULL, + `created_at` datetime(6) DEFAULT NULL, + `display_medal` varchar(255) DEFAULT NULL, + `email` varchar(255) NOT NULL, + `experience` int DEFAULT NULL, + `introduction` text, + `password` varchar(255) NOT NULL, + `password_reset_code` varchar(255) DEFAULT NULL, + `point` int DEFAULT NULL, + `register_reason` text, + `role` varchar(20) DEFAULT 'USER', + `username` varchar(50) NOT NULL, + `verification_code` varchar(255) DEFAULT NULL, + `verified` bit(1) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UK_users_email` (`email`), + UNIQUE KEY `UK_users_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `categories` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `description` text, + `icon` varchar(255) DEFAULT NULL, + `name` varchar(50) NOT NULL, + `small_icon` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UK_categories_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `tags` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `approved` bit(1) DEFAULT NULL, + `created_at` datetime(6) DEFAULT NULL, + `description` text, + `icon` varchar(255) DEFAULT NULL, + `name` varchar(50) NOT NULL, + `small_icon` varchar(255) DEFAULT NULL, + `creator_id` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UK_tags_name` (`name`), + KEY `FK_tags_creator` (`creator_id`), + CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/backend/src/main/resources/db/init/02_seed_data.sql b/backend/src/main/resources/db/init/02_seed_data.sql new file mode 100644 index 000000000..f3f375b7a --- /dev/null +++ b/backend/src/main/resources/db/init/02_seed_data.sql @@ -0,0 +1,26 @@ +USE `openisle`; +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DELETE FROM `tags`; +DELETE FROM `categories`; +DELETE FROM `users`; + +-- 插入用户,两个普通用户,一个管理员 +-- username:admin/user1/user2 password:123456 +INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES +(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'), +(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'), +(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1'); + +INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES +(1,'测试用分类1','star','测试用分类1',NULL), +(2,'测试用分类2','star','测试用分类2',NULL), +(3,'测试用分类3','star','测试用分类3',NULL); + +INSERT INTO `tags` (`id`,`approved`,`created_at`,`description`,`icon`,`name`,`small_icon`,`creator_id`) VALUES +(1,b'1','2025-09-02 10:51:56.000000','测试用标签1',NULL,'测试用标签1',NULL,NULL), +(2,b'1','2025-09-02 10:51:56.000000','测试用标签2',NULL,'测试用标签2',NULL,NULL), +(3,b'1','2025-09-02 10:51:56.000000','测试用标签3',NULL,'测试用标签3',NULL,NULL); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/backend/src/main/resources/db/init/init_script.sql b/backend/src/main/resources/db/init/init_script.sql deleted file mode 100644 index 3ea4bd2c1..000000000 --- a/backend/src/main/resources/db/init/init_script.sql +++ /dev/null @@ -1,81 +0,0 @@ --- 2025-09-02 --- 本地化开发,初始化脚本 --- 抽奖的时候奖品图片是必须的,把相关代码注释掉即可跳过check - --- 设置字符集和排序规则 -SET NAMES utf8; -SET CHARACTER SET utf8; -SET collation_connection = utf8_general_ci; - --- 创建 users 表(如果不存在) -CREATE TABLE IF NOT EXISTS `users` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `approved` bit(1) DEFAULT NULL, - `avatar` varchar(255) DEFAULT NULL, - `created_at` datetime(6) DEFAULT NULL, - `display_medal` varchar(255) DEFAULT NULL, - `email` varchar(255) NOT NULL, - `experience` int DEFAULT NULL, - `introduction` text, - `password` varchar(255) NOT NULL, - `password_reset_code` varchar(255) DEFAULT NULL, - `point` int DEFAULT NULL, - `register_reason` text, - `role` varchar(20) DEFAULT 'USER', - `username` varchar(50) NOT NULL, - `verification_code` varchar(255) DEFAULT NULL, - `verified` bit(1) DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `UK_users_email` (`email`), - UNIQUE KEY `UK_users_username` (`username`) -); - --- 清空users表 -DELETE FROM `users`; --- 插入用户,两个普通用户,一个管理员 --- username:admin/user1/user2 password:123321 -INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES - (1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', 'admin', NULL, b'1'), - (2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user1', NULL, b'1'), - (3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user2', NULL, b'1'); - --- 创建 tags 表(如果不存在) -CREATE TABLE IF NOT EXISTS `tags` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `approved` bit(1) DEFAULT NULL, - `created_at` datetime(6) DEFAULT NULL, - `description` text, - `icon` varchar(255) DEFAULT NULL, - `name` varchar(50) NOT NULL, - `small_icon` varchar(255) DEFAULT NULL, - `creator_id` bigint DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `UK_tags_name` (`name`), - KEY `FK_tags_creator` (`creator_id`), - CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`) -); --- 清空tags表 -DELETE FROM `tags`; --- 插入标签,三个测试用标签 -INSERT INTO `tags` (`id`, `approved`, `created_at`, `description`, `icon`, `name`, `small_icon`, `creator_id`) VALUES - (1, b'1', '2025-09-02 10:51:56.000000', '测试用标签1', NULL, '测试用标签1', NULL, NULL), - (2, b'1', '2025-09-02 10:51:56.000000', '测试用标签2', NULL, '测试用标签2', NULL, NULL), - (3, b'1', '2025-09-02 10:51:56.000000', '测试用标签3', NULL, '测试用标签3', NULL, NULL); - --- 创建 categories 表(如果不存在) -CREATE TABLE IF NOT EXISTS `categories` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `description` text, - `icon` varchar(255) DEFAULT NULL, - `name` varchar(50) NOT NULL, - `small_icon` varchar(255) DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `UK_categories_name` (`name`) -); --- 清空categories表 -DELETE FROM `categories`; --- 插入分类,三个测试用分类 -INSERT INTO `categories` (`id`, `description`, `icon`, `name`, `small_icon`) VALUES - (1, '测试用分类1', '1', '测试用分类1', NULL), - (2, '测试用分类2', '2', '测试用分类2', NULL), - (3, '测试用分类3', '3', '测试用分类3', NULL); \ No newline at end of file diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 000000000..36e0fc5e1 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 可用法: +# ./deploy.sh +# ./deploy.sh feature/docker +deploy_branch="${1:-main}" + +repo_dir="/opt/openisle/OpenIsle" +compose_file="${repo_dir}/docker/docker-compose.yaml" +env_file="${repo_dir}/.env" +project="openisle" + +echo "👉 Enter repo..." +cd "$repo_dir" + +echo "👉 Syncing code & switching to branch: $deploy_branch" +git fetch --all --prune +git checkout -B "$deploy_branch" "origin/$deploy_branch" +git reset --hard "origin/$deploy_branch" + +echo "👉 Ensuring env file: $env_file" +if [ ! -f "$env_file" ]; then + echo "❌ ${env_file} not found. Create it based on .env.example (with domains)." + exit 1 +fi + +export COMPOSE_PROJECT_NAME="$project" +# 供 compose 内各 service 的 env_file 使用 +export ENV_FILE="$env_file" + +echo "👉 Validate compose..." +docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null + +echo "👉 Pull base images (for image-based services)..." +docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures + +echo "👉 Build images ..." +# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像 +docker compose -f "$compose_file" --env-file "$env_file" \ + build --pull \ + --build-arg NUXT_ENV=production \ + frontend_service + +echo "👉 Recreate & start all target services (no dev profile)..." +docker compose -f "$compose_file" --env-file "$env_file" \ + up -d --force-recreate --remove-orphans --no-deps \ + mysql redis rabbitmq websocket-service springboot frontend_service + +echo "👉 Current status:" +docker compose -f "$compose_file" --env-file "$env_file" ps + +echo "👉 Pruning dangling images..." +docker image prune -f + +echo "✅ Stack deployed at $(date)" \ No newline at end of file diff --git a/deploy/deploy_staging.sh b/deploy/deploy_staging.sh new file mode 100644 index 000000000..2f6a3d066 --- /dev/null +++ b/deploy/deploy_staging.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 可用法: +# ./deploy-staging.sh +# ./deploy-staging.sh feature/docker +deploy_branch="${1:-main}" + +repo_dir="/opt/openisle/OpenIsle-staging" +compose_file="${repo_dir}/docker/docker-compose.yaml" +env_file="${repo_dir}/.env" +project="openisle_staging" + +echo "👉 Enter repo..." +cd "$repo_dir" + +echo "👉 Syncing code & switching to branch: $deploy_branch" +git fetch --all --prune +git checkout -B "$deploy_branch" "origin/$deploy_branch" +git reset --hard "origin/$deploy_branch" + +echo "👉 Ensuring env file: $env_file" +if [ ! -f "$env_file" ]; then + echo "❌ ${env_file} not found. Create it based on .env.example (with staging domains)." + exit 1 +fi + +export COMPOSE_PROJECT_NAME="$project" +# 供 compose 内各 service 的 env_file 使用 +export ENV_FILE="$env_file" + +echo "👉 Validate compose..." +docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null + +echo "👉 Pull base images (for image-based services)..." +docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures + +echo "👉 Build images (staging)..." +# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像 +docker compose -f "$compose_file" --env-file "$env_file" \ + build --pull \ + --build-arg NUXT_ENV=staging \ + frontend_service + +echo "👉 Recreate & start all target services (no dev profile)..." +docker compose -f "$compose_file" --env-file "$env_file" \ + up -d --force-recreate --remove-orphans --no-deps \ + mysql redis rabbitmq websocket-service springboot frontend_service + +echo "👉 Current status:" +docker compose -f "$compose_file" --env-file "$env_file" ps + +echo "👉 Pruning dangling images..." +docker image prune -f + +echo "✅ Staging stack deployed at $(date)" \ No newline at end of file diff --git a/docker/.env.example b/docker/.env.example index 0ad80a93c..a798793ea 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,16 +1,4 @@ -# 前端访问端口 -SERVER_PORT=8080 - -# OpenSearch 配置 -OPENSEARCH_PORT=9200 -OPENSEARCH_METRICS_PORT=9600 -OPENSEARCH_DASHBOARDS_PORT=5601 - -# MySQL 配置 -MYSQL_ROOT_PASSWORD=toor - -# 会覆盖 `open-isle.env` -MYSQL_PORT=3306 -MYSQL_DATABASE=openisle -MYSQL_USER=<数据库用户名> -MYSQL_PASSWORD=<数据库密码> +# 已迁移到仓库根目录的 .env.*.example 文件。 +# 请复制对应环境的示例文件到项目根目录,例如: +# cp ../.env.dev.example ../.env +# docker-compose 将自动读取 ../.env。 diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 000000000..6320cd248 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 50a60557d..56afacd53 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -2,25 +2,37 @@ services: # MySQL service mysql: image: mysql:8.0 - container_name: openisle-mysql + container_name: ${COMPOSE_PROJECT_NAME}-openisle-mysql restart: always env_file: - - ../backend/open-isle.env - - ./.env + - ${ENV_FILE:-../.env} + command: > + --character-set-server=utf8mb4 + --collation-server=utf8mb4_0900_ai_ci + --default-time-zone=+08:00 + --skip-character-set-client-handshake ports: - - "${MYSQL_PORT}:3306" + - "${MYSQL_PORT:-3306}:3306" volumes: - mysql-data:/var/lib/mysql - - ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d + - ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d:ro + - ./mysql/conf.d:/etc/mysql/conf.d:ro networks: - openisle-network - + healthcheck: + test: ["CMD","mysqladmin","ping","-h","127.0.0.1","-u","root","-p$MYSQL_ROOT_PASSWORD"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 20s + # OpenSearch Service opensearch: + user: "1000:1000" build: context: . - dockerfile: Dockerfile - container_name: opensearch + dockerfile: opensearch.Dockerfile + container_name: ${COMPOSE_PROJECT_NAME}-opensearch environment: - cluster.name=os-single - node.name=os-node-1 @@ -31,53 +43,259 @@ services: - cluster.blocks.create_index=false ulimits: memlock: { soft: -1, hard: -1 } - nofile: { soft: 65536, hard: 65536 } + nofile: { soft: 65536, hard: 65536 } volumes: - - ./data:/usr/share/opensearch/data - - ./snapshots:/snapshots + - opensearch-data:/usr/share/opensearch/data + - opensearch-snapshots:/snapshots ports: - "${OPENSEARCH_PORT:-9200}:9200" - "${OPENSEARCH_METRICS_PORT:-9600}:9600" restart: unless-stopped + healthcheck: + test: + - CMD-SHELL + - curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + networks: + - openisle-network dashboards: image: opensearchproject/opensearch-dashboards:3.0.0 - container_name: os-dashboards + container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards environment: - - OPENSEARCH_HOSTS=["http://opensearch:9200"] - - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + OPENSEARCH_HOSTS: '["http://opensearch:9200"]' + DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true" ports: - "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601" depends_on: - opensearch restart: unless-stopped + networks: + - openisle-network + rabbitmq: + image: rabbitmq:3.13-management + container_name: ${COMPOSE_PROJECT_NAME}-openisle-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_VHOST: "${RABBITMQ_VHOST:-/}" + ports: + - "${RABBITMQ_PORT:-5672}:5672" + - "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + - ./rabbitmq/conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro + - ./rabbitmq/conf/enabled_plugins:/etc/rabbitmq/enabled_plugins:ro + - ./rabbitmq/definitions.json:/etc/rabbitmq/definitions.json:ro + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + networks: + - openisle-network - # Java spring boot service + redis: + image: redis:7 + container_name: ${COMPOSE_PROJECT_NAME}-openisle-redis + restart: unless-stopped + env_file: + - ${ENV_FILE:-../.env} + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + networks: + - openisle-network + + # Java spring boot service (开发便捷镜像,后续可换成打包镜像) springboot: image: maven:3.9-eclipse-temurin-17 - container_name: openisle-springboot + container_name: ${COMPOSE_PROJECT_NAME}-openisle-springboot working_dir: /app env_file: - - ../backend/open-isle.env - - ./.env + - ${ENV_FILE:-../.env} environment: - - MYSQL_URL=jdbc:mysql://mysql:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true + TZ: "Asia/Shanghai" + SPRING_HEALTH_PATH: ${SPRING_HEALTH_PATH:-/actuator/health} + SERVER_PORT: ${SERVER_PORT:-8080} + RABBITMQ_PORT: 5672 + OPENSEARCH_PORT: 9200 + MYSQL_PORT: 3306 + REDIS_PORT: 6379 + JAVA_OPTS: "-Duser.timezone=Asia/Shanghai" ports: - - "${SERVER_PORT}:8080" + - "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}" volumes: - ../backend:/app - maven-repo:/root/.m2 depends_on: - - mysql - command: mvn clean spring-boot:run -Dmaven.test.skip=true + mysql: + condition: service_healthy + redis: + condition: service_started + rabbitmq: + condition: service_started + websocket-service: + condition: service_healthy + opensearch: + condition: service_healthy + command: > + sh -c "apt-get update && apt-get install -y --no-install-recommends curl && + mvn clean spring-boot:run -Dmaven.test.skip=true" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}${SPRING_HEALTH_PATH:-/actuator/health} || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s networks: - openisle-network + websocket-service: + image: maven:3.9-eclipse-temurin-17 + container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket + working_dir: /app + env_file: + - ${ENV_FILE:-../.env} + environment: + WS_HEALTH_PATH: ${WS_HEALTH_PATH:-/actuator/health} + WEBSOCKET_PORT: ${WEBSOCKET_PORT:-8082} + SERVER_PORT: ${WEBSOCKET_PORT:-8082} + RABBITMQ_PORT: 5672 + ports: + - "${WEBSOCKET_PORT:-8082}:${WEBSOCKET_PORT:-8082}" + volumes: + - ../websocket_service:/app + - websocket-maven-repo:/root/.m2 + depends_on: + rabbitmq: + condition: service_healthy + command: > + sh -c "apt-get update && apt-get install -y --no-install-recommends curl && + mvn clean spring-boot:run -Dmaven.test.skip=true" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEBSOCKET_PORT:-8082}${WS_HEALTH_PATH:-/actuator/health} || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + networks: + - openisle-network + + frontend_dev: + image: node:20 + container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev + working_dir: /app + env_file: + - ${ENV_FILE:-../.env} + command: sh -c "npm install && npm run dev" + volumes: + - ../frontend_nuxt:/app + - frontend-node-modules:/app/node_modules + ports: + - "${FRONTEND_PORT:-3000}:3000" + depends_on: + springboot: + condition: service_healthy + websocket-service: + condition: service_healthy + networks: + - openisle-network + profiles: + - dev + + frontend_service: + build: + context: .. + dockerfile: docker/frontend-service.Dockerfile + args: + NUXT_ENV: ${NUXT_ENV:-staging} + container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend + env_file: + - ${ENV_FILE:-../.env} + ports: + - "${FRONTEND_PORT:-3000}:3000" + depends_on: + springboot: + condition: service_healthy + websocket-service: + condition: service_healthy + restart: unless-stopped + profiles: ["staging", "prod"] + + + loopback_8080: + image: alpine/socat + container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080 + # 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080 + command: + - -d + - -d + - -ly + - TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork + - TCP4:springboot:8080 + depends_on: + springboot: + condition: service_healthy + network_mode: "service:frontend_dev" + profiles: ["dev"] + healthcheck: + test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + + loopback_8082: + image: alpine/socat + container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082 + # 监听 127.0.0.1:8082 → 转发到 websocket-service:8082(WS 纯 TCP 可直接过) + command: + - -d + - -d + - -ly + - TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork + - TCP4:websocket-service:8082 + depends_on: + websocket-service: + condition: service_healthy + network_mode: "service:frontend_dev" + profiles: ["dev"] + healthcheck: + test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 10s + networks: openisle-network: + name: "${COMPOSE_PROJECT_NAME}_net" driver: bridge volumes: mysql-data: + name: "${COMPOSE_PROJECT_NAME}_mysql-data" maven-repo: + name: "${COMPOSE_PROJECT_NAME}_maven-repo" + redis-data: + name: "${COMPOSE_PROJECT_NAME}_redis-data" + rabbitmq-data: + name: "${COMPOSE_PROJECT_NAME}_rabbitmq-data" + websocket-maven-repo: + name: "${COMPOSE_PROJECT_NAME}_websocket-maven-repo" + frontend-node-modules: + name: "${COMPOSE_PROJECT_NAME}_frontend-node-modules" + frontend-service-node-modules: + name: "${COMPOSE_PROJECT_NAME}_frontend-service-node-modules" + frontend-static: + name: "${COMPOSE_PROJECT_NAME}_frontend-static" + opensearch-data: + name: "${COMPOSE_PROJECT_NAME}_opensearch-data" + opensearch-snapshots: + name: "${COMPOSE_PROJECT_NAME}_opensearch-snapshots" diff --git a/docker/frontend-service.Dockerfile b/docker/frontend-service.Dockerfile new file mode 100644 index 000000000..132e185e0 --- /dev/null +++ b/docker/frontend-service.Dockerfile @@ -0,0 +1,39 @@ +# ==== builder ==== +FROM node:20-bullseye AS builder +WORKDIR /app + +# 通过构建参数选择环境:staging / production(默认 staging) +ARG NUXT_ENV=staging +ENV NODE_ENV=production \ + NUXT_TELEMETRY_DISABLED=1 + +# 复制源代码(假设仓库根目录包含 frontend_nuxt) +# 构建上下文由 docker-compose 指向仓库根目录 +COPY ./frontend_nuxt/package*.json /app/ +RUN npm ci + +# 拷贝剩余代码 +COPY ./frontend_nuxt/ /app/ + +# 若存在环境样例文件,则在构建期复制为 .env(你也可以用 --build-arg 覆盖) +RUN if [ -f ".env.${NUXT_ENV}.example" ]; then cp ".env.${NUXT_ENV}.example" .env; fi + +# 构建 SSR:产物在 .output +RUN npm run build + +# ==== runner ==== +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NUXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOST=0.0.0.0 + +# 复制构建产物 +COPY --from=builder /app/.output /app/.output + +# 健康检查(简洁起见,探测首页) +HEALTHCHECK --interval=10s --timeout=5s --retries=30 CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1 + +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] diff --git a/docker/mysql/conf.d/charset.cnf b/docker/mysql/conf.d/charset.cnf new file mode 100644 index 000000000..2464cce02 --- /dev/null +++ b/docker/mysql/conf.d/charset.cnf @@ -0,0 +1,10 @@ +[mysqld] +character-set-server = utf8mb4 +collation-server = utf8mb4_0900_ai_ci +skip-character-set-client-handshake + +[client] +default-character-set = utf8mb4 + +[mysql] +default-character-set = utf8mb4 diff --git a/docker/DockerFile b/docker/opensearch.Dockerfile similarity index 100% rename from docker/DockerFile rename to docker/opensearch.Dockerfile diff --git a/docker/rabbitmq/conf/enabled_plugins b/docker/rabbitmq/conf/enabled_plugins new file mode 100644 index 000000000..5b44fea2e --- /dev/null +++ b/docker/rabbitmq/conf/enabled_plugins @@ -0,0 +1 @@ +[rabbitmq_management, rabbitmq_prometheus]. diff --git a/docker/rabbitmq/conf/rabbitmq.conf b/docker/rabbitmq/conf/rabbitmq.conf new file mode 100644 index 000000000..7734d03e0 --- /dev/null +++ b/docker/rabbitmq/conf/rabbitmq.conf @@ -0,0 +1,6 @@ +# 管理插件加载 definitions(仅空库时生效) +management.load_definitions = /etc/rabbitmq/definitions.json + +# (可选)禁用管理老式统计采集,转 Prometheus,避免弃用告警 +management_agent.disable_metrics_collector = true +management.disable_stats = true diff --git a/docker/rabbitmq/definitions.json b/docker/rabbitmq/definitions.json new file mode 100644 index 000000000..bf8fc561f --- /dev/null +++ b/docker/rabbitmq/definitions.json @@ -0,0 +1,31 @@ +{ + "users": [ + { "name": "nagisa", "password": "nagisa", "tags": "administrator" } + ], + "vhosts": [{ "name": "/" }], + "permissions": [ + { "user": "nagisa", "vhost": "/", "configure": ".*", "write": ".*", "read": ".*" } + ], + "queues": [ + { "name": "notifications-queue", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-0", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-1", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-2", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-3", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-4", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-5", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-6", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-7", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-8", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-9", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-a", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-b", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-c", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-d", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-e", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }, + { "name": "notifications-queue-f", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} } + ], + "exchanges": [], + "bindings": [] + } + \ No newline at end of file diff --git a/frontend_nuxt/.env.dev.example b/frontend_nuxt/.env.dev.example index bb0ee2635..9a41ba7af 100644 --- a/frontend_nuxt/.env.dev.example +++ b/frontend_nuxt/.env.dev.example @@ -1,12 +1,3 @@ -; 本地部署后端 -NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080 -NUXT_PUBLIC_WEBSOCKET_URL=https://127.0.0.1:8082 -NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000 - -NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com -# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ -; 本地 -NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN -NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 -NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ -NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135 +# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。 +# 如需在本地运行 Nuxt,请复制对应的示例文件到项目根目录: +# cp ../.env.dev.example ../.env diff --git a/frontend_nuxt/.env.example b/frontend_nuxt/.env.example index dadb36387..bcbdb7bf6 100644 --- a/frontend_nuxt/.env.example +++ b/frontend_nuxt/.env.example @@ -1,19 +1,5 @@ -; 本地部署后端 -; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 -; 预发环境后端 -; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com -; 生产环境后端 -NUXT_PUBLIC_API_BASE_URL=https://open-isle.com - -; 生产环境ws后端 -NUXT_PUBLIC_WEBSOCKET_URL=https://open-isle.com/websocket - -; 预发环境 -; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com -; 正式环境/生产环境 -NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com -NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com -NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ -NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 -NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ -NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135 +# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。 +# 根据环境选择对应文件复制至项目根目录: +# cp ../.env.dev.example ../.env +# cp ../.env.staging.example ../.env +# cp ../.env.production.example ../.env diff --git a/frontend_nuxt/.env.production.example b/frontend_nuxt/.env.production.example index a1bff6842..ee3573dd6 100644 --- a/frontend_nuxt/.env.production.example +++ b/frontend_nuxt/.env.production.example @@ -1,13 +1,3 @@ - -; 生产环境后端 -NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com -; 正式环境/生产环境 -NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com -; 生产环境ws后端 -NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket - -NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com -NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ -NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 -NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ -NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135 +# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。 +# 如需配置生产环境,请复制并修改对应示例文件: +# cp ../.env.production.example ../.env diff --git a/frontend_nuxt/.env.staging.example b/frontend_nuxt/.env.staging.example index 0cc8ef140..dde477e44 100644 --- a/frontend_nuxt/.env.staging.example +++ b/frontend_nuxt/.env.staging.example @@ -1,17 +1,3 @@ -; 本地部署后端 -; NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080 - -; 预发环境后端 -NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com - -; 预发环境ws后端 -NUXT_PUBLIC_WEBSOCKET_URL=https://staging.open-isle.com/websocket - -; 预发环境 -NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com - -NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com -NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ -NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779 -NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ -NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135 +# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。 +# 如需配置预发环境,请复制并修改对应示例文件: +# cp ../.env.staging.example ../.env diff --git a/frontend_nuxt/app.vue b/frontend_nuxt/app.vue index 408521752..7b70e4858 100644 --- a/frontend_nuxt/app.vue +++ b/frontend_nuxt/app.vue @@ -41,10 +41,13 @@ import GlobalPopups from '~/components/GlobalPopups.vue' import ConfirmDialog from '~/components/ConfirmDialog.vue' import MessageFloatWindow from '~/components/MessageFloatWindow.vue' import { useIsMobile } from '~/utils/screen' +import { checkToken } from '~/utils/auth' const isMobile = useIsMobile() const menuVisible = ref(!isMobile.value) +await checkToken() + const showNewPostIcon = computed(() => useRoute().path === '/') const hideMenu = computed(() => { diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index f46edefbf..05302372a 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -3,7 +3,7 @@ --primary-color: rgb(10, 110, 120); --primary-color-disabled: rgba(93, 152, 156, 0.5); --secondary-color: rgb(255, 255, 255); - --secondary-color-hover: rgba(10, 111, 120, 0.184); + --secondary-color-hover: rgba(10, 111, 120, 0.079); --new-post-icon-color: rgba(10, 111, 120, 0.598); --header-height: 60px; --header-background-color: white; @@ -54,6 +54,7 @@ --header-border-color: #555; --primary-color: rgb(17, 182, 197); --primary-color-hover: rgb(13, 137, 151); + --secondary-color-hover: rgba(17, 182, 197, 0.238); --new-post-icon-color: rgba(10, 111, 120, 0.598); --header-text-color: white; --app-menu-background-color: #333; @@ -179,7 +180,9 @@ body { .info-content-text pre .line-numbers { counter-reset: line-number 0; - width: 2em; + white-space: nowrap; /* 禁止数字换行 */ + font-variant-numeric: tabular-nums; /* 数字等宽 */ + /* width: 2em; */ font-size: 13px; position: sticky; flex-shrink: 0; @@ -342,6 +345,16 @@ body { line-height: 1.5; } + /*处理iframe视频标签*/ + .info-content-text iframe { + width: 100%; + max-width: 100%; + height: auto; + aspect-ratio: 16 / 9; /* 保持 16:9 比例 */ + border: none; + display: block; + } + .d2h-file-name { font-size: 14px !important; } @@ -357,7 +370,10 @@ body { .d2h-code-line { padding-left: 10px !important; } - + /* 手机端不换行 */ + .info-content-text code { + white-space: pre; /* 禁止自动换行 */ + } /* .d2h-diff-table { font-size: 6px !important; } diff --git a/frontend_nuxt/components/AvatarCropper.vue b/frontend_nuxt/components/AvatarCropper.vue index 2cf260fd7..538ebe5cb 100644 --- a/frontend_nuxt/components/AvatarCropper.vue +++ b/frontend_nuxt/components/AvatarCropper.vue @@ -119,7 +119,7 @@ export default { .cropper-btn { padding: 6px 12px; - border-radius: 4px; + border-radius: 10px; color: var(--primary-color); border: none; background: transparent; @@ -128,7 +128,7 @@ export default { .cropper-btn.primary { background: var(--primary-color); - color: var(--text-color); + color: #ffff; border-color: var(--primary-color); } diff --git a/frontend_nuxt/components/BaseImage.vue b/frontend_nuxt/components/BaseImage.vue index 2aa8a9cda..4c62021f6 100644 --- a/frontend_nuxt/components/BaseImage.vue +++ b/frontend_nuxt/components/BaseImage.vue @@ -17,7 +17,7 @@ import { computed, ref } from 'vue' import { useAttrs } from 'vue' const props = defineProps({ - src: { type: String, required: true }, + src: { type: String, default: '' }, alt: { type: String, default: '' }, }) @@ -39,9 +39,6 @@ const placeholder = computed(() => { function onLoad() { loaded.value = true } -function onError() { - loaded.value = true -} diff --git a/frontend_nuxt/components/BaseUserAvatar.vue b/frontend_nuxt/components/BaseUserAvatar.vue index 6e0b62ab5..b1ea2fc39 100644 --- a/frontend_nuxt/components/BaseUserAvatar.vue +++ b/frontend_nuxt/components/BaseUserAvatar.vue @@ -1,22 +1,20 @@ @@ -109,7 +103,7 @@ function onError() { } .base-user-avatar:hover { - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1); transform: scale(1.05); } diff --git a/frontend_nuxt/components/CommentItem.vue b/frontend_nuxt/components/CommentItem.vue index b47456dda..34a7e703d 100644 --- a/frontend_nuxt/components/CommentItem.vue +++ b/frontend_nuxt/components/CommentItem.vue @@ -53,14 +53,29 @@ @click="handleContentClick" > @@ -158,9 +168,19 @@ export default { const mobileMenuRef = ref(null) const isMobile = useIsMobile() + const openMenu = () => { + if (!open.value) { + open.value = true + } + } + const toggle = () => { - open.value = !open.value - if (!open.value) emit('close') + if (open.value) { + open.value = false + emit('close') + } else { + open.value = true + } } const close = () => { @@ -265,7 +285,7 @@ export default { return /^https?:\/\//.test(icon) || icon.startsWith('/') } - expose({ toggle, close, reload, scrollToBottom }) + expose({ toggle, close, reload, scrollToBottom, openMenu }) return { open, @@ -283,6 +303,7 @@ export default { isImageIcon, setSearch, isMobile, + remote: props.remote, } }, } @@ -297,7 +318,6 @@ export default { border: 1px solid var(--normal-border-color); border-radius: 5px; padding: 5px 10px; - margin-bottom: 4px; cursor: pointer; display: flex; justify-content: space-between; @@ -320,6 +340,7 @@ export default { z-index: 10000; max-height: 300px; min-width: 350px; + margin-top: 4px; overflow-y: auto; } @@ -384,6 +405,13 @@ export default { padding: 10px 0; } +.dropdown-empty { + padding: 20px; + text-align: center; + color: var(--muted-text-color, #8c8c8c); + font-size: 14px; +} + .dropdown-mobile-page { position: fixed; top: 0; diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue index 8b977c513..0833e0e81 100644 --- a/frontend_nuxt/components/HeaderComponent.vue +++ b/frontend_nuxt/components/HeaderComponent.vue @@ -26,40 +26,63 @@
-
- -
- -
- -
- -
- - 邀请 - -
+ + + +
+ + 搜索 +
+
+ + +
+ + 主题 +
+
+ + +
+ + +
+
+ -
- - {{ onlineCount }} +
+ + 在线 + {{ onlineCount }}
+ -
- +
+ + RSS
- + -
- +
+ + 发帖
+ -
- +
+ + 消息 {{ unreadMessageCount }} @@ -73,10 +96,9 @@
@@ -89,7 +111,6 @@
-
@@ -101,7 +122,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue' import ToolTip from '~/components/ToolTip.vue' import SearchDropdown from '~/components/SearchDropdown.vue' import BaseUserAvatar from '~/components/BaseUserAvatar.vue' -import { authState, clearToken, loadCurrentUser } from '~/utils/auth' +import { authState, clearToken } from '~/utils/auth' import { useUnreadCount } from '~/composables/useUnreadCount' import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount' import { useIsMobile } from '~/utils/screen' @@ -123,13 +144,11 @@ const isLogin = computed(() => authState.loggedIn) const isMobile = useIsMobile() const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount() const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount() -const avatar = ref('') const showSearch = ref(false) const searchDropdown = ref(null) const userMenu = ref(null) const menuBtn = ref(null) const isCopying = ref(false) - const onlineCount = ref(0) // 心跳检测 @@ -192,6 +211,7 @@ const copyInviteLink = async () => { const token = getToken() if (!token) { toast.error('请先登录') + isCopying.value = false // 🔥 修复:未登录时立即复原状态 return } try { @@ -235,17 +255,7 @@ const copyRssLink = async () => { } const goToProfile = async () => { - if (!authState.loggedIn) { - navigateTo('/login', { replace: true }) - return - } - let id = authState.username || authState.userId - if (!id) { - const user = await loadCurrentUser() - if (user) { - id = user.username || user.id - } - } + let id = authState.username || authState.id if (id) { navigateTo(`/users/${id}`, { replace: true }) } @@ -289,14 +299,6 @@ const iconClass = computed(() => { }) onMounted(async () => { - const updateAvatar = async () => { - if (authState.loggedIn) { - const user = await loadCurrentUser() - if (user && user.avatar) { - avatar.value = user.avatar - } - } - } const updateUnread = async () => { if (authState.loggedIn) { fetchUnreadCount() @@ -306,17 +308,8 @@ onMounted(async () => { } } - await updateAvatar() await updateUnread() - watch( - () => authState.loggedIn, - async (isLoggedIn) => { - await updateAvatar() - await updateUnread() - }, - ) - // 新增的在线人数逻辑 sendPing() fetchCount() @@ -333,7 +326,7 @@ onMounted(async () => { height: var(--header-height); background-color: var(--background-color-blur); backdrop-filter: var(--blur-10); - color: var(--header-text-color); + color: var(--primary-color); border-bottom: 1px solid var(--header-border-color); } @@ -376,6 +369,7 @@ onMounted(async () => { flex-direction: row; align-items: center; gap: 20px; + padding-right: 15px; } .micon { @@ -464,16 +458,13 @@ onMounted(async () => { cursor: pointer; } -.invite_text { - font-size: 12px; - cursor: pointer; - color: var(--primary-color); -} - .invite_text:hover { + opacity: 0.8; text-decoration: underline; } +.invite_text, +.online-count, .rss-icon, .new-post-icon, .messages-icon { @@ -484,8 +475,8 @@ onMounted(async () => { .unread-badge { position: absolute; - top: -5px; - right: -10px; + top: -4px; + right: -6px; background-color: #ff4d4f; color: white; border-radius: 50%; @@ -500,8 +491,8 @@ onMounted(async () => { .unread-dot { position: absolute; - top: -2px; - right: -4px; + top: 0; + right: -1px; width: 8px; height: 8px; border-radius: 50%; @@ -513,14 +504,60 @@ onMounted(async () => { } .online-count { - font-size: 14px; - display: flex; - align-items: center; - gap: 5px; - color: var(--primary-color); cursor: default; } +/* === 统一图标按钮风格 === */ +.header-icon-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 14px; + color: var(--primary-color); + cursor: pointer; + position: relative; + transition: + color 0.25s ease, + transform 0.15s ease, + opacity 0.2s ease; +} + +.header-icon-item:hover { + opacity: 0.8; + transform: translateY(-1px); +} + +/* 点击时瞬间高亮 + 轻微缩放 */ +.header-icon-item:active { + color: var(--primary-color-hover); + transform: scale(0.92); +} + +.header-icon { + font-size: 20px; + line-height: 1; +} + +.header-label { + font-size: 12px; + line-height: 1; +} + +/* 在线人数的数字文字样式(无背景) */ +.header-badge { + position: absolute; + top: -4px; + right: -6px; + color: var(--primary-color); /* 🔹 使用主题主色 */ + background: none; /* 🔹 去掉背景 */ + font-size: 11px; /* 字体稍微大一点以便清晰 */ + font-weight: 600; /* 加一点权重让数字更醒目 */ + line-height: 1; + padding: 0; /* 去掉内边距 */ +} + @keyframes rss-glow { 0% { text-shadow: 0 0 0px var(--primary-color); @@ -556,5 +593,12 @@ onMounted(async () => { .header-content-right { gap: 15px; } + /* 手机不显示文字 */ + .header-label { + display: none; + } + .header-badge { + display: none; + } } diff --git a/frontend_nuxt/components/NotificationContainer.vue b/frontend_nuxt/components/NotificationContainer.vue index db002aab4..2a97a8fd7 100644 --- a/frontend_nuxt/components/NotificationContainer.vue +++ b/frontend_nuxt/components/NotificationContainer.vue @@ -45,6 +45,7 @@ export default { font-size: 12px; cursor: pointer; margin-left: 10px; + white-space: nowrap; } .mark-read-button:hover { @@ -53,6 +54,7 @@ export default { .has-read-button { font-size: 12px; + white-space: nowrap; } @media (max-width: 768px) { diff --git a/frontend_nuxt/components/PostChangeLogItem.vue b/frontend_nuxt/components/PostChangeLogItem.vue index b9ead1887..c55d15763 100644 --- a/frontend_nuxt/components/PostChangeLogItem.vue +++ b/frontend_nuxt/components/PostChangeLogItem.vue @@ -42,6 +42,9 @@ 系统已「精密计算」抽奖结果 (=゚ω゚)ノ + 为文章打赏了 {{ log.amount ?? 0 }} 积分
{{ log.time }}
{{ counts[r.type] }}
-
- -
+ +
+ +
+
@@ -253,11 +281,7 @@ onMounted(async () => { position: relative; display: flex; flex-direction: row; - gap: 10px; align-items: center; - width: 100%; - justify-content: space-between; - flex-wrap: wrap; } .reactions-viewer { @@ -295,40 +319,15 @@ onMounted(async () => { padding-left: 5px; } -.make-reaction-container { - display: flex; - flex-direction: row; - gap: 10px; -} - -.make-reaction-item { - cursor: pointer; - padding: 4px; - opacity: 0.5; - border-radius: 8px; - font-size: 20px; -} - -.like-reaction { - color: #ff0000; - display: flex; - flex-direction: row; - align-items: center; - gap: 5px; -} - -.make-reaction-item:hover { - background-color: #ffe2e2; -} - .reactions-count { font-size: 16px; font-weight: bold; + margin-right: 15px; } .reactions-panel { position: absolute; - bottom: 50px; + bottom: 35px; background-color: var(--background-color); border: 1px solid var(--normal-border-color); border-radius: 20px; @@ -361,7 +360,6 @@ onMounted(async () => { border: 1px solid var(--normal-border-color); border-radius: 10px; margin-right: 5px; - margin-bottom: 5px; font-size: 14px; color: var(--text-color); align-items: center; diff --git a/frontend_nuxt/components/SearchDropdown.vue b/frontend_nuxt/components/SearchDropdown.vue index e8fde21d6..fedbee882 100644 --- a/frontend_nuxt/components/SearchDropdown.vue +++ b/frontend_nuxt/components/SearchDropdown.vue @@ -17,7 +17,8 @@ @@ -48,7 +49,7 @@