Compare commits

...

26 Commits

Author SHA1 Message Date
Tim
47a72dc9b0 Fix duplicate WebSocket broadcasts 2025-09-04 13:50:05 +08:00
Tim
70a83cbe06 fix: 日志等级可配置 2025-09-04 13:01:57 +08:00
Tim
0ff6f13c86 fix: ws新增 .env 文件 2025-09-04 12:24:30 +08:00
Tim
8895405606 fix: 提交一部份修改,以方便预发部署 2025-09-03 18:02:21 +08:00
Tim
12b697d9dd Merge branch 'main' into main 2025-09-03 16:24:56 +08:00
Tim
49a55bcc36 Merge pull request #870 from palmcivet/docs/contributing
docs: 优化 contributing 文档 !869
2025-09-03 14:37:27 +08:00
Palm Civet
690aae3577 docs: 优化 contributing 文档 2025-09-03 14:14:15 +08:00
Tim
93d2c39f6e Merge pull request #867 from palmcivet/docs/openapi-springdoc
docs: backend 引入 springdoc-openapi 生成 OpenAPI 文档
2025-09-03 11:35:18 +08:00
Tim
99b824d852 Merge pull request #868 from smallclover/main
部署教程修改
2025-09-03 11:34:51 +08:00
wangshun
67fae4129f 部署教程修改
1.配图统一改为项目内图片
2.增加laragon配置
3.增加github第三方登录配置
2025-09-03 10:27:57 +08:00
Palm Civet
3739286cca chore: 修改配置 2025-09-03 00:07:53 +08:00
Palm Civet
ec76e70ad0 build: backend 引入 springdoc-openapi 2025-09-02 23:54:23 +08:00
zpaeng
f482d9ff9d fix:【站内信】 2025-09-02 23:16:27 +08:00
zpaeng
5e13b4bdd3 Merge remote-tracking branch 'origin/main' 2025-09-02 23:12:50 +08:00
zpaeng
78a65c6afe feat:Websocket服务拆到单独服务,主后台保持单工通信 2025-09-02 23:10:56 +08:00
zpaeng
84236b0174 feat:Websocket服务拆到单独服务,主后台保持单工通信 2025-09-02 23:10:29 +08:00
tim
c337195b16 fix: ui简要修改 2025-09-02 16:27:05 +08:00
Tim
c506aec506 Merge pull request #835 from smallclover/main
倒计时修改
2025-09-02 16:18:07 +08:00
夢夢の幻想郷
aa4274052e Merge branch 'nagisa77:main' into main 2025-09-02 14:47:29 +08:00
wangshun
e96ba3c26f 1.追加:投票结束查看倒计时时间
2.修改:倒计时样式
3.优化:抽奖和投票倒计时代码统一
2025-09-02 14:46:18 +08:00
tim
36758624c2 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-02 13:01:34 +08:00
tim
4427eff78a fix: 新增Google Search Console网域识别 2025-09-02 13:01:10 +08:00
Tim
ab85e67d69 Merge pull request #830 from nagisa77/feature/ui_fix
reaction 相关修改/timeline相关修改
2025-09-02 12:44:24 +08:00
tim
d7f6bb507d reaction 相关修改/timeline相关修改 2025-09-02 12:43:30 +08:00
Tim
bced7807ae Merge pull request #829 from nagisa77/feature/cdn_change
fix: cdn 修复
2025-09-02 12:29:47 +08:00
Tim
73bb873bfe fix: cdn 修复 2025-09-02 11:45:35 +08:00
61 changed files with 1963 additions and 515 deletions

View File

@@ -1,113 +1,149 @@
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
- [前置工作](#前置工作)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [环境变量配置](#环境变量配置)
- [配置参数](#配置参数)
- [配置 MySQL](#配置-mysql)
- [Docker 环境](#docker-环境)
- [环境变量配置](#环境变量配置-1)
- [构建并启动镜像](#构建并启动镜像)
- [启动前端服务](#启动前端服务)
- [环境变量配置](#环境变量配置-2)
- [安装依赖和运行](#安装依赖和运行)
- [其他配置](#其他配置)
## 如何部署
## 前置工作
> Step1 先克隆仓库
先克隆仓库
```shell
git clone https://github.com/nagisa77/OpenIsle.git
cd OpenIsle
```
> Step2 后端部署
## 启动后端服务
```shell
cd backend
启动后端服务有多种方式,选择一种即可。
> [!IMPORTANT]
> 仅想修改前端的朋友可不用部署后端服务。转到 [启动前端服务](#启动前端服务) 章节。
### 本地 IDEA
IDEA 打开 `backend/` 文件夹。
#### 环境变量配置
1. 生成环境变量文件
```shell
cp open-isle.env.example open-isle.env
```
`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
```
以IDEA编辑器为例IDEA打开backend文件夹
另一种方式是修改 `.properities` 文件(但不建议),位于 `src/main/application.properties`,该配置同样来源于 `open-isle.env`,但修改 `.properties` 文件会被 Git 追踪
- 设置VM Option最好运行在其他端口非8080这里设置8081
![配置数据库](assets/contributing/backend_img_5.png)
#### 配置参数
- 设置 JDK 版本为 java 17
- 设置 VM Option最好运行在其他端口非 `8080`,这里设置 `8081`
```shell
-Dserver.port=8081
```
![配置1](assets/contributing/backend_img_3.png)
![配置2](assets/contributing/backend_img_2.png)
#### 配置 MySQL
1. 本机配置 MySQL 服务(网上很多教程,忽略)
+ 可以用 Laragon自带 MySQL 包括 Nodejs版本建议 `6.x``7` 以后需要 Lisence
+ [下载地址](https://github.com/leokhoa/laragon/releases)
> [!TIP]
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
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.sql 脚本,导入基本的数据
![初始化脚本](assets/contributing/resources_img.png)
4. 处理完环境问题直接跑起来就能通了
![运行画面](assets/contributing/backend_img_4.png)
### Docker 环境
#### 环境变量配置
同上,见 [环境变量配置](#环境变量配置)
#### 构建并启动镜像
```shell
-Dserver.port=8081
cd docker
docker compose up -d
```
![CleanShot 2025-08-04 at 11.35.49.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/4cf210cfc6ea478a80dfc744c85ccdc4.png)
- 设置jdk版本为java 17
![CleanShot 2025-08-04 at 11.38.03@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/392eeec753ae436ca12a78f750dfea2d.png)
- 本机配置MySQL服务网上很多教程忽略
- 设置环境变量.env 文件 或.properties 文件(二选一)
1. 环境变量文件生成
```shell
cp open-isle.env.example open-isle.env
```
修改环境变量留下需要的比如你要开发Google登录业务就需要谷歌相关的变量数据库是一定要的
![CleanShot 2025-08-04 at 11.41.36@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/896c8363b6e64ea19d18c12ec4dae2b4.png)
应用环境文件, 选择刚刚的`open-isle.env`
![CleanShot 2025-08-04 at 11.44.41.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f588e37838014a6684c141605639b9fa.png)
2. 直接修改 .properities 文件
位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
![CleanShot 2025-08-04 at 11.47.11@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/28c3104448a245419e0b06aee861abb4.png)
处理完环境问题直接跑起来就能通了
![CleanShot 2025-08-04 at 11.49.01@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2c945eae44b1477db09e80fc96b5e02d.png)
> Step3 前端部署
## 启动前端服务
**⚠️ 环境要求Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
```shell
cd ../frontend_nuxt/
cd frontend_nuxt/
```
copy环境.env文件
### 环境变量配置
```shell
cp .env.staging.example .env
```
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。
1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口
- 利用预发环境:**(⚠️强烈推荐只部署前端的朋友使用该环境)**
```yaml
; 本地部署后端
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 开发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=localhost:3000
```
```shell
cp .env.staging.example .env
```
2. 依赖预发环境后台环境
- 利用生产环境
**(⚠️强烈推荐只部署前端的朋友使用该环境)**
```shell
cp .env.production.example .env
```
```yaml
; 本地部署后端
; 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://www.open-isle.com
```
- 利用本地环境
4. 依赖线上后台环境
```shell
cp .env.dev.example .env
```
```yaml
; 本地部署后端
; 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://www.open-isle.com
```
### 安装依赖和运行
前端安装编译并启动服务
```shell
# 安装依赖
@@ -118,3 +154,21 @@ npm run dev
```
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
## 其他配置
配置第三方登录,这里以 GitHub 为例
- 修改 `application.properties` 配置
![后端配置](assets/contributing/backend_img.png)
- 修改 `.env` 配置
![前端](assets/contributing/fontend_img.png)
- 配置第三方登录回调地址
![github配置](assets/contributing/github_img.png)
![github配置2](assets/contributing/github_img_2.png)

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,7 +1,11 @@
# === Spring Boot ===
SERVER_PORT=8080
# === Database ===
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码>
MYSQL_ROOT_PASSWORD=<数据库 root 用户密码>
# === JWT ===
JWT_SECRET=<jwt secret>
@@ -37,4 +41,10 @@ OPENAI_API_KEY=<你的openai-api-key>
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
# === RabbitMQ ===
RABBITMQ_HOST=<你的rabbitmq_host>
RABBITMQ_PORT=<你的rabbitmq_port>
RABBITMQ_USERNAME=<你的rabbitmq_username>
RABBITMQ_PASSWORD=<你的rabbitmq_password>
# LOG_LEVEL=DEBUG

View File

@@ -27,8 +27,8 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@@ -114,6 +114,11 @@
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
<build>
@@ -141,6 +146,26 @@
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<!-- https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-maven-plugin/README.md -->
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<!-- 此处为硬编码,应优化为 env 的配置 -->
<apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
<outputFileName>openapi.json</outputFileName>
<outputDir>${project.build.directory}</outputDir>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,48 @@
package com.openisle.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Value("${springdoc.info.title}")
private String title;
@Value("${springdoc.info.description}")
private String description;
@Value("${springdoc.info.version}")
private String version;
@Value("${springdoc.info.scheme}")
private String scheme;
@Value("${springdoc.info.header}")
private String header;
@Bean
public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase())
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(header);
return new OpenAPI()
.info(new Info()
.title(title)
.description(description)
.version(version))
.components(new Components()
.addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT"));
}
}

View File

@@ -0,0 +1,204 @@
package com.openisle.config;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.DependsOn;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
@Configuration
@RequiredArgsConstructor
public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "openisle-exchange";
// 保持向后兼容的常量
public static final String QUEUE_NAME = "notifications-queue";
public static final String ROUTING_KEY = "notifications.routingkey";
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
private final int queueCount = 16;
@Value("${rabbitmq.queue.durable}")
private boolean queueDurable;
@PostConstruct
public void init() {
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable);
}
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}
/**
* 创建所有分片队列, 使用十六进制后缀 (0-f)
*/
@Bean
public List<Queue> shardedQueues() {
System.out.println("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) {
String shardKey = Integer.toHexString(i);
String queueName = "notifications-queue-" + shardKey;
Queue queue = new Queue(queueName, queueDurable);
queues.add(queue);
}
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size());
return queues;
}
/**
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
*/
@Bean
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
System.out.println("开始创建分片绑定 Bean...");
List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) {
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
String shardKey = queueName.substring("notifications-queue-".length());
String routingKey = "notifications.shard." + shardKey;
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
bindings.add(binding);
}
}
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
return bindings;
}
/**
* 保持向后兼容的单队列配置(可选)
*/
@Bean
public Queue legacyQueue() {
return new Queue(QUEUE_NAME, queueDurable);
}
/**
* 保持向后兼容的单队列绑定(可选)
*/
@Bean
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
}
@Bean
public Jackson2JsonMessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return new Jackson2JsonMessageConverter(objectMapper);
}
@Bean
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
/**
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
*/
@Bean
@DependsOn({"rabbitAdmin", "shardedQueues", "exchange"})
public CommandLineRunner queueDeclarationRunner(RabbitAdmin rabbitAdmin,
@Qualifier("shardedQueues") List<Queue> shardedQueues,
TopicExchange exchange,
Queue legacyQueue,
@Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding) {
return args -> {
System.out.println("=== 开始主动声明 RabbitMQ 组件 ===");
try {
// 声明交换
rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性
System.out.println("开始检查并声明 " + shardedQueues.size() + " 个分片队列...");
int successCount = 0;
int skippedCount = 0;
for (Queue queue : shardedQueues) {
String queueName = queue.getName();
try {
// 使用 declareQueue 的返回值判断队列是否已存在
// 如果队列已存在且配置匹配declareQueue 会返回现有队列信息
// 如果不匹配或不存在,会创建新队列
rabbitAdmin.declareQueue(queue);
successCount++;
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
skippedCount++;
}
} catch (Exception e) {
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage());
}
}
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size());
// 声明分片绑定
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定...");
int bindingSuccessCount = 0;
for (Binding binding : shardedBindings) {
try {
rabbitAdmin.declareBinding(binding);
bindingSuccessCount++;
} catch (Exception e) {
System.err.println("绑定声明失败: " + e.getMessage());
}
}
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size());
// 声明遗留队列和绑定 - 检查存在性
try {
rabbitAdmin.declareQueue(legacyQueue);
rabbitAdmin.declareBinding(legacyBinding);
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)");
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列");
} else {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
}
} catch (Exception e) {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
}
System.out.println("=== RabbitMQ 组件声明完成 ===");
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建");
} catch (Exception e) {
System.err.println("RabbitMQ 组件声明过程中发生严重错误:");
e.printStackTrace();
}
};
}
}

View File

@@ -74,10 +74,14 @@ public class SecurityConfig {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:8081",
"http://127.0.0.1:8082",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
@@ -106,6 +110,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
@@ -176,7 +181,8 @@ public class SecurityConfig {
return;
}
} else if (!uri.startsWith("/api/auth") && !publicGet
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) {
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")
&& !uri.startsWith("/v3/api-docs")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");

View File

@@ -0,0 +1,14 @@
package com.openisle.config;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ShardInfo {
private int shardIndex;
private String queueName;
private String routingKey;
}

View File

@@ -0,0 +1,84 @@
package com.openisle.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.Map;
import java.util.stream.Collectors;
@Component
@Slf4j
public class ShardingStrategy {
// 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑
private static final int QUEUE_COUNT = 16;
// 分片分布统计
private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>();
/**
* 根据用户名获取分片信息(基于哈希值首字符)
*/
public ShardInfo getShardInfo(String username) {
if (username == null || username.isEmpty()) {
// 空用户名默认分到第0个分片
return getShardInfoByIndex(0);
}
// 计算用户名的哈希值并转为十六进制字符串
String hash = Integer.toHexString(Math.abs(username.hashCode()));
// 取哈希值的第一个字符 (0-9, a-f)
char firstChar = hash.charAt(0);
// 十六进制字符映射到队列
int shard = getShardFromHexChar(firstChar);
recordShardUsage(shard);
log.debug("Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
username, hash, firstChar, shard);
return getShardInfoByIndex(shard);
}
/**
* 将十六进制字符映射到分片索引
*/
private int getShardFromHexChar(char hexChar) {
int charValue;
if (hexChar >= '0' && hexChar <= '9') {
charValue = hexChar - '0'; // 0-9
} else if (hexChar >= 'a' && hexChar <= 'f') {
charValue = hexChar - 'a' + 10; // 10-15
} else {
// 异常情况默认为0
charValue = 0;
}
// 映射到队列数量范围内
return charValue % QUEUE_COUNT;
}
/**
* 根据分片索引获取分片信息
*/
private ShardInfo getShardInfoByIndex(int shard) {
String shardKey = Integer.toHexString(shard);
return new ShardInfo(
shard,
"notifications-queue-" + shardKey,
"notifications.shard." + shardKey
);
}
/**
* 记录分片使用统计
*/
private void recordShardUsage(int shard) {
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
}
}

View File

@@ -1,110 +0,0 @@
package com.openisle.config;
import com.openisle.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Value("${app.website-url}")
private String websiteUrl;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue"
config.enableSimpleBroker("/topic", "/queue");
// Set user destination prefix for personal messages
config.setUserDestinationPrefix("/user");
// Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods.
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 1) 原生 WebSocket不带 SockJS
registry.addEndpoint("/api/ws")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
);
// 2) SockJS 回退:单独路径
registry.addEndpoint("/api/sockjs")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
)
.withSockJS()
.setWebSocketEnabled(true)
.setSessionCookieNeeded(false);
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
System.out.println("WebSocket CONNECT command received");
String authHeader = accessor.getFirstNativeHeader("Authorization");
System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
String username = jwtService.validateAndGetSubject(token);
System.out.println("JWT validated for user: " + username);
var userDetails = userDetailsService.loadUserByUsername(username);
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
accessor.setUser(auth);
System.out.println("WebSocket user set: " + username);
} catch (Exception e) {
System.err.println("JWT validation failed: " + e.getMessage());
}
}
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination());
System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null"));
}
return message;
}
});
}
}

View File

@@ -0,0 +1,15 @@
package com.openisle.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageNotificationPayload implements Serializable {
private String targetUsername;
private Object payload;
}

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -20,6 +21,7 @@ public class Message {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id")
@JsonBackReference
private MessageConversation conversation;
@ManyToOne(optional = false, fetch = FetchType.LAZY)

View File

@@ -1,5 +1,7 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -41,8 +43,10 @@ public class MessageConversation {
private Message lastMessage;
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonBackReference
private Set<MessageParticipant> participants = new HashSet<>();
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonBackReference
private Set<Message> messages = new HashSet<>();
}

View File

@@ -1,5 +1,6 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -19,6 +20,7 @@ public class MessageParticipant {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id")
@JsonBackReference
private MessageConversation conversation;
@ManyToOne(optional = false, fetch = FetchType.LAZY)

View File

@@ -11,6 +11,9 @@ import java.util.List;
@Repository
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
@Query("SELECT c FROM MessageConversation c LEFT JOIN FETCH c.participants p LEFT JOIN FETCH p.user WHERE c.id = :id")
java.util.Optional<MessageConversation> findByIdWithParticipantsAndUsers(@Param("id") Long id);
@Query("SELECT c FROM MessageConversation c " +
"WHERE c.channel = false AND size(c.participants) = 2 " +
"AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " +

View File

@@ -16,16 +16,18 @@ import com.openisle.dto.MessageDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.mapper.ReactionMapper;
import com.openisle.dto.MessageNotificationPayload;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@@ -37,7 +39,7 @@ public class MessageService {
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final UserRepository userRepository;
private final SimpMessagingTemplate messagingTemplate;
private final NotificationProducer notificationProducer;
private final ReactionRepository reactionRepository;
private final ReactionMapper reactionMapper;
@@ -69,26 +71,41 @@ public class MessageService {
conversationRepository.save(conversation);
log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId());
// Broadcast the new message to subscribed clients
MessageDto messageDto = toDto(message);
String conversationDestination = "/topic/conversation/" + conversation.getId();
messagingTemplate.convertAndSend(conversationDestination, messageDto);
log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination);
// Also notify the recipient on their personal channel to update the conversation list
String userDestination = "/topic/user/" + recipient.getId() + "/messages";
messagingTemplate.convertAndSend(userDestination, messageDto);
log.info("Message {} notification sent to destination: {}", message.getId(), userDestination);
// Notify recipient of new unread count
long unreadCount = getUnreadMessageCount(recipientId);
log.info("Calculating unread count for user {}: {}", recipientId, unreadCount);
// Send using username instead of user ID for WebSocket routing
String recipientUsername = recipient.getUsername();
messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount);
log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count",
unreadCount, recipientId, recipientUsername, recipientUsername);
try {
MessageDto messageDto = toDto(message);
long unreadCount = getUnreadMessageCount(recipientId);
// 创建包含对话和参与者信息的完整payload
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", conversation.getParticipants().stream()
.map(p -> {
Map<String, Object> participantInfo = new HashMap<>();
participantInfo.put("userId", p.getUser().getId());
participantInfo.put("username", p.getUser().getUsername());
return participantInfo;
}).collect(Collectors.toList()));
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("unreadCount", unreadCount);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
if (notificationProducer != null) {
log.info("NotificationProducer is available");
} else {
log.info("ERROR: NotificationProducer is NULL!");
return message;
}
log.info("Recipient username: {}", recipient.getUsername());
notificationProducer.sendNotification(new MessageNotificationPayload(recipient.getUsername(), combinedPayload));
log.info("=== Notification call completed ===");
} catch (Exception e) {
log.error("=== Error in notification process ===", e);
}
return message;
}
@@ -97,7 +114,7 @@ public class MessageService {
public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) {
User sender = userRepository.findById(senderId)
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
MessageConversation conversation = conversationRepository.findById(conversationId)
MessageConversation conversation = conversationRepository.findByIdWithParticipantsAndUsers(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
// Join the conversation if not already a participant (useful for channels)
@@ -124,22 +141,30 @@ public class MessageService {
conversationRepository.save(conversation);
MessageDto messageDto = toDto(message);
String conversationDestination = "/topic/conversation/" + conversation.getId();
messagingTemplate.convertAndSend(conversationDestination, messageDto);
// Notify all participants except sender for updates
for (MessageParticipant participant : conversation.getParticipants()) {
if (participant.getUser().getId().equals(senderId)) continue;
String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages";
messagingTemplate.convertAndSend(userDestination, messageDto);
// Build participant payloads once to avoid duplicate broadcasts
java.util.List<Map<String, Object>> participantInfos = conversation.getParticipants().stream()
.filter(p -> !p.getUser().getId().equals(senderId))
.map(p -> {
Map<String, Object> info = new HashMap<>();
info.put("userId", p.getUser().getId());
info.put("username", p.getUser().getUsername());
info.put("unreadCount", getUnreadMessageCount(p.getUser().getId()));
info.put("channelUnread", getUnreadChannelCount(p.getUser().getId()));
return info;
}).collect(Collectors.toList());
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
String username = participant.getUser().getUsername();
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
Map<String, Object> conversationInfo = new HashMap<>();
conversationInfo.put("id", conversation.getId());
conversationInfo.put("participants", participantInfos);
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
}
Map<String, Object> combinedPayload = new HashMap<>();
combinedPayload.put("message", messageDto);
combinedPayload.put("conversation", conversationInfo);
combinedPayload.put("senderId", senderId);
// Use sender's username for sharding; only one notification is needed
notificationProducer.sendNotification(new MessageNotificationPayload(sender.getUsername(), combinedPayload));
return message;
}

View File

@@ -0,0 +1,63 @@
package com.openisle.service;
import com.openisle.config.RabbitMQConfig;
import com.openisle.config.ShardInfo;
import com.openisle.config.ShardingStrategy;
import com.openisle.dto.MessageNotificationPayload;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationProducer {
private final RabbitTemplate rabbitTemplate;
private final ShardingStrategy shardingStrategy;
@Value("${rabbitmq.sharding.enabled}")
private boolean shardingEnabled;
public void sendNotification(MessageNotificationPayload payload) {
String targetUsername = payload.getTargetUsername();
try {
if (shardingEnabled) {
// 使用分片策略发送消息
sendShardedNotification(payload, targetUsername);
} else {
// 使用原始单队列方式发送(向后兼容)
sendLegacyNotification(payload);
}
} catch (Exception e) {
log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e);
throw e;
}
}
/**
* 使用分片策略发送消息
*/
private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) {
ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
shardInfo.getRoutingKey(),
payload
);
}
/**
* 使用原始单队列方式发送消息(向后兼容)
*/
private void sendLegacyNotification(MessageNotificationPayload payload) {
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.ROUTING_KEY,
payload
);
}
}

View File

@@ -1,3 +1,6 @@
# for spring boot
server.port=${SERVER_PORT:8080}
# for mysql
logging.level.root=${LOG_LEVEL:INFO}
logging.level.com.openisle.service.CosImageUploader=DEBUG
@@ -83,3 +86,23 @@ app.website-url=${WEBSITE_URL:https://www.open-isle.com}
# Web push configuration
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
# RabbitMQ Configuration
spring.rabbitmq.host=${RABBITMQ_HOST:localhost}
spring.rabbitmq.port=${RABBITMQ_PORT:5672}
spring.rabbitmq.username=${RABBITMQ_USERNAME:guest}
spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest}
# RabbitMQ 队列配置 - 修改为非持久化以匹配现有队列
rabbitmq.queue.durable=true
rabbitmq.sharding.enabled=true
# springdoc-openapi-starter-webmvc-api
# see https://springdoc.org/#springdoc-openapi-core-properties
springdoc.api-docs.path=/v3/api-docs
springdoc.api-docs.enabled=true
springdoc.info.title=OpenIsle
springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1
springdoc.info.scheme=Bearer
springdoc.info.header=Authorization

View File

@@ -0,0 +1,27 @@
-- 2025-09-02
-- 本地化开发,初始化脚本
-- 抽奖的时候奖品图片是必须的把相关代码注释掉即可跳过check
-- 清空users表
DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
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$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', '管理员', NULL, b'1'),
(2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', '普通用户2', NULL, b'1'),
(3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', '普通用户1', NULL, b'1');
-- 清空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表
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);

View File

@@ -0,0 +1,41 @@
services:
# MySQL service
mysql:
image: mysql:8.0
container_name: openisle-mysql
restart: always
env_file:
- ../backend/open-isle.env
ports:
- '3306:3306'
volumes:
- mysql-data:/var/lib/mysql
- ../backend/src/main/resources/db/init/init_script.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- openisle-network
# Java spring boot service
springboot:
image: maven:3.9-eclipse-temurin-17
container_name: openisle-springboot
working_dir: /app
volumes:
- ../backend:/app
- maven-repo:/root/.m2
ports:
- '8080:8080'
env_file:
- ../backend/open-isle.env
depends_on:
- mysql
command: mvn clean spring-boot:run -Dmaven.test.skip=true
networks:
- openisle-network
networks:
openisle-network:
driver: bridge
volumes:
mysql-data:
maven-repo:

View File

@@ -0,0 +1,10 @@
; 本地部署后端
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
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_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135

View File

@@ -5,6 +5,9 @@
; 生产环境后端
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
; 正式环境/生产环境

View File

@@ -0,0 +1,13 @@
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
; 正式环境/生产环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
; 生产环境ws后端
NUXT_PUBLIC_WEBSOCKET_URL=https://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

View File

@@ -1,14 +1,14 @@
; 本地部署后端
; 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://staging.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

View File

@@ -16,7 +16,8 @@
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<i class="fas fa-stopwatch prize-end-time-icon"></i>
<div v-if="!isMobile" class="prize-end-time-title">离结束</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
@@ -84,6 +85,7 @@ import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useIsMobile } from '~/utils/screen'
import { useCountdown } from '~/composables/useCountdown'
const props = defineProps({
lottery: { type: Object, required: true },
@@ -95,54 +97,14 @@ const isMobile = useIsMobile()
const loggedIn = computed(() => authState.loggedIn)
const lotteryParticipants = computed(() => props.lottery?.participants || [])
const lotteryWinners = computed(() => props.lottery?.winners || [])
const lotteryEnded = computed(() => {
if (!props.lottery || !props.lottery.endTime) return false
return new Date(props.lottery.endTime).getTime() <= Date.now()
})
// 倒计时和结束flg
const { countdown, isEnded } = useCountdown(props.lottery?.endTime)
const lotteryEnded = computed(() => isEnded.value)
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.lottery || !props.lottery.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.lottery.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.lottery?.endTime,
() => {
if (props.lottery && props.lottery.endTime) startCountdown()
},
)
onMounted(() => {
if (props.lottery && props.lottery.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
@@ -229,6 +191,10 @@ const joinLottery = async () => {
margin-left: 10px;
}
.prize-end-time-icon {
font-size: 13px;
}
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;

View File

@@ -34,7 +34,8 @@
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<div class="poll-left-time-title">离结束还有</div>
<i class="fas fa-stopwatch poll-left-time-icon"></i>
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
@@ -107,7 +108,12 @@
<i class="fas fa-stopwatch"></i> 投票已结束
</div>
<div v-else class="poll-option-hint">
<i class="fas fa-stopwatch"></i> 您已投票等待结束查看结果
<div>您已投票等待结束查看结果</div>
<div class="poll-left-time">
<i class="fas fa-stopwatch poll-left-time-icon"></i>
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
</div>
</div>
@@ -118,6 +124,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useCountdown } from '~/composables/useCountdown'
const props = defineProps({
poll: { type: Object, required: true },
@@ -140,10 +147,9 @@ const pollPercentages = computed(() =>
})
: [],
)
const pollEnded = computed(() => {
if (!props.poll || !props.poll.endTime) return false
return new Date(props.poll.endTime).getTime() <= Date.now()
})
// 倒计时
const { countdown, isEnded } = useCountdown(props.poll?.endTime)
const pollEnded = computed(() => isEnded.value)
const hasVoted = computed(() => {
if (!loggedIn.value) return false
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
@@ -152,45 +158,6 @@ watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.poll || !props.poll.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.poll.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.poll?.endTime,
() => {
if (props.poll && props.poll.endTime) startCountdown()
},
)
onMounted(() => {
if (props.poll && props.poll.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
@@ -361,6 +328,18 @@ const submitMultiPoll = async () => {
gap: 5px;
}
.poll-left-time-icon {
font-size: 13px;
}
.poll-option-hint {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.poll-left-time-title {
font-size: 13px;
opacity: 0.7;

View File

@@ -14,7 +14,7 @@
:class="{ selected: userReacted(r.type) }"
@click="toggleReaction(r.type)"
>
<BaseImage :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
<BaseImage :src="reactionEmojiMap[r.type]" class="reaction-emoji" alt="emoji" />
<div>{{ counts[r.type] }}</div>
</div>
@@ -220,6 +220,7 @@ onMounted(async () => {
align-items: center;
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.reactions-viewer {
@@ -229,6 +230,12 @@ onMounted(async () => {
align-items: center;
}
.reaction-emoji {
width: 20px;
height: 20px;
vertical-align: middle;
}
.reactions-viewer-item-container {
display: flex;
flex-direction: row;
@@ -337,5 +344,23 @@ onMounted(async () => {
font-size: 16px;
padding: 3px 5px;
}
.reactions-viewer-item.placeholder,
.reactions-viewer-single-item {
padding: 4px 8px;
gap: 3px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 3px;
margin-bottom: 3px;
font-size: 12px;
color: var(--text-color);
align-items: center;
}
.reaction-emoji {
width: 14px;
height: 14px;
}
}
</style>

View File

@@ -3,82 +3,73 @@ import { useWebSocket } from './useWebSocket'
import { getToken } from '~/utils/auth'
const count = ref(0)
let isInitialized = false
let wsSubscription = null
let isInitialized = false;
export function useChannelsUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const { subscribe, isConnected, connect } = useWebSocket()
const config = useRuntimeConfig();
const API_BASE_URL = config.public.apiBaseUrl;
const { subscribe, isConnected, connect } = useWebSocket();
const fetchChannelUnread = async () => {
const token = getToken()
const token = getToken();
if (!token) {
count.value = 0
return
count.value = 0;
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
headers: { Authorization: `Bearer ${token}` },
})
});
if (response.ok) {
const data = await response.json()
count.value = data
const data = await response.json();
count.value = data;
}
} catch (e) {
console.error('Failed to fetch channel unread count:', e)
console.error('Failed to fetch channel unread count:', e);
}
}
};
const setupWebSocketListener = () => {
const destination = '/user/queue/channel-unread';
subscribe(destination, (message) => {
const unread = parseInt(message.body, 10);
if (!isNaN(unread)) {
count.value = unread;
}
}).then(subscription => {
if (subscription) {
console.log('频道未读消息订阅成功');
}
});
};
const initialize = () => {
const token = getToken()
const token = getToken();
if (!token) {
count.value = 0
return
count.value = 0;
return;
}
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
const setupWebSocketListener = () => {
if (!wsSubscription) {
watch(
isConnected,
(newValue) => {
if (newValue && !wsSubscription) {
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
const unread = parseInt(message.body, 10)
if (!isNaN(unread)) {
count.value = unread
}
})
}
},
{ immediate: true },
)
if (!isConnected.value) {
connect(token);
}
}
fetchChannelUnread();
setupWebSocketListener();
};
const setFromList = (channels) => {
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
}
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0;
};
const hasUnread = computed(() => count.value > 0)
const hasUnread = computed(() => count.value > 0);
const token = getToken()
if (token) {
if (!isInitialized) {
isInitialized = true
initialize()
} else {
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
if (!isInitialized) {
const token = getToken();
if (token) {
isInitialized = true;
initialize();
}
}
@@ -88,5 +79,5 @@ export function useChannelsUnreadCount() {
fetchChannelUnread,
initialize,
setFromList,
}
};
}

View File

@@ -0,0 +1,51 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
/**
* 通用倒计时 composable
* @param endTime 截止时间字符串或 Date 对象
* @returns { countdown, isEnded }
*/
export function useCountdown(endTime?: string | Date) {
const countdown = ref('')
const isEnded = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const update = () => {
if (!endTime) {
countdown.value = ''
isEnded.value = true
return
}
const diff = new Date(endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '已结束'
isEnded.value = true
if (timer) clearInterval(timer)
return
}
// 计算天、时、分、秒
const days = Math.floor(diff / (24 * 3600 * 1000))
const hours = Math.floor((diff % (24 * 3600 * 1000)) / 3600000)
const minutes = Math.floor((diff % 3600000) / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
if (days > 0) {
countdown.value = `${days}${hours}小时 ${minutes}${seconds}`
} else if (hours > 0) {
countdown.value = `${hours}小时 ${minutes}${seconds}`
} else {
countdown.value = `${minutes}${seconds}`
}
}
onMounted(() => {
update()
timer = setInterval(update, 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
return { countdown, isEnded }
}

View File

@@ -4,7 +4,6 @@ import { getToken } from '~/utils/auth';
const count = ref(0);
let isInitialized = false;
let wsSubscription = null;
export function useUnreadCount() {
const config = useRuntimeConfig();
@@ -30,64 +29,48 @@ export function useUnreadCount() {
}
};
const initialize = async () => {
const setupWebSocketListener = () => {
console.log('设置未读消息订阅...');
const destination = '/user/queue/unread-count';
subscribe(destination, (message) => {
const unreadCount = parseInt(message.body, 10);
if (!isNaN(unreadCount)) {
count.value = unreadCount;
}
}).then(subscription => {
if (subscription) {
console.log('未读消息订阅成功');
}
});
};
const initialize = () => {
const token = getToken();
if (!token) {
count.value = 0;
return;
}
// 总是获取最新的未读数量
fetchUnreadCount();
// 确保WebSocket连接
if (!isConnected.value) {
connect(token);
}
// 设置WebSocket监听
await setupWebSocketListener();
fetchUnreadCount();
setupWebSocketListener();
};
const setupWebSocketListener = async () => {
// 只有在还没有订阅的情况下才设置监听
if (!wsSubscription) {
watch(isConnected, (newValue) => {
if (newValue && !wsSubscription) {
const destination = `/user/queue/unread-count`;
wsSubscription = subscribe(destination, (message) => {
const unreadCount = parseInt(message.body, 10);
if (!isNaN(unreadCount)) {
count.value = unreadCount;
}
});
}
}, { immediate: true });
}
};
// 自动初始化逻辑 - 确保每次调用都能获取到未读数量并设置监听
const token = getToken();
if (token) {
if (!isInitialized) {
if (!isInitialized) {
const token = getToken();
if (token) {
isInitialized = true;
initialize(); // 完整初始化包括WebSocket监听
} else {
// 即使已经初始化也要确保获取最新的未读数量并确保WebSocket监听存在
fetchUnreadCount();
// 确保WebSocket连接和监听都存在
if (!isConnected.value) {
connect(token);
}
setupWebSocketListener();
initialize();
}
}
return {
count,
fetchUnreadCount,
initialize,
initialize,
};
}

View File

@@ -1,86 +1,182 @@
import { ref } from 'vue'
import { ref, readonly, watch } from 'vue'
import { Client } from '@stomp/stompjs'
import SockJS from 'sockjs-client/dist/sockjs.min.js'
import { useRuntimeConfig } from '#app'
const client = ref(null)
const isConnected = ref(false)
const activeSubscriptions = ref(new Map())
// Store callbacks to allow for re-subscription after reconnect
const resubscribeCallbacks = new Map()
// Helper for unified subscription logging
const logSubscriptionActivity = (action, destination, subscriptionId = 'N/A') => {
console.log(
`[SUB_MAN] ${action} | Dest: ${destination} | SubID: ${subscriptionId} | Active: ${activeSubscriptions.value.size}`
)
}
const connect = (token) => {
if (isConnected.value) {
if (isConnected.value || (client.value && client.value.active)) {
return
}
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const socketUrl = `${API_BASE_URL}/api/sockjs`
const socket = new SockJS(socketUrl)
const config = useRuntimeConfig()
const WEBSOCKET_URL = config.public.websocketUrl
const socketUrl = `${WEBSOCKET_URL}/api/sockjs`
const stompClient = new Client({
webSocketFactory: () => socket,
webSocketFactory: () => new SockJS(socketUrl),
connectHeaders: {
Authorization: `Bearer ${token}`,
},
debug: function (str) {},
reconnectDelay: 5000,
debug: function (str) {
},
reconnectDelay: 10000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
})
stompClient.onConnect = (frame) => {
isConnected.value = true
resubscribeCallbacks.forEach((callback, destination) => {
doSubscribe(destination, callback)
})
}
stompClient.onStompError = (frame) => {
console.error('WebSocket STOMP error:', frame)
console.error('Full frame:', frame)
}
stompClient.onWebSocketError = (event) => {
}
stompClient.onWebSocketClose = (event) => {
isConnected.value = false;
activeSubscriptions.value.clear();
logSubscriptionActivity('Cleared all subscriptions due to WebSocket close', 'N/A');
};
stompClient.onDisconnect = (frame) => {
isConnected.value = false
}
stompClient.activate()
client.value = stompClient
}
const unsubscribe = (destination) => {
if (!destination) {
return false
}
const subscription = activeSubscriptions.value.get(destination)
if (subscription) {
try {
subscription.unsubscribe()
logSubscriptionActivity('Unsubscribed', destination, subscription.id)
} catch (e) {
console.error(`Error during unsubscribe for ${destination}:`, e)
} finally {
activeSubscriptions.value.delete(destination)
resubscribeCallbacks.delete(destination)
}
return true
} else {
return false
}
}
const unsubscribeAll = () => {
logSubscriptionActivity('Unsubscribing from ALL', `Total: ${activeSubscriptions.value.size}`)
const destinations = [...activeSubscriptions.value.keys()]
destinations.forEach(dest => {
unsubscribe(dest)
})
}
const disconnect = () => {
unsubscribeAll()
if (client.value) {
isConnected.value = false
client.value.deactivate()
try {
client.value.deactivate()
} catch (e) {
console.error('Error during client deactivation:', e)
}
client.value = null
isConnected.value = false
}
}
const doSubscribe = (destination, callback) => {
try {
if (!client.value || !client.value.connected) {
return null
}
if (activeSubscriptions.value.has(destination)) {
unsubscribe(destination)
}
const subscription = client.value.subscribe(destination, (message) => {
callback(message)
})
if (subscription) {
activeSubscriptions.value.set(destination, subscription)
resubscribeCallbacks.set(destination, callback) // Store for re-subscription
logSubscriptionActivity('Subscribed', destination, subscription.id)
return subscription
} else {
return null
}
} catch (error) {
console.error(`Exception during subscription to ${destination}:`, error)
return null
}
}
const subscribe = (destination, callback) => {
if (!isConnected.value || !client.value || !client.value.connected) {
return null
if (!destination) {
return Promise.resolve(null)
}
try {
const subscription = client.value.subscribe(destination, (message) => {
try {
if (
destination.includes('/queue/unread-count') ||
destination.includes('/queue/channel-unread')
) {
callback(message)
} else {
const parsedMessage = JSON.parse(message.body)
callback(parsedMessage)
return new Promise((resolve) => {
if (client.value && client.value.connected) {
const sub = doSubscribe(destination, callback)
resolve(sub)
} else {
const unwatch = watch(isConnected, (newVal) => {
if (newVal) {
setTimeout(() => {
const sub = doSubscribe(destination, callback)
unwatch()
resolve(sub)
}, 100)
}
} catch (error) {
callback(message)
}
})
return subscription
} catch (error) {
return null
}
}, { immediate: false })
setTimeout(() => {
unwatch()
if (!isConnected.value) {
resolve(null)
}
}, 15000)
}
})
}
export function useWebSocket() {
return {
client,
client: readonly(client),
isConnected,
connect,
disconnect,
subscribe,
unsubscribe,
unsubscribeAll,
activeSubscriptions: readonly(activeSubscriptions),
}
}

View File

@@ -6,6 +6,7 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
websocketUrl: process.env.NUXT_PUBLIC_WEBSOCKET_URL || '',
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',

View File

@@ -100,10 +100,9 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
const config = useRuntimeConfig()
const route = useRoute()
const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { connect, subscribe, unsubscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
let subscription = null
const messages = ref([])
const participants = ref([])
@@ -338,8 +337,12 @@ onMounted(async () => {
// 初次进入频道时,平滑滚动到底部
scrollToBottomSmooth()
const token = getToken()
if (token && !isConnected.value) {
connect(token)
if (token) {
if (isConnected.value) {
subscribeToConversation()
} else {
connect(token)
}
}
} else {
toast.error('请先登录')
@@ -347,26 +350,39 @@ onMounted(async () => {
}
})
const subscribeToConversation = () => {
if (!currentUser.value) return;
const destination = `/topic/conversation/${conversationId}`
subscribe(destination, async (message) => {
try {
const parsedMessage = JSON.parse(message.body)
if (parsedMessage.sender && parsedMessage.sender.id === currentUser.value.id) {
return
}
messages.value.push({
...parsedMessage,
src: parsedMessage.sender.avatar,
iconClick: () => openUser(parsedMessage.sender.id),
})
await markConversationAsRead()
await nextTick()
if (isUserNearBottom.value) {
scrollToBottomSmooth()
}
} catch (e) {
console.error("Failed to parse websocket message", e)
}
})
}
watch(isConnected, (newValue) => {
if (newValue) {
setTimeout(() => {
subscription = subscribe(`/topic/conversation/${conversationId}`, async (message) => {
// 避免重复显示当前用户发送的消息
if (message.sender.id !== currentUser.value.id) {
messages.value.push({
...message,
src: message.sender.avatar,
iconClick: () => {
openUser(message.sender.id)
},
})
// 收到消息后只标记已读,不强制滚动(符合“非发送不拉底”)
markConversationAsRead()
await nextTick()
updateNearBottom()
}
})
}, 500)
subscribeToConversation()
}
})
@@ -378,7 +394,12 @@ onActivated(async () => {
await nextTick()
scrollToBottomSmooth()
updateNearBottom()
if (!isConnected.value) {
if (isConnected.value) {
// 如果已连接,重新订阅
subscribeToConversation()
} else {
// 如果未连接,则发起连接
const token = getToken()
if (token) connect(token)
}
@@ -386,22 +407,17 @@ onActivated(async () => {
})
onDeactivated(() => {
if (subscription) {
subscription.unsubscribe()
subscription = null
}
disconnect()
const destination = `/topic/conversation/${conversationId}`
unsubscribe(destination)
})
onUnmounted(() => {
if (subscription) {
subscription.unsubscribe()
subscription = null
}
const destination = `/topic/conversation/${conversationId}`
unsubscribe(destination)
if (messagesListEl.value) {
messagesListEl.value.removeEventListener('scroll', updateNearBottom)
}
disconnect()
})
function minimize() {

View File

@@ -118,7 +118,7 @@
</template>
<script setup>
import { ref, onUnmounted, watch, onActivated, computed } from 'vue'
import { ref, onUnmounted, watch, onActivated, computed, onDeactivated } from 'vue'
import { useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
@@ -139,11 +139,10 @@ const error = ref(null)
const route = useRoute()
const currentUser = ref(null)
const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { connect, subscribe, unsubscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadFromList } =
useChannelsUnreadCount()
let subscription = null
const activeTab = ref('channels')
const tabs = [
@@ -259,37 +258,45 @@ onActivated(async () => {
refreshGlobalUnreadCount()
refreshChannelUnread()
const token = getToken()
if (token && !isConnected.value) {
connect(token)
if (token) {
if (isConnected.value) {
// 如果已经连接,但可能因为组件销毁而取消了订阅,所以需要重新订阅
subscribeToUserMessages()
} else {
// 如果未连接,则发起连接,连接成功后 watch 回调会处理订阅
connect(token)
}
}
} else {
loading.value = false
}
})
watch(isConnected, (newValue) => {
if (newValue && currentUser.value) {
const destination = `/topic/user/${currentUser.value.id}/messages`
// 清理旧的订阅
if (subscription) {
subscription.unsubscribe()
}
subscription = subscribe(destination, (message) => {
const subscribeToUserMessages = () => {
if (!currentUser.value) return;
const destination = `/topic/user/${currentUser.value.id}/messages`
subscribe(destination, (message) => {
if (activeTab.value === 'messages') {
fetchConversations()
if (activeTab.value === 'channels') {
fetchChannels()
}
})
}
fetchChannels()
refreshGlobalUnreadCount()
refreshChannelUnread()
})
}
watch(isConnected, (newValue) => {
if (newValue) {
subscribeToUserMessages()
}
})
onUnmounted(() => {
if (subscription) {
subscription.unsubscribe()
onDeactivated(() => {
if (currentUser.value) {
const destination = `/topic/user/${currentUser.value.id}/messages`
unsubscribe(destination)
}
disconnect()
})
function goToConversation(id) {

View File

@@ -895,6 +895,7 @@ watch(selectedTab, async (val) => {
font-weight: bold;
color: var(--primary-color);
text-decoration: none;
word-break: break-word;
}
.timeline-link:hover {
@@ -969,9 +970,5 @@ watch(selectedTab, async (val) => {
.hot-tag {
width: 100%;
}
.profile-timeline {
width: calc(100vw - 40px);
}
}
</style>

View File

@@ -0,0 +1 @@
google-site-verification: googlea6f18c4a543fb356.html

View File

@@ -2,7 +2,11 @@ const toCdnUrl = (emoji) => {
const codepoints = Array.from(emoji)
.map((c) => c.codePointAt(0).toString(16))
.join('_')
return `https://fonts.gstatic.com/s/e/notoemoji/latest/${codepoints}/emoji.svg`
// 国外镜像有点小卡 (=゚ω゚)ノ, 国内大部分地区访问时会触发 SNI 封锁 / DNS 污染
// return `https://fonts.gstatic.com/s/e/notoemoji/latest/${codepoints}/emoji.svg`
// loli.net即字节系开源社区 mirror比如 jsDelivr 中国优化节点背后的 CDN 体系). 不会被墙
return `https://gstatic.loli.net/s/e/notoemoji/latest/${codepoints}/emoji.svg`
}
export const reactionEmojiMap = {

View File

@@ -1,6 +1,9 @@
// cdn.jsdelivr.net/gh/... 国内容易抽风
// export const TIEBA_EMOJI_CDN = 'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/'
// Finally方案: 自托管
export const TIEBA_EMOJI_CDN =
'https://cdn.jsdelivr.net/gh/microlong666/tieba_mobile_emotions@master/'
// export const TIEBA_EMOJI_CDN = 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor/dist/images/emoji/'
'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/tieba/'
export const tiebaEmoji = (() => {
const map = { tieba1: TIEBA_EMOJI_CDN + 'image_emoticon.png' }

74
websocket_service/pom.xml Normal file
View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.openisle</groupId>
<artifactId>websocket-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>websocket-service</name>
<description>Dedicated WebSocket service for OpenIsle</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-actuator</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package com.openisle.websocket;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WebsocketServiceApplication {
public static void main(String[] args) {
SpringApplication.run(WebsocketServiceApplication.class, args);
}
}

View File

@@ -0,0 +1,27 @@
package com.openisle.websocket.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitMQConfig {
@Bean
public Jackson2JsonMessageConverter messageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return new Jackson2JsonMessageConverter(objectMapper);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
}

View File

@@ -0,0 +1,84 @@
package com.openisle.websocket.config;
import com.openisle.websocket.security.JwtService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketAuthInterceptor implements ChannelInterceptor {
private final JwtService jwtService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
log.info("WebSocket CONNECT 请求 - 开始认证");
String authHeader = accessor.getFirstNativeHeader("Authorization");
log.debug("Authorization 头: {}", authHeader != null ? "存在" : "缺失");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
log.debug("提取的token长度: {}", token.length());
try {
String username = jwtService.extractUsername(token);
log.debug("从token中提取的用户名: {}", username);
if (username != null && jwtService.isTokenValid(token)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authToken);
accessor.setUser(authToken);
log.info("WebSocket 连接认证成功,用户: {}", username);
} else {
log.warn("WebSocket 连接认证失败 - token无效或用户名为空");
log.debug("用户名: {}, token有效性: {}", username, jwtService.isTokenValid(token));
return null; // 拒绝连接
}
} catch (Exception e) {
log.error("WebSocket JWT token处理异常: {}", e.getMessage(), e);
return null; // 拒绝连接
}
} else {
log.warn("WebSocket 连接认证失败 - 缺少有效的Authorization头");
log.debug("Authorization头内容: {}", authHeader);
return null; // 拒绝连接
}
} else if (accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
log.debug("WebSocket SUBSCRIBE 请求到: {}", accessor.getDestination());
} else if (accessor != null && StompCommand.SEND.equals(accessor.getCommand())) {
log.debug("WebSocket SEND 请求到: {}", accessor.getDestination());
}
return message;
}
@Override
public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null) {
if (StompCommand.CONNECT.equals(accessor.getCommand()) && sent) {
log.info("WebSocket 连接建立成功");
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
log.info("WebSocket 连接已断开");
}
}
}
}

View File

@@ -0,0 +1,73 @@
package com.openisle.websocket.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketAuthInterceptor webSocketAuthInterceptor;
@Value("${app.website-url}")
private String websiteUrl;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
ThreadPoolTaskScheduler ts = new ThreadPoolTaskScheduler();
ts.setPoolSize(1);
ts.setThreadNamePrefix("wss-heartbeat-thread-");
ts.initialize();
config.enableSimpleBroker("/queue", "/topic")
.setHeartbeatValue(new long[]{10000, 10000})
.setTaskScheduler(ts);
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 1) 原生 WebSocket不带 SockJS
registry.addEndpoint("/api/ws")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
);
// 2) SockJS 回退:单独路径
registry.addEndpoint("/api/sockjs")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
)
.withSockJS()
.setWebSocketEnabled(true)
.setSessionCookieNeeded(false);
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketAuthInterceptor);
}
}

View File

@@ -0,0 +1,15 @@
package com.openisle.websocket.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageNotificationPayload implements Serializable {
private String targetUsername;
private Object payload;
}

View File

@@ -0,0 +1,114 @@
package com.openisle.websocket.listener;
import com.openisle.websocket.dto.MessageNotificationPayload;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.lang.Nullable;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {
private final SimpMessagingTemplate messagingTemplate;
/**
* Unified listener for all sharded queues and the backward-compatible legacy queue.
*
* @param payload The message payload.
* @param queueName The name of the queue the message was consumed from. This header is optional.
*/
@RabbitListener(
id = "shardedListenerContainer",
queues = {
"notifications-queue-0", "notifications-queue-1", "notifications-queue-2", "notifications-queue-3",
"notifications-queue-4", "notifications-queue-5", "notifications-queue-6", "notifications-queue-7",
"notifications-queue-8", "notifications-queue-9", "notifications-queue-a", "notifications-queue-b",
"notifications-queue-c", "notifications-queue-d", "notifications-queue-e", "notifications-queue-f",
"notifications-queue"
}
)
public void receiveMessage(MessageNotificationPayload payload, @Header("amqp_consumedQueue") @Nullable String queueName) {
if (queueName != null) {
String queueNamePrefix = "notifications-queue-";
if (queueName.startsWith(queueNamePrefix)) {
String shardIndexStr = queueName.substring(queueNamePrefix.length());
log.info("=== RabbitMQ Message Received from Shard {} ({}) ===", shardIndexStr, queueName);
} else {
log.info("=== RabbitMQ Message Received from Legacy Queue ({}) ===", queueName);
}
}
String username = payload.getTargetUsername();
Object payloadObject = payload.getPayload();
log.info("Target username: {}", username);
log.info("Payload object type: {}", payloadObject != null ? payloadObject.getClass().getSimpleName() : "null");
log.info("Payload content: {}", payloadObject);
try {
if (payloadObject instanceof Map) {
Map<String, Object> payloadMap = (Map<String, Object>) payloadObject;
// 处理包含完整对话信息的消息 - 完全复制之前的WebSocket发送逻辑
if (payloadMap.containsKey("message") && payloadMap.containsKey("conversation") && payloadMap.containsKey("senderId")) {
Object messageObj = payloadMap.get("message");
Map<String, Object> conversationInfo = (Map<String, Object>) payloadMap.get("conversation");
Long conversationId = ((Number) conversationInfo.get("id")).longValue();
Long senderId = ((Number) payloadMap.get("senderId")).longValue();
List<Map<String, Object>> participants = (List<Map<String, Object>>) conversationInfo.get("participants");
// 1. 发送到conversation topic
String conversationDestination = "/topic/conversation/" + conversationId;
messagingTemplate.convertAndSend(conversationDestination, messageObj);
log.info("Message broadcasted to destination: {}", conversationDestination);
// 2. 为所有参与者(除发送者外)发送到个人频道和未读数量
for (Map<String, Object> participant : participants) {
Long participantUserId = ((Number) participant.get("userId")).longValue();
String participantUsername = (String) participant.get("username");
if (!participantUserId.equals(senderId)) {
// 发送到用户个人消息频道
String userDestination = "/topic/user/" + participantUserId + "/messages";
messagingTemplate.convertAndSend(userDestination, messageObj);
log.info("Message notification sent to destination: {}", userDestination);
// 优先从 participant 中获取未读信息,兼容旧格式
Object unreadCount = participant.getOrDefault("unreadCount", payloadMap.get("unreadCount"));
if (unreadCount != null) {
messagingTemplate.convertAndSendToUser(participantUsername, "/queue/unread-count", unreadCount);
log.info("Sent unread count to user {} via /user/{}/queue/unread-count", participantUsername, participantUsername);
}
Object channelUnread = participant.getOrDefault("channelUnread", payloadMap.get("channelUnread"));
if (channelUnread != null) {
messagingTemplate.convertAndSendToUser(participantUsername, "/queue/channel-unread", channelUnread);
log.info("Sent channel-unread to {}", participantUsername);
}
}
}
}
// 处理简化的消息格式(向后兼容)
else if (payloadMap.containsKey("message")) {
if (payloadMap.containsKey("unreadCount")) {
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", payloadMap.get("unreadCount"));
log.info("Sent unread count to user {} via /user/{}/queue/unread-count", username, username);
}
if (payloadMap.containsKey("channelUnread")) {
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", payloadMap.get("channelUnread"));
log.info("Sent channel-unread to {}", username);
}
}
}
} catch (Exception e) {
log.error("Failed to process and send message for user {}", username, e);
}
}
}

View File

@@ -0,0 +1,19 @@
package com.openisle.websocket.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import java.util.Collections;
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
@Bean
public UserDetailsService userDetailsService() {
return username -> new User(username, "", Collections.emptyList());
}
}

View File

@@ -0,0 +1,98 @@
package com.openisle.websocket.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.function.Function;
@Service
public class JwtService {
private static final Logger logger = LoggerFactory.getLogger(JwtService.class);
@Value("${app.jwt.secret}")
private String secret;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public boolean isTokenValid(String token) {
try {
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
logger.debug("解析JWT token - secret长度: {}", secret != null ? secret.length() : "null");
try {
return Jwts
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
logger.error("JWT解析失败: {}", e.getMessage());
throw e;
}
}
private Key getSignInKey() {
// 使用与backend相同的密钥处理方式直接Base64解码
byte[] keyBytes;
try {
// 尝试Base64解码
keyBytes = java.util.Base64.getDecoder().decode(secret);
} catch (IllegalArgumentException e) {
// 如果不是Base64格式使用UTF-8字节
keyBytes = secret.getBytes(StandardCharsets.UTF_8);
// 确保密钥长度至少256位32字节
if (keyBytes.length < 32) {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
keyBytes = digest.digest(keyBytes);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
}
return Keys.hmacShaKeyFor(keyBytes);
}
public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}

View File

@@ -0,0 +1,71 @@
package com.openisle.websocket.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Value("${app.website-url}")
private String websiteUrl;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://127.0.0.1:8080",
"http://127.0.0.1:8081",
"http://127.0.0.1:8082",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").permitAll() // Permit all HTTP requests
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}

View File

@@ -0,0 +1,22 @@
server.port=${SERVER_PORT:8082}
# 服务器配置
spring.application.name=websocket-service
# RabbitMQ 配置
spring.rabbitmq.host=${RABBITMQ_HOST:localhost}
spring.rabbitmq.port=${RABBITMQ_PORT:5672}
spring.rabbitmq.username=${RABBITMQ_USERNAME:guest}
spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest}
spring.rabbitmq.virtual-host=/
# JWT 配置
app.jwt.secret=${JWT_SECRET:jwt_sec}
# 日志配置
logging.level.com.openisle=${LOG_LEVEL:INFO}
logging.level.org.springframework.messaging=${MESSAGING_LOG_LEVEL:DEBUG}
logging.level.org.springframework.web.socket=${WEBSOCKET_LOG_LEVEL:DEBUG}
# 网站 URL 配置
app.website-url=${WEBSITE_URL:https://www.open-isle.com}

View File

@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 定义日志输出格式 -->
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/websocket-service.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/websocket-service.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- WebSocket 相关日志 -->
<appender name="WEBSOCKET_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/websocket.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/websocket.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>500MB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 错误日志单独输出 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<file>logs/error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>500MB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 异步日志配置 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>512</queueSize>
<includeCallerData>false</includeCallerData>
<appender-ref ref="FILE"/>
</appender>
<!-- 异步 WebSocket 日志 -->
<appender name="ASYNC_WEBSOCKET" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>256</queueSize>
<includeCallerData>false</includeCallerData>
<appender-ref ref="WEBSOCKET_FILE"/>
</appender>
<!-- 特定包的日志级别配置 -->
<logger name="com.openisle.websocket" level="INFO" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</logger>
<!-- WebSocket 相关日志 -->
<logger name="com.openisle.websocket.controller.WebSocketController" level="DEBUG" additivity="false">
<appender-ref ref="ASYNC_WEBSOCKET"/>
<appender-ref ref="CONSOLE"/>
</logger>
<logger name="com.openisle.websocket.config.WebSocketAuthInterceptor" level="INFO" additivity="false">
<appender-ref ref="ASYNC_WEBSOCKET"/>
<appender-ref ref="CONSOLE"/>
</logger>
<logger name="com.openisle.websocket.listener.NotificationListener" level="INFO" additivity="false">
<appender-ref ref="ASYNC_WEBSOCKET"/>
<appender-ref ref="CONSOLE"/>
</logger>
<!-- Spring WebSocket 日志 -->
<logger name="org.springframework.web.socket" level="DEBUG" additivity="false">
<appender-ref ref="ASYNC_WEBSOCKET"/>
</logger>
<logger name="org.springframework.messaging" level="DEBUG" additivity="false">
<appender-ref ref="ASYNC_WEBSOCKET"/>
</logger>
<!-- RabbitMQ 日志 -->
<logger name="org.springframework.amqp" level="INFO" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
</logger>
<!-- 根日志级别配置 -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<!-- 开发环境配置 -->
<springProfile name="dev">
<root level="DEBUG">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- 生产环境配置 -->
<springProfile name="prod">
<root level="WARN">
<appender-ref ref="ASYNC_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</springProfile>
</configuration>

View File

@@ -0,0 +1,12 @@
SERVER_PORT=<your-server-port>
# RabbitMQ 配置
RABBITMQ_HOST=<your-host>
RABBITMQ_PORT=<your-port>
RABBITMQ_USERNAME=<your-username>
RABBITMQ_PASSWORD=<your-password>
# JWT 配置
JWT_SECRET=<your-jwt-secret>
WEBSITE_URL=<your-website-url>