Compare commits

...

85 Commits

Author SHA1 Message Date
tim
44642783e6 fix: 修改ffmpeg路径 2025-09-11 14:27:57 +08:00
Tim
1e284e15df Merge pull request #970 from sivdead/feat/video_upload_and_compress
feat(frontend/vditor): 实现基于 FFmpeg.wasm 的视频压缩功能
2025-09-11 12:54:12 +08:00
sivdead
9d76926b8a feat(frontend/vditor): 实现基于 FFmpeg.wasm 的视频压缩功能
- 添加视频压缩相关配置和工具函数
- 实现 FFmpeg.wasm 初始化和视频压缩功能
- 优化文件上传流程,支持视频文件压缩
2025-09-11 10:05:50 +08:00
tim
d2ce203236 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-10 18:13:16 +08:00
tim
b2228296af fix: hover 新增动画 2025-09-10 18:13:04 +08:00
Tim
7020ae19d0 Merge pull request #968 from smallclover/main
追加快捷键
2025-09-10 18:09:18 +08:00
tim
227fb6f6cc fix: 首页padding修改 2025-09-10 18:07:22 +08:00
wangshun
0e46a67ea6 评论追加快捷键
1.手机时不显示icon,且快捷键不起用
2.电脑端适配win和mac
2025-09-10 18:01:07 +08:00
wangshun
b20b705e46 添加快捷键
+ 不是手机的情况下不启用快捷键
2025-09-10 17:44:53 +08:00
夢夢の幻想郷
4b3ffbab99 Merge branch 'nagisa77:main' into main 2025-09-10 17:43:40 +08:00
tim
74039c89f9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-10 17:42:32 +08:00
tim
10dca73d2f fix: 新增本地GitHub调试 2025-09-10 17:42:19 +08:00
wangshun
e37ed1b70b 评论追加快捷键
ps:schedule包名拼写错误修正
2025-09-10 15:40:49 +08:00
Tim
8500a7a914 Merge pull request #965 from nagisa77/feature/homepage_ui
fix: 首页banner和帖子之间间距可以大一些 #849
2025-09-10 14:00:19 +08:00
Tim
3adf722b3b fix: 首页banner和帖子之间间距可以大一些 #849 2025-09-10 13:58:11 +08:00
Tim
791e5a4daf Merge pull request #964 from nagisa77/feature/change-log-ui
fix: changelog--文章内容更新 移动端适配 #937
2025-09-10 12:50:11 +08:00
tim
7d25e87fbc fix: changelog--文章内容更新 移动端适配 #937 2025-09-10 12:47:09 +08:00
Tim
d02c316a70 Merge pull request #963 from nagisa77/codex/fix-log-usage-with-slf4j
refactor: replace console prints with slf4j logging
2025-09-10 12:01:43 +08:00
Tim
c189c80c05 refactor: replace console prints with slf4j logging 2025-09-10 12:01:15 +08:00
Tim
07db73c9c7 Merge pull request #959 from nagisa77/codex/fix-chat-markdown-rendering-issues
feat: enhance chat markdown and editor
2025-09-09 21:20:49 +08:00
tim
c296e25927 fix: 聊天UI优化 #957 2025-09-09 21:02:59 +08:00
Tim
61fc9d799d feat(chat): improve markdown and editor 2025-09-09 20:03:22 +08:00
Tim
20c6c73f8c Merge pull request #954 from smallclover/main
用户访问统计使用缓存+定时任务
2025-09-09 19:35:45 +08:00
Tim
81d1f79aae Merge pull request #958 from WoJiaoFuXiaoYun/main
fix: 修复发帖框/修改框边缘不对齐的case
2025-09-09 19:35:18 +08:00
WangHe
4ff76d2586 fix: 修复发帖框/修改框边缘不对齐的case 2025-09-09 17:16:18 +08:00
Tim
f24bc239cc Update CONTRIBUTING.md 2025-09-09 16:49:49 +08:00
Tim
143691206d Merge pull request #955 from nagisa77/codex/add-openapi-annotations-to-controller-methods
doc: add OpenAPI annotations to demo controllers
2025-09-09 16:37:47 +08:00
Tim
15ad85e6f1 doc: add OpenAPI annotations to remaining controllers 2025-09-09 16:37:08 +08:00
wangshun
843e53143d 用户访问统计使用缓存+定时任务
+ 重要:注释的地方如果没用到@nagisa77可以删除
2025-09-09 16:31:59 +08:00
Tim
16c94690bd fix: 未登录UI适配 2025-09-09 15:58:50 +08:00
Tim
5be00e7013 Merge pull request #952 from nagisa77/codex/modify-about-page-with-new-tab
feat: add API debug tab and query param navigation for about page
2025-09-09 15:49:57 +08:00
Tim
1e0f62b421 fix: 正式环境/预发环境切换为英文 2025-09-09 15:40:55 +08:00
Tim
a3201f05fb fix: share icon 2025-09-09 15:39:08 +08:00
Tim
62cccb794d Merge pull request #953 from nagisa77/codex/fix-compilation-errors-in-postservice
Fix PostServiceTest constructor with RedisTemplate mock
2025-09-09 15:32:22 +08:00
Tim
afa0c7fb8f test: update PostServiceTest for redis template 2025-09-09 15:32:03 +08:00
Tim
da311806c1 feat: add API tab to about page 2025-09-09 15:04:49 +08:00
Tim
1852f87341 Merge pull request #951 from nagisa77/codex/update-openapi-servers-configuration
feat: allow configuring multiple OpenAPI servers
2025-09-09 15:03:43 +08:00
Tim
7010e8a058 feat: allow configuring multiple openapi servers 2025-09-09 15:03:25 +08:00
Tim
38ee37d5be Merge pull request #946 from smallclover/main 2025-09-09 14:29:06 +08:00
Tim
e398d8e989 Merge pull request #949 from nagisa77/codex/remove-/docs/-prefix-from-url-uh7skh
feat(docs): remove /docs URL prefix
2025-09-09 14:03:20 +08:00
Tim
85e77c265e feat(docs): remove /docs prefix 2025-09-09 14:03:04 +08:00
tim
8abdc73497 Revert "feat(docs): remove path prefix"
This reverts commit 09cefbedbf.
2025-09-09 14:02:23 +08:00
Tim
747d9c07d1 Merge pull request #948 from nagisa77/codex/remove-/docs/-prefix-from-url-3n0gdr
feat(docs): serve documentation from root
2025-09-09 13:48:51 +08:00
Tim
09cefbedbf feat(docs): remove path prefix 2025-09-09 13:48:26 +08:00
tim
d772bc182f fix: 允许自建OpenAPI地址 2025-09-09 13:46:25 +08:00
tim
358c53338d Revert "fix: 新增检查"
This reverts commit 1cd89eaa54.
2025-09-09 13:23:30 +08:00
wangshun
2110980797 控制用户发帖频率 2025-09-09 13:23:14 +08:00
tim
1cd89eaa54 fix: 新增检查 2025-09-09 13:16:52 +08:00
tim
1d2e7eb96e Revert "Update deploy-docs.yml"
This reverts commit 4428e06f1d.
2025-09-09 13:10:46 +08:00
Tim
4428e06f1d Update deploy-docs.yml 2025-09-09 13:03:08 +08:00
Tim
dddff54556 Update README.md 2025-09-09 12:18:10 +08:00
Tim
e7f7bbac22 Update README.md 2025-09-09 12:17:49 +08:00
Tim
37aae4ba5c Update README.md 2025-09-09 12:17:24 +08:00
Tim
54cfc98336 Merge pull request #945 from nagisa77/codex/fix-server-url-in-api-docs
Add configurable OpenAPI server URL
2025-09-09 12:12:41 +08:00
Tim
d42d38ff7a Add configurable OpenAPI server URL 2025-09-09 12:12:10 +08:00
Tim
2b4601bd4b Update CONTRIBUTING.md 2025-09-09 11:56:15 +08:00
Tim
5071d9c6d5 Merge pull request #944 from nagisa77/codex/fix-api-docs-base-url-to-use-https
docs: use https for OpenAPI base URL
2025-09-09 11:48:53 +08:00
Tim
cfaa4cd094 Update application.properties 2025-09-09 11:48:42 +08:00
Tim
fc414794ff docs: use https for openapi base url 2025-09-09 11:48:07 +08:00
Tim
d8264956c3 Merge pull request #943 from nagisa77/codex/fix-invalid-workflow-permissions-in-deploy-staging.yml
fix: grant write permissions for docs deployment
2025-09-09 11:30:28 +08:00
Tim
effa7f25ca fix: grant write permissions for docs deployment 2025-09-09 11:30:11 +08:00
Tim
9b19fae69a Merge pull request #942 from nagisa77/codex/resolve-conflict-between-deploy-staging-and-deploy-docs
Run docs deployment after staging deploy
2025-09-09 11:06:39 +08:00
Tim
ec04f64ce1 chore: trigger docs deployment after staging 2025-09-09 11:06:16 +08:00
Tim
50bea76c0e Merge pull request #940 from nagisa77/codex/adjust-diff2html-font-for-mobile-ui
style: adjust diff2html fonts on mobile
2025-09-09 00:33:58 +08:00
tim
05522fcdc7 fix: 修改分割线颜色 2025-09-09 00:32:17 +08:00
tim
3820eaa774 fix: changlog--移动端支持换行 #938 2025-09-09 00:23:53 +08:00
Tim
7effaf920a style: adjust diff2html fonts on mobile 2025-09-08 23:48:32 +08:00
Tim
e40a6a3ca9 Merge pull request #935 from smallclover/main
redis功能-注册找回密码
2025-09-08 17:14:04 +08:00
Tim
7c9475cfe2 Merge pull request #936 from nagisa77/codex/fix-compilation-issues-in-postservicetest
test: add PostChangeLogService to PostService tests
2025-09-08 15:42:20 +08:00
Tim
17929dd95d test: add PostChangeLogService to PostService tests 2025-09-08 15:42:08 +08:00
Tim
f478b55538 Merge pull request #924 from nagisa77/codex/add-article-metadata-change-logging
Track post metadata changes and display in timeline
2025-09-08 15:35:44 +08:00
Tim
c58c14f9b7 feat: 设置system的icon+role 2025-09-08 15:35:09 +08:00
Tim
990d7cfbf9 fix: 投票结果UI 2025-09-08 15:32:57 +08:00
wangshun
43fa408f46 redis功能-注册找回密码
+ 注册功能,验证码使用缓存,五分钟过期
+ 重置密码,验证码使用缓存,五分钟过期
2025-09-08 15:23:52 +08:00
Tim
eb860a74af Merge pull request #934 from nagisa77/codex/add-system-user-for-vote-and-lottery-results
Create system user for internal logging
2025-09-08 15:21:30 +08:00
Tim
b3d050b42e Add system user and log attribution 2025-09-08 15:19:17 +08:00
Tim
db678a95c6 Merge pull request #933 from nagisa77/codex/call-recordlotteryresult-and-recordvoteresult
feat: log poll and lottery results
2025-09-08 15:00:30 +08:00
Tim
6d66cb48dc feat: log poll and lottery results 2025-09-08 15:00:15 +08:00
Tim
1fe2994743 fix: 适配分类/tags ui 2025-09-08 14:56:44 +08:00
Tim
126b10ce45 Merge pull request #932 from nagisa77/codex/update-changelog-to-return-dto-format-rnzqgd
Expose category and tag changes as DTOs
2025-09-08 14:46:09 +08:00
Tim
3b1843b6dd Return category and tag change logs as DTOs 2025-09-08 14:45:47 +08:00
Tim
6a5d00f086 Revert "Return structured category and tag data in change logs"
This reverts commit fe167aa0b9.
2025-09-08 14:44:08 +08:00
Tim
06368a6cf1 Merge pull request #931 from nagisa77/codex/add-dark-mode-support-for-diff2html
feat: enable dark mode for diff2html
2025-09-08 14:29:01 +08:00
Tim
e9f25d3b1a Merge pull request #930 from nagisa77/codex/update-changelog-to-return-dto-format
Return structured category and tag data in change logs
2025-09-08 14:27:36 +08:00
Tim
fe167aa0b9 Return structured category and tag data in change logs 2025-09-08 14:27:18 +08:00
87 changed files with 2176 additions and 203 deletions

View File

@@ -1,7 +1,11 @@
name: Deploy Documentation
on:
push:
workflow_call:
inputs:
build-id:
required: false
type: string
workflow_dispatch:
permissions:
@@ -16,6 +20,9 @@ jobs:
with:
fetch-depth: 1
- name: Log build
run: echo "Running documentation deployment from build ${{ inputs.build-id }}"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:

View File

@@ -5,6 +5,9 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build-and-deploy:
runs-on: ubuntu-latest
@@ -21,3 +24,11 @@ jobs:
key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/deploy-staging.sh
deploy-docs:
needs: build-and-deploy
if: ${{ success() }}
uses: ./.github/workflows/deploy-docs.yml
secrets: inherit
with:
build-id: ${{ github.run_id }}

View File

@@ -246,3 +246,9 @@ https://resend.com/emails 创建账号并登录
`RESEND_FROM_EMAIL` **noreply@域名**
`RESEND_API_KEY`**刚刚复制的 Key**
![image-20250906151218330](assets/contributing/image-20250906151218330.png)
## 开源共建和API文档
- API文档: https://docs.open-isle.com/openapi

View File

@@ -4,6 +4,8 @@
高效的开源社区前后端平台
<br><br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
<br><br><br>
<a href="https://hellogithub.com/repository/nagisa77/OpenIsle" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8605546658d94cbab45182af2a02e4c8&claim_uid=p5GNFTtZl6HBAYQ" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
## 💡 简介

View File

@@ -40,6 +40,12 @@ public class CachingConfig {
public static final String CATEGORY_CACHE_NAME="openisle_categories";
// 在线人数缓存名
public static final String ONLINE_CACHE_NAME="openisle_online";
// 注册验证码
public static final String VERIFY_CACHE_NAME="openisle_verify";
// 发帖频率限制
public static final String LIMIT_CACHE_NAME="openisle_limit";
// 用户访问统计
public static final String VISIT_CACHE_NAME="openisle_visit";
/**
* 自定义Redis的序列化器

View File

@@ -5,13 +5,21 @@ 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 io.swagger.v3.oas.models.servers.Server;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
public class OpenApiConfig {
private final SpringDocProperties springDocProperties;
@Value("${springdoc.info.title}")
private String title;
@@ -30,19 +38,23 @@ public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase())
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(header);
.type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase())
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(header);
List<Server> servers = springDocProperties.getServers().stream()
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
.collect(Collectors.toList());
return new OpenAPI()
.servers(servers)
.info(new Info()
.title(title)
.description(description)
.version(version))
.components(new Components()
.addSecuritySchemes("JWT", securityScheme))
.title(title)
.description(description)
.version(version))
.components(new Components().addSecuritySchemes("JWT", securityScheme))
.addSecurityItem(new SecurityRequirement().addList("JWT"));
}
}

View File

@@ -1,6 +1,7 @@
package com.openisle.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
@@ -23,6 +24,7 @@ import java.util.List;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "openisle-exchange";
@@ -38,7 +40,7 @@ public class RabbitMQConfig {
@PostConstruct
public void init() {
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable);
log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
}
@Bean
@@ -51,7 +53,7 @@ public class RabbitMQConfig {
*/
@Bean
public List<Queue> shardedQueues() {
System.out.println("开始创建分片队列 Bean...");
log.info("开始创建分片队列 Bean...");
List<Queue> queues = new ArrayList<>();
for (int i = 0; i < queueCount; i++) {
@@ -61,7 +63,7 @@ public class RabbitMQConfig {
queues.add(queue);
}
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size());
log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
return queues;
}
@@ -70,7 +72,7 @@ public class RabbitMQConfig {
*/
@Bean
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
System.out.println("开始创建分片绑定 Bean...");
log.info("开始创建分片绑定 Bean...");
List<Binding> bindings = new ArrayList<>();
if (shardedQueues != null) {
for (Queue queue : shardedQueues) {
@@ -82,7 +84,7 @@ public class RabbitMQConfig {
}
}
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size());
return bindings;
}
@@ -135,14 +137,14 @@ public class RabbitMQConfig {
@Qualifier("shardedBindings") List<Binding> shardedBindings,
Binding legacyBinding) {
return args -> {
System.out.println("=== 开始主动声明 RabbitMQ 组件 ===");
log.info("=== 开始主动声明 RabbitMQ 组件 ===");
try {
// 声明交换
rabbitAdmin.declareExchange(exchange);
// 声明分片队列 - 检查存在性
System.out.println("开始检查并声明 " + shardedQueues.size() + " 个分片队列...");
log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
int successCount = 0;
int skippedCount = 0;
@@ -159,45 +161,44 @@ public class RabbitMQConfig {
skippedCount++;
}
} catch (Exception e) {
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage());
log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
}
}
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size());
log.info("分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", successCount, skippedCount, shardedQueues.size());
// 声明分片绑定
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定...");
log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
int bindingSuccessCount = 0;
for (Binding binding : shardedBindings) {
try {
rabbitAdmin.declareBinding(binding);
bindingSuccessCount++;
} catch (Exception e) {
System.err.println("绑定声明失败: " + e.getMessage());
log.error("绑定声明失败: {}", e.getMessage());
}
}
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size());
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
// 声明遗留队列和绑定 - 检查存在性
try {
rabbitAdmin.declareQueue(legacyQueue);
rabbitAdmin.declareBinding(legacyBinding);
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)");
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
} catch (org.springframework.amqp.AmqpIOException e) {
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列");
log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
} else {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
} catch (Exception e) {
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
}
System.out.println("=== RabbitMQ 组件声明完成 ===");
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建");
log.info("=== RabbitMQ 组件声明完成 ===");
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
} catch (Exception e) {
System.err.println("RabbitMQ 组件声明过程中发生严重错误:");
e.printStackTrace();
log.error("RabbitMQ 组件声明过程中发生严重错误", e);
}
};
}

View File

@@ -6,6 +6,7 @@ import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -26,6 +27,8 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDate;
import java.util.List;
import jakarta.servlet.FilterChain;
@@ -44,6 +47,8 @@ public class SecurityConfig {
@Value("${app.website-url}")
private String websiteUrl;
private final RedisTemplate redisTemplate;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
@@ -90,6 +95,9 @@ public class SecurityConfig {
"http://192.168.7.98",
"http://192.168.7.98:3000",
"https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
"https://www.docs.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://")
));
@@ -205,7 +213,8 @@ public class SecurityConfig {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
userVisitService.recordVisit(auth.getName());
String key = CachingConfig.VISIT_CACHE_NAME+":"+ LocalDate.now();
redisTemplate.opsForSet().add(key, auth.getName());
}
filterChain.doFilter(request, response);
}

View File

@@ -0,0 +1,20 @@
package com.openisle.config;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "springdoc.api-docs")
public class SpringDocProperties {
private List<ServerConfig> servers = new ArrayList<>();
@Data
public static class ServerConfig {
private String url;
private String description;
}
}

View File

@@ -0,0 +1,36 @@
package com.openisle.config;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* Ensure a dedicated "system" user exists for internal operations.
*/
@Component
@RequiredArgsConstructor
public class SystemUserInitializer implements CommandLineRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public void run(String... args) {
userRepository.findByUsername("system").orElseGet(() -> {
User system = new User();
system.setUsername("system");
system.setEmail("system@openisle.local");
// todo(tim): raw password 采用环境变量
system.setPassword(passwordEncoder.encode("system"));
system.setRole(Role.USER);
system.setVerified(true);
system.setApproved(true);
system.setAvatar("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png");
return userRepository.save(system);
});
}
}

View File

@@ -12,6 +12,12 @@ import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
import java.util.stream.Collectors;
@@ -25,6 +31,9 @@ public class ActivityController {
private final ActivityMapper activityMapper;
@GetMapping
@Operation(summary = "List activities", description = "Retrieve all activities")
@ApiResponse(responseCode = "200", description = "List of activities",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ActivityDto.class))))
public List<ActivityDto> list() {
return activityService.list().stream()
.map(activityMapper::toDto)
@@ -32,6 +41,9 @@ public class ActivityController {
}
@GetMapping("/milk-tea")
@Operation(summary = "Milk tea info", description = "Get milk tea activity information")
@ApiResponse(responseCode = "200", description = "Milk tea info",
content = @Content(schema = @Schema(implementation = MilkTeaInfoDto.class)))
public MilkTeaInfoDto milkTea() {
Activity a = activityService.getByType(ActivityType.MILK_TEA);
long count = activityService.countParticipants(a);
@@ -45,6 +57,10 @@ public class ActivityController {
}
@PostMapping("/milk-tea/redeem")
@Operation(summary = "Redeem milk tea", description = "Redeem milk tea activity reward")
@ApiResponse(responseCode = "200", description = "Redeem result",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
@SecurityRequirement(name = "JWT")
public java.util.Map<String, String> redeemMilkTea(@RequestBody MilkTeaRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
Activity a = activityService.getByType(ActivityType.MILK_TEA);

View File

@@ -3,6 +3,11 @@ package com.openisle.controller;
import com.openisle.dto.CommentDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.service.CommentService;
import io.swagger.v3.oas.annotations.Operation;
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 lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -18,11 +23,19 @@ public class AdminCommentController {
private final CommentMapper commentMapper;
@PostMapping("/{id}/pin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Pin comment", description = "Pin a comment by its id")
@ApiResponse(responseCode = "200", description = "Pinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
public CommentDto pin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Unpin comment", description = "Remove pin from a comment")
@ApiResponse(responseCode = "200", description = "Unpinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}

View File

@@ -5,6 +5,11 @@ import com.openisle.service.AiUsageService;
import com.openisle.service.PasswordValidator;
import com.openisle.service.PostService;
import com.openisle.service.RegisterModeService;
import io.swagger.v3.oas.annotations.Operation;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -18,6 +23,10 @@ public class AdminConfigController {
private final RegisterModeService registerModeService;
@GetMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Get configuration", description = "Retrieve application configuration settings")
@ApiResponse(responseCode = "200", description = "Current configuration",
content = @Content(schema = @Schema(implementation = ConfigDto.class)))
public ConfigDto getConfig() {
ConfigDto dto = new ConfigDto();
dto.setPublishMode(postService.getPublishMode());
@@ -28,6 +37,10 @@ public class AdminConfigController {
}
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Update configuration", description = "Update application configuration settings")
@ApiResponse(responseCode = "200", description = "Updated configuration",
content = @Content(schema = @Schema(implementation = ConfigDto.class)))
public ConfigDto updateConfig(@RequestBody ConfigDto dto) {
if (dto.getPublishMode() != null) {
postService.setPublishMode(dto.getPublishMode());

View File

@@ -1,5 +1,10 @@
package com.openisle.controller;
import io.swagger.v3.oas.annotations.Operation;
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 org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@@ -10,6 +15,10 @@ import java.util.Map;
@RestController
public class AdminController {
@GetMapping("/api/admin/hello")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Admin greeting", description = "Returns a greeting for admin users")
@ApiResponse(responseCode = "200", description = "Greeting payload",
content = @Content(schema = @Schema(implementation = Map.class)))
public Map<String, String> adminHello() {
return Map.of("message", "Hello, Admin User");
}

View File

@@ -3,6 +3,12 @@ package com.openisle.controller;
import com.openisle.dto.PostSummaryDto;
import com.openisle.mapper.PostMapper;
import com.openisle.service.PostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -20,6 +26,10 @@ public class AdminPostController {
private final PostMapper postMapper;
@GetMapping("/pending")
@SecurityRequirement(name = "JWT")
@Operation(summary = "List pending posts", description = "Retrieve posts awaiting approval")
@ApiResponse(responseCode = "200", description = "Pending posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> pendingPosts() {
return postService.listPendingPosts().stream()
.map(postMapper::toSummaryDto)
@@ -27,31 +37,55 @@ public class AdminPostController {
}
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve post", description = "Approve a pending post")
@ApiResponse(responseCode = "200", description = "Approved post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto approve(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.approvePost(id));
}
@PostMapping("/{id}/reject")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Reject post", description = "Reject a pending post")
@ApiResponse(responseCode = "200", description = "Rejected post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto reject(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.rejectPost(id));
}
@PostMapping("/{id}/pin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Pin post", description = "Pin a post to the top")
@ApiResponse(responseCode = "200", description = "Pinned post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto pin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.pinPost(id, auth.getName()));
}
@PostMapping("/{id}/unpin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Unpin post", description = "Remove a post from the top")
@ApiResponse(responseCode = "200", description = "Unpinned post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto unpin(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.unpinPost(id, auth.getName()));
}
@PostMapping("/{id}/rss-exclude")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Exclude from RSS", description = "Exclude a post from RSS feed")
@ApiResponse(responseCode = "200", description = "Updated post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto excludeFromRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.excludeFromRss(id, auth.getName()));
}
@PostMapping("/{id}/rss-include")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Include in RSS", description = "Include a post in the RSS feed")
@ApiResponse(responseCode = "200", description = "Updated post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto includeInRss(@PathVariable Long id, org.springframework.security.core.Authentication auth) {
return postMapper.toSummaryDto(postService.includeInRss(id, auth.getName()));
}

View File

@@ -5,6 +5,12 @@ import com.openisle.mapper.TagMapper;
import com.openisle.model.Tag;
import com.openisle.service.PostService;
import com.openisle.service.TagService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -20,6 +26,10 @@ public class AdminTagController {
private final TagMapper tagMapper;
@GetMapping("/pending")
@SecurityRequirement(name = "JWT")
@Operation(summary = "List pending tags", description = "Retrieve tags awaiting approval")
@ApiResponse(responseCode = "200", description = "Pending tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
public List<TagDto> pendingTags() {
return tagService.listPendingTags().stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
@@ -27,6 +37,10 @@ public class AdminTagController {
}
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve tag", description = "Approve a pending tag")
@ApiResponse(responseCode = "200", description = "Approved tag",
content = @Content(schema = @Schema(implementation = TagDto.class)))
public TagDto approve(@PathVariable Long id) {
Tag tag = tagService.approveTag(id);
long count = postService.countPostsByTag(tag.getId());

View File

@@ -6,6 +6,9 @@ import com.openisle.model.User;
import com.openisle.service.EmailSender;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.UserRepository;
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.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
@@ -22,6 +25,9 @@ public class AdminUserController {
private String websiteUrl;
@PostMapping("/{id}/approve")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Approve user", description = "Approve a pending user registration")
@ApiResponse(responseCode = "200", description = "User approved")
public ResponseEntity<?> approve(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setApproved(true);
@@ -33,6 +39,9 @@ public class AdminUserController {
}
@PostMapping("/{id}/reject")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Reject user", description = "Reject a pending user registration")
@ApiResponse(responseCode = "200", description = "User rejected")
public ResponseEntity<?> reject(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setApproved(false);

View File

@@ -9,6 +9,11 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
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.util.Map;
@@ -21,6 +26,10 @@ public class AiController {
private final AiUsageService aiUsageService;
@PostMapping("/format")
@Operation(summary = "Format markdown", description = "Format text via AI")
@ApiResponse(responseCode = "200", description = "Formatted content",
content = @Content(schema = @Schema(implementation = Map.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<Map<String, String>> format(@RequestBody Map<String, String> req,
Authentication auth) {
String text = req.get("text");

View File

@@ -1,18 +1,27 @@
package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.*;
import com.openisle.exception.FieldException;
import com.openisle.model.RegisterMode;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.*;
import com.openisle.util.VerifyType;
import io.swagger.v3.oas.annotations.Operation;
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 lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/auth")
@@ -43,6 +52,9 @@ public class AuthController {
private boolean loginCaptchaEnabled;
@PostMapping("/register")
@Operation(summary = "Register user", description = "Register a new user account")
@ApiResponse(responseCode = "200", description = "Registration result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
@@ -56,7 +68,8 @@ public class AuthController {
User user = userService.registerWithInvite(
req.getUsername(), req.getEmail(), req.getPassword());
inviteService.consume(req.getInviteToken(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -70,7 +83,8 @@ public class AuthController {
}
User user = userService.register(
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
// 发送确认邮件
userService.sendVerifyMail(user, VerifyType.REGISTER);
if (!user.isApproved()) {
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
}
@@ -78,14 +92,16 @@ public class AuthController {
}
@PostMapping("/verify")
@Operation(summary = "Verify account", description = "Verify registration code")
@ApiResponse(responseCode = "200", description = "Verification result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.REGISTER);
if (ok) {
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
User user = userOpt.get();
if (user.isApproved()) {
@@ -106,6 +122,9 @@ public class AuthController {
}
@PostMapping("/login")
@Operation(summary = "Login", description = "Authenticate with username/email and password")
@ApiResponse(responseCode = "200", description = "Authentication result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
if (captchaEnabled && loginCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
@@ -122,7 +141,7 @@ public class AuthController {
User user = userOpt.get();
if (!user.isVerified()) {
user = userService.register(user.getUsername(), user.getEmail(), user.getPassword(), user.getRegisterReason(), registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.badRequest().body(Map.of(
"error", "User not verified",
"reason_code", "NOT_VERIFIED",
@@ -144,6 +163,9 @@ public class AuthController {
}
@PostMapping("/google")
@Operation(summary = "Login with Google", description = "Authenticate using Google account")
@ApiResponse(responseCode = "200", description = "Authentication result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
@@ -191,6 +213,9 @@ public class AuthController {
@PostMapping("/reason")
@Operation(summary = "Submit register reason", description = "Submit registration reason for approval")
@ApiResponse(responseCode = "200", description = "Submission result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> reason(@RequestBody MakeReasonRequest req) {
String username = jwtService.validateAndGetSubjectForReason(req.getToken());
Optional<User> userOpt = userService.findByUsername(username);
@@ -219,6 +244,9 @@ public class AuthController {
}
@PostMapping("/github")
@Operation(summary = "Login with GitHub", description = "Authenticate using GitHub account")
@ApiResponse(responseCode = "200", description = "Authentication result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
@@ -267,6 +295,9 @@ public class AuthController {
}
@PostMapping("/discord")
@Operation(summary = "Login with Discord", description = "Authenticate using Discord account")
@ApiResponse(responseCode = "200", description = "Authentication result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
@@ -314,6 +345,9 @@ public class AuthController {
}
@PostMapping("/twitter")
@Operation(summary = "Login with Twitter", description = "Authenticate using Twitter account")
@ApiResponse(responseCode = "200", description = "Authentication result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
@@ -362,6 +396,9 @@ public class AuthController {
}
@PostMapping("/telegram")
@Operation(summary = "Login with Telegram", description = "Authenticate using Telegram data")
@ApiResponse(responseCode = "200", description = "Authentication result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
@@ -407,24 +444,37 @@ public class AuthController {
}
@GetMapping("/check")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Check token", description = "Validate JWT token")
@ApiResponse(responseCode = "200", description = "Token valid",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> checkToken() {
return ResponseEntity.ok(Map.of("valid", true));
}
@PostMapping("/forgot/send")
@Operation(summary = "Send reset code", description = "Send verification code for password reset")
@ApiResponse(responseCode = "200", description = "Sending result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> sendReset(@RequestBody ForgotPasswordRequest req) {
Optional<User> userOpt = userService.findByEmail(req.getEmail());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
String code = userService.generatePasswordResetCode(req.getEmail());
emailService.sendEmail(req.getEmail(), "请填写验证码以重置密码", "您的验证码是" + code);
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
}
@PostMapping("/forgot/verify")
@Operation(summary = "Verify reset code", description = "Verify password reset code")
@ApiResponse(responseCode = "200", description = "Verification result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> verifyReset(@RequestBody VerifyForgotRequest req) {
boolean ok = userService.verifyPasswordResetCode(req.getEmail(), req.getCode());
Optional<User> userOpt = userService.findByEmail(req.getEmail());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
boolean ok = userService.verifyCode(userOpt.get(), req.getCode(), VerifyType.RESET_PASSWORD);
if (ok) {
String username = userService.findByEmail(req.getEmail()).get().getUsername();
return ResponseEntity.ok(Map.of("token", jwtService.generateResetToken(username)));
@@ -433,6 +483,9 @@ public class AuthController {
}
@PostMapping("/forgot/reset")
@Operation(summary = "Reset password", description = "Reset user password after verification")
@ApiResponse(responseCode = "200", description = "Reset result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> resetPassword(@RequestBody ResetPasswordRequest req) {
String username = jwtService.validateAndGetSubjectForReset(req.getToken());
try {

View File

@@ -10,6 +10,11 @@ import com.openisle.service.CategoryService;
import com.openisle.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 java.util.List;
import java.util.Map;
@@ -25,6 +30,9 @@ public class CategoryController {
private final CategoryMapper categoryMapper;
@PostMapping
@Operation(summary = "Create category", description = "Create a new category")
@ApiResponse(responseCode = "200", description = "Created category",
content = @Content(schema = @Schema(implementation = CategoryDto.class)))
public CategoryDto create(@RequestBody CategoryRequest req) {
Category c = categoryService.createCategory(req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByCategory(c.getId());
@@ -32,6 +40,9 @@ public class CategoryController {
}
@PutMapping("/{id}")
@Operation(summary = "Update category", description = "Update an existing category")
@ApiResponse(responseCode = "200", description = "Updated category",
content = @Content(schema = @Schema(implementation = CategoryDto.class)))
public CategoryDto update(@PathVariable Long id, @RequestBody CategoryRequest req) {
Category c = categoryService.updateCategory(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByCategory(c.getId());
@@ -39,11 +50,16 @@ public class CategoryController {
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete category", description = "Remove a category by id")
@ApiResponse(responseCode = "200", description = "Category deleted")
public void delete(@PathVariable Long id) {
categoryService.deleteCategory(id);
}
@GetMapping
@Operation(summary = "List categories", description = "Get all categories")
@ApiResponse(responseCode = "200", description = "List of categories",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CategoryDto.class))))
public List<CategoryDto> list() {
List<Category> all = categoryService.listCategories();
List<Long> ids = all.stream().map(Category::getId).toList();
@@ -55,6 +71,9 @@ public class CategoryController {
}
@GetMapping("/{id}")
@Operation(summary = "Get category", description = "Get category by id")
@ApiResponse(responseCode = "200", description = "Category detail",
content = @Content(schema = @Schema(implementation = CategoryDto.class)))
public CategoryDto get(@PathVariable Long id) {
Category c = categoryService.getCategory(id);
long count = postService.countPostsByCategory(c.getId());
@@ -62,6 +81,9 @@ public class CategoryController {
}
@GetMapping("/{id}/posts")
@Operation(summary = "List posts by category", description = "Get posts under a category")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> listPostsByCategory(@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {

View File

@@ -8,6 +8,12 @@ import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
@@ -26,16 +32,28 @@ public class ChannelController {
}
@GetMapping
@Operation(summary = "List channels", description = "List channels for the current user")
@ApiResponse(responseCode = "200", description = "Channels",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ChannelDto.class))))
@SecurityRequirement(name = "JWT")
public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
@PostMapping("/{channelId}/join")
@Operation(summary = "Join channel", description = "Join a channel")
@ApiResponse(responseCode = "200", description = "Joined channel",
content = @Content(schema = @Schema(implementation = ChannelDto.class)))
@SecurityRequirement(name = "JWT")
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping("/unread-count")
@Operation(summary = "Unread count", description = "Get unread channel count")
@ApiResponse(responseCode = "200", description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class)))
@SecurityRequirement(name = "JWT")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}

View File

@@ -14,6 +14,12 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
import java.util.stream.Collectors;
@@ -36,6 +42,10 @@ public class CommentController {
private boolean commentCaptchaEnabled;
@PostMapping("/posts/{postId}/comments")
@Operation(summary = "Create comment", description = "Add a comment to a post")
@ApiResponse(responseCode = "200", description = "Created comment",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<CommentDto> createComment(@PathVariable Long postId,
@RequestBody CommentRequest req,
Authentication auth) {
@@ -53,6 +63,10 @@ public class CommentController {
}
@PostMapping("/comments/{commentId}/replies")
@Operation(summary = "Reply to comment", description = "Reply to an existing comment")
@ApiResponse(responseCode = "200", description = "Reply created",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<CommentDto> replyComment(@PathVariable Long commentId,
@RequestBody CommentRequest req,
Authentication auth) {
@@ -69,6 +83,9 @@ public class CommentController {
}
@GetMapping("/posts/{postId}/comments")
@Operation(summary = "List comments", description = "List comments for a post")
@ApiResponse(responseCode = "200", description = "Comments",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentDto.class))))
public List<CommentDto> listComments(@PathVariable Long postId,
@RequestParam(value = "sort", required = false, defaultValue = "OLDEST") com.openisle.model.CommentSort sort) {
log.debug("listComments called for post {} with sort {}", postId, sort);
@@ -80,6 +97,9 @@ public class CommentController {
}
@DeleteMapping("/comments/{id}")
@Operation(summary = "Delete comment", description = "Delete a comment")
@ApiResponse(responseCode = "200", description = "Deleted")
@SecurityRequirement(name = "JWT")
public void deleteComment(@PathVariable Long id, Authentication auth) {
log.debug("deleteComment called by user {} for comment {}", auth.getName(), id);
commentService.deleteComment(auth.getName(), id);
@@ -87,12 +107,20 @@ public class CommentController {
}
@PostMapping("/comments/{id}/pin")
@Operation(summary = "Pin comment", description = "Pin a comment")
@ApiResponse(responseCode = "200", description = "Pinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
@SecurityRequirement(name = "JWT")
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/comments/{id}/unpin")
@Operation(summary = "Unpin comment", description = "Unpin a comment")
@ApiResponse(responseCode = "200", description = "Unpinned comment",
content = @Content(schema = @Schema(implementation = CommentDto.class)))
@SecurityRequirement(name = "JWT")
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));

View File

@@ -6,6 +6,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@RestController
@RequestMapping("/api")
@@ -33,6 +37,9 @@ public class ConfigController {
private final RegisterModeService registerModeService;
@GetMapping("/config")
@Operation(summary = "Site config", description = "Get site configuration")
@ApiResponse(responseCode = "200", description = "Site configuration",
content = @Content(schema = @Schema(implementation = SiteConfigDto.class)))
public SiteConfigDto getConfig() {
SiteConfigDto resp = new SiteConfigDto();
resp.setCaptchaEnabled(captchaEnabled);

View File

@@ -9,6 +9,11 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
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;
@RestController
@RequestMapping("/api/drafts")
@@ -18,12 +23,20 @@ public class DraftController {
private final DraftMapper draftMapper;
@PostMapping
@Operation(summary = "Save draft", description = "Save a draft for current user")
@ApiResponse(responseCode = "200", description = "Draft saved",
content = @Content(schema = @Schema(implementation = DraftDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<DraftDto> saveDraft(@RequestBody DraftRequest req, Authentication auth) {
Draft draft = draftService.saveDraft(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent(), req.getTagIds());
return ResponseEntity.ok(draftMapper.toDto(draft));
}
@GetMapping("/me")
@Operation(summary = "Get my draft", description = "Get current user's draft")
@ApiResponse(responseCode = "200", description = "Draft details",
content = @Content(schema = @Schema(implementation = DraftDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<DraftDto> getMyDraft(Authentication auth) {
return draftService.getDraft(auth.getName())
.map(d -> ResponseEntity.ok(draftMapper.toDto(d)))
@@ -31,6 +44,9 @@ public class DraftController {
}
@DeleteMapping("/me")
@Operation(summary = "Delete my draft", description = "Delete current user's draft")
@ApiResponse(responseCode = "200", description = "Draft deleted")
@SecurityRequirement(name = "JWT")
public ResponseEntity<?> deleteMyDraft(Authentication auth) {
draftService.deleteDraft(auth.getName());
return ResponseEntity.ok().build();

View File

@@ -1,5 +1,10 @@
package com.openisle.controller;
import io.swagger.v3.oas.annotations.Operation;
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 org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@@ -7,6 +12,10 @@ import java.util.Map;
@RestController
public class HelloController {
@GetMapping("/api/hello")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Hello endpoint", description = "Returns a greeting for authenticated users")
@ApiResponse(responseCode = "200", description = "Greeting payload",
content = @Content(schema = @Schema(implementation = Map.class)))
public Map<String, String> hello() {
return Map.of("message", "Hello, Authenticated User");
}

View File

@@ -6,6 +6,11 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
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.util.Map;
@@ -16,6 +21,10 @@ public class InviteController {
private final InviteService inviteService;
@PostMapping("/generate")
@Operation(summary = "Generate invite", description = "Generate an invite token")
@ApiResponse(responseCode = "200", description = "Invite token",
content = @Content(schema = @Schema(implementation = Map.class)))
@SecurityRequirement(name = "JWT")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);

View File

@@ -7,6 +7,12 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
@@ -17,11 +23,17 @@ public class MedalController {
private final MedalService medalService;
@GetMapping
@Operation(summary = "List medals", description = "List medals for user or globally")
@ApiResponse(responseCode = "200", description = "List of medals",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MedalDto.class))))
public List<MedalDto> getMedals(@RequestParam(value = "userId", required = false) Long userId) {
return medalService.getMedals(userId);
}
@PostMapping("/select")
@Operation(summary = "Select medal", description = "Select a medal for current user")
@ApiResponse(responseCode = "200", description = "Medal selected")
@SecurityRequirement(name = "JWT")
public ResponseEntity<Void> selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) {
try {
medalService.selectMedal(auth.getName(), req.getType());

View File

@@ -18,6 +18,12 @@ import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
@@ -37,12 +43,20 @@ public class MessageController {
}
@GetMapping("/conversations")
@Operation(summary = "List conversations", description = "Get all conversations of current user")
@ApiResponse(responseCode = "200", description = "List of conversations",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ConversationDto.class))))
@SecurityRequirement(name = "JWT")
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
return ResponseEntity.ok(conversations);
}
@GetMapping("/conversations/{conversationId}")
@Operation(summary = "Get conversation", description = "Get messages of a conversation")
@ApiResponse(responseCode = "200", description = "Conversation detail",
content = @Content(schema = @Schema(implementation = ConversationDetailDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@@ -53,12 +67,20 @@ public class MessageController {
}
@PostMapping
@Operation(summary = "Send message", description = "Send a direct message to a user")
@ApiResponse(responseCode = "200", description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/messages")
@Operation(summary = "Send message to conversation", description = "Reply within a conversation")
@ApiResponse(responseCode = "200", description = "Message sent",
content = @Content(schema = @Schema(implementation = MessageDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
@RequestBody ChannelMessageRequest req,
Authentication auth) {
@@ -67,18 +89,29 @@ public class MessageController {
}
@PostMapping("/conversations/{conversationId}/read")
@Operation(summary = "Mark conversation read", description = "Mark messages in conversation as read")
@ApiResponse(responseCode = "200", description = "Marked as read")
@SecurityRequirement(name = "JWT")
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
return ResponseEntity.ok().build();
}
@PostMapping("/conversations")
@Operation(summary = "Find or create conversation", description = "Find existing or create new conversation with recipient")
@ApiResponse(responseCode = "200", description = "Conversation id",
content = @Content(schema = @Schema(implementation = CreateConversationResponse.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
}
@GetMapping("/unread-count")
@Operation(summary = "Unread message count", description = "Get unread message count for current user")
@ApiResponse(responseCode = "200", description = "Unread count",
content = @Content(schema = @Schema(implementation = Long.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
}

View File

@@ -10,6 +10,12 @@ import com.openisle.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
import java.util.stream.Collectors;
@@ -23,6 +29,10 @@ public class NotificationController {
private final NotificationMapper notificationMapper;
@GetMapping
@Operation(summary = "List notifications", description = "Retrieve notifications for the current user")
@ApiResponse(responseCode = "200", description = "Notifications",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))))
@SecurityRequirement(name = "JWT")
public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
@@ -32,6 +42,10 @@ public class NotificationController {
}
@GetMapping("/unread")
@Operation(summary = "List unread notifications", description = "Retrieve unread notifications for the current user")
@ApiResponse(responseCode = "200", description = "Unread notifications",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationDto.class))))
@SecurityRequirement(name = "JWT")
public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
@@ -41,6 +55,10 @@ public class NotificationController {
}
@GetMapping("/unread-count")
@Operation(summary = "Unread count", description = "Get count of unread notifications")
@ApiResponse(responseCode = "200", description = "Unread count",
content = @Content(schema = @Schema(implementation = NotificationUnreadCountDto.class)))
@SecurityRequirement(name = "JWT")
public NotificationUnreadCountDto unreadCount(Authentication auth) {
long count = notificationService.countUnread(auth.getName());
NotificationUnreadCountDto uc = new NotificationUnreadCountDto();
@@ -49,26 +67,43 @@ public class NotificationController {
}
@PostMapping("/read")
@Operation(summary = "Mark notifications read", description = "Mark notifications as read")
@ApiResponse(responseCode = "200", description = "Marked read")
@SecurityRequirement(name = "JWT")
public void markRead(@RequestBody NotificationMarkReadRequest req, Authentication auth) {
notificationService.markRead(auth.getName(), req.getIds());
}
@GetMapping("/prefs")
@Operation(summary = "List preferences", description = "List notification preferences")
@ApiResponse(responseCode = "200", description = "Preferences",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))))
@SecurityRequirement(name = "JWT")
public List<NotificationPreferenceDto> prefs(Authentication auth) {
return notificationService.listPreferences(auth.getName());
}
@PostMapping("/prefs")
@Operation(summary = "Update preference", description = "Update notification preference")
@ApiResponse(responseCode = "200", description = "Preference updated")
@SecurityRequirement(name = "JWT")
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
}
@GetMapping("/email-prefs")
@Operation(summary = "List email preferences", description = "List email notification preferences")
@ApiResponse(responseCode = "200", description = "Email preferences",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationPreferenceDto.class))))
@SecurityRequirement(name = "JWT")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/email-prefs")
@Operation(summary = "Update email preference", description = "Update email notification preference")
@ApiResponse(responseCode = "200", description = "Email preference updated")
@SecurityRequirement(name = "JWT")
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}

View File

@@ -5,6 +5,10 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
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 java.time.Duration;
@@ -22,11 +26,16 @@ public class OnlineController {
private static final String ONLINE_KEY = CachingConfig.ONLINE_CACHE_NAME +":";
@PostMapping("/heartbeat")
@Operation(summary = "Heartbeat", description = "Record user heartbeat")
@ApiResponse(responseCode = "200", description = "Heartbeat recorded")
public void ping(@RequestParam String userId){
redisTemplate.opsForValue().set(ONLINE_KEY+userId,"1", Duration.ofSeconds(150));
}
@GetMapping("/count")
@Operation(summary = "Online count", description = "Get current online user count")
@ApiResponse(responseCode = "200", description = "Online count",
content = @Content(schema = @Schema(implementation = Long.class)))
public long count(){
return redisTemplate.keys(ONLINE_KEY+"*").size();
}

View File

@@ -9,6 +9,12 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
import java.util.Map;
@@ -22,6 +28,10 @@ public class PointHistoryController {
private final PointHistoryMapper pointHistoryMapper;
@GetMapping
@Operation(summary = "Point history", description = "List point history for current user")
@ApiResponse(responseCode = "200", description = "List of point histories",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointHistoryDto.class))))
@SecurityRequirement(name = "JWT")
public List<PointHistoryDto> list(Authentication auth) {
return pointService.listHistory(auth.getName()).stream()
.map(pointHistoryMapper::toDto)
@@ -29,6 +39,10 @@ public class PointHistoryController {
}
@GetMapping("/trend")
@Operation(summary = "Point trend", description = "Get point trend data for current user")
@ApiResponse(responseCode = "200", description = "Trend data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
@SecurityRequirement(name = "JWT")
public List<Map<String, Object>> trend(Authentication auth,
@RequestParam(value = "days", defaultValue = "30") int days) {
return pointService.trend(auth.getName(), days);

View File

@@ -9,6 +9,12 @@ import com.openisle.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
import java.util.Map;
@@ -24,6 +30,9 @@ public class PointMallController {
private final PointGoodMapper pointGoodMapper;
@GetMapping
@Operation(summary = "List goods", description = "List all point goods")
@ApiResponse(responseCode = "200", description = "List of goods",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PointGoodDto.class))))
public List<PointGoodDto> list() {
return pointMallService.listGoods().stream()
.map(pointGoodMapper::toDto)
@@ -31,6 +40,10 @@ public class PointMallController {
}
@PostMapping("/redeem")
@Operation(summary = "Redeem good", description = "Redeem a point good")
@ApiResponse(responseCode = "200", description = "Remaining points",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
@SecurityRequirement(name = "JWT")
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());

View File

@@ -5,6 +5,11 @@ import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.service.PostChangeLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 java.util.List;
import java.util.stream.Collectors;
@@ -17,6 +22,9 @@ public class PostChangeLogController {
private final PostChangeLogMapper mapper;
@GetMapping("/{id}/change-logs")
@Operation(summary = "Post change logs", description = "List change logs for a post")
@ApiResponse(responseCode = "200", description = "Change logs",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostChangeLogDto.class))))
public List<PostChangeLogDto> listLogs(@PathVariable Long id) {
return changeLogService.listLogs(id).stream()
.map(mapper::toDto)

View File

@@ -7,6 +7,12 @@ import com.openisle.dto.PollDto;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Post;
import com.openisle.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
@@ -35,6 +41,10 @@ public class PostController {
private boolean postCaptchaEnabled;
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Create post", description = "Create a new post")
@ApiResponse(responseCode = "200", description = "Created post",
content = @Content(schema = @Schema(implementation = PostDetailDto.class)))
public ResponseEntity<PostDetailDto> createPost(@RequestBody PostRequest req, Authentication auth) {
if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().build();
@@ -53,6 +63,10 @@ public class PostController {
}
@PutMapping("/{id}")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Update post", description = "Update an existing post")
@ApiResponse(responseCode = "200", description = "Updated post",
content = @Content(schema = @Schema(implementation = PostDetailDto.class)))
public ResponseEntity<PostDetailDto> updatePost(@PathVariable Long id, @RequestBody PostRequest req,
Authentication auth) {
Post post = postService.updatePost(id, auth.getName(), req.getCategoryId(),
@@ -61,21 +75,35 @@ public class PostController {
}
@DeleteMapping("/{id}")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Delete post", description = "Delete a post")
@ApiResponse(responseCode = "200", description = "Post deleted")
public void deletePost(@PathVariable Long id, Authentication auth) {
postService.deletePost(id, auth.getName());
}
@PostMapping("/{id}/close")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Close post", description = "Close a post to prevent further replies")
@ApiResponse(responseCode = "200", description = "Closed post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
}
@PostMapping("/{id}/reopen")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Reopen post", description = "Reopen a closed post")
@ApiResponse(responseCode = "200", description = "Reopened post",
content = @Content(schema = @Schema(implementation = PostSummaryDto.class)))
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
}
@GetMapping("/{id}")
@Operation(summary = "Get post", description = "Get post details by id")
@ApiResponse(responseCode = "200", description = "Post detail",
content = @Content(schema = @Schema(implementation = PostDetailDto.class)))
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
String viewer = auth != null ? auth.getName() : null;
Post post = postService.viewPost(id, viewer);
@@ -83,23 +111,35 @@ public class PostController {
}
@PostMapping("/{id}/lottery/join")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Join lottery", description = "Join a lottery for the post")
@ApiResponse(responseCode = "200", description = "Joined lottery")
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
postService.joinLottery(id, auth.getName());
return ResponseEntity.ok().build();
}
@GetMapping("/{id}/poll/progress")
@Operation(summary = "Poll progress", description = "Get poll progress for a post")
@ApiResponse(responseCode = "200", description = "Poll progress",
content = @Content(schema = @Schema(implementation = PollDto.class)))
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@PostMapping("/{id}/poll/vote")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Vote poll", description = "Vote on a poll option")
@ApiResponse(responseCode = "200", description = "Vote recorded")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@GetMapping
@Operation(summary = "List posts", description = "List posts by various filters")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@@ -115,10 +155,10 @@ public class PostController {
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
boolean hasCategories = ids != null && !ids.isEmpty();
boolean hasTags = tids != null && !tids.isEmpty();
@@ -137,6 +177,9 @@ public class PostController {
}
@GetMapping("/ranking")
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
@ApiResponse(responseCode = "200", description = "Ranked posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> rankingPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@@ -152,16 +195,19 @@ public class PostController {
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService.listPostsByViews(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/latest-reply")
@Operation(summary = "Latest reply posts", description = "List posts by latest replies")
@ApiResponse(responseCode = "200", description = "Posts sorted by latest reply",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> latestReplyPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@@ -177,16 +223,19 @@ public class PostController {
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/featured")
@Operation(summary = "Featured posts", description = "List featured posts")
@ApiResponse(responseCode = "200", description = "Featured posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@@ -202,9 +251,10 @@ public class PostController {
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
// 只需要在请求的一开始统计一次
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postService.listFeaturedPosts(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}

View File

@@ -7,6 +7,11 @@ import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
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;
@RestController
@RequestMapping("/api/push")
@@ -17,6 +22,9 @@ public class PushSubscriptionController {
private String publicKey;
@GetMapping("/public-key")
@Operation(summary = "Get public key", description = "Retrieve web push public key")
@ApiResponse(responseCode = "200", description = "Public key",
content = @Content(schema = @Schema(implementation = PushPublicKeyDto.class)))
public PushPublicKeyDto getPublicKey() {
PushPublicKeyDto r = new PushPublicKeyDto();
r.setKey(publicKey);
@@ -24,6 +32,9 @@ public class PushSubscriptionController {
}
@PostMapping("/subscribe")
@Operation(summary = "Subscribe", description = "Subscribe to push notifications")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribe(@RequestBody PushSubscriptionRequest req, Authentication auth) {
pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
}

View File

@@ -12,6 +12,11 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
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;
@RestController
@RequestMapping("/api")
@@ -26,11 +31,18 @@ public class ReactionController {
* Get all available reaction types.
*/
@GetMapping("/reaction-types")
@Operation(summary = "List reaction types", description = "Get all available reaction types")
@ApiResponse(responseCode = "200", description = "Reaction types",
content = @Content(schema = @Schema(implementation = ReactionType[].class)))
public ReactionType[] listReactionTypes() {
return ReactionType.values();
}
@PostMapping("/posts/{postId}/reactions")
@Operation(summary = "React to post", description = "React to a post")
@ApiResponse(responseCode = "200", description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
@RequestBody ReactionRequest req,
Authentication auth) {
@@ -46,6 +58,10 @@ public class ReactionController {
}
@PostMapping("/comments/{commentId}/reactions")
@Operation(summary = "React to comment", description = "React to a comment")
@ApiResponse(responseCode = "200", description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
@RequestBody ReactionRequest req,
Authentication auth) {
@@ -61,6 +77,10 @@ public class ReactionController {
}
@PostMapping("/messages/{messageId}/reactions")
@Operation(summary = "React to message", description = "React to a message")
@ApiResponse(responseCode = "200", description = "Reaction result",
content = @Content(schema = @Schema(implementation = ReactionDto.class)))
@SecurityRequirement(name = "JWT")
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
@RequestBody ReactionRequest req,
Authentication auth) {

View File

@@ -13,6 +13,10 @@ import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
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 com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
@@ -63,6 +67,8 @@ public class RssController {
}
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
@Operation(summary = "RSS feed", description = "Generate RSS feed for latest posts")
@ApiResponse(responseCode = "200", description = "RSS XML", content = @Content(schema = @Schema(implementation = String.class)))
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);

View File

@@ -11,6 +11,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 java.util.List;
import java.util.stream.Collectors;
@@ -24,6 +29,9 @@ public class SearchController {
private final PostMapper postMapper;
@GetMapping("/users")
@Operation(summary = "Search users", description = "Search users by keyword")
@ApiResponse(responseCode = "200", description = "List of users",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
public List<UserDto> searchUsers(@RequestParam String keyword) {
return searchService.searchUsers(keyword).stream()
.map(userMapper::toDto)
@@ -31,6 +39,9 @@ public class SearchController {
}
@GetMapping("/posts")
@Operation(summary = "Search posts", description = "Search posts by keyword")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> searchPosts(@RequestParam String keyword) {
return searchService.searchPosts(keyword).stream()
.map(postMapper::toSummaryDto)
@@ -38,6 +49,9 @@ public class SearchController {
}
@GetMapping("/posts/content")
@Operation(summary = "Search posts by content", description = "Search posts by content keyword")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> searchPostsByContent(@RequestParam String keyword) {
return searchService.searchPostsByContent(keyword).stream()
.map(postMapper::toSummaryDto)
@@ -45,6 +59,9 @@ public class SearchController {
}
@GetMapping("/posts/title")
@Operation(summary = "Search posts by title", description = "Search posts by title keyword")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> searchPostsByTitle(@RequestParam String keyword) {
return searchService.searchPostsByTitle(keyword).stream()
.map(postMapper::toSummaryDto)
@@ -52,6 +69,9 @@ public class SearchController {
}
@GetMapping("/global")
@Operation(summary = "Global search", description = "Search users and posts globally")
@ApiResponse(responseCode = "200", description = "Search results",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = SearchResultDto.class))))
public List<SearchResultDto> global(@RequestParam String keyword) {
return searchService.globalSearch(keyword).stream()
.map(r -> {

View File

@@ -10,6 +10,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
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 java.util.List;
@@ -26,6 +30,9 @@ public class SitemapController {
private String websiteUrl;
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
@Operation(summary = "Sitemap", description = "Generate sitemap xml")
@ApiResponse(responseCode = "200", description = "Sitemap xml",
content = @Content(schema = @Schema(implementation = String.class)))
public ResponseEntity<String> sitemap() {
List<Post> posts = postRepository.findByStatus(PostStatus.PUBLISHED);

View File

@@ -8,6 +8,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 java.time.LocalDate;
import java.util.List;
@@ -21,6 +26,9 @@ public class StatController {
private final StatService statService;
@GetMapping("/dau")
@Operation(summary = "Daily active users", description = "Get daily active user count")
@ApiResponse(responseCode = "200", description = "DAU count",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
public Map<String, Long> dau(@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
long count = userVisitService.countDau(date);
@@ -28,6 +36,9 @@ public class StatController {
}
@GetMapping("/dau-range")
@Operation(summary = "DAU range", description = "Get daily active users over range of days")
@ApiResponse(responseCode = "200", description = "DAU data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
public List<Map<String, Object>> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
@@ -42,6 +53,9 @@ public class StatController {
}
@GetMapping("/new-users-range")
@Operation(summary = "New users range", description = "Get new users over range of days")
@ApiResponse(responseCode = "200", description = "New user data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
public List<Map<String, Object>> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
@@ -56,6 +70,9 @@ public class StatController {
}
@GetMapping("/posts-range")
@Operation(summary = "Posts range", description = "Get posts count over range of days")
@ApiResponse(responseCode = "200", description = "Post data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
public List<Map<String, Object>> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();
@@ -70,6 +87,9 @@ public class StatController {
}
@GetMapping("/comments-range")
@Operation(summary = "Comments range", description = "Get comments count over range of days")
@ApiResponse(responseCode = "200", description = "Comment data",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = java.util.Map.class))))
public List<Map<String, Object>> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) {
if (days < 1) days = 1;
LocalDate end = LocalDate.now();

View File

@@ -4,6 +4,9 @@ import com.openisle.service.SubscriptionService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
/** Endpoints for subscribing to posts, comments and users. */
@RestController
@@ -13,31 +16,49 @@ public class SubscriptionController {
private final SubscriptionService subscriptionService;
@PostMapping("/posts/{postId}")
@Operation(summary = "Subscribe post", description = "Subscribe to a post")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.subscribePost(auth.getName(), postId);
}
@DeleteMapping("/posts/{postId}")
@Operation(summary = "Unsubscribe post", description = "Unsubscribe from a post")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribePost(@PathVariable Long postId, Authentication auth) {
subscriptionService.unsubscribePost(auth.getName(), postId);
}
@PostMapping("/comments/{commentId}")
@Operation(summary = "Subscribe comment", description = "Subscribe to a comment")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.subscribeComment(auth.getName(), commentId);
}
@DeleteMapping("/comments/{commentId}")
@Operation(summary = "Unsubscribe comment", description = "Unsubscribe from a comment")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) {
subscriptionService.unsubscribeComment(auth.getName(), commentId);
}
@PostMapping("/users/{username}")
@Operation(summary = "Subscribe user", description = "Subscribe to a user")
@ApiResponse(responseCode = "200", description = "Subscribed")
@SecurityRequirement(name = "JWT")
public void subscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.subscribeUser(auth.getName(), username);
}
@DeleteMapping("/users/{username}")
@Operation(summary = "Unsubscribe user", description = "Unsubscribe from a user")
@ApiResponse(responseCode = "200", description = "Unsubscribed")
@SecurityRequirement(name = "JWT")
public void unsubscribeUser(@PathVariable String username, Authentication auth) {
subscriptionService.unsubscribeUser(auth.getName(), username);
}

View File

@@ -13,6 +13,12 @@ import com.openisle.service.PostService;
import com.openisle.service.TagService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.util.List;
import java.util.Map;
@@ -29,6 +35,10 @@ public class TagController {
private final TagMapper tagMapper;
@PostMapping
@Operation(summary = "Create tag", description = "Create a new tag")
@ApiResponse(responseCode = "200", description = "Created tag",
content = @Content(schema = @Schema(implementation = TagDto.class)))
@SecurityRequirement(name = "JWT")
public TagDto create(@RequestBody TagRequest req, org.springframework.security.core.Authentication auth) {
boolean approved = true;
if (postService.getPublishMode() == PublishMode.REVIEW && auth != null) {
@@ -49,6 +59,9 @@ public class TagController {
}
@PutMapping("/{id}")
@Operation(summary = "Update tag", description = "Update an existing tag")
@ApiResponse(responseCode = "200", description = "Updated tag",
content = @Content(schema = @Schema(implementation = TagDto.class)))
public TagDto update(@PathVariable Long id, @RequestBody TagRequest req) {
Tag tag = tagService.updateTag(id, req.getName(), req.getDescription(), req.getIcon(), req.getSmallIcon());
long count = postService.countPostsByTag(tag.getId());
@@ -56,11 +69,16 @@ public class TagController {
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete tag", description = "Delete a tag by id")
@ApiResponse(responseCode = "200", description = "Tag deleted")
public void delete(@PathVariable Long id) {
tagService.deleteTag(id);
}
@GetMapping
@Operation(summary = "List tags", description = "List tags with optional keyword")
@ApiResponse(responseCode = "200", description = "List of tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) {
List<Tag> tags = tagService.searchTags(keyword);
@@ -77,6 +95,9 @@ public class TagController {
}
@GetMapping("/{id}")
@Operation(summary = "Get tag", description = "Get tag by id")
@ApiResponse(responseCode = "200", description = "Tag detail",
content = @Content(schema = @Schema(implementation = TagDto.class)))
public TagDto get(@PathVariable Long id) {
Tag tag = tagService.getTag(id);
long count = postService.countPostsByTag(tag.getId());
@@ -84,6 +105,9 @@ public class TagController {
}
@GetMapping("/{id}/posts")
@Operation(summary = "List posts by tag", description = "Get posts with specific tag")
@ApiResponse(responseCode = "200", description = "List of posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))))
public List<PostSummaryDto> listPostsByTag(@PathVariable Long id,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {

View File

@@ -6,6 +6,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
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 java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -27,6 +31,9 @@ public class UploadController {
private long maxUploadSize;
@PostMapping
@Operation(summary = "Upload file", description = "Upload image file")
@ApiResponse(responseCode = "200", description = "Upload result",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
return ResponseEntity.badRequest().body(Map.of("code", 1, "msg", "File is not an image"));
@@ -48,6 +55,9 @@ public class UploadController {
}
@PostMapping("/url")
@Operation(summary = "Upload from URL", description = "Upload image from remote URL")
@ApiResponse(responseCode = "200", description = "Upload result",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
public ResponseEntity<?> uploadUrl(@RequestBody Map<String, String> body) {
String link = body.get("url");
if (link == null || link.isBlank()) {
@@ -76,6 +86,9 @@ public class UploadController {
}
@GetMapping("/presign")
@Operation(summary = "Presign upload", description = "Get presigned upload URL")
@ApiResponse(responseCode = "200", description = "Presigned URL",
content = @Content(schema = @Schema(implementation = java.util.Map.class)))
public java.util.Map<String, String> presign(@RequestParam("filename") String filename) {
return imageUploader.presignUpload(filename);
}

View File

@@ -6,6 +6,12 @@ import com.openisle.mapper.TagMapper;
import com.openisle.mapper.UserMapper;
import com.openisle.model.User;
import com.openisle.service.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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 lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
@@ -48,12 +54,20 @@ public class UserController {
private int defaultTagsLimit;
@GetMapping("/me")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Current user", description = "Get current authenticated user information")
@ApiResponse(responseCode = "200", description = "User detail",
content = @Content(schema = @Schema(implementation = UserDto.class)))
public ResponseEntity<UserDto> me(Authentication auth) {
User user = userService.findByUsername(auth.getName()).orElseThrow();
return ResponseEntity.ok(userMapper.toDto(user, auth));
}
@PostMapping("/me/avatar")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Upload avatar", description = "Upload avatar for current user")
@ApiResponse(responseCode = "200", description = "Upload result",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file,
Authentication auth) {
if (checkImageType && (file.getContentType() == null || !file.getContentType().startsWith("image/"))) {
@@ -73,6 +87,10 @@ public class UserController {
}
@PutMapping("/me")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Update profile", description = "Update current user's profile")
@ApiResponse(responseCode = "200", description = "Updated profile",
content = @Content(schema = @Schema(implementation = Map.class)))
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
Authentication auth) {
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
@@ -82,13 +100,21 @@ public class UserController {
));
}
// 这个方法似乎没有使用?
@PostMapping("/me/signin")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
@ApiResponse(responseCode = "200", description = "Sign in reward",
content = @Content(schema = @Schema(implementation = Map.class)))
public Map<String, Integer> signIn(Authentication auth) {
int reward = levelService.awardForSignin(auth.getName());
return Map.of("reward", reward);
}
@GetMapping("/{identifier}")
@Operation(summary = "Get user", description = "Get user by identifier")
@ApiResponse(responseCode = "200", description = "User detail",
content = @Content(schema = @Schema(implementation = UserDto.class)))
public ResponseEntity<UserDto> getUser(@PathVariable("identifier") String identifier,
Authentication auth) {
User user = userService.findByIdentifier(identifier).orElseThrow(() -> new NotFoundException("User not found"));
@@ -96,6 +122,9 @@ public class UserController {
}
@GetMapping("/{identifier}/posts")
@Operation(summary = "User posts", description = "Get recent posts by user")
@ApiResponse(responseCode = "200", description = "User posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))))
public java.util.List<PostMetaDto> userPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultPostsLimit;
@@ -106,6 +135,9 @@ public class UserController {
}
@GetMapping("/{identifier}/subscribed-posts")
@Operation(summary = "Subscribed posts", description = "Get posts the user subscribed to")
@ApiResponse(responseCode = "200", description = "Subscribed posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))))
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultPostsLimit;
@@ -117,6 +149,9 @@ public class UserController {
}
@GetMapping("/{identifier}/replies")
@Operation(summary = "User replies", description = "Get recent replies by user")
@ApiResponse(responseCode = "200", description = "User replies",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))))
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultRepliesLimit;
@@ -127,6 +162,9 @@ public class UserController {
}
@GetMapping("/{identifier}/hot-posts")
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
@ApiResponse(responseCode = "200", description = "Hot posts",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostMetaDto.class))))
public java.util.List<PostMetaDto> hotPosts(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
@@ -138,6 +176,9 @@ public class UserController {
}
@GetMapping("/{identifier}/hot-replies")
@Operation(summary = "User hot replies", description = "Get most reacted replies by user")
@ApiResponse(responseCode = "200", description = "Hot replies",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = CommentInfoDto.class))))
public java.util.List<CommentInfoDto> hotReplies(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
@@ -149,6 +190,9 @@ public class UserController {
}
@GetMapping("/{identifier}/hot-tags")
@Operation(summary = "User hot tags", description = "Get tags frequently used by user")
@ApiResponse(responseCode = "200", description = "Hot tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
public java.util.List<TagDto> hotTags(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : 10;
@@ -161,6 +205,9 @@ public class UserController {
}
@GetMapping("/{identifier}/tags")
@Operation(summary = "User tags", description = "Get recent tags used by user")
@ApiResponse(responseCode = "200", description = "User tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class))))
public java.util.List<TagDto> userTags(@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit) {
int l = limit != null ? limit : defaultTagsLimit;
@@ -171,6 +218,9 @@ public class UserController {
}
@GetMapping("/{identifier}/following")
@Operation(summary = "Following users", description = "Get users that this user is following")
@ApiResponse(responseCode = "200", description = "Following list",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
public java.util.List<UserDto> following(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribedUsers(user.getUsername()).stream()
@@ -179,6 +229,9 @@ public class UserController {
}
@GetMapping("/{identifier}/followers")
@Operation(summary = "Followers", description = "Get followers of this user")
@ApiResponse(responseCode = "200", description = "Followers list",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
public java.util.List<UserDto> followers(@PathVariable("identifier") String identifier) {
User user = userService.findByIdentifier(identifier).orElseThrow();
return subscriptionService.getSubscribers(user.getUsername()).stream()
@@ -187,6 +240,9 @@ public class UserController {
}
@GetMapping("/admins")
@Operation(summary = "Admin users", description = "List administrator users")
@ApiResponse(responseCode = "200", description = "Admin users",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserDto.class))))
public java.util.List<UserDto> admins() {
return userService.getAdmins().stream()
.map(userMapper::toDto)
@@ -194,6 +250,9 @@ public class UserController {
}
@GetMapping("/{identifier}/all")
@Operation(summary = "User aggregate", description = "Get aggregate information for user")
@ApiResponse(responseCode = "200", description = "User aggregate",
content = @Content(schema = @Schema(implementation = UserAggregateDto.class)))
public ResponseEntity<UserAggregateDto> userAggregate(@PathVariable("identifier") String identifier,
@RequestParam(value = "postsLimit", required = false) Integer postsLimit,
@RequestParam(value = "repliesLimit", required = false) Integer repliesLimit,

View File

@@ -5,6 +5,7 @@ import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Getter
@Setter
@@ -18,10 +19,10 @@ public class PostChangeLogDto {
private String newTitle;
private String oldContent;
private String newContent;
private String oldCategory;
private String newCategory;
private String oldTags;
private String newTags;
private CategoryDto oldCategory;
private CategoryDto newCategory;
private List<TagDto> oldTags;
private List<TagDto> newTags;
private Boolean oldClosed;
private Boolean newClosed;
private LocalDateTime oldPinnedAt;

View File

@@ -1,11 +1,28 @@
package com.openisle.mapper;
import com.openisle.dto.CategoryDto;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TagDto;
import com.openisle.model.*;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.TagRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class PostChangeLogMapper {
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
public PostChangeLogDto toDto(PostChangeLog log) {
PostChangeLogDto dto = new PostChangeLogDto();
dto.setId(log.getId());
@@ -22,11 +39,11 @@ public class PostChangeLogMapper {
dto.setOldContent(c.getOldContent());
dto.setNewContent(c.getNewContent());
} else if (log instanceof PostCategoryChangeLog cat) {
dto.setOldCategory(cat.getOldCategory());
dto.setNewCategory(cat.getNewCategory());
dto.setOldCategory(mapCategory(cat.getOldCategory()));
dto.setNewCategory(mapCategory(cat.getNewCategory()));
} else if (log instanceof PostTagChangeLog tag) {
dto.setOldTags(tag.getOldTags());
dto.setNewTags(tag.getNewTags());
dto.setOldTags(mapTags(tag.getOldTags()));
dto.setNewTags(mapTags(tag.getNewTags()));
} else if (log instanceof PostClosedChangeLog cl) {
dto.setOldClosed(cl.isOldClosed());
dto.setNewClosed(cl.isNewClosed());
@@ -39,4 +56,37 @@ public class PostChangeLogMapper {
}
return dto;
}
private CategoryDto mapCategory(String name) {
if (name == null) {
return null;
}
return categoryRepository.findByName(name)
.map(categoryMapper::toDto)
.orElseGet(() -> {
CategoryDto dto = new CategoryDto();
dto.setName(name);
return dto;
});
}
private List<TagDto> mapTags(String tags) {
if (tags == null || tags.isBlank()) {
return Collections.emptyList();
}
return Arrays.stream(tags.split(","))
.map(String::trim)
.map(this::mapTag)
.collect(Collectors.toList());
}
private TagDto mapTag(String name) {
return tagRepository.findByName(name)
.map(tagMapper::toDto)
.orElseGet(() -> {
TagDto dto = new TagDto();
dto.setName(name);
return dto;
});
}
}

View File

@@ -4,7 +4,10 @@ import com.openisle.model.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category, Long> {
List<Category> findByNameContainingIgnoreCase(String keyword);
Optional<Category> findByName(String name);
}

View File

@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByNameContainingIgnoreCase(String keyword);
@@ -15,4 +16,6 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
List<Tag> findByCreator(User creator);
Optional<Tag> findByName(String name);
}

View File

@@ -0,0 +1,48 @@
package com.openisle.scheduler;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.model.UserVisit;
import com.openisle.repository.UserRepository;
import com.openisle.repository.UserVisitRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.time.LocalDate;
import java.util.Set;
/**
* 执行计划
* 将每天用户访问落库
* @author smallclover
* @since 2025-09-09
*/
@Component
@RequiredArgsConstructor
public class UserVisitScheduler {
private final RedisTemplate redisTemplate;
private final UserRepository userRepository;
private final UserVisitRepository userVisitRepository;
@Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行
public void persistDailyVisits(){
LocalDate yesterday = LocalDate.now().minusDays(1);
String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday;
Set<String> usernames = redisTemplate.opsForSet().members(key);
if(!CollectionUtils.isEmpty(usernames)){
for(String username: usernames){
User user = userRepository.findByUsername(username).orElse(null);
if(user != null){
UserVisit userVisit = new UserVisit();
userVisit.setUser(user);
userVisit.setVisitDate(yesterday);
userVisitRepository.save(userVisit);
}
}
redisTemplate.delete(key);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.openisle.service;
import com.openisle.model.*;
import com.openisle.repository.PostChangeLogRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -15,6 +16,12 @@ import java.util.stream.Collectors;
public class PostChangeLogService {
private final PostChangeLogRepository logRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
private User getSystemUser() {
return userRepository.findByUsername("system")
.orElseThrow(() -> new IllegalStateException("System user not found"));
}
public void recordContentChange(Post post, User user, String oldContent, String newContent) {
PostContentChangeLog log = new PostContentChangeLog();
@@ -89,6 +96,7 @@ public class PostChangeLogService {
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.VOTE_RESULT);
logRepository.save(log);
}
@@ -96,6 +104,7 @@ public class PostChangeLogService {
public void recordLotteryResult(Post post) {
PostLotteryResultChangeLog log = new PostLotteryResultChangeLog();
log.setPost(post);
log.setUser(getSystemUser());
log.setType(PostChangeType.LOTTERY_RESULT);
logRepository.save(log);
}

View File

@@ -1,5 +1,6 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Post;
import com.openisle.model.PostStatus;
import com.openisle.model.PostType;
@@ -28,12 +29,15 @@ import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.scheduling.TaskScheduler;
import com.openisle.service.EmailSender;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
@@ -80,6 +84,8 @@ public class PostService {
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
private final RedisTemplate redisTemplate;
@org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository,
UserRepository userRepository,
@@ -102,7 +108,8 @@ public class PostService {
ApplicationContext applicationContext,
PointService pointService,
PostChangeLogService postChangeLogService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate) {
this.postRepository = postRepository;
this.userRepository = userRepository;
this.categoryRepository = categoryRepository;
@@ -125,6 +132,8 @@ public class PostService {
this.pointService = pointService;
this.postChangeLogService = postChangeLogService;
this.publishMode = publishMode;
this.redisTemplate = redisTemplate;
}
@EventListener(ApplicationReadyEvent.class)
@@ -201,9 +210,9 @@ public class PostService {
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple) {
long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) {
// 限制访问次数
boolean limitResult = postRateLimit(username);
if (!limitResult) {
throw new RateLimitException("Too many posts");
}
if (tagIds == null || tagIds.isEmpty()) {
@@ -300,6 +309,23 @@ public class PostService {
return post;
}
/**
* 限制发帖频率
* @param username
* @return
*/
private boolean postRateLimit(String username){
String key = CachingConfig.LIMIT_CACHE_NAME +":posts:"+username;
String result = (String)redisTemplate.opsForValue().get(key);
//最近没有创建过文章
if(StringUtils.isEmpty(result)){
// 限制频率为5分钟
redisTemplate.opsForValue().set(key,"1", Duration.ofMinutes(5));
return true;
}
return false;
}
public void joinLottery(Long postId, String username) {
LotteryPost post = lotteryPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
@@ -368,6 +394,7 @@ public class PostService {
for (User participant : pp.getParticipants()) {
notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null);
}
postChangeLogService.recordVoteResult(pp);
});
}
@@ -402,6 +429,7 @@ public class PostService {
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
postChangeLogService.recordLotteryResult(lp);
});
}

View File

@@ -1,5 +1,6 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.model.Role;
import com.openisle.service.PasswordValidator;
@@ -7,13 +8,18 @@ import com.openisle.service.UsernameValidator;
import com.openisle.service.AvatarGenerator;
import com.openisle.exception.FieldException;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
@@ -25,6 +31,10 @@ public class UserService {
private final ImageUploader imageUploader;
private final AvatarGenerator avatarGenerator;
private final RedisTemplate redisTemplate;
private final EmailSender emailService;
public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) {
usernameValidator.validate(username);
passwordValidator.validate(password);
@@ -38,7 +48,7 @@ public class UserService {
// 未验证 → 允许“重注册”:覆盖必要字段并重新发验证码
u.setEmail(email); // 若不允许改邮箱可去掉
u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode());
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
@@ -54,7 +64,7 @@ public class UserService {
// 未验证 → 允许“重注册”
u.setUsername(username); // 若不允许改用户名可去掉
u.setPassword(passwordEncoder.encode(password));
u.setVerificationCode(genCode());
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
@@ -67,7 +77,7 @@ public class UserService {
user.setPassword(passwordEncoder.encode(password));
user.setRole(Role.USER);
user.setVerified(false);
user.setVerificationCode(genCode());
// user.setVerificationCode(genCode());
user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
@@ -77,7 +87,7 @@ public class UserService {
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
user.setVerificationCode(genCode());
// user.setVerificationCode(genCode());
return userRepository.save(user);
}
@@ -85,16 +95,58 @@ public class UserService {
return String.format("%06d", new Random().nextInt(1000000));
}
public boolean verifyCode(String username, String code) {
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isPresent() && code.equals(userOpt.get().getVerificationCode())) {
User user = userOpt.get();
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
return true;
/**
* 将验证码存入缓存,并发送邮件
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType){
//缓存验证码
String code = genCode();
String key;
String subject;
String content = "您的验证码是:" + code;
// 注册类型
if(verifyType.equals(VerifyType.REGISTER)){
key = CachingConfig.VERIFY_CACHE_NAME + ":register:code:" + user.getUsername();
subject = "在网站填写验证码以验证(有效期为5分钟)";
}else {
// 重置密码
key = CachingConfig.VERIFY_CACHE_NAME + ":reset_password:code:" + user.getUsername();
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
return false;
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);// 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
}
/**
* 验证code是否正确
* @param user
* @param code
* @param verifyType
* @return
*/
public boolean verifyCode(User user, String code, VerifyType verifyType) {
// 生成key
String key1 = VerifyType.REGISTER.equals(verifyType)?":register:code:":":reset_password:code:";
String key = CachingConfig.VERIFY_CACHE_NAME + key1 + user.getUsername();
// 这里不能使用getAndDelete,需要6.x版本
String cachedCode = (String)redisTemplate.opsForValue().get(key);
// 如果校验code过期或者不存在
// 或者校验code不一致
if(Objects.isNull(cachedCode)
|| !cachedCode.equals(code)){
return false;
}
// 注册模式需要设置已经确认
if(VerifyType.REGISTER.equals(verifyType)){
user.setVerified(true);
userRepository.save(user);
}
// 走到这里说明验证成功删除验证码
redisTemplate.delete(key);
return true;
}
public Optional<User> authenticate(String username, String password) {
@@ -165,26 +217,6 @@ public class UserService {
return userRepository.save(user);
}
public String generatePasswordResetCode(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
String code = genCode();
user.setPasswordResetCode(code);
userRepository.save(user);
return code;
}
public boolean verifyPasswordResetCode(String email, String code) {
Optional<User> userOpt = userRepository.findByEmail(email);
if (userOpt.isPresent() && code.equals(userOpt.get().getPasswordResetCode())) {
User user = userOpt.get();
user.setPasswordResetCode(null);
userRepository.save(user);
return true;
}
return false;
}
public User updatePassword(String username, String newPassword) {
passwordValidator.validate(newPassword);
User user = userRepository.findByUsername(username)

View File

@@ -1,15 +1,22 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.model.UserVisit;
import com.openisle.repository.UserRepository;
import com.openisle.repository.UserVisitRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -17,6 +24,8 @@ public class UserVisitService {
private final UserVisitRepository userVisitRepository;
private final UserRepository userRepository;
private final RedisTemplate redisTemplate;
public boolean recordVisit(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
@@ -30,10 +39,36 @@ public class UserVisitService {
});
}
/**
* 统计访问次数,改为从缓存获取/数据库获取
* @param username
* @return
*/
public long countVisits(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return userVisitRepository.countByUser(user);
// 如果缓存存在就返回
String key1 = CachingConfig.VISIT_CACHE_NAME + ":"+LocalDate.now()+":count:"+username;
Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
if(cached != null){
return cached.longValue();
}
// Redis Set 检查今天是否访问
String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username);
Long visitCount = userVisitRepository.countByUser(user);
if (todayVisited) visitCount += 1;
LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds();
// 写入缓存,设置 TTL当天剩余时间
redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay));
return visitCount;
}
public long countDau(LocalDate date) {

View File

@@ -0,0 +1,20 @@
package com.openisle.util;
/**
* 验证码类型
* @author smallclover
* @since 2025-09-08
*/
public enum VerifyType {
REGISTER(1),
RESET_PASSWORD(2);
private final int code;
VerifyType(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -108,6 +108,10 @@ rabbitmq.sharding.enabled=true
# see https://springdoc.org/#springdoc-openapi-core-properties
springdoc.api-docs.path=/api/v3/api-docs
springdoc.api-docs.enabled=true
springdoc.api-docs.servers[0].url=https://www.open-isle.com
springdoc.api-docs.servers[0].description=Production Environment
springdoc.api-docs.servers[1].url=https://www.staging.open-isle.com
springdoc.api-docs.servers[1].description=Staging Environment
springdoc.info.title=OpenIsle
springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1

View File

@@ -4,6 +4,7 @@ import com.openisle.model.User;
import com.openisle.service.*;
import com.openisle.model.RegisterMode;
import com.openisle.repository.UserRepository;
import com.openisle.util.VerifyType;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
@@ -71,7 +72,9 @@ class AuthControllerTest {
@Test
void verifyCodeEndpoint() throws Exception {
Mockito.when(userService.verifyCode("u", "123")).thenReturn(true);
User user = new User();
user.setUsername("u");
Mockito.when(userService.verifyCode(user, "123", VerifyType.REGISTER)).thenReturn(true);
Mockito.when(jwtService.generateReasonToken("u")).thenReturn("reason_token");
mockMvc.perform(post("/api/auth/verify")

View File

@@ -6,6 +6,7 @@ import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import static org.junit.jupiter.api.Assertions.*;
@@ -37,11 +38,13 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -86,11 +89,13 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -141,11 +146,13 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -177,11 +184,13 @@ class PostServiceTest {
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();

View File

@@ -16,6 +16,6 @@ bun dev
使用以下路由:
- `docs/frontend/` 前端技术文档
- `docs/backend/` 后端技术文档
- `docs/openapi/` 后端 API 文档
- `frontend/` 前端技术文档
- `backend/` 后端技术文档
- `openapi/` 后端 API 文档

View File

@@ -19,7 +19,7 @@ function DocsCategory({ url }: { url: string }) {
);
}
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -48,7 +48,7 @@ export async function generateStaticParams() {
}
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>
props: PageProps<'/[[...slug]]'>
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);

View File

@@ -28,7 +28,7 @@ function TabTitle({ children }: { children: React.ReactNode }) {
return <span className="text-[11px]">{children}</span>;
}
export default function Layout({ children }: LayoutProps<'/docs'>) {
export default function Layout({ children }: LayoutProps<'/'>) {
return (
// @ts-ignore
<DocsLayout
@@ -40,7 +40,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
{
title: 'OpenIsle 前端',
description: <TabTitle></TabTitle>,
url: '/docs/frontend',
url: '/frontend',
icon: (
<TabIcon color="#4ca154">
<CompassIcon />
@@ -50,7 +50,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
{
title: 'OpenIsle 后端',
description: <TabTitle></TabTitle>,
url: '/docs/backend',
url: '/backend',
icon: (
<TabIcon color="#1f66f4">
<ServerIcon />
@@ -60,7 +60,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
{
title: 'OpenIsle API',
description: <TabTitle> API </TabTitle>,
url: '/docs/openapi',
url: '/openapi',
icon: (
<TabIcon color="#677489">
<CodeXmlIcon />

View File

@@ -6,7 +6,7 @@ const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: LayoutProps<'/docs'>) {
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="zh" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">

View File

@@ -40,4 +40,4 @@ backend/
## API 接口
详细的 API 接口文档请查看 [API 文档](/docs/openapi)。
详细的 API 接口文档请查看 [API 文档](/openapi)。

View File

@@ -9,6 +9,6 @@ OpenIsle 是一个现代化的社区平台,提供完整的社交功能。
## 快速开始
- [后端开发指南](/docs/backend) - 了解后端架构和开发
- [前端开发指南](/docs/frontend) - 了解前端技术栈和组件
- [API 文档](/docs/openapi) - 查看完整的 API 接口文档
- [后端开发指南](/backend) - 了解后端架构和开发
- [前端开发指南](/frontend) - 了解前端技术栈和组件
- [API 文档](/openapi) - 查看完整的 API 接口文档

View File

@@ -8,7 +8,7 @@ export function baseOptions(): BaseLayoutProps {
githubUrl: 'https://github.com/nagisa77/OpenIsle',
nav: {
title: 'OpenIsle Docs',
url: '/docs',
url: '/',
},
searchToggle: {
enabled: false,

View File

@@ -10,7 +10,7 @@ import * as ClientAdapters from './media-adapter.client';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/docs',
baseUrl: '/',
source: docs.toFumadocsSource(),
pageTree: {
transformers: [transformerOpenAPI()],

View File

@@ -4,7 +4,14 @@ 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=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
# 视频压缩配置 - FFmpeg.wasm 专用
# 支持 Chrome 60+ 和 Safari 11.1+
NUXT_PUBLIC_VIDEO_MAX_SIZE=52428800 # 20MB (字节)
NUXT_PUBLIC_VIDEO_TARGET_SIZE=20971520 # 5MB (字节)

View File

@@ -17,3 +17,8 @@ NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 视频压缩配置 - FFmpeg.wasm 专用
# 支持 Chrome 60+ 和 Safari 11.1+
NUXT_PUBLIC_VIDEO_MAX_SIZE=52428800 # 20MB (字节)
NUXT_PUBLIC_VIDEO_TARGET_SIZE=20971520 # 5MB (字节)

View File

@@ -15,3 +15,8 @@ NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 视频压缩配置 - FFmpeg.wasm 专用
# 支持 Chrome 60+ 和 Safari 11.1+
NUXT_PUBLIC_VIDEO_MAX_SIZE=52428800 # 20MB (字节)
NUXT_PUBLIC_VIDEO_TARGET_SIZE=20971520 # 5MB (字节)

View File

@@ -17,7 +17,7 @@
--background-color: white;
--background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray;
--normal-border-color: lightgray;
--normal-border-color: rgba(211, 211, 211, 0.63);
--menu-selected-background-color: rgba(88, 241, 255, 0.166);
--normal-light-background-color: rgba(242, 242, 242, 0.884);
--menu-selected-background-color-hover: rgba(242, 242, 242, 0.884);
@@ -239,8 +239,16 @@ body {
}
.info-content-text img {
max-width: 100%;
max-width: min(800px, 100%);
max-height: 600px;
height: auto;
cursor: pointer;
box-shadow: 4px 12px 48px 0 rgba(0, 0, 0, 0.11);
transition: box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.info-content-text img:hover {
box-shadow: 4px 12px 48px 0 rgba(0, 0, 0, 0.35);
}
.info-content-text table {
@@ -346,9 +354,41 @@ body {
position: relative;
min-width: 0;
}
.d2h-file-name {
font-size: 12px !important;
}
.d2h-file-header {
height: auto !important;
}
.d2h-code-linenumber {
display: none !important;
}
.d2h-code-line {
padding-left: 10px !important;
}
.d2h-diff-table {
font-size: 6px !important;
}
.d2h-code-line ins {
height: 100%;
font-size: 13px !important;
}
/* .d2h-code-line {
height: 12px;
}
.d2h-code-line-ctn {
font-size: 12px !important;
} */
}
/* Transition API */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;

View File

@@ -100,7 +100,7 @@ export default {
.timeline-content {
flex: 1;
width: calc(100% - 32px);
width: calc(100% - 42px);
}
@media (max-width: 768px) {

View File

@@ -6,8 +6,15 @@
</div>
<div class="comment-bottom-container">
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
<template v-if="!loading"> 发布评论 </template>
<template v-else> <loading-four /> 发布中... </template>
<template v-if="!loading">
发布评论
<span class="shortcut-icon" v-if="!isMobile">
{{ isMac ? '' : 'Ctrl' }}
</span>
</template>
<template v-else>
<loading-four /> 发布中...
</template>
</div>
</div>
</div>
@@ -24,6 +31,7 @@ import {
} from '~/utils/vditor'
import '~/assets/global.css'
import LoginOverlay from '~/components/LoginOverlay.vue'
import { useIsMobile } from '~/utils/screen'
export default {
name: 'CommentEditor',
@@ -52,12 +60,22 @@ export default {
},
components: { LoginOverlay },
setup(props, { emit }) {
const isMobile = useIsMobile()
const vditorInstance = ref(null)
const text = ref('')
const editorId = ref(props.editorId)
if (!editorId.value) {
editorId.value = 'editor-' + useId()
}
const isMac = ref(false)
if (navigator.userAgentData) {
isMac.value = navigator.userAgentData.platform === 'macOS'
} else {
isMac.value = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
}
const getEditorTheme = getEditorThemeUtil
const getPreviewTheme = getPreviewThemeUtil
const applyTheme = () => {
@@ -96,7 +114,27 @@ export default {
applyTheme()
},
})
// applyTheme()
// 不是手机的情况下不添加快捷键
if(!isMobile.value){
// 添加快捷键监听 (Ctrl+Enter 或 Cmd+Enter)
const handleKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
submit()
}
}
const el = document.getElementById(editorId.value)
if (el) {
el.addEventListener('keydown', handleKeydown)
}
onUnmounted(() => {
if (el) {
el.removeEventListener('keydown', handleKeydown)
}
})
}
})
onUnmounted(() => {
@@ -134,7 +172,7 @@ export default {
},
)
return { submit, isDisabled, editorId }
return { submit, isDisabled, editorId, isMac, isMobile}
},
}
</script>
@@ -174,10 +212,16 @@ export default {
.comment-submit:hover {
background-color: var(--primary-color-hover);
}
@media (max-width: 768px) {
.comment-editor-container {
margin-bottom: 10px;
}
/** 评论按钮快捷键样式 */
.shortcut-icon {
padding: 2px 6px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
background-color: rgba(0, 0, 0, 0.25);
}
.comment-submit.disabled .shortcut-icon {
background-color: rgba(0, 0, 0, 0);
}
</style>

View File

@@ -314,6 +314,7 @@ const gotoTag = (t) => {
border-radius: 10px;
display: flex;
align-items: center;
transition: background-color 0.5s ease;
}
.menu-item:hover {
@@ -408,6 +409,7 @@ const gotoTag = (t) => {
gap: 5px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.5s ease;
}
.section-item:hover {

View File

@@ -70,23 +70,6 @@ export default {
onMounted(() => {
vditorInstance.value = createVditor(editorId.value, {
placeholder: '输入消息...',
height: 150,
toolbar: [
'emoji',
'bold',
'italic',
'strike',
'link',
'|',
'list',
'|',
'line',
'quote',
'code',
'inline-code',
'|',
'upload',
],
preview: {
actions: [],
markdown: { toc: false },
@@ -149,11 +132,17 @@ export default {
border-radius: 8px;
}
.vditor {
min-height: 50px;
max-height: 150px;
}
.message-bottom-container {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: 10px;
margin-top: 10px;
margin-bottom: 10px;
background-color: var(--bg-color-soft);
border-top: 1px solid var(--border-color);
border-bottom-left-radius: 8px;

View File

@@ -11,8 +11,18 @@
<span v-if="log.username" class="change-log-user">{{ log.username }}</span>
<span v-if="log.type === 'CONTENT'" class="change-log-content">变更了文章内容</span>
<span v-else-if="log.type === 'TITLE'" class="change-log-content">变更了文章标题</span>
<span v-else-if="log.type === 'CATEGORY'" class="change-log-content">变更了文章分类</span>
<span v-else-if="log.type === 'TAG'" class="change-log-content">变更了文章标签</span>
<template v-else-if="log.type === 'CATEGORY'">
<div class="change-log-category-text">变更了文章分类, </div>
<ArticleCategory :category="log.oldCategory" />
<div class="change-log-category-text">修改为</div>
<ArticleCategory :category="log.newCategory" />
</template>
<template v-else-if="log.type === 'TAG'">
<div class="change-log-category-text">变更了文章标签, </div>
<ArticleTags :tags="log.oldTags" />
<div class="change-log-category-text">修改为</div>
<ArticleTags :tags="log.newTags" />
</template>
<span v-else-if="log.type === 'CLOSED'" class="change-log-content">
<template v-if="log.newClosed">关闭了文章</template>
<template v-else>重新打开了文章</template>
@@ -25,8 +35,12 @@
<template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template>
</span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content">投票已出结果</span>
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content">抽奖已开奖</span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
>系统已计算投票结果</span
>
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
>系统已精密计算抽奖结果 (=゚ω゚)</span
>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div
@@ -46,13 +60,14 @@ import 'diff2html/bundles/css/diff2html.min.css'
import BaseImage from '~/components/BaseImage.vue'
import { navigateTo } from 'nuxt/app'
import { themeState } from '~/utils/theme'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
const props = defineProps({
log: Object,
title: String,
})
const diffHtml = computed(() => {
const isMobile = useIsMobile()
// Track theme changes
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
themeState.mode
@@ -67,7 +82,6 @@ const diffHtml = computed(() => {
showFiles: false,
matching: 'lines',
drawFileList: false,
outputFormat: isMobile.value ? 'line-by-line' : 'side-by-side',
colorScheme,
})
} else if (props.log.type === 'TITLE') {
@@ -79,7 +93,6 @@ const diffHtml = computed(() => {
showFiles: false,
matching: 'lines',
drawFileList: false,
outputFormat: isMobile.value ? 'line-by-line' : 'side-by-side',
colorScheme,
})
}
@@ -94,11 +107,16 @@ const diffHtml = computed(() => {
/* padding-top: 5px; */
/* padding-bottom: 30px; */
font-size: 14px;
border-bottom: 1px solid var(--normal-border-color);
padding-bottom: 10px;
}
.change-log-text {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.change-log-user {
font-weight: bold;
margin-right: 4px;
@@ -116,6 +134,7 @@ const diffHtml = computed(() => {
margin-right: 4px;
cursor: pointer;
}
.change-log-time {
font-size: 12px;
opacity: 0.6;
@@ -124,4 +143,12 @@ const diffHtml = computed(() => {
.content-diff {
margin-top: 8px;
}
.change-log-category {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,137 @@
/**
* 文件上传配置 - 简化版
* 专注于 FFmpeg.wasm 视频压缩,支持 Chrome/Safari
*/
// 声明全局变量以避免 TypeScript 错误
/* global useRuntimeConfig */
// 简化的环境变量读取功能
function getEnvNumber(key, defaultValue) {
if (typeof window !== 'undefined') {
// 客户端:尝试从 Nuxt 环境获取
try {
// 使用 globalThis 避免直接引用未定义的变量
const nuxtApp = globalThis.$nuxt || globalThis.nuxtApp
if (nuxtApp && nuxtApp.$config) {
const value = nuxtApp.$config.public?.[key.replace('NUXT_PUBLIC_', '').toLowerCase()]
return value ? Number(value) : defaultValue
}
return defaultValue
} catch {
return defaultValue
}
}
// 服务端:从 process.env 获取
return process.env[key] ? Number(process.env[key]) : defaultValue
}
function getEnvBoolean(key, defaultValue) {
if (typeof window !== 'undefined') {
try {
// 使用 globalThis 避免直接引用未定义的变量
const nuxtApp = globalThis.$nuxt || globalThis.nuxtApp
if (nuxtApp && nuxtApp.$config) {
const value = nuxtApp.$config.public?.[key.replace('NUXT_PUBLIC_', '').toLowerCase()]
return value === 'true' || value === true
}
return defaultValue
} catch {
return defaultValue
}
}
const envValue = process.env[key]
return envValue ? envValue === 'true' : defaultValue
}
export const UPLOAD_CONFIG = {
// 视频文件配置 - 专为 FFmpeg.wasm 优化
VIDEO: {
// 文件大小限制 (字节)
MAX_SIZE: getEnvNumber('NUXT_PUBLIC_VIDEO_MAX_SIZE', 20 * 1024 * 1024), // 5MB
TARGET_SIZE: getEnvNumber('NUXT_PUBLIC_VIDEO_TARGET_SIZE', 5 * 1024 * 1024), // 5MB
// 支持的输入格式 (FFmpeg.wasm 支持更多格式)
SUPPORTED_FORMATS: ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv'],
// 输出格式 - MP4 (兼容性最好)
OUTPUT_FORMAT: 'mp4',
OUTPUT_CODEC: 'h264',
},
// 图片文件配置
IMAGE: {
MAX_SIZE: 5 * 1024 * 1024, // 5MB
TARGET_SIZE: 5 * 1024 * 1024, // 5MB
SUPPORTED_FORMATS: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'],
},
// 音频文件配置
AUDIO: {
MAX_SIZE: 5 * 1024 * 1024, // 5MB
TARGET_SIZE: 5 * 1024 * 1024, // 5MB
SUPPORTED_FORMATS: ['mp3', 'wav', 'ogg', 'aac', 'm4a'],
},
// 通用文件配置
GENERAL: {
MAX_SIZE: 100 * 1024 * 1024, // 100MB
CHUNK_SIZE: 5 * 1024 * 1024, // 5MB 分片大小
},
// 用户体验配置
UI: {
SUCCESS_DURATION: 2000,
ERROR_DURATION: 3000,
WARNING_DURATION: 3000,
},
}
/**
* 获取文件类型配置
*/
export function getFileTypeConfig(filename) {
const ext = filename.split('.').pop().toLowerCase()
if (UPLOAD_CONFIG.VIDEO.SUPPORTED_FORMATS.includes(ext)) {
return { type: 'video', config: UPLOAD_CONFIG.VIDEO }
}
if (UPLOAD_CONFIG.IMAGE.SUPPORTED_FORMATS.includes(ext)) {
return { type: 'image', config: UPLOAD_CONFIG.IMAGE }
}
if (UPLOAD_CONFIG.AUDIO.SUPPORTED_FORMATS.includes(ext)) {
return { type: 'audio', config: UPLOAD_CONFIG.AUDIO }
}
return { type: 'general', config: UPLOAD_CONFIG.GENERAL }
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
/**
* 计算压缩节省的费用 (示例函数)
*/
export function calculateSavings(originalSize, compressedSize, costPerMB = 0.01) {
const originalMB = originalSize / (1024 * 1024)
const compressedMB = compressedSize / (1024 * 1024)
const savedMB = originalMB - compressedMB
const savedCost = savedMB * costPerMB
return {
savedMB: savedMB.toFixed(2),
savedCost: savedCost.toFixed(4),
originalCost: (originalMB * costPerMB).toFixed(4),
compressedCost: (compressedMB * costPerMB).toFixed(4),
}
}

View File

@@ -1,9 +1,16 @@
import { defineNuxtConfig } from 'nuxt/config'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const appPkg = require('./package.json') as {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
export default defineNuxtConfig({
devServer: {
host: '0.0.0.0',
port: 3000
port: 3000,
},
ssr: true,
modules: ['@nuxt/image'],

View File

@@ -6,6 +6,8 @@
"": {
"name": "frontend_nuxt",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@icon-park/vue-next": "^1.4.2",
"@nuxt/image": "^1.11.0",
"@stomp/stompjs": "^7.0.0",
@@ -19,6 +21,7 @@
"ldrs": "^1.0.0",
"markdown-it": "^14.1.0",
"mermaid": "^10.9.4",
"nanoid": "^5.1.5",
"nprogress": "^0.2.0",
"nuxt": "latest",
"sanitize-html": "^2.17.0",
@@ -996,9 +999,39 @@
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.15",
"resolved": "https://registry.npmmirror.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
"integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
"license": "MIT",
"dependencies": {
"@ffmpeg/types": "^0.12.4"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/@ffmpeg/types": {
"version": "0.12.4",
"resolved": "https://registry.npmmirror.com/@ffmpeg/types/-/types-0.12.4.tgz",
"integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
"license": "MIT",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/util": {
"version": "0.12.2",
"resolved": "https://registry.npmmirror.com/@ffmpeg/util/-/util-0.12.2.tgz",
"integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
"license": "MIT",
"engines": {
"node": ">=18.x"
}
},
"node_modules/@icon-park/vue-next": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@icon-park/vue-next/-/vue-next-1.4.2.tgz",
"resolved": "https://registry.npmmirror.com/@icon-park/vue-next/-/vue-next-1.4.2.tgz",
"integrity": "sha512-+QklF255wkfBOabY+xw6FAI0Bwln/RhdwCunNy/9sKdKuChtaU67QZqU67KGAvZUTeeBgsL+yaHHxqfQeGZXEQ==",
"license": "Apache-2.0",
"engines": {
@@ -7222,7 +7255,7 @@
},
"node_modules/diff2html": {
"version": "3.4.52",
"resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.52.tgz",
"resolved": "https://registry.npmmirror.com/diff2html/-/diff2html-3.4.52.tgz",
"integrity": "sha512-qhMg8/I3sZ4zm/6R/Kh0xd6qG6Vm86w6M+C9W+DuH1V8ACz+1cgEC8/k0ucjv6AGqZWzHm/8G1gh7IlrUqCMhg==",
"license": "MIT",
"dependencies": {
@@ -7238,7 +7271,7 @@
},
"node_modules/diff2html/node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"resolved": "https://registry.npmmirror.com/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
@@ -7247,7 +7280,7 @@
},
"node_modules/diff2html/node_modules/highlight.js": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.9.0.tgz",
"integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==",
"license": "BSD-3-Clause",
"optional": true,
@@ -8330,7 +8363,7 @@
},
"node_modules/hogan.js": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz",
"resolved": "https://registry.npmmirror.com/hogan.js/-/hogan.js-3.0.2.tgz",
"integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==",
"dependencies": {
"mkdirp": "0.3.0",
@@ -8342,13 +8375,13 @@
},
"node_modules/hogan.js/node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/hogan.js/node_modules/mkdirp": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz",
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.3.0.tgz",
"integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==",
"deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"license": "MIT/X11",
@@ -8358,7 +8391,7 @@
},
"node_modules/hogan.js/node_modules/nopt": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
"resolved": "https://registry.npmmirror.com/nopt/-/nopt-1.0.10.tgz",
"integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
"license": "MIT",
"dependencies": {
@@ -8614,7 +8647,7 @@
},
"node_modules/ipx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/ipx/-/ipx-3.1.1.tgz",
"resolved": "https://registry.npmmirror.com/ipx/-/ipx-3.1.1.tgz",
"integrity": "sha512-7Xnt54Dco7uYkfdAw0r2vCly3z0rSaVhEXMzPvl3FndsTVm5p26j+PO+gyinkYmcsEUvX2Rh7OGK7KzYWRu6BA==",
"license": "MIT",
"dependencies": {
@@ -10171,7 +10204,7 @@
},
"node_modules/nanoid": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"funding": [
{

View File

@@ -13,6 +13,8 @@
},
"dependencies": {
"@icon-park/vue-next": "^1.4.2",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@nuxt/image": "^1.11.0",
"@stomp/stompjs": "^7.0.0",
"cropperjs": "^1.6.2",
@@ -25,6 +27,7 @@
"ldrs": "^1.0.0",
"markdown-it": "^14.1.0",
"mermaid": "^10.9.4",
"nanoid": "^5.1.5",
"nprogress": "^0.2.0",
"nuxt": "latest",
"sanitize-html": "^2.17.0",

View File

@@ -1,23 +1,48 @@
<template>
<div class="about-page">
<BaseTabs v-model="selectedTab" :tabs="tabs">
<div class="about-loading" v-if="isFetching">
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
</div>
<div
v-else
class="about-content"
v-html="renderMarkdown(content)"
@click="handleContentClick"
></div>
<template v-if="selectedTab === 'api'">
<div class="about-api">
<div class="about-api-title">调试Token</div>
<div v-if="!authState.loggedIn" class="about-api-login">
<NuxtLink to="/login" class="about-api-login-link">登录</NuxtLink>后查看 Token
</div>
<div v-else class="about-api-token">
<div class="token-row">
<span class="token-text">{{ shortToken }}</span>
<span @click="copyToken"><copy class="copy-icon" /></span>
</div>
<div class="warning-row">
<info-icon class="warning-icon" />
<div class="token-warning">请不要将 Token 泄露给他人</div>
</div>
</div>
<div class="about-api-title">API文档和调试入口</div>
<div class="about-api-link">API Playground <share /></div>
</div>
</template>
<template v-else>
<div class="about-loading" v-if="isFetching">
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
</div>
<div
v-else
class="about-content"
v-html="renderMarkdown(content)"
@click="handleContentClick"
></div>
</template>
</BaseTabs>
</div>
</template>
<script>
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import BaseTabs from '~/components/BaseTabs.vue'
import { toast } from '~/composables/useToast'
export default {
name: 'AboutPageView',
@@ -44,11 +69,25 @@ export default {
label: '隐私政策',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
},
{
key: 'api',
label: 'API与调试',
},
]
const route = useRoute()
const router = useRouter()
const selectedTab = ref(tabs[0].key)
const content = ref('')
const token = computed(() => (authState.loggedIn ? getToken() : ''))
const shortToken = computed(() => {
if (!token.value) return ''
if (token.value.length <= 20) return token.value
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
})
const loadContent = async (file) => {
if (!file) return
try {
isFetching.value = true
const res = await fetch(file)
@@ -65,19 +104,58 @@ export default {
}
onMounted(() => {
loadContent(tabs[0].file)
const initTab = route.query.tab
if (initTab && tabs.find((t) => t.key === initTab)) {
selectedTab.value = initTab
const tab = tabs.find((t) => t.key === initTab)
if (tab && tab.file) loadContent(tab.file)
} else {
loadContent(tabs[0].file)
}
})
watch(selectedTab, (name) => {
const tab = tabs.find((t) => t.key === name)
if (tab) loadContent(tab.file)
if (tab && tab.file) loadContent(tab.file)
router.replace({ query: { ...route.query, tab: name } })
})
watch(
() => route.query.tab,
(name) => {
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
selectedTab.value = name
}
},
)
const copyToken = async () => {
if (import.meta.client && token.value) {
try {
await navigator.clipboard.writeText(token.value)
toast.success('已复制 Token')
} catch (e) {
toast.error('复制失败')
}
}
}
const handleContentClick = (e) => {
handleMarkdownClick(e)
}
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
return {
tabs,
selectedTab,
content,
renderMarkdown,
isFetching,
handleContentClick,
authState,
token,
copyToken,
shortToken,
}
},
}
</script>
@@ -101,6 +179,66 @@ export default {
height: 200px;
}
.about-api {
padding: 20px;
}
.about-api-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
margin-top: 30px;
margin-bottom: 15px;
}
.about-api-login-link {
color: var(--primary-color);
cursor: pointer;
text-decoration: none;
}
.about-api-login-link:hover {
text-decoration: underline;
}
.warning-row {
display: flex;
align-items: center;
gap: 4px;
opacity: 0.7;
}
.warning-icon {
font-size: 13px;
}
.token-warning {
font-size: 13px;
}
.token-row {
display: flex;
align-items: center;
gap: 10px;
font: 14px;
margin-bottom: 10px;
word-break: break-all;
}
.copy-btn {
padding: 4px 8px;
cursor: pointer;
}
.about-api-link {
color: var(--primary-color);
cursor: pointer;
}
.about-api-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.about-tabs {
width: 100vw;

View File

@@ -424,7 +424,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
.topic-container {
position: sticky;
top: calc(var(--header-height) + 1px);
top: var(--header-height);
padding-top: 10px;
z-index: 10;
background-color: var(--background-color-blur);
display: flex;
@@ -432,12 +433,10 @@ const sanitizeDescription = (text) => stripMarkdown(text)
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 0;
backdrop-filter: var(--blur-10);
}
.topic-item-container {
margin-left: 20px;
display: flex;
flex-direction: row;
align-items: center;
@@ -478,6 +477,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
width: 100%;
color: gray;
border-bottom: 1px solid var(--normal-border-color);
padding-top: 30px;
padding-bottom: 10px;
}
@@ -487,6 +487,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
align-items: center;
width: 100%;
border-bottom: 1px solid var(--normal-border-color);
transition: background-color 0.5s ease;
}
.article-item:hover {

View File

@@ -1,5 +1,11 @@
<template>
<div class="chat-container" :class="{ float: isFloatMode }">
<vue-easy-lightbox
:visible="lightboxVisible"
:index="lightboxIndex"
:imgs="lightboxImgs"
@hide="lightboxVisible = false"
/>
<div v-if="!loading" class="chat-header">
<div class="header-main">
<div class="back-button" @click="goBack">
@@ -14,7 +20,7 @@
</div>
</div>
<div class="messages-list" ref="messagesListEl">
<div class="messages-list" ref="messagesListEl" @click="handleContentClick">
<div v-if="loading" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
@@ -101,7 +107,7 @@ import {
import { useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
import { renderMarkdown, stripMarkdownLength } from '~/utils/markdown'
import { renderMarkdown, stripMarkdownLength, handleMarkdownClick } from '~/utils/markdown'
import MessageEditor from '~/components/MessageEditor.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import { useWebSocket } from '~/composables/useWebSocket'
@@ -110,6 +116,7 @@ import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import VueEasyLightbox from 'vue-easy-lightbox'
const config = useRuntimeConfig()
const route = useRoute()
@@ -135,6 +142,9 @@ const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
const replyTo = ref(null)
const newMessagesCount = ref(0)
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const isUserNearBottom = ref(true)
function updateNearBottom() {
@@ -451,6 +461,17 @@ function minimize() {
navigateTo('/')
}
function handleContentClick(e) {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
function openUser(id) {
if (isFloatMode.value) {
// 先不处理...

View File

@@ -1163,6 +1163,7 @@ onMounted(async () => {
margin-top: 10px;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.info-content-container {
@@ -1218,7 +1219,7 @@ onMounted(async () => {
}
.post-time {
font-size: 14px;
font-size: 12px;
opacity: 0.5;
}
@@ -1284,10 +1285,6 @@ onMounted(async () => {
font-size: 12px;
}
.post-time {
font-size: 12px;
}
.info-content-text {
line-height: 1.5;
}

View File

@@ -0,0 +1,24 @@
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { toBlobURL } from '@ffmpeg/util'
import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
let ffmpeg: FFmpeg | null = null
export default defineNuxtPlugin(() => {
return {
provide: {
ffmpeg: async () => {
if (ffmpeg) return ffmpeg
ffmpeg = new FFmpeg()
const base = '~/assets/js/ffmpeg/core-mt/dist/umd'
await ffmpeg.load({
coreURL: await toBlobURL(`${base}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${base}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: await toBlobURL(`${base}/ffmpeg-core.worker.js`, 'text/javascript'),
})
return ffmpeg
},
},
}
})

View File

@@ -76,6 +76,8 @@ import {
DoubleDown,
Open,
Dislike,
CheckOne,
Share,
} from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => {
@@ -155,4 +157,6 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('DoubleDown', DoubleDown)
nuxtApp.vueApp.component('OpenIcon', Open)
nuxtApp.vueApp.component('Dislike', Dislike)
nuxtApp.vueApp.component('CheckOne', CheckOne)
nuxtApp.vueApp.component('Share', Share)
})

View File

@@ -0,0 +1,327 @@
/**
* FFmpeg.wasm 视频压缩器
*
* 用法:
* const { $ffmpeg } = useNuxtApp()
* const ff = await $ffmpeg() // 插件里已完成 ffmpeg.load()
* const out = await compressVideoWithFFmpeg(ff, file, { onProgress, strictSize: false })
*
* 设计要点:
* - 本文件不再负责加载/初始化,只负责转码逻辑;和 Nuxt 插件解耦。
* - 针对【同一个 ffmpeg 实例】做串行队列,避免并发 exec 踩内存文件系统。
* - 使用 nanoid 生成唯一文件名;日志环形缓冲;默认 CRF+VBV可选 strictSizetwo-pass
* - 体积明显小于目标时直通返回,减少无谓重编码。
*/
import { fetchFile } from '@ffmpeg/util'
import { nanoid } from 'nanoid'
import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
/*************************
* 每实例一个串行队列 *
*************************/
// WeakMap<FFmpeg, { q: (()=>Promise<any>)[], running: boolean, resolvers: {res,rej}[] }>
const queues = new WeakMap()
function enqueueOn(instance, taskFn) {
return new Promise((res, rej) => {
let st = queues.get(instance)
if (!st) {
st = { q: [], running: false, resolvers: [] }
queues.set(instance, st)
}
st.q.push(taskFn)
st.resolvers.push({ res, rej })
drain(instance)
})
}
async function drain(instance) {
const st = queues.get(instance)
if (!st || st.running) return
st.running = true
try {
while (st.q.length) {
const task = st.q.shift()
const rr = st.resolvers.shift()
try {
rr.res(await task())
} catch (e) {
rr.rej(e)
}
}
} finally {
st.running = false
}
}
/*****************
* 工具函数 *
*****************/
function decideScale(widthHint) {
if (!widthHint) return { filter: null, width: null }
const evenW = widthHint % 2 === 0 ? widthHint : widthHint - 1
return { filter: `scale=${evenW}:-2:flags=bicubic,setsar=1`, width: evenW }
}
function calculateParamsByRatio(originalSize, targetSize) {
const ratio = Math.min(targetSize / originalSize, 1)
const crf = ratio < 0.35 ? 29 : ratio < 0.5 ? 27 : ratio < 0.7 ? 25 : 23
const preset = ratio < 0.35 ? 'slow' : ratio < 0.5 ? 'medium' : 'veryfast'
const s =
ratio < 0.35
? decideScale(720)
: ratio < 0.6
? decideScale(960)
: ratio < 0.8
? decideScale(1280)
: { filter: null, width: null }
const audioBitrateK = ratio < 0.5 ? 96 : ratio < 0.7 ? 128 : 160
const profile = s.width && s.width <= 1280 ? 'main' : 'high'
return { crf, preset, scaleFilter: s.filter, scaleWidth: s.width, audioBitrateK, profile }
}
function makeRingLogger(capBytes = 4000) {
const buf = []
let total = 0
function push(s) {
if (!s) return
buf.push(s)
total += s.length
while (total > capBytes) total -= buf.shift().length
}
return { push, dump: () => buf.slice() }
}
function parseDurationFromLogs(logs) {
// 避免正则:查找 Duration: 后的 00:00:00.xx
const text = logs.join(' ')
const idx = text.indexOf('Duration:')
if (idx === -1) return null
let i = idx + 'Duration:'.length
while (i < text.length && text[i] === ' ') i++
function read2(start) {
const a = text.charCodeAt(start) - 48
const b = text.charCodeAt(start + 1) - 48
if (a < 0 || a > 9 || b < 0 || b > 9) return null
return a * 10 + b
}
const hh = read2(i)
if (hh === null) return null
i += 2
if (text[i++] !== ':') return null
const mm = read2(i)
if (mm === null) return null
i += 2
if (text[i++] !== ':') return null
const s1 = read2(i)
if (s1 === null) return null
i += 2
if (text[i++] !== '.') return null
let j = i
while (j < text.length && text.charCodeAt(j) >= 48 && text.charCodeAt(j) <= 57) j++
const frac = parseFloat('0.' + text.slice(i, j) || '0')
return hh * 3600 + mm * 60 + s1 + frac
}
export function isFFmpegSupported() {
return typeof WebAssembly !== 'undefined' && typeof Worker !== 'undefined'
}
/**
* 读取 ffmpeg 核心版本(通过 -version会进入队列避免并发冲突
*/
export async function getFFmpegInfo(ffmpegInstance) {
return enqueueOn(ffmpegInstance, async () => {
const logs = []
const onLog = ({ type, message }) => {
if (type === 'info' || type === 'fferr') logs.push(message)
}
ffmpegInstance.on('log', onLog)
try {
await ffmpegInstance.exec(['-version'])
} finally {
ffmpegInstance.off('log', onLog)
}
const line = logs.find((l) => l.toLowerCase().includes('ffmpeg version')) || ''
const parts = line.trim().split(' ').filter(Boolean)
const version = parts.length > 2 ? parts[2] : parts[1] || null
return { version }
})
}
/**
* 压缩:接受一个已经 load() 完成的 ffmpeg 实例
* @param {*} ffmpegInstance 已初始化的 FFmpeg 实例(来自 Nuxt 插件)
* @param {File|Blob} file 输入文件
* @param {{ onProgress?:(p:{stage:string,progress:number})=>void, signal?:AbortSignal, strictSize?:boolean, targetSize?:number }} opts
*/
export async function compressVideoWithFFmpeg(ffmpegInstance, file, opts = {}) {
return enqueueOn(ffmpegInstance, () => doCompress(ffmpegInstance, file, opts))
}
async function doCompress(ffmpegInstance, file, opts) {
const onProgress = opts.onProgress || (() => {})
const { signal, strictSize = false } = opts
onProgress({ stage: 'preparing', progress: 10 })
const targetSize = opts.targetSize ?? UPLOAD_CONFIG?.VIDEO?.TARGET_SIZE ?? 12 * 1024 * 1024
// 小体积直通
const sizeKnown = 'size' in file && typeof file.size === 'number'
if (sizeKnown && file.size <= targetSize * 0.9) {
onProgress({ stage: 'skipped', progress: 100 })
return file
}
const params = calculateParamsByRatio(sizeKnown ? file.size : targetSize * 2, targetSize)
const { crf, preset, scaleFilter, audioBitrateK, profile } = params
const name = 'name' in file && typeof file.name === 'string' ? file.name : 'input.mp4'
const dot = name.lastIndexOf('.')
const outName = (dot > -1 ? name.slice(0, dot) : name) + '.mp4'
const ext = dot > -1 ? name.slice(dot + 1).toLowerCase() : 'mp4'
const id = nanoid()
const inputName = `input-${id}.${ext}`
const outputName = `output-${id}.mp4`
const passlog = `ffpass-${id}`
// 监听
const ring = makeRingLogger()
const onFfmpegProgress = ({ progress: p }) => {
const adjusted = 20 + p * 70
onProgress({ stage: 'compressing', progress: Math.min(90, adjusted) })
}
const onFfmpegLog = ({ type, message }) => {
if (type === 'fferr' || type === 'info') ring.push(message)
}
ffmpegInstance.on('progress', onFfmpegProgress)
ffmpegInstance.on('log', onFfmpegLog)
let aborted = false
const abortHandler = () => {
aborted = true
}
if (signal) signal.addEventListener('abort', abortHandler, { once: true })
try {
await ffmpegInstance.writeFile(inputName, await fetchFile(file))
onProgress({ stage: 'analyzing', progress: 20 })
let durationSec = null
try {
await ffmpegInstance.exec(['-hide_banner', '-i', inputName, '-f', 'null', '-'])
durationSec = parseDurationFromLogs(ring.dump())
} catch {
durationSec = durationSec ?? parseDurationFromLogs(ring.dump())
}
let videoBitrate = null
if (durationSec && sizeKnown && targetSize < file.size) {
const totalTargetBits = targetSize * 8
const audioBits = audioBitrateK * 1000 * durationSec
const maxVideoBits = Math.max(totalTargetBits - audioBits, totalTargetBits * 0.7)
const bps = Math.max(180000, Math.floor(maxVideoBits / durationSec))
videoBitrate = String(Math.min(bps, 5000000))
}
const baseArgs = [
'-hide_banner',
'-i',
inputName,
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-profile:v',
profile,
'-movflags',
'+faststart',
'-preset',
preset,
'-c:a',
'aac',
'-b:a',
`${audioBitrateK}k`,
'-ac',
'2',
]
if (scaleFilter) baseArgs.push('-vf', scaleFilter)
const onePassArgs = [...baseArgs, '-crf', String(crf)]
if (videoBitrate)
onePassArgs.push('-maxrate', videoBitrate, '-bufsize', String(parseInt(videoBitrate, 10) * 2))
const twoPassFirst = [
'-y',
'-hide_banner',
'-i',
inputName,
'-c:v',
'libx264',
'-b:v',
`${videoBitrate || '1000000'}`,
'-pass',
'1',
'-passlogfile',
passlog,
'-an',
'-f',
'mp4',
'/dev/null',
]
const twoPassSecond = [
...baseArgs,
'-b:v',
`${videoBitrate || '1000000'}`,
'-pass',
'2',
'-passlogfile',
passlog,
outputName,
]
if (aborted) throw new DOMException('Aborted', 'AbortError')
if (!strictSize) {
await ffmpegInstance.exec([...onePassArgs, outputName])
} else {
if (!videoBitrate) videoBitrate = '1000000'
await ffmpegInstance.exec(twoPassFirst)
onProgress({ stage: 'second-pass', progress: 85 })
await ffmpegInstance.exec(twoPassSecond)
}
if (aborted) throw new DOMException('Aborted', 'AbortError')
onProgress({ stage: 'finalizing', progress: 95 })
const out = await ffmpegInstance.readFile(outputName)
const mime = 'video/mp4'
const blob = new Blob([out], { type: mime })
const hasFileCtor = typeof File === 'function'
const result = hasFileCtor ? new File([blob], outName, { type: mime }) : blob
onProgress({ stage: 'completed', progress: 100 })
return result
} finally {
try {
await ffmpegInstance.deleteFile(inputName)
} catch {}
try {
await ffmpegInstance.deleteFile(outputName)
} catch {}
try {
await ffmpegInstance.deleteFile(`${passlog}-0.log`)
} catch {}
try {
await ffmpegInstance.deleteFile(`${passlog}-0.log.mbtree`)
} catch {}
ffmpegInstance.off('progress', onFfmpegProgress)
ffmpegInstance.off('log', onFfmpegLog)
if (signal) signal.removeEventListener('abort', abortHandler)
}
}

View File

@@ -3,6 +3,8 @@ import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji'
import vditorPostCitation from './vditorPostCitation.js'
import { checkFileSize, formatFileSize, compressVideo, VIDEO_CONFIG } from './videoCompressor.js'
import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -91,10 +93,81 @@ export function createVditor(editorId, options = {}) {
multiple: false,
handler: async (files) => {
const file = files[0]
vditor.tip('图片上传中', 0)
const ext = file.name.split('.').pop().toLowerCase()
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v', 'ogv']
const isVideo = videoExts.includes(ext)
// 检查文件大小
const sizeCheck = checkFileSize(file)
if (!sizeCheck.isValid) {
console.log(
'文件大小不能超过',
formatFileSize(sizeCheck.maxSize),
',当前文件',
formatFileSize(sizeCheck.actualSize),
)
vditor.tip(
`文件大小不能超过 ${formatFileSize(sizeCheck.maxSize)},当前文件 ${formatFileSize(sizeCheck.actualSize)}`,
3000,
)
return '文件过大'
}
let processedFile = file
// 如果是视频文件且需要压缩
if (isVideo && sizeCheck.needsCompression) {
try {
vditor.tip('视频压缩中...', 0)
vditor.disabled()
// 使用 FFmpeg 压缩视频
processedFile = await compressVideo(file, (progress) => {
const messages = {
initializing: '初始化 FFmpeg',
preparing: '准备压缩',
analyzing: '分析视频',
compressing: '压缩中',
finalizing: '完成压缩',
completed: '压缩完成',
}
const message = messages[progress.stage] || progress.stage
vditor.tip(`${message} ${progress.progress}%`, 0)
})
const originalSize = formatFileSize(file.size)
const compressedSize = formatFileSize(processedFile.size)
const savings = Math.round((1 - processedFile.size / file.size) * 100)
vditor.tip(`压缩完成!${originalSize}${compressedSize} (节省 ${savings}%)`, 2000)
// 压缩成功但仍然超过最大限制,则阻止上传
if (processedFile.size > VIDEO_CONFIG.MAX_SIZE) {
vditor.tip(
`压缩后仍超过限制 (${formatFileSize(VIDEO_CONFIG.MAX_SIZE)}). 请降低分辨率或码率后再上传。`,
4000,
)
vditor.enable()
return '压缩后仍超过大小限制'
}
} catch (error) {
// 压缩失败时,如果原文件超过最大限制,则阻止上传
if (file.size > VIDEO_CONFIG.MAX_SIZE) {
vditor.tip(
`视频压缩失败,且文件超过限制 (${formatFileSize(VIDEO_CONFIG.MAX_SIZE)}). 请先压缩后再上传。`,
4000,
)
vditor.enable()
return '视频压缩失败且文件过大'
}
vditor.tip('视频压缩失败,将尝试上传原文件', 3000)
processedFile = file
}
}
vditor.tip('文件上传中', 0)
vditor.disabled()
const res = await fetch(
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`,
`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(processedFile.name)}`,
{ headers: { Authorization: `Bearer ${getToken()}` } },
)
if (!res.ok) {
@@ -103,14 +176,13 @@ export function createVditor(editorId, options = {}) {
return '获取上传地址失败'
}
const info = await res.json()
const put = await fetch(info.uploadUrl, { method: 'PUT', body: file })
const put = await fetch(info.uploadUrl, { method: 'PUT', body: processedFile })
if (!put.ok) {
vditor.enable()
vditor.tip('上传失败')
return '上传失败'
}
const ext = file.name.split('.').pop().toLowerCase()
const imageExts = [
'apng',
'bmp',
@@ -132,6 +204,8 @@ export function createVditor(editorId, options = {}) {
md = `![${file.name}](${info.fileUrl})`
} else if (audioExts.includes(ext)) {
md = `<audio controls="controls" src="${info.fileUrl}"></audio>`
} else if (videoExts.includes(ext)) {
md = `<video width="600" controls>\n <source src="${info.fileUrl}" type="video/${ext}">\n 你的浏览器不支持 video 标签。\n</video>`
} else {
md = `[${file.name}](${info.fileUrl})`
}

View File

@@ -0,0 +1,79 @@
/**
* 基于 FFmpeg.wasm 的视频压缩工具
* 专为现代浏览器 (Chrome/Safari) 优化
*/
import { UPLOAD_CONFIG } from '../config/uploadConfig.js'
import { compressVideoWithFFmpeg, isFFmpegSupported } from './ffmpegVideoCompressor.js'
import { useNuxtApp } from '#app'
// 导出配置供外部使用
export const VIDEO_CONFIG = UPLOAD_CONFIG.VIDEO
/**
* 检查文件大小是否超出限制
*/
export function checkFileSize(file) {
return {
isValid: file.size <= VIDEO_CONFIG.MAX_SIZE,
actualSize: file.size,
maxSize: VIDEO_CONFIG.MAX_SIZE,
needsCompression: file.size > VIDEO_CONFIG.TARGET_SIZE,
}
}
/**
* 格式化文件大小显示
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
/**
* 压缩视频文件 - 使用 FFmpeg.wasm
*/
export async function compressVideo(file, onProgress = () => {}) {
// 检查是否需要压缩
const sizeCheck = checkFileSize(file)
if (!sizeCheck.needsCompression) {
onProgress({ stage: 'completed', progress: 100 })
return file
}
// 检查 FFmpeg 支持
if (!isFFmpegSupported()) {
throw new Error('当前浏览器不支持视频压缩功能,请使用 Chrome 或 Safari 浏览器')
}
try {
const { $ffmpeg } = useNuxtApp()
const ff = await $ffmpeg()
return await compressVideoWithFFmpeg(ff, file, { onProgress })
} catch (error) {
console.error('FFmpeg 压缩失败:', error)
throw new Error(`视频压缩失败: ${error.message}`)
}
}
/**
* 预加载 FFmpeg可选的性能优化
*/
export async function preloadVideoCompressor() {
try {
// FFmpeg 初始化现在通过 Nuxt 插件处理
// 这里只需要检查支持性
if (!isFFmpegSupported()) {
throw new Error('当前浏览器不支持 FFmpeg')
}
const { $ffmpeg } = useNuxtApp()
await $ffmpeg()
return { success: true, message: 'FFmpeg 预加载成功' }
} catch (error) {
console.warn('FFmpeg 预加载失败:', error)
return { success: false, error: error.message }
}
}