Compare commits

..

1 Commits

Author SHA1 Message Date
Tim
d7e58a5741 feat: add base item group component 2025-10-15 21:04:29 +08:00
127 changed files with 566 additions and 5582 deletions

View File

@@ -2,7 +2,6 @@
SERVER_PORT=8080
FRONTEND_PORT=3000
WEBSOCKET_PORT=8082
OPENISLE_MCP_PORT=8085
MYSQL_PORT=3306
REDIS_PORT=6379
RABBITMQ_PORT=5672

View File

@@ -1,30 +0,0 @@
name: Coffee Bot
on:
schedule:
- cron: "0 23 * * 0-4"
workflow_dispatch:
jobs:
run-coffee-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run coffee bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/coffee_bot.ts

View File

@@ -1,30 +0,0 @@
name: Daily News Bot
on:
schedule:
- cron: "0 22 * * 0-4"
workflow_dispatch:
jobs:
run-daily-news-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run daily news bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/daily_news_bot.ts

View File

@@ -1,30 +0,0 @@
name: Open Source Reply Bot
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
jobs:
run-open-source-reply-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run open source reply bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN_BOT_1 }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/open_source_reply_bot.ts

View File

@@ -1,30 +0,0 @@
name: Reply Bots
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
jobs:
run-reply-bot:
environment: Bots
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents tsx typescript
- name: Run reply bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
APIFY_API_TOKEN: ${{ secrets.APIFY_API_TOKEN }}
run: npx tsx bots/instance/reply_bot.ts

View File

@@ -1,6 +1,5 @@
- [前置工作](#前置工作)
- [前端极速调试Docker 全量环境)](#前端极速调试docker-全量环境)
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
@@ -40,6 +39,13 @@ cd OpenIsle
```
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
2. 启动 Dev Profile
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev build
```
```shell
docker compose \
-f docker/docker-compose.yaml \
@@ -47,8 +53,8 @@ cd OpenIsle
--profile dev up -d
```
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
修改前端代码,页面会热更新。
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA采用IDEA编译运行也可以哦。
修改代码,可以强制重新创建所有容器,执行:
```shell
docker compose \
@@ -57,9 +63,6 @@ cd OpenIsle
--profile dev up -d --force-recreate
```
数据初始化sql会创建几个帐户供大家测试使用
> username:admin/user1/user2 password:123456
3. 查看服务状态:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env ps
@@ -70,49 +73,8 @@ cd OpenIsle
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
```
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
```
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
<a id="dev-dev_local_backend-guide"></a>
### 🧭 dev 与 dev_local_backend 巡航指南
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile
```bash
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev_local_backend up -d
```
> [!TIP]
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
| --- | --- | --- | --- |
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
```bash
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
# 或者
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
```
常见小贴士:
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
## 启动后端服务
启动后端服务有多种方式,选择一种即可。
@@ -142,17 +104,6 @@ IDEA 打开 `backend/` 文件夹。
LOG_LEVEL=DEBUG
```
> [!WARNING]
> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`
>
> ```ini
> MYSQL_HOST=localhost
> REDIS_HOST=localhost
> RABBITMQ_HOST=localhost
> ```
>
> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`IDEA 将尝试解析容器网络内的别名而导致连接失败。
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
![配置数据库](assets/contributing/backend_img_5.png)

View File

@@ -26,7 +26,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
- 集成 OpenAI 提供的 Markdown 格式化功能
- 通过环境变量可调整密码强度、登录方式、保护码等多种配置
- 支持图片上传,默认使用腾讯云 COS 扩展
- Bot 集成,可在平台内快速连接自定义机器人,并通过 Telegram 的 BotFather 创建和管理消息机器人,拓展社区互动渠道
- 默认头像使用 DiceBear Avatars可通过 `AVATAR_STYLE``AVATAR_SIZE` 环境变量自定义主题和大小
- 浏览器推送通知,离开网站也能及时收到提醒
## 🌟 项目优势
@@ -41,7 +41,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
## 🏘️ 社区
- 欢迎彼此交流和使用 OpenIsle项目以开源方式提供;如果遇到问题请到 GitHub 的 Issues 页面反馈,想发起话题讨论也可以前往源站 <https://www.open-isle.com>,这里提供更完整的社区板块与互动体验。
欢迎彼此交流和使用 OpenIsle项目以开源方式提供,想了解更多可访问:<https://github.com/nagisa77/OpenIsle>
## 📋 授权

View File

@@ -1,176 +0,0 @@
# Security Policy
## Supported Versions
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
| Version | Supported |
| ------- | ------------------ |
| 0.0.x | :white_check_mark: |
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
### How to Report a Security Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via one of the following methods:
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
### What to Include in Your Report
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit it
### Response Timeline
- **Initial Response**: We will acknowledge your report within 48 hours
- **Status Updates**: We will provide status updates at least every 5 business days
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
### What to Expect
After you submit a report:
1. We will confirm receipt of your vulnerability report and may ask for additional information
2. We will investigate the issue and determine its impact and severity
3. We will work on a fix and coordinate disclosure timing with you
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
## Security Considerations for Deployment
### Authentication & Authorization
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
- **Session Management**: Configure appropriate session timeout values
### Database Security
- Use strong database passwords
- Never expose database ports publicly
- Use database connection encryption when available
- Regularly backup your database
### API Security
- Enable rate limiting to prevent abuse
- Validate all user inputs on both client and server side
- Use HTTPS in production environments
- Configure CORS properly to restrict origins
### Environment Variables
The following sensitive environment variables should be kept secure:
- `JWT_SECRET` - JWT signing key
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
- Database connection strings and credentials
- Cloud storage credentials (Tencent COS)
**Never commit these values to version control or expose them in logs.**
### File Upload Security
- Validate file types and sizes
- Scan uploaded files for malware
- Store uploaded files outside the web root
- Use cloud storage with proper access controls
### Password Security
- Configure password strength requirements via environment variables
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
- Implement account lockout after failed login attempts
### Web Push Notifications
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
- Only send notifications to users who have explicitly opted in
- Validate notification payloads
### Dependency Management
- Regularly update dependencies to patch known vulnerabilities
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
- Monitor GitHub security advisories for this project
### Production Deployment Checklist
- [ ] Use HTTPS/TLS for all connections
- [ ] Set strong, unique secrets for all environment variables
- [ ] Enable CSRF protection
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
- [ ] Disable debug mode and verbose error messages
- [ ] Set up proper logging and monitoring
- [ ] Implement rate limiting and DDoS protection
- [ ] Regular security updates and patches
- [ ] Database backups and disaster recovery plan
- [ ] Restrict admin access to trusted IPs when possible
## Known Security Features
OpenIsle includes the following security features:
- JWT-based authentication with configurable expiration
- OAuth 2.0 integration with major providers
- Password strength validation
- Protection codes for sensitive operations
- Input validation and sanitization
- SQL injection prevention through ORM (JPA/Hibernate)
- XSS protection in Vue.js templates
- CSRF protection (Spring Security)
## Security Best Practices for Contributors
- Never commit credentials, API keys, or secrets
- Follow secure coding practices (OWASP Top 10)
- Validate and sanitize all user inputs
- Use parameterized queries for database operations
- Implement proper error handling without exposing sensitive information
- Write security tests for new features
- Review code for security issues before submitting PRs
## Disclosure Policy
When we receive a security bug report, we will:
1. Confirm the problem and determine affected versions
2. Audit code to find any similar problems
3. Prepare fixes for all supported versions
4. Release patches as soon as possible
We appreciate your help in keeping OpenIsle and its users safe!
## Attribution
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
- Security advisory
- Release notes
- A security hall of fame (if established)
If you prefer to remain anonymous, we will respect your wishes.
## Contact
For any security-related questions or concerns, please reach out through the channels mentioned above.
---
Thank you for helping keep OpenIsle secure!

View File

@@ -19,7 +19,6 @@ JWT_EXPIRATION=2592000000
# === Redis ===
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
REDIS_PASS=<Redis 密码>
# === Resend ===
RESEND_API_KEY=<你的resend-api-key>

View File

@@ -6,12 +6,10 @@ import com.openisle.model.User;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import com.openisle.exception.EmailSendException;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -19,7 +17,6 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
@Slf4j
public class AdminUserController {
private final UserRepository userRepository;
@@ -38,15 +35,11 @@ public class AdminUserController {
user.setApproved(true);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
try {
emailSender.sendEmail(
user.getEmail(),
"您的注册已审核通过",
"🎉您的注册已经审核通过, 点击以访问网站: " + websiteUrl
);
} catch (EmailSendException e) {
log.warn("Failed to send approve email to {}: {}", user.getEmail(), e.getMessage());
}
emailSender.sendEmail(
user.getEmail(),
"您的注册已审核通过",
"🎉您的注册已审核通过, 点击以访问网站: " + websiteUrl
);
return ResponseEntity.ok().build();
}
@@ -59,15 +52,11 @@ public class AdminUserController {
user.setApproved(false);
userRepository.save(user);
markRegisterRequestNotificationsRead(user);
try {
emailSender.sendEmail(
user.getEmail(),
"您的注册被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
);
} catch (EmailSendException e) {
log.warn("Failed to send reject email to {}: {}", user.getEmail(), e.getMessage());
}
emailSender.sendEmail(
user.getEmail(),
"您的注册已被管理员拒绝",
"您的注册被管理员拒绝, 点击链接可以重新填写理由申请: " + websiteUrl
);
return ResponseEntity.ok().build();
}

View File

@@ -2,7 +2,6 @@ package com.openisle.controller;
import com.openisle.config.CachingConfig;
import com.openisle.dto.*;
import com.openisle.exception.EmailSendException;
import com.openisle.exception.FieldException;
import com.openisle.model.RegisterMode;
import com.openisle.model.User;
@@ -20,7 +19,6 @@ import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -85,17 +83,6 @@ public class AuthController {
"INVITE_APPROVED"
)
);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"邮件发送失败: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
} catch (FieldException e) {
return ResponseEntity.badRequest().body(
Map.of("field", e.getField(), "error", e.getMessage())
@@ -110,20 +97,7 @@ public class AuthController {
registerModeService.getRegisterMode()
);
// 发送确认邮件
try {
userService.sendVerifyMail(user, VerifyType.REGISTER);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"邮件发送失败: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
}
userService.sendVerifyMail(user, VerifyType.REGISTER);
if (!user.isApproved()) {
notificationService.createRegisterRequestNotifications(user, user.getRegisterReason());
}
@@ -195,28 +169,14 @@ public class AuthController {
}
User user = userOpt.get();
if (!user.isVerified()) {
user =
userService.register(
user.getUsername(),
user.getEmail(),
user.getPassword(),
user.getRegisterReason(),
registerModeService.getRegisterMode()
);
try {
userService.sendVerifyMail(user, VerifyType.REGISTER);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"Failed to send verification email: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
}
user = userService.register(
user.getUsername(),
user.getEmail(),
user.getPassword(),
user.getRegisterReason(),
registerModeService.getRegisterMode()
);
userService.sendVerifyMail(user, VerifyType.REGISTER);
return ResponseEntity.badRequest().body(
Map.of(
"error",
@@ -703,20 +663,7 @@ public class AuthController {
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "User not found"));
}
try {
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
} catch (EmailSendException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
Map.of(
"error",
"邮件发送失败: " + e.getMessage(),
"reason_code",
"EMAIL_SEND_FAILED"
)
);
}
userService.sendVerifyMail(userOpt.get(), VerifyType.RESET_PASSWORD);
return ResponseEntity.ok(Map.of("message", "Verification code sent"));
}

View File

@@ -1,13 +1,11 @@
package com.openisle.controller;
import com.openisle.dto.CommentContextDto;
import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TimelineItemDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.*;
@@ -42,7 +40,6 @@ public class CommentController {
private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
private final PostMapper postMapper;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@@ -187,37 +184,6 @@ public class CommentController {
return itemDtoList;
}
@GetMapping("/comments/{commentId}/context")
@Operation(
summary = "Comment context",
description = "Get a comment along with its previous comments and related post"
)
@ApiResponse(
responseCode = "200",
description = "Comment context",
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
)
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
log.debug("getCommentContext called for comment {}", commentId);
Comment comment = commentService.getComment(commentId);
CommentContextDto dto = new CommentContextDto();
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
dto.setPreviousComments(
commentService
.getCommentsBefore(comment)
.stream()
.map(commentMapper::toDtoWithReplies)
.collect(Collectors.toList())
);
log.debug(
"getCommentContext returning {} previous comments for comment {}",
dto.getPreviousComments().size(),
commentId
);
return ResponseEntity.ok(dto);
}
@DeleteMapping("/comments/{id}")
@Operation(summary = "Delete comment", description = "Delete a comment")
@ApiResponse(responseCode = "200", description = "Deleted")

View File

@@ -66,7 +66,6 @@ public class PostController {
req.getContent(),
req.getTagIds(),
req.getType(),
req.getPostVisibleScopeType(),
req.getPrizeDescription(),
req.getPrizeIcon(),
req.getPrizeCount(),
@@ -74,9 +73,7 @@ public class PostController {
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple(),
req.getProposedName(),
req.getProposalDescription()
req.getMultiple()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
@@ -104,8 +101,7 @@ public class PostController {
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds(),
req.getPostVisibleScopeType()
req.getTagIds()
);
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
}
@@ -217,24 +213,8 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postMapper.toListDtos(postService.defaultListPosts(ids, tids, page, pageSize));
}
@GetMapping("/recent")
@Operation(
summary = "Recent posts",
description = "List posts created within the specified number of minutes"
)
@ApiResponse(
responseCode = "200",
description = "Recent posts",
content = @Content(
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
)
)
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
return postService
.listRecentPosts(minutes)
.defaultListPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
@@ -265,7 +245,11 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postMapper.toListDtos(postService.listPostsByViews(ids, tids, page, pageSize));
return postService
.listPostsByViews(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
@GetMapping("/latest-reply")
@@ -297,7 +281,8 @@ public class PostController {
// userVisitService.recordVisit(auth.getName());
// }
return postMapper.toListDtos(postService.listPostsByLatestReply(ids, tids, page, pageSize));
List<Post> posts = postService.listPostsByLatestReply(ids, tids, page, pageSize);
return posts.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/featured")
@@ -324,6 +309,10 @@ public class PostController {
// if (auth != null) {
// userVisitService.recordVisit(auth.getName());
// }
return postMapper.toListDtos(postService.listFeaturedPosts(ids, tids, page, pageSize));
return postService
.listFeaturedPosts(ids, tids, page, pageSize)
.stream()
.map(postMapper::toSummaryDto)
.collect(Collectors.toList());
}
}

View File

@@ -1,43 +0,0 @@
package com.openisle.controller;
import com.openisle.dto.DonationRequest;
import com.openisle.dto.DonationResponse;
import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts/{postId}/donations")
@RequiredArgsConstructor
public class PostDonationController {
private final PointService pointService;
@GetMapping
@Operation(summary = "List donations", description = "Get recent donations for a post")
@ApiResponse(responseCode = "200", description = "Donation summary")
public DonationResponse list(@PathVariable Long postId) {
return pointService.getPostDonations(postId);
}
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Donate", description = "Donate points to the post author")
@ApiResponse(responseCode = "200", description = "Donation result")
public DonationResponse donate(
@PathVariable Long postId,
@RequestBody DonationRequest req,
Authentication auth
) {
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
}
}

View File

@@ -34,7 +34,6 @@ public class UserController {
private final TagService tagService;
private final SubscriptionService subscriptionService;
private final LevelService levelService;
private final PostReadService postReadService;
private final JwtService jwtService;
private final UserMapper userMapper;
private final TagMapper tagMapper;
@@ -54,9 +53,6 @@ public class UserController {
@Value("${app.user.tags-limit:50}")
private int defaultTagsLimit;
@Value("${app.user.read-posts-limit:50}")
private int defaultReadPostsLimit;
@GetMapping("/me")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Current user", description = "Get current authenticated user information")
@@ -215,33 +211,6 @@ public class UserController {
.collect(java.util.stream.Collectors.toList());
}
@GetMapping("/{identifier}/read-posts")
@SecurityRequirement(name = "JWT")
@Operation(summary = "User read posts", description = "Get post read history (self only)")
@ApiResponse(
responseCode = "200",
description = "Post read history",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PostReadDto.class)))
)
public ResponseEntity<java.util.List<PostReadDto>> userReadPosts(
@PathVariable("identifier") String identifier,
@RequestParam(value = "limit", required = false) Integer limit,
Authentication auth
) {
User user = userService.findByIdentifier(identifier).orElseThrow();
if (auth == null || !auth.getName().equals(user.getUsername())) {
return ResponseEntity.status(403).body(java.util.List.of());
}
int l = limit != null ? limit : defaultReadPostsLimit;
return ResponseEntity.ok(
postReadService
.getRecentReadsByUser(user.getUsername(), l)
.stream()
.map(userMapper::toPostReadDto)
.collect(java.util.stream.Collectors.toList())
);
}
@GetMapping("/{identifier}/hot-posts")
@Operation(summary = "User hot posts", description = "Get most reacted posts by user")
@ApiResponse(

View File

@@ -13,5 +13,4 @@ public class AuthorDto {
private String username;
private String avatar;
private MedalType displayMedal;
private boolean bot;
}

View File

@@ -1,15 +0,0 @@
package com.openisle.dto;
import java.util.List;
import lombok.Data;
/**
* DTO representing the context of a comment including its post and previous comments.
*/
@Data
public class CommentContextDto {
private PostSummaryDto post;
private CommentDto targetComment;
private List<CommentDto> previousComments;
}

View File

@@ -1,16 +0,0 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationDto {
private Long userId;
private String username;
private String avatar;
private int amount;
private LocalDateTime createdAt;
}

View File

@@ -1,11 +0,0 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationRequest {
private int amount;
}

View File

@@ -1,15 +0,0 @@
package com.openisle.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationResponse {
private int totalAmount;
private List<DonationDto> donations = new ArrayList<>();
private Integer balance;
}

View File

@@ -1,7 +1,6 @@
package com.openisle.dto;
import com.openisle.model.PostChangeType;
import com.openisle.model.PostVisibleScopeType;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Getter;
@@ -30,7 +29,4 @@ public class PostChangeLogDto {
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
private PostVisibleScopeType oldVisibleScope;
private PostVisibleScopeType newVisibleScope;
private Integer amount;
}

View File

@@ -1,12 +0,0 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import lombok.Data;
/** DTO for a user's post read record. */
@Data
public class PostReadDto {
private PostMetaDto post;
private LocalDateTime lastReadAt;
}

View File

@@ -3,8 +3,6 @@ package com.openisle.dto;
import com.openisle.model.PostType;
import java.time.LocalDateTime;
import java.util.List;
import com.openisle.model.PostVisibleScopeType;
import lombok.Data;
/**
@@ -21,7 +19,6 @@ public class PostRequest {
// optional for lottery posts
private PostType type;
private PostVisibleScopeType postVisibleScopeType;
private String prizeDescription;
private String prizeIcon;
private Integer prizeCount;
@@ -31,8 +28,4 @@ public class PostRequest {
// fields for poll posts
private List<String> options;
private Boolean multiple;
// fields for category proposal posts
private String proposedName;
private String proposalDescription;
}

View File

@@ -4,8 +4,6 @@ import com.openisle.model.PostStatus;
import com.openisle.model.PostType;
import java.time.LocalDateTime;
import java.util.List;
import com.openisle.model.PostVisibleScopeType;
import lombok.Data;
/**
@@ -36,5 +34,4 @@ public class PostSummaryDto {
private PollDto poll;
private boolean rssExcluded;
private boolean closed;
private PostVisibleScopeType visibleScope;
}

View File

@@ -1,20 +0,0 @@
package com.openisle.dto;
import com.openisle.model.CategoryProposalStatus;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ProposalDto extends PollDto {
private CategoryProposalStatus proposalStatus;
private String proposedName;
private String description;
private int approveThreshold;
private int quorum;
private LocalDateTime startAt;
private String resultSnapshot;
private String rejectReason;
}

View File

@@ -28,5 +28,4 @@ public class UserDto {
private int point;
private int currentLevel;
private int nextLevelExp;
private boolean bot;
}

View File

@@ -8,5 +8,4 @@ public class UserSummaryDto {
private Long id;
private String username;
private String avatar;
private boolean bot;
}

View File

@@ -1,15 +0,0 @@
package com.openisle.exception;
/**
* Thrown when email sending fails so callers can surface a clear error upstream.
*/
public class EmailSendException extends RuntimeException {
public EmailSendException(String message) {
super(message);
}
public EmailSendException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -52,11 +52,6 @@ public class PostChangeLogMapper {
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
} else if (log instanceof PostVisibleScopeChangeLog v) {
dto.setOldVisibleScope(v.getOldVisibleScope());
dto.setNewVisibleScope(v.getNewVisibleScope());
} else if (log instanceof PostDonateChangeLog d) {
dto.setAmount(d.getAmount());
}
return dto;
}

View File

@@ -6,9 +6,7 @@ import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ProposalDto;
import com.openisle.dto.ReactionDto;
import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CommentSort;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
@@ -48,38 +46,6 @@ public class PostMapper {
return dto;
}
public List<PostSummaryDto> toListDtos(List<Post> posts) {
if (posts == null || posts.isEmpty()) {
return List.of();
}
Map<Long, List<User>> participantsMap = commentService.getParticipantsForPosts(posts, 5);
return posts
.stream()
.map(post -> {
PostSummaryDto dto = new PostSummaryDto();
applyListFields(post, dto);
List<User> participants = participantsMap.get(post.getId());
if (participants != null) {
dto.setParticipants(
participants.stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
} else {
dto.setParticipants(List.of());
}
dto.setReactions(List.of());
return dto;
})
.collect(Collectors.toList());
}
public PostSummaryDto toListDto(Post post) {
PostSummaryDto dto = new PostSummaryDto();
applyListFields(post, dto);
dto.setParticipants(List.of());
dto.setReactions(List.of());
return dto;
}
public PostDetailDto toDetailDto(Post post, String viewer) {
PostDetailDto dto = new PostDetailDto();
applyCommon(post, dto);
@@ -93,25 +59,6 @@ public class PostMapper {
return dto;
}
private void applyListFields(Post post, PostSummaryDto dto) {
dto.setId(post.getId());
dto.setTitle(post.getTitle());
dto.setContent(post.getContent());
dto.setCreatedAt(post.getCreatedAt());
dto.setAuthor(userMapper.toAuthorDto(post.getAuthor()));
dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
dto.setViews(post.getViews());
dto.setCommentCount(post.getCommentCount());
dto.setStatus(post.getStatus());
dto.setPinnedAt(post.getPinnedAt());
dto.setLastReplyAt(post.getLastReplyAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
dto.setVisibleScope(post.getVisibleScope());
dto.setType(post.getType());
}
private void applyCommon(Post post, PostSummaryDto dto) {
dto.setId(post.getId());
dto.setTitle(post.getTitle());
@@ -126,7 +73,6 @@ public class PostMapper {
dto.setPinnedAt(post.getPinnedAt());
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
dto.setClosed(post.isClosed());
dto.setVisibleScope(post.getVisibleScope());
List<ReactionDto> reactions = reactionService
.getReactionsForPost(post.getId())
@@ -167,40 +113,26 @@ public class PostMapper {
dto.setLottery(l);
}
if (post instanceof CategoryProposalPost cp) {
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
proposalDto.setProposalStatus(cp.getProposalStatus());
proposalDto.setProposedName(cp.getProposedName());
proposalDto.setDescription(cp.getDescription());
proposalDto.setApproveThreshold(cp.getApproveThreshold());
proposalDto.setQuorum(cp.getQuorum());
proposalDto.setStartAt(cp.getStartAt());
proposalDto.setResultSnapshot(cp.getResultSnapshot());
proposalDto.setRejectReason(cp.getRejectReason());
dto.setPoll(proposalDto);
} else if (post instanceof PollPost pp) {
dto.setPoll(buildPollDto(pp, new PollDto()));
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pp.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
p.setOptionParticipants(optionParticipants);
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
}
}
private PollDto buildPollDto(PollPost pollPost, PollDto target) {
target.setOptions(pollPost.getOptions());
target.setVotes(pollPost.getVotes());
target.setEndTime(pollPost.getEndTime());
target.setParticipants(
pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pollPost.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
target.setOptionParticipants(optionParticipants);
target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple()));
return target;
}
}

View File

@@ -3,7 +3,6 @@ package com.openisle.mapper;
import com.openisle.dto.*;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.PostRead;
import com.openisle.model.User;
import com.openisle.service.*;
import java.util.stream.Collectors;
@@ -38,7 +37,6 @@ public class UserMapper {
dto.setUsername(user.getUsername());
dto.setAvatar(user.getAvatar());
dto.setDisplayMedal(user.getDisplayMedal());
dto.setBot(user.isBot());
return dto;
}
@@ -65,7 +63,6 @@ public class UserMapper {
dto.setPoint(user.getPoint());
dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
dto.setBot(user.isBot());
if (viewer != null) {
dto.setSubscribed(subscriptionService.isSubscribed(viewer.getName(), user.getUsername()));
} else {
@@ -116,11 +113,4 @@ public class UserMapper {
}
return dto;
}
public PostReadDto toPostReadDto(PostRead read) {
PostReadDto dto = new PostReadDto();
dto.setPost(toMetaDto(read.getPost()));
dto.setLastReadAt(read.getLastReadAt());
return dto;
}
}

View File

@@ -1,59 +0,0 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.PrimaryKeyJoinColumn;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A specialized post type used for proposing new categories.
* It reuses poll mechanics (participants, votes, endTime) by extending PollPost.
*/
@Entity
@Table(
name = "category_proposal_posts",
indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") }
)
@Getter
@Setter
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class CategoryProposalPost extends PollPost {
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING;
@Column(name = "proposed_name", nullable = false, unique = true)
private String proposedName;
@Column(name = "description")
private String description;
// Approval threshold as percentage (0-100), default 60
@Column(name = "approve_threshold", nullable = false)
private int approveThreshold = 60;
// Minimum number of participants required to meet quorum
@Column(name = "quorum", nullable = false)
private int quorum = 10;
// Optional voting start time (end time inherited from PollPost)
@Column(name = "start_at")
private LocalDateTime startAt;
// Snapshot of poll results at finalization (e.g., JSON)
@Column(name = "result_snapshot", columnDefinition = "TEXT")
private String resultSnapshot;
// Reason when proposal is rejected
@Column(name = "reject_reason")
private String rejectReason;
}

View File

@@ -1,10 +0,0 @@
package com.openisle.model;
public enum CategoryProposalStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -46,14 +46,8 @@ public enum NotificationType {
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your category proposal has concluded */
CATEGORY_PROPOSAL_RESULT_OWNER,
/** A category proposal you participated in has concluded */
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** Someone donated to your post */
DONATION,
/** You were mentioned in a post or comment */
MENTION,
}

View File

@@ -13,6 +13,4 @@ public enum PointHistoryType {
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD,
DONATE_SENT,
DONATE_RECEIVED,
}

View File

@@ -66,10 +66,6 @@ public class Post {
@Column(nullable = false)
private PostType type = PostType.NORMAL;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
@Column(nullable = false)
private boolean closed = false;

View File

@@ -8,8 +8,6 @@ public enum PostChangeType {
CLOSED,
PINNED,
FEATURED,
VISIBLE_SCOPE,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,
}

View File

@@ -1,19 +0,0 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_donate_change_logs")
public class PostDonateChangeLog extends PostChangeLog {
@Column(nullable = false)
private int amount;
}

View File

@@ -4,5 +4,4 @@ public enum PostType {
NORMAL,
LOTTERY,
POLL,
PROPOSAL
}

View File

@@ -1,23 +0,0 @@
package com.openisle.model;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_visible_scope_change_logs")
public class PostVisibleScopeChangeLog extends PostChangeLog {
@Enumerated(EnumType.STRING)
private PostVisibleScopeType oldVisibleScope;
@Enumerated(EnumType.STRING)
private PostVisibleScopeType newVisibleScope;
}

View File

@@ -1,32 +0,0 @@
package com.openisle.model;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
public enum PostVisibleScopeType {
ALL,
ONLY_ME,
ONLY_REGISTER;
/**
* 防止画面传递错误的值
* @param value
* @return
*/
@JsonCreator
public static PostVisibleScopeType fromString(String value) {
if (value == null) return ALL;
for (PostVisibleScopeType type : PostVisibleScopeType.values()) {
if (type.name().equalsIgnoreCase(value)) {
return type;
}
}
// 不匹配时给默认值,而不是抛异常
return ALL;
}
@JsonValue
public String toValue() {
return this.name();
}
}

View File

@@ -62,9 +62,6 @@ public class User {
@Column(nullable = false)
private Role role = Role.USER;
@Column(name = "is_bot", nullable = false)
private boolean bot = false;
@Enumerated(EnumType.STRING)
private MedalType displayMedal;

View File

@@ -1,19 +0,0 @@
package com.openisle.repository;
import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CategoryProposalStatus;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryProposalPostRepository extends JpaRepository<CategoryProposalPost, Long> {
List<CategoryProposalPost> findByEndTimeAfterAndProposalStatus(
LocalDateTime now,
CategoryProposalStatus status
);
List<CategoryProposalPost> findByEndTimeBeforeAndProposalStatus(
LocalDateTime now,
CategoryProposalStatus status
);
boolean existsByProposedNameIgnoreCase(String proposedName);
}

View File

@@ -3,7 +3,6 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -11,10 +10,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
Post post,
LocalDateTime createdAt
);
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
List<Comment> findByContentContainingIgnoreCase(String keyword);
@@ -25,13 +20,6 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
@org.springframework.data.repository.query.Param("post") Post post
);
@org.springframework.data.jpa.repository.Query(
"SELECT DISTINCT c.post.id, c.author FROM Comment c WHERE c.post.id IN :postIds"
)
java.util.List<Object[]> findDistinctAuthorsByPostIds(
@org.springframework.data.repository.query.Param("postIds") java.util.List<Long> postIds
);
@org.springframework.data.jpa.repository.Query(
"SELECT MAX(c.createdAt) FROM Comment c WHERE c.post = :post"
)

View File

@@ -2,14 +2,11 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
@@ -24,11 +21,4 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
List<PointHistory> findByComment(Comment comment);
List<PointHistory> findByPost(Post post);
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
@Query(
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
)
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
}

View File

@@ -3,14 +3,11 @@ package com.openisle.repository;
import com.openisle.model.Post;
import com.openisle.model.PostRead;
import com.openisle.model.User;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostReadRepository extends JpaRepository<PostRead, Long> {
Optional<PostRead> findByUserAndPost(User user, Post post);
List<PostRead> findByUserOrderByLastReadAtDesc(User user, Pageable pageable);
long countByUser(User user);
void deleteByPost(Post post);
}

View File

@@ -19,12 +19,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByPinnedAtDescViewsDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByPinnedAtDescLastReplyAtDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus status,
LocalDateTime createdAt
);
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
User author,
PostStatus status,
@@ -45,16 +39,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
PostStatus status,
Pageable pageable
);
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
List<Category> categories,
PostStatus status,
Pageable pageable
);
List<Post> findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
List<Category> categories,
PostStatus status,
Pageable pageable
);
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status);
List<Post> findDistinctByTagsInAndStatus(List<Tag> tags, PostStatus status, Pageable pageable);
List<Post> findDistinctByTagsInAndStatusOrderByCreatedAtDesc(List<Tag> tags, PostStatus status);
@@ -144,26 +128,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
)
List<Post> findByAllTagsOrderByPinnedAtDescViewsDesc(
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
)
List<Post> findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount"
)
@@ -206,28 +170,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.views DESC"
)
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
@Param("categories") List<Category> categories,
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.pinnedAt DESC, p.lastReplyAt DESC"
)
List<Post> findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
@Param("categories") List<Category> categories,
@Param("tags") List<Tag> tags,
@Param("status") PostStatus status,
@Param("tagCount") long tagCount,
Pageable pageable
);
@Query(
"SELECT p FROM Post p JOIN p.tags t WHERE p.category IN :categories AND t IN :tags AND p.status = :status GROUP BY p.id HAVING COUNT(DISTINCT t.id) = :tagCount ORDER BY p.createdAt DESC"
)

View File

@@ -105,7 +105,6 @@ public class ChannelService {
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
userDto.setBot(message.getSender().isBot());
dto.setSender(userDto);
return dto;

View File

@@ -21,12 +21,8 @@ import com.openisle.service.NotificationService;
import com.openisle.service.PointService;
import com.openisle.service.SubscriptionService;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@@ -270,27 +266,6 @@ public class CommentService {
return replies;
}
public Comment getComment(Long commentId) {
log.debug("getComment called for id {}", commentId);
return commentRepository
.findById(commentId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
}
public List<Comment> getCommentsBefore(Comment comment) {
log.debug("getCommentsBefore called for comment {}", comment.getId());
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
comment.getPost(),
comment.getCreatedAt()
);
log.debug(
"getCommentsBefore returning {} comments for comment {}",
comments.size(),
comment.getId()
);
return comments;
}
public List<Comment> getRecentCommentsByUser(String username, int limit) {
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
User user = userRepository
@@ -320,37 +295,6 @@ public class CommentService {
return result;
}
public Map<Long, List<User>> getParticipantsForPosts(List<Post> posts, int limit) {
if (posts == null || posts.isEmpty()) {
return Map.of();
}
Map<Long, LinkedHashSet<User>> map = new HashMap<>();
List<Long> postIds = new ArrayList<>(posts.size());
for (Post post : posts) {
postIds.add(post.getId());
LinkedHashSet<User> set = new LinkedHashSet<>();
set.add(post.getAuthor());
map.put(post.getId(), set);
}
for (Object[] row : commentRepository.findDistinctAuthorsByPostIds(postIds)) {
Long postId = (Long) row[0];
User author = (User) row[1];
LinkedHashSet<User> set = map.get(postId);
if (set != null) {
set.add(author);
}
}
Map<Long, List<User>> result = new HashMap<>(map.size());
for (Map.Entry<Long, LinkedHashSet<User>> entry : map.entrySet()) {
List<User> list = new ArrayList<>(entry.getValue());
if (list.size() > limit) {
list = list.subList(0, limit);
}
result.put(entry.getKey(), list);
}
return result;
}
public java.util.List<Comment> getCommentsByIds(java.util.List<Long> ids) {
log.debug("getCommentsByIds called for ids {}", ids);
java.util.List<Comment> comments = commentRepository.findAllById(ids);

View File

@@ -211,7 +211,6 @@ public class MessageService {
userSummaryDto.setId(message.getSender().getId());
userSummaryDto.setUsername(message.getSender().getUsername());
userSummaryDto.setAvatar(message.getSender().getAvatar());
userSummaryDto.setBot(message.getSender().isBot());
dto.setSender(userSummaryDto);
if (message.getReplyTo() != null) {
@@ -223,7 +222,6 @@ public class MessageService {
replySender.setId(reply.getSender().getId());
replySender.setUsername(reply.getSender().getUsername());
replySender.setAvatar(reply.getSender().getAvatar());
replySender.setBot(reply.getSender().isBot());
replyDto.setSender(replySender);
dto.setReplyTo(replyDto);
}
@@ -318,7 +316,6 @@ public class MessageService {
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
userDto.setBot(p.getUser().isBot());
return userDto;
})
.collect(Collectors.toList())
@@ -368,7 +365,6 @@ public class MessageService {
userDto.setId(p.getUser().getId());
userDto.setUsername(p.getUser().getUsername());
userDto.setAvatar(p.getUser().getAvatar());
userDto.setBot(p.getUser().isBot());
return userDto;
})
.collect(Collectors.toList());

View File

@@ -7,7 +7,6 @@ import com.openisle.repository.NotificationRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import com.openisle.exception.EmailSendException;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashSet;
@@ -18,7 +17,6 @@ import java.util.concurrent.Executor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,7 +26,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
/** Service for creating and retrieving notifications. */
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {
private final NotificationRepository notificationRepository;
@@ -111,11 +108,7 @@ public class NotificationService {
post.getId(),
comment.getId()
);
try {
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
} catch (EmailSendException e) {
log.warn("Failed to send notification email to {}: {}", user.getEmail(), e.getMessage());
}
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
sendCustomPush(user, "有人回复了你", url);
} else if (type == NotificationType.REACTION && comment != null) {
// long count = reactionRepository.countReceived(comment.getAuthor().getUsername());

View File

@@ -1,7 +1,5 @@
package com.openisle.service;
import com.openisle.dto.DonationDto;
import com.openisle.dto.DonationResponse;
import com.openisle.exception.FieldException;
import com.openisle.model.*;
import com.openisle.repository.*;
@@ -10,10 +8,8 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -24,8 +20,6 @@ public class PointService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
private final NotificationService notificationService;
private final PostChangeLogService postChangeLogService;
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
@@ -278,95 +272,4 @@ public class PointService {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
@Transactional
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
if (amount <= 0) {
throw new FieldException("amount", "打赏积分必须大于0");
}
User donor = userRepository.findByUsername(donorName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
User author = post.getAuthor();
if (author.getId().equals(donor.getId())) {
throw new FieldException("post", "不能给自己打赏");
}
if (donor.getPoint() < amount) {
throw new FieldException("point", "积分不足");
}
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
notificationService.createNotification(
author,
NotificationType.DONATION,
post,
null,
null,
donor,
null,
String.valueOf(amount)
);
postChangeLogService.recordDonation(post, donor, amount);
DonationResponse response = buildDonationResponse(post);
response.setBalance(donor.getPoint());
return response;
}
public DonationResponse getPostDonations(Long postId) {
Post post = postRepository.findById(postId).orElseThrow();
return buildDonationResponse(post);
}
private DonationResponse buildDonationResponse(Post post) {
List<PointHistory> histories =
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
post,
PointHistoryType.DONATE_RECEIVED
);
List<DonationDto> donations = histories
.stream()
.collect(Collectors.collectingAndThen(Collectors.toMap(
history -> {
User donor = history.getFromUser();
if (donor != null && donor.getId() != null) {
return "user:" + donor.getId();
}
return "history:" + history.getId();
},
history -> {
DonationDto dto = new DonationDto();
User donor = history.getFromUser();
if (donor != null) {
dto.setUserId(donor.getId());
dto.setUsername(donor.getUsername());
dto.setAvatar(donor.getAvatar());
}
dto.setAmount(history.getAmount());
dto.setCreatedAt(history.getCreatedAt());
return dto;
},
(left, right) -> {
left.setAmount(left.getAmount() + right.getAmount());
if (
left.getCreatedAt() == null ||
(right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt()))
) {
left.setCreatedAt(right.getCreatedAt());
}
return left;
},
java.util.LinkedHashMap::new
), map -> new java.util.ArrayList<>(map.values())));
Long total = pointHistoryRepository.sumAmountByPostAndType(
post,
PointHistoryType.DONATE_RECEIVED
);
int safeTotal = 0;
if (total != null) {
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
}
DonationResponse response = new DonationResponse();
response.setDonations(donations);
response.setTotalAmount(safeTotal);
return response;
}
}

View File

@@ -99,21 +99,6 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordVisibleScopeChange(
Post post,
User user,
PostVisibleScopeType oldVisibleScope,
PostVisibleScopeType newVisibleScope
) {
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.VISIBLE_SCOPE);
log.setOldVisibleScope(oldVisibleScope);
log.setNewVisibleScope(newVisibleScope);
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
@@ -130,15 +115,6 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordDonation(Post post, User donor, int amount) {
PostDonateChangeLog log = new PostDonateChangeLog();
log.setPost(post);
log.setUser(donor);
log.setType(PostChangeType.DONATE);
log.setAmount(amount);
logRepository.save(log);
}
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}

View File

@@ -7,10 +7,7 @@ import com.openisle.repository.PostReadRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.UserRepository;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
@@ -46,14 +43,6 @@ public class PostReadService {
);
}
public List<PostRead> getRecentReadsByUser(String username, int limit) {
User user = userRepository
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Pageable pageable = PageRequest.of(0, limit);
return postReadRepository.findByUserOrderByLastReadAtDesc(user, pageable);
}
public long countReads(String username) {
User user = userRepository
.findByUsername(username)

View File

@@ -1,10 +1,9 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.exception.NotFoundException;
import com.openisle.exception.RateLimitException;
import com.openisle.mapper.PostMapper;
import com.openisle.model.*;
import com.openisle.repository.CategoryProposalPostRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.LotteryPostRepository;
@@ -19,10 +18,10 @@ import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import com.openisle.service.EmailSender;
import com.openisle.exception.EmailSendException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -33,6 +32,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.PageRequest;
@@ -54,7 +54,6 @@ public class PostService {
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final CategoryProposalPostRepository categoryProposalPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
@@ -72,17 +71,11 @@ public class PostService {
private final PointService pointService;
private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final CategoryService categoryService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
new ConcurrentHashMap<>();
private final SearchIndexEventPublisher searchIndexEventPublisher;
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -96,7 +89,6 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
CategoryProposalPostRepository categoryProposalPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
@@ -115,8 +107,7 @@ public class PostService {
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate,
SearchIndexEventPublisher searchIndexEventPublisher,
CategoryService categoryService
SearchIndexEventPublisher searchIndexEventPublisher
) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -124,7 +115,6 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.categoryProposalPostRepository = categoryProposalPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
@@ -145,7 +135,6 @@ public class PostService {
this.redisTemplate = redisTemplate;
this.searchIndexEventPublisher = searchIndexEventPublisher;
this.categoryService = categoryService;
}
@EventListener(ApplicationReadyEvent.class)
@@ -171,24 +160,6 @@ public class PostService {
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
if (cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
}
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
}
}
public PublishMode getPublishMode() {
@@ -254,7 +225,6 @@ public class PostService {
String content,
List<Long> tagIds,
PostType type,
PostVisibleScopeType postVisibleScopeType,
String prizeDescription,
String prizeIcon,
Integer prizeCount,
@@ -262,12 +232,10 @@ public class PostService {
LocalDateTime startTime,
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple,
String proposedName,
String proposalDescription
Boolean multiple
) {
// 限制访问次数
boolean limitResult = isPostLimitReached(username);
boolean limitResult = postRateLimit(username);
if (!limitResult) {
throw new RateLimitException("Too many posts");
}
@@ -310,25 +278,6 @@ public class PostService {
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else if (actualType == PostType.PROPOSAL) {
CategoryProposalPost cp = new CategoryProposalPost();
if (proposedName == null || proposedName.isBlank()) {
throw new IllegalArgumentException("Proposed name required");
}
String normalizedName = proposedName.trim();
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
}
cp.setProposedName(normalizedName);
cp.setDescription(proposalDescription);
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
LocalDateTime now = LocalDateTime.now();
cp.setStartAt(now);
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
cp.setMultiple(false);
post = cp;
} else {
post = new Post();
}
@@ -339,19 +288,8 @@ public class PostService {
post.setCategory(category);
post.setTags(new HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
post.setLastReplyAt(LocalDateTime.now());
// 什么都没设置的情况下默认为ALL
if (Objects.isNull(postVisibleScopeType)) {
post.setVisibleScope(PostVisibleScopeType.ALL);
} else {
post.setVisibleScope(postVisibleScopeType);
}
if (post instanceof LotteryPost) {
post = lotteryPostRepository.save((LotteryPost) post);
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
post = categoryProposalPostRepository.save(categoryProposalPost);
} else if (post instanceof PollPost) {
post = pollPostRepository.save((PollPost) post);
} else {
@@ -406,12 +344,6 @@ public class PostService {
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
@@ -422,110 +354,24 @@ public class PostService {
if (post.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(post);
}
markPostLimit(author.getUsername());
return post;
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public void finalizeProposal(Long postId) {
scheduledFinalizations.remove(postId);
categoryProposalPostRepository
.findById(postId)
.ifPresent(cp -> {
if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) {
return;
}
int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0;
int approveVotes = 0;
if (cp.getVotes() != null) {
approveVotes = cp.getVotes().getOrDefault(0, 0);
}
boolean quorumMet = totalParticipants >= cp.getQuorum();
int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0;
boolean thresholdMet = approvePercent >= cp.getApproveThreshold();
boolean approved = false;
String rejectReason = null;
if (quorumMet && thresholdMet) {
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
approved = true;
} else {
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
String reason;
if (!quorumMet && !thresholdMet) {
reason = "未达到法定人数且赞成率不足";
} else if (!quorumMet) {
reason = "未达到法定人数";
} else {
reason = "赞成率不足";
}
cp.setRejectReason(reason);
rejectReason = reason;
}
cp.setResultSnapshot(
"approveVotes=" +
approveVotes +
", totalParticipants=" +
totalParticipants +
", approvePercent=" +
approvePercent
);
categoryProposalPostRepository.save(cp);
if (approved) {
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
}
if (cp.getAuthor() != null) {
notificationService.createNotification(
cp.getAuthor(),
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
for (User participant : cp.getParticipants()) {
if (
cp.getAuthor() != null &&
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
) {
continue;
}
notificationService.createNotification(
participant,
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
postChangeLogService.recordVoteResult(cp);
});
}
/**
* 检查用户是否达到发帖限制
* 限制发帖频率
* @param username
* @return true - 允许发帖false - 已达限制
* @return
*/
private boolean isPostLimitReached(String username) {
private boolean postRateLimit(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
String result = (String) redisTemplate.opsForValue().get(key);
return StringUtils.isEmpty(result);
}
/**
* 标记用户发帖触发limit计时
* @param username
*/
private void markPostLimit(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
//最近没有创建过文章
if (StringUtils.isEmpty(result)) {
// 限制频率为5分钟
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
return true;
}
return false;
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@@ -604,9 +450,6 @@ public class PostService {
pollPostRepository
.findById(postId)
.ifPresent(pp -> {
if (pp instanceof CategoryProposalPost) {
return;
}
if (pp.isResultAnnounced()) {
return;
}
@@ -665,15 +508,11 @@ public class PostService {
w.getEmail() != null &&
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)
) {
try {
emailSender.sendEmail(
w.getEmail(),
"你中奖了",
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
);
} catch (EmailSendException e) {
log.warn("Failed to send lottery win email to {}: {}", w.getEmail(), e.getMessage());
}
emailSender.sendEmail(
w.getEmail(),
"你中奖了",
"恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"
);
}
notificationService.createNotification(
w,
@@ -699,19 +538,11 @@ public class PostService {
.getDisabledEmailNotificationTypes()
.contains(NotificationType.LOTTERY_DRAW)
) {
try {
emailSender.sendEmail(
lp.getAuthor().getEmail(),
"抽奖已开奖",
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
);
} catch (EmailSendException e) {
log.warn(
"Failed to send lottery draw email to {}: {}",
lp.getAuthor().getEmail(),
e.getMessage()
);
}
emailSender.sendEmail(
lp.getAuthor().getEmail(),
"抽奖已开奖",
"您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"
);
}
notificationService.createNotification(
lp.getAuthor(),
@@ -740,7 +571,7 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getStatus() != PostStatus.PUBLISHED) {
if (viewer == null) {
throw new com.openisle.exception.NotFoundException("User not found");
throw new com.openisle.exception.NotFoundException("Post not found");
}
User viewerUser = userRepository
.findByUsername(viewer)
@@ -784,18 +615,6 @@ public class PostService {
return listPostsByCategories(null, null, null);
}
public List<Post> listRecentPosts(int minutes) {
if (minutes <= 0) {
throw new IllegalArgumentException("Minutes must be positive");
}
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus.PUBLISHED,
since
);
return sortByPinnedAndCreated(posts);
}
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
return listPostsByViews(null, null, page, pageSize);
}
@@ -810,10 +629,9 @@ public class PostService {
boolean hasTags = tagIds != null && !tagIds.isEmpty();
java.util.List<Post> posts;
Pageable pageable = buildPageable(page, pageSize);
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByPinnedAtDescViewsDesc(PostStatus.PUBLISHED, pageable);
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
@@ -824,18 +642,16 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescViewsDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc(
categories,
tags,
PostStatus.PUBLISHED,
tags.size(),
pageable
tags.size()
);
} else {
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescViewsDesc(
posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(
categories,
PostStatus.PUBLISHED,
pageable
PostStatus.PUBLISHED
);
}
} else {
@@ -843,15 +659,10 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByAllTagsOrderByPinnedAtDescViewsDesc(
tags,
PostStatus.PUBLISHED,
tags.size(),
pageable
);
posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size());
}
return posts;
return paginate(sortByPinnedAndViews(posts), page, pageSize);
}
public List<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
@@ -868,13 +679,9 @@ public class PostService {
boolean hasTags = tagIds != null && !tagIds.isEmpty();
java.util.List<Post> posts;
Pageable pageable = buildPageable(page, pageSize);
if (!hasCategories && !hasTags) {
posts = postRepository.findByStatusOrderByPinnedAtDescLastReplyAtDesc(
PostStatus.PUBLISHED,
pageable
);
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
} else if (hasCategories) {
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
if (categories.isEmpty()) {
@@ -885,18 +692,16 @@ public class PostService {
if (tags.isEmpty()) {
return java.util.List.of();
}
posts = postRepository.findByCategoriesAndAllTagsOrderByPinnedAtDescLastReplyAtDesc(
posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(
categories,
tags,
PostStatus.PUBLISHED,
tags.size(),
pageable
tags.size()
);
} else {
posts = postRepository.findByCategoryInAndStatusOrderByPinnedAtDescLastReplyAtDesc(
posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(
categories,
PostStatus.PUBLISHED,
pageable
PostStatus.PUBLISHED
);
}
} else {
@@ -904,15 +709,14 @@ public class PostService {
if (tags.isEmpty()) {
return new ArrayList<>();
}
posts = postRepository.findByAllTagsOrderByPinnedAtDescLastReplyAtDesc(
posts = postRepository.findByAllTagsOrderByCreatedAtDesc(
tags,
PostStatus.PUBLISHED,
tags.size(),
pageable
tags.size()
);
}
return posts;
return paginate(sortByPinnedAndLastReply(posts), page, pageSize);
}
public List<Post> listPostsByCategories(
@@ -1198,8 +1002,7 @@ public class PostService {
Long categoryId,
String title,
String content,
List<Long> tagIds,
PostVisibleScopeType postVisibleScopeType
java.util.List<Long> tagIds
) {
if (tagIds == null || tagIds.isEmpty()) {
throw new IllegalArgumentException("At least one tag required");
@@ -1231,8 +1034,6 @@ public class PostService {
post.setContent(content);
post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags));
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
post.setVisibleScope(postVisibleScopeType);
Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null);
@@ -1254,14 +1055,6 @@ public class PostService {
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
postChangeLogService.recordVisibleScopeChange(
updated,
user,
oldVisibleScope,
postVisibleScopeType
);
}
if (updated.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(updated);
}
@@ -1410,13 +1203,6 @@ public class PostService {
.toList();
}
private Pageable buildPageable(Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return Pageable.unpaged();
}
return PageRequest.of(page, pageSize);
}
private List<Post> paginate(List<Post> posts, Integer page, Integer pageSize) {
if (page == null || pageSize == null) {
return posts;

View File

@@ -1,6 +1,5 @@
package com.openisle.service;
import com.openisle.exception.EmailSendException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
@@ -8,9 +7,8 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@Service
@@ -25,6 +23,7 @@ public class ResendEmailSender extends EmailSender {
private final RestTemplate restTemplate = new RestTemplate();
@Override
@Async("notificationExecutor")
public void sendEmail(String to, String subject, String text) {
String url = "https://api.resend.com/emails"; // hypothetical endpoint
@@ -39,20 +38,6 @@ public class ResendEmailSender extends EmailSender {
body.put("from", "openisle <" + fromEmail + ">");
HttpEntity<Map<String, String>> entity = new HttpEntity<>(body, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String.class
);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new EmailSendException(
"Email service returned status " + response.getStatusCodeValue()
);
}
} catch (RestClientException e) {
throw new EmailSendException("Failed to send email: " + e.getMessage(), e);
}
restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
}
}

View File

@@ -118,6 +118,7 @@ public class UserService {
* @param user
*/
public void sendVerifyMail(User user, VerifyType verifyType) {
// 缓存验证码
String code = genCode();
String key;
String subject;
@@ -132,9 +133,8 @@ public class UserService {
subject = "请填写验证码以重置密码(有效期为5分钟)";
}
emailService.sendEmail(user.getEmail(), subject, content);
// 邮件发送成功后再缓存验证码,避免发送失败时用户收不到但验证被要求
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES); // 五分钟后验证码过期
emailService.sendEmail(user.getEmail(), subject, content);
}
/**

View File

@@ -13,7 +13,6 @@ spring.jpa.hibernate.ddl-auto=update
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
spring.data.redis.database=${REDIS_DATABASE:0}
spring.data.redis.password=${REDIS_PASS: null}
# for jwt
app.jwt.secret=${JWT_SECRET:jwt_sec}

View File

@@ -20,7 +20,6 @@ CREATE TABLE IF NOT EXISTS `users` (
`username` varchar(50) NOT NULL,
`verification_code` varchar(255) DEFAULT NULL,
`verified` bit(1) DEFAULT NULL,
`is_bot` bit(1) NOT NULL DEFAULT b'0',
PRIMARY KEY (`id`),
UNIQUE KEY `UK_users_email` (`email`),
UNIQUE KEY `UK_users_username` (`username`)

View File

@@ -8,28 +8,10 @@ DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123456
INSERT INTO `users` (
`id`,
`approved`,
`avatar`,
`created_at`,
`display_medal`,
`email`,
`experience`,
`introduction`,
`password`,
`password_reset_code`,
`point`,
`register_reason`,
`role`,
`username`,
`verification_code`,
`verified`,
`is_bot`
) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1', b'0'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1', b'0'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1', b'0');
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1');
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
(1,'测试用分类1','star','测试用分类1',NULL),

View File

@@ -1,4 +0,0 @@
-- Backfill last_reply_at for posts without comments to preserve latest-reply ordering
UPDATE posts
SET last_reply_at = created_at
WHERE last_reply_at IS NULL;

View File

@@ -1,25 +0,0 @@
-- Create table for category proposal posts (subclass of poll_posts)
CREATE TABLE IF NOT EXISTS category_proposal_posts (
post_id BIGINT NOT NULL,
status VARCHAR(50) NOT NULL,
proposed_name VARCHAR(255) NOT NULL,
proposed_slug VARCHAR(255) NOT NULL,
description VARCHAR(255),
approve_threshold INT NOT NULL DEFAULT 60,
quorum INT NOT NULL DEFAULT 10,
start_at DATETIME(6) NULL,
result_snapshot LONGTEXT NULL,
reject_reason VARCHAR(255),
PRIMARY KEY (post_id),
CONSTRAINT fk_category_proposal_posts_parent
FOREIGN KEY (post_id) REFERENCES poll_posts (post_id)
);
CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status
ON category_proposal_posts (status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug
ON category_proposal_posts (proposed_slug);

View File

@@ -1 +0,0 @@
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'

View File

@@ -1,8 +0,0 @@
ALTER TABLE category_proposal_posts
DROP INDEX idx_category_proposal_posts_slug;
ALTER TABLE category_proposal_posts
DROP COLUMN proposed_slug;
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name
ON category_proposal_posts (proposed_name);

View File

@@ -1,2 +0,0 @@
ALTER TABLE users
ADD COLUMN is_bot BIT(1) NOT NULL DEFAULT b'0';

View File

@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS post_reads (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
post_id BIGINT NOT NULL,
last_read_at DATETIME(6) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY UK_post_reads_user_post (user_id, post_id),
KEY IDX_post_reads_user (user_id),
KEY IDX_post_reads_post (post_id),
CONSTRAINT FK_post_reads_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT FK_post_reads_post FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);

View File

@@ -76,15 +76,6 @@ class PostControllerTest {
@MockBean
private MedalService medalService;
@MockBean
private CategoryService categoryService;
@MockBean
private TagService tagService;
@MockBean
private PointService pointService;
@MockBean
private com.openisle.repository.PollVoteRepository pollVoteRepository;
@@ -126,11 +117,6 @@ class PostControllerTest {
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull()
)
).thenReturn(post);
@@ -280,11 +266,6 @@ class PostControllerTest {
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()
);
}

View File

@@ -26,7 +26,6 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -53,7 +52,6 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -106,7 +104,6 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -133,7 +130,6 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -199,7 +195,6 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -226,7 +221,6 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -266,11 +260,6 @@ class PostServiceTest {
null,
null,
null,
null,
null,
null,
null,
null,
null
)
);
@@ -284,7 +273,6 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -311,7 +299,6 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -380,7 +367,6 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -407,7 +393,6 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,

View File

@@ -46,4 +46,3 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
# Web push configuration
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
app.snippet-length=${SNIPPET_LENGTH:200}

View File

@@ -1,186 +0,0 @@
import { Agent, Runner, hostedMcpTool, withTrace, webSearchTool } from "@openai/agents";
export type WorkflowInput = { input_as_text: string };
export abstract class BotFather {
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
protected readonly weatherToken = (process.env.APIFY_API_TOKEN ?? "").trim();
protected readonly openisleMcp = this.createHostedMcpTool();
protected readonly weatherMcp = this.createWeatherMcpTool();
protected readonly webSearchPreview = this.createWebSearchPreviewTool();
protected readonly agent: Agent;
constructor(protected readonly name: string) {
console.log(`${this.name} starting...`);
console.log(
this.openisleToken
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
);
console.log(
this.weatherToken
? "☁️ APIFY_API_TOKEN detected; weather MCP server will be available."
: "🌥️ APIFY_API_TOKEN not set; weather updates will be unavailable."
);
this.agent = new Agent({
name: this.name,
instructions: this.buildInstructions(),
tools: [
this.openisleMcp,
this.weatherMcp,
this.webSearchPreview
],
model: this.getModel(),
modelSettings: {
temperature: 0.7,
topP: 1,
maxTokens: 2048,
toolChoice: "auto",
store: true,
},
});
}
protected buildInstructions(): string {
const instructions = [
...this.getBaseInstructions(),
...this.getAdditionalInstructions(),
].filter(Boolean);
return instructions.join("\n");
}
protected getBaseInstructions(): string[] {
return [
"You are a helpful assistant for https://www.open-isle.com.",
"Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.",
"When presenting the result, reply in Chinese with a concise summary and include any important URLs or IDs.",
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
];
}
private createWebSearchPreviewTool() {
return webSearchTool({
userLocation: {
type: "approximate",
country: undefined,
region: undefined,
city: undefined,
timezone: undefined
},
searchContextSize: "medium"
})
}
private createHostedMcpTool() {
const token = this.openisleToken;
const authConfig = token
? {
headers: {
Authorization: `Bearer ${token}`,
},
}
: {};
return hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: [
"search", // 用于搜索帖子、内容等
"create_post", // 创建新帖子
"reply_to_post", // 回复帖子
"reply_to_comment", // 回复评论
"recent_posts", // 获取最新帖子
"get_post", // 获取特定帖子的详细信息
"list_unread_messages", // 列出未读消息或通知
"mark_notifications_read", // 标记通知为已读
],
requireApproval: "never",
...authConfig,
});
}
private createWeatherMcpTool(): ReturnType<typeof hostedMcpTool> {
return hostedMcpTool({
serverLabel: "weather_mcp_server",
serverUrl: "https://jiri-spilka--weather-mcp-server.apify.actor/mcp",
requireApproval: "never",
allowedTools: [
"get_current_weather", // 天气 MCP 工具
],
headers: {
Authorization: `Bearer ${this.weatherToken || ""}`,
},
});
}
protected getAdditionalInstructions(): string[] {
return [];
}
protected getModel(): string {
return "gpt-4o-mini";
}
protected createRunner(): Runner {
return new Runner({
workflowName: this.name,
traceMetadata: {
__trace_source__: "agent-builder",
workflow_id: "wf_69003cbd47e08190928745d3c806c0b50d1a01cfae052be8",
},
});
}
public async runWorkflow(workflow: WorkflowInput) {
if (!process.env.OPENAI_API_KEY) {
throw new Error("Missing OPENAI_API_KEY");
}
const runner = this.createRunner();
return await withTrace(`${this.name} run`, async () => {
const preview = workflow.input_as_text.trim();
console.log(
"📝 Received workflow input (preview):",
preview.length > 200 ? `${preview.slice(0, 200)}` : preview
);
console.log("🚦 Starting agent run with maxTurns=16...");
const result = await runner.run(this.agent, workflow.input_as_text, {
maxTurns: 16,
});
console.log("📬 Agent run completed. Result keys:", Object.keys(result));
if (!result.finalOutput) {
throw new Error("Agent result is undefined (no final output).");
}
const openisleBotResult = { output_text: String(result.finalOutput) };
console.log(
"🤖 Agent result (length=%d):\n%s",
openisleBotResult.output_text.length,
openisleBotResult.output_text
);
return openisleBotResult;
});
}
protected abstract getCliQuery(): string;
public async runCli(): Promise<void> {
try {
const query = this.getCliQuery();
console.log("🔍 Running workflow...");
await this.runWorkflow({ input_as_text: query });
process.exit(0);
} catch (err: any) {
console.error("❌ Agent failed:", err?.stack || err);
process.exit(1);
}
}
}

View File

@@ -1,56 +0,0 @@
import { BotFather, WorkflowInput } from "../bot_father";
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
class CoffeeBot extends BotFather {
constructor() {
super("Coffee Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"记住你的系统代号是 system有需要自称或签名时都要使用这个名字。",
"You are responsible for 发布每日抽奖早安贴。",
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
"确保只发布一个帖子,避免重复调用 create_post。",
"使用标签为 weather_mcp_server 的 MCP 工具获取北京、上海、广州、深圳当天的天气信息,并把结果写入早安问候之后。",
];
}
protected override getCliQuery(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const weekday = WEEKDAY_NAMES[now.getDay()];
const drawTime = new Date(now);
drawTime.setHours(15, 0, 0, 0);
return `
请立即在 https://www.open-isle.com 使用 create_post 发表一篇帖子,遵循以下要求:
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
2. 正文包含:
- 亲切的早安问候;
- 早安问候后立即列出北京、上海、广州、深圳当天的天气信息,每行格式为“城市:天气描述,最低温~最高温”;天气需调用 weather_mcp_server 获取;
- 标注“领奖请私聊站长 @[nagisa]”;
- 鼓励大家留言互动。
3. 奖品信息
- 明确奖品写作“Coffee”
- 帖子类型必须为 LOTTERY
- 奖品图片链接https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png
- 公布开奖时间为 ${drawTime}, 直接传UTC时间给接口不要考虑时区问题
- categoryId 固定为 10tagIds 设为 [36]。
4. 帖子语言使用简体中文。
`.trim();
}
}
const coffeeBot = new CoffeeBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return coffeeBot.runWorkflow(workflow);
};
if (require.main === module) {
coffeeBot.runCli();
}

View File

@@ -1,69 +0,0 @@
import { BotFather, WorkflowInput } from "../bot_father";
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
class DailyNewsBot extends BotFather {
constructor() {
super("Daily News Bot");
}
protected override getModel(): string {
return "gpt-4o";
}
protected override getAdditionalInstructions(): string[] {
return [
"You are DailyNewsBot专职在 OpenIsle 发布每日新闻速递。",
"始终使用简体中文回复,并以结构化 Markdown 呈现内容。",
"发布内容前务必完成资讯核实:分别通过 web_search 调研 CoinDesk 所有要闻、Reuters 重点国际新闻,以及全球 AI 领域的重大进展。",
"整合新闻时,将同源资讯合并,突出影响力、涉及主体与潜在影响,保持语句简洁。",
"所有新闻要点都要附带来源链接,并在括号中标注来源站点名。",
"使用 weather_mcp_server 的 get_current_weather 获取北京、上海、广州、深圳的天气,并在正文中列表展示",
"正文结尾补充一个行动建议或提醒,帮助读者快速把握重点。",
"严禁发布超过一篇帖子create_post 只调用一次。",
];
}
protected override getCliQuery(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const weekday = WEEKDAY_NAMES[now.getDay()];
const dateLabel = `${year}${month}${day}日 星期${weekday}`;
const isoDate = `${year}-${month}-${day}`;
const categoryId = Number(process.env.DAILY_NEWS_CATEGORY_ID ?? "6");
const tagIdsEnv = process.env.DAILY_NEWS_TAG_IDS ?? "3,33";
const tagIds = tagIdsEnv
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => !Number.isNaN(id));
const finalTagIds = tagIds.length > 0 ? tagIds : [1];
const tagIdsText = `[${finalTagIds.join(", ")}]`;
return `
请立即在 https://www.open-isle.com 使用 create_post 发布一篇名为「OpenIsle 每日新闻速递|${dateLabel}」的帖子,并遵循以下要求:
1. 发布类型为 NORMALcategoryId = ${categoryId}tagIds = ${tagIdsText}
2. 正文以简洁问候开头, 不用再重复标题
3. 使用 web_search 工具按以下顺序收集资讯,并在正文中以 Markdown 小节呈现, 需要调用3次web_search
- 「全球区块链与加密」:汇总 coindesk.com 版面所有重点新闻, 列出至少5条
- 「国际新闻速览」:汇总 reuters.com 版面重点头条关注宏观经济、市场波动或政策变化。列出至少5条
- 「AI 行业快讯」:检索今天全球 AI 领域的重要发布或事件(例如 OpenAI、Google、Meta、国内大模型厂商等。列出至少5条
4. 每条新闻采用项目符号,先写结论再给出关键数字或细节,末尾添加来源超链接,格式示例:「**结论** —— 关键细节。(来源:[Reuters](URL))」
5. 资讯整理完毕后,调用 weather_mcp_server.get_current_weather列出北京、上海、广州、深圳今日天气放置在「城市天气」小节下, 本小节可加emoji。
6. 最后一节为「今日提醒」,给出 2-3 条与新闻或天气相关的行动建议。
7. 若在资讯搜集过程中发现相互矛盾的信息,须在正文中以「⚠️ 风险提示」说明原因及尚待确认的点。
9. 发布完成后,不要再次调用 create_post。
`.trim();
}
}
const dailyNewsBot = new DailyNewsBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return dailyNewsBot.runWorkflow(workflow);
};
if (require.main === module) {
dailyNewsBot.runCli();
}

View File

@@ -1,65 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { BotFather, WorkflowInput } from "../bot_father";
class OpenSourceReplyBot extends BotFather {
constructor() {
super("OpenSource Reply Bot");
}
protected override getAdditionalInstructions(): string[] {
const knowledgeBase = this.loadKnowledgeBase();
return [
"You are OpenSourceReplyBot, a professional helper who focuses on answering open-source development and code-related questions for the OpenIsle community.",
"Respond in Chinese using well-structured Markdown sections such as 标题、列表、代码块等,让回复清晰易读。",
"保持语气专业、耐心、详尽,绝不使用表情符号或颜文字,也不要卖萌。",
"优先解答与项目代码、贡献流程、架构设计或排错相关的问题;",
"在需要时引用 README.md 与 CONTRIBUTING.md 中的要点,帮助用户快速定位文档位置。",
knowledgeBase,
].filter(Boolean);
}
protected override getCliQuery(): string {
return `
【AUTO】每30分钟自动巡检未读提及与评论严格遵守以下流程
1调用 list_unread_messages 获取待处理的“提及/评论”;
2按时间从新到旧逐条处理最多10条如需上下文请调用 get_post
3仅对与开源项目、代码实现或贡献流程直接相关的问题生成详尽的 Markdown 中文回复,
若与主题无关则礼貌说明并跳过;
4回复时引用 README 或 CONTRIBUTING 中的要点(如适用),并优先给出可执行的排查步骤或代码建议;
5回复评论使用 reply_to_comment回复帖子使用 reply_to_post
6若某通知最后一条已由本 bot 回复,则跳过避免重复;
7整理已处理通知 ID 调用 mark_notifications_read
8结束时输出包含处理条目概览URL或ID的总结。`.trim();
}
private loadKnowledgeBase(): string {
const docs = ["../../README.md", "../../CONTRIBUTING.md"];
const sections: string[] = [];
for (const relativePath of docs) {
try {
const absolutePath = path.resolve(__dirname, relativePath);
const content = readFileSync(absolutePath, "utf-8").trim();
if (content) {
sections.push(`以下是 ${path.basename(absolutePath)} 的内容:\n${content}`);
}
} catch (error) {
sections.push(`未能加载 ${relativePath},请检查文件路径或权限。`);
}
}
return sections.join("\n\n");
}
}
const openSourceReplyBot = new OpenSourceReplyBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return openSourceReplyBot.runWorkflow(workflow);
};
if (require.main === module) {
openSourceReplyBot.runCli();
}

View File

@@ -1,38 +0,0 @@
// reply_bot.ts
import { BotFather, WorkflowInput } from "../bot_father";
class ReplyBot extends BotFather {
constructor() {
super("OpenIsle Bot");
}
protected override getAdditionalInstructions(): string[] {
return [
"记住你的系统代号是 system任何需要自称、署名或解释身份的时候都使用这个名字。",
"以阴阳怪气的方式回复各种互动",
"你每天会发布咖啡抽奖贴,跟大家互动",
];
}
protected override getCliQuery(): string {
return `
【AUTO】无需确认自动处理所有未读的提及与评论
1调用 list_unread_messages
2依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment否则用 reply_to_post
3跳过关注和系统事件
4保证幂等性如该贴最后一条是你自己发的回复则跳过
5调用 mark_notifications_read传入本次已处理的通知 ID 清理已读;
6最多只处理最新10条结束时仅输出简要摘要包含URL或ID
`.trim();
}
}
const replyBot = new ReplyBot();
export const runWorkflow = async (workflow: WorkflowInput) => {
return replyBot.runWorkflow(workflow);
};
if (require.main === module) {
replyBot.runCli();
}

View File

@@ -40,12 +40,12 @@ echo "👉 Build images ..."
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=production \
frontend_service mcp
frontend_service
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service mcp
mysql redis rabbitmq websocket-service springboot frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -36,15 +36,16 @@ echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images (staging)..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=staging \
frontend_service mcp
frontend_service
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service mcp
mysql redis rabbitmq websocket-service springboot frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -25,10 +25,6 @@ services:
timeout: 3s
retries: 30
start_period: 20s
profiles:
- dev
- dev_local_backend
- prod
# OpenSearch Service
opensearch:
@@ -65,9 +61,6 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
@@ -82,10 +75,6 @@ services:
restart: unless-stopped
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
rabbitmq:
image: rabbitmq:3.13-management
@@ -109,10 +98,6 @@ services:
start_period: 30s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
redis:
image: redis:7
@@ -126,10 +111,6 @@ services:
- redis-data:/data
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
springboot:
@@ -161,8 +142,8 @@ services:
condition: service_started
websocket-service:
condition: service_healthy
# opensearch:
# condition: service_healthy
opensearch:
condition: service_healthy
command: >
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
mvn clean spring-boot:run -Dmaven.test.skip=true"
@@ -174,35 +155,6 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- prod
mcp:
build:
context: ..
dockerfile: docker/mcp.Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
env_file:
- ${ENV_FILE:-../.env}
environment:
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
OPENISLE_MCP_HOST: 0.0.0.0
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
ports:
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
depends_on:
springboot:
condition: service_started
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
websocket-service:
image: maven:3.9-eclipse-temurin-17
@@ -234,10 +186,6 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
frontend_dev:
image: node:20
@@ -260,28 +208,6 @@ services:
- openisle-network
profiles:
- dev
frontend_dev_local_backend:
image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend
working_dir: /app
env_file:
- ${ENV_FILE:-../.env}
command: sh -c "npm install && npm run dev"
volumes:
- ../frontend_nuxt:/app
- frontend-node-modules:/app/node_modules
ports:
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
websocket-service:
condition: service_healthy
networks:
- openisle-network
profiles:
- dev_local_backend
extra_hosts:
- "host.docker.internal:host-gateway"
frontend_service:
build:
@@ -300,13 +226,13 @@ services:
websocket-service:
condition: service_healthy
restart: unless-stopped
profiles:
- prod
profiles: ["staging", "prod"]
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
loopback_8080:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
command:
- -d
- -d
@@ -317,37 +243,13 @@ services:
springboot:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080
loopback_8080_host:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
- TCP4:host.docker.internal:8080
network_mode: "service:frontend_dev_local_backend"
depends_on:
frontend_dev_local_backend:
condition: service_started
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev_local_backend
loopback_8082:
image: alpine/socat
@@ -363,37 +265,13 @@ services:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev
loopback_8082_host:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082WS 纯 TCP 可直接过)
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
- TCP4:websocket-service:8082
depends_on:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev_local_backend"
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev_local_backend
networks:
openisle-network:

View File

@@ -1,21 +0,0 @@
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY mcp/pyproject.toml mcp/README.md ./
COPY mcp/src ./src
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir .
ENV OPENISLE_MCP_HOST=0.0.0.0 \
OPENISLE_MCP_PORT=8085 \
OPENISLE_MCP_TRANSPORT=streamable-http
EXPOSE 8085
CMD ["openisle-mcp"]

View File

@@ -41,13 +41,10 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
import { checkToken } from '~/utils/auth'
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
await checkToken()
const showNewPostIcon = computed(() => useRoute().path === '/')
const hideMenu = computed(() => {

View File

@@ -3,7 +3,7 @@
--primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5);
--secondary-color: rgb(255, 255, 255);
--secondary-color-hover: rgba(10, 111, 120, 0.079);
--secondary-color-hover: rgba(10, 111, 120, 0.184);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px;
--header-background-color: white;
@@ -54,7 +54,6 @@
--header-border-color: #555;
--primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151);
--secondary-color-hover: rgba(17, 182, 197, 0.238);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white;
--app-menu-background-color: #333;
@@ -180,7 +179,7 @@ body {
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
white-space: nowrap; /* 禁止数字换行 */
white-space: nowrap; /* 禁止数字换行 */
font-variant-numeric: tabular-nums; /* 数字等宽 */
/* width: 2em; */
font-size: 13px;
@@ -206,6 +205,7 @@ body {
border-radius: 4px;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
white-space: pre; /* 禁止自动换行 */
}
.copy-code-btn {
@@ -344,7 +344,7 @@ body {
.info-content-text pre {
line-height: 1.5;
}
/*处理iframe视频标签*/
.info-content-text iframe {
width: 100%;
@@ -370,10 +370,7 @@ body {
.d2h-code-line {
padding-left: 10px !important;
}
/* 手机端不换行 */
.info-content-text code {
white-space: pre; /* 禁止自动换行 */
}
/* .d2h-diff-table {
font-size: 6px !important;
}

View File

@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
import { useAttrs } from 'vue'
const props = defineProps({
src: { type: String, default: '' },
src: { type: String, required: true },
alt: { type: String, default: '' },
})
@@ -39,6 +39,9 @@ const placeholder = computed(() => {
function onLoad() {
loaded.value = true
}
function onError() {
loaded.value = true
}
</script>
<style scoped>

View File

@@ -1,187 +1,157 @@
<template>
<div
ref="groupRef"
class="base-item-group"
:class="groupClass"
:style="groupStyle"
:style="{
width: `${containerWidth}px`,
height: `${itemSize}px`,
'--base-item-group-duration': `${animationDuration}ms`,
}"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@focusin="onFocusIn"
@focusout="onFocusOut"
>
<div
v-for="(item, index) in normalizedItems"
:key="resolveKey(item, index)"
class="base-item-group-item"
:style="{ zIndex: getZIndex(index) }"
v-for="(item, index) in items"
:key="itemKey(item, index)"
class="base-item-group__item"
:style="{
width: `${itemSize}px`,
height: `${itemSize}px`,
transform: `translateX(${index * activeGap}px)`,
zIndex: items.length - index,
}"
>
<slot name="item" :item="item" :index="index"></slot>
<slot :item="item" :index="index">
<BaseImage
v-if="item && (item.src || typeof item === 'string')"
class="base-item-group__image"
:src="typeof item === 'string' ? item : item.src"
:alt="itemAlt(item, index)"
/>
<div v-else class="base-item-group__placeholder">{{ placeholderText(item) }}</div>
</slot>
</div>
<slot name="after"></slot>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import BaseImage from './BaseImage.vue'
const props = defineProps({
items: {
type: Array,
default: () => [],
},
itemKey: {
type: [String, Function],
default: null,
itemSize: {
type: Number,
default: 40,
},
overlap: {
type: [Number, String],
collapsedGap: {
type: Number,
default: 12,
},
expandedGap: {
type: [Number, String],
default: 8,
},
direction: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical'].includes(value),
},
reverse: {
type: Boolean,
default: false,
type: Number,
default: null,
},
animationDuration: {
type: [Number, String],
type: Number,
default: 200,
},
})
const groupRef = ref(null)
const state = reactive({
hovering: false,
focused: false,
})
const normalizedItems = computed(() => props.items || [])
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
const groupClass = computed(() => [
`base-item-group--${props.direction}`,
{
'is-expanded': isExpanded.value,
'is-reversed': props.reverse,
itemKeyField: {
type: String,
default: 'id',
},
])
})
const groupStyle = computed(() => ({
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
}))
const isHovered = ref(false)
const isExpanded = computed(() => state.hovering || state.focused)
function onMouseEnter() {
state.hovering = true
const onMouseEnter = () => {
isHovered.value = true
}
function onMouseLeave() {
state.hovering = false
const onMouseLeave = () => {
isHovered.value = false
}
function onFocusIn() {
state.focused = true
}
const effectiveExpandedGap = computed(() =>
props.expandedGap == null ? props.itemSize : props.expandedGap,
)
function onFocusOut(event) {
const nextTarget = event.relatedTarget
if (!groupRef.value) {
state.focused = false
return
const activeGap = computed(() =>
isHovered.value ? effectiveExpandedGap.value : props.collapsedGap,
)
const containerWidth = computed(() =>
props.items.length ? props.itemSize + (props.items.length - 1) * activeGap.value : props.itemSize,
)
watchEffect(() => {
if (effectiveExpandedGap.value < props.collapsedGap) {
console.warn('[BaseItemGroup] `expandedGap` should be greater than or equal to `collapsedGap`.')
}
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
state.focused = false
}
}
})
function resolveKey(item, index) {
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index)
}
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
return item[props.itemKey]
const itemKey = (item, index) => {
if (item && typeof item === 'object' && props.itemKeyField in item) {
return item[props.itemKeyField]
}
return index
}
function getZIndex(index) {
if (props.reverse) {
return index + 1
const itemAlt = (item, index) => {
if (item && typeof item === 'object') {
return item.alt || `item-${index}`
}
return normalizedItems.value.length - index
if (typeof item === 'string') {
return `item-${index}`
}
return 'item'
}
const placeholderText = (item) => {
if (item == null) return ''
if (typeof item === 'object' && 'text' in item) return item.text
return String(item)
}
</script>
<style scoped>
.base-item-group {
--base-item-group-overlap: 12px;
--base-item-group-expanded-gap: 8px;
--base-item-group-transition-duration: 200ms;
display: inline-flex;
display: flex;
position: relative;
align-items: center;
transition: width var(--base-item-group-duration) ease;
}
.base-item-group:focus-within {
outline: none;
}
.base-item-group--horizontal {
flex-direction: row;
}
.base-item-group--horizontal.is-reversed {
flex-direction: row-reverse;
}
.base-item-group--vertical {
flex-direction: column;
align-items: flex-start;
}
.base-item-group--vertical.is-reversed {
flex-direction: column-reverse;
}
.base-item-group-item {
transition:
margin var(--base-item-group-transition-duration) ease,
transform var(--base-item-group-transition-duration) ease;
display: inline-flex;
.base-item-group__item {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
overflow: hidden;
background-color: var(--color-neutral-100, #f0f2f5);
transition: transform var(--base-item-group-duration) ease;
box-shadow: 0 0 0 2px var(--color-surface, #fff);
}
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
margin-left: calc(var(--base-item-group-overlap) * -1);
.base-item-group__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
margin-left: var(--base-item-group-expanded-gap);
}
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
margin-top: calc(var(--base-item-group-overlap) * -1);
}
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
margin-top: var(--base-item-group-expanded-gap);
}
.base-item-group.is-expanded .base-item-group-item {
transform: translateZ(0);
.base-item-group__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-neutral-500, #666);
background-color: var(--color-neutral-200, #e5e7eb);
}
</style>

View File

@@ -1,20 +1,22 @@
<template>
<div
<NuxtLink
:to="resolvedLink"
class="base-user-avatar"
:class="wrapperClass"
:style="wrapperStyle"
v-bind="wrapperAttrs"
@click="handleClick"
>
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
</div>
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
</NuxtLink>
</template>
<script setup>
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useAttrs } from 'vue'
import BaseImage from './BaseImage.vue'
const DEFAULT_AVATAR = '/default-avatar.svg'
const props = defineProps({
userId: {
type: [String, Number],
@@ -48,6 +50,15 @@ const props = defineProps({
const attrs = useAttrs()
const currentSrc = ref(props.src || DEFAULT_AVATAR)
watch(
() => props.src,
(value) => {
currentSrc.value = value || DEFAULT_AVATAR
},
)
const resolvedLink = computed(() => {
if (props.to) return props.to
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
@@ -59,16 +70,10 @@ const resolvedLink = computed(() => {
const altText = computed(() => props.alt || '用户头像')
const sizeStyle = computed(() => {
var style = {}
if (props.width > 0) {
style.width = `${props.width}px`
}
if (props.height > 0) {
style.height = `${props.height}px`
}
return style
if (!props.width && props.width !== 0) return null
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
if (!value) return null
return { width: value, height: value }
})
const wrapperStyle = computed(() => {
@@ -83,9 +88,10 @@ const wrapperAttrs = computed(() => {
return rest
})
const handleClick = () => {
if (props.disableLink) return
navigateTo(resolvedLink.value)
function onError() {
if (currentSrc.value !== DEFAULT_AVATAR) {
currentSrc.value = DEFAULT_AVATAR
}
}
</script>
@@ -103,7 +109,7 @@ const handleClick = () => {
}
.base-user-avatar:hover {
box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
transform: scale(1.05);
}

View File

@@ -16,7 +16,6 @@
<div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span>
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
<span v-if="comment.isBot" class="bot-badge" title="Bot">Bot</span>
<medal-one class="medal-icon" />
<NuxtLink
v-if="comment.medal"
@@ -489,16 +488,6 @@ const handleContentClick = (e) => {
font-weight: bold;
}
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
margin-bottom: 0px;
}
.medal-name {
font-size: 12px;
margin-left: 1px;
@@ -523,21 +512,6 @@ const handleContentClick = (e) => {
line-height: 1;
}
.bot-badge {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 0 6px;
height: 18px;
border-radius: 9px;
background-color: rgba(76, 175, 80, 0.16);
color: #2e7d32;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.medal-icon {
font-size: 12px;
opacity: 0.6;

View File

@@ -1,319 +0,0 @@
<template>
<div class="donate-container">
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
<div class="donate-viewer" @click="openPanel">
<div
class="donate-viewer-item-container"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<BaseItemGroup
:items="donationList"
:overlap="10"
:expanded-gap="2"
:direction="vertical"
>
<template #item="{ item }">
<BaseUserAvatar
:user-id="item.userId"
:src="item.avatar"
:alt="item.username"
:width="20"
:disable-link="true"
/>
</template>
</BaseItemGroup>
<div class="donate-counts-text">{{ totalAmount }}</div>
</div>
</div>
</ToolTip>
<ToolTip content="赞赏作者" placement="bottom" v-else>
<div class="donate-viewer-item placeholder" @click="openPanel">
<financing class="donate-viewer-item-placeholder-icon" />
</div>
</ToolTip>
<div
v-if="panelVisible"
class="donate-panel"
ref="donatePanelRef"
:style="panelInlineStyle"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<div
v-for="option in donateOptions"
:key="option"
class="donate-option"
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
@click="handleDonate(option)"
>
<financing class="donate-option-icon" />
<div class="donate-counts-text">{{ option }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { Finance } from '@icon-park/vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const financing = Finance
const props = defineProps({
postId: {
type: [Number, String],
required: true,
},
authorId: {
type: [Number, String],
required: true,
},
isAuthor: {
type: Boolean,
default: false,
},
})
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const panelVisible = ref(false)
const donatePanelRef = ref(null)
const panelInlineStyle = ref({})
const donationSummary = ref({ totalAmount: 0, donations: [] })
const donating = ref(false)
let hideTimer = null
const donateOptions = [10, 30, 100]
const donationList = computed(() => donationSummary.value?.donations ?? [])
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
const isAuthorUser = computed(() => {
if (props.isAuthor) return true
if (!authState.userId || !props.authorId) return false
return Number(authState.userId) === Number(props.authorId)
})
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = donatePanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
width: 'max-content',
maxWidth: `${parentWidth}px`,
}
}
watch(panelVisible, async (visible) => {
if (visible) {
await nextTick()
updatePanelInlineStyle()
}
})
const normalizeSummary = (data) => ({
totalAmount: data?.totalAmount ?? 0,
donations: Array.isArray(data?.donations) ? data.donations : [],
})
const loadDonations = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
if (!res.ok) return
const data = await res.json()
donationSummary.value = normalizeSummary(data)
} catch (e) {
// ignore network errors for donation summary
}
}
const handleDonate = async (amount) => {
if (!amount || donating.value) return
if (!authState.loggedIn) {
toast.error('请先登录后再打赏')
panelVisible.value = false
return
}
if (isAuthorUser.value) {
toast.warning('不能给自己打赏')
return
}
try {
donating.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : '',
},
body: JSON.stringify({ amount }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
if (res.status === 401) {
toast.error('请先登录后再打赏')
} else {
toast.error(data?.error || '打赏失败')
}
return
}
donationSummary.value = normalizeSummary(data)
toast.success('打赏成功,感谢你的支持!')
panelVisible.value = false
} catch (e) {
toast.error('打赏失败,请稍后再试')
} finally {
donating.value = false
}
}
onMounted(async () => {
window.addEventListener('resize', updatePanelInlineStyle)
await loadDonations()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
watch(
() => props.postId,
async () => {
donationSummary.value = { totalAmount: 0, donations: [] }
await loadDonations()
},
)
</script>
<style scoped>
.donate-container {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.donate-viewer-item-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.donate-viewer {
border-radius: 13px;
padding: 3px;
padding-right: 6px;
cursor: pointer;
transition: background-color 0.5s ease;
}
.donate-viewer:hover {
background-color: var(--secondary-color-hover);
}
.donate-counts-text {
color: var(--primary-color);
font-size: 14px;
}
.donate-panel {
position: absolute;
bottom: 35px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
padding: 5px 10px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
z-index: 10;
gap: 5px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
.donate-viewer-item.placeholder {
display: flex;
cursor: pointer;
flex-direction: row;
padding: 2px 10px;
gap: 5px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;
background-color: var(--normal-light-background-color);
}
.donate-viewer-item {
font-size: 16px;
}
.donate-viewer-item-placeholder-icon {
opacity: 0.5;
}
.donate-option {
cursor: pointer;
padding: 3px 6px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.donate-option:hover {
background-color: var(--normal-light-background-color);
}
.donate-option.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.donate-option.disabled:hover {
background-color: transparent;
}
.donate-option-icon {
color: var(--primary-color);
}
@media (max-width: 768px) {
.donate-viewer-item.placeholder {
padding: 4px 8px;
gap: 3px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 3px;
margin-bottom: 3px;
font-size: 12px;
color: var(--text-color);
align-items: center;
}
}
</style>

View File

@@ -168,19 +168,9 @@ export default {
const mobileMenuRef = ref(null)
const isMobile = useIsMobile()
const openMenu = () => {
if (!open.value) {
open.value = true
}
}
const toggle = () => {
if (open.value) {
open.value = false
emit('close')
} else {
open.value = true
}
open.value = !open.value
if (!open.value) emit('close')
}
const close = () => {
@@ -285,7 +275,7 @@ export default {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
expose({ toggle, close, reload, scrollToBottom, openMenu })
expose({ toggle, close, reload, scrollToBottom })
return {
open,
@@ -318,6 +308,7 @@ export default {
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px 10px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
@@ -340,7 +331,6 @@ export default {
z-index: 10000;
max-height: 300px;
min-width: 350px;
margin-top: 4px;
overflow-y: auto;
}

View File

@@ -26,11 +26,6 @@
<ClientOnly>
<div class="header-content-right">
<SearchDropdown
ref="searchDropdown"
v-if="!isMobile || showSearch"
@close="closeSearch"
/>
<!-- 搜索 -->
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
<div class="header-icon-item" @click="search">
@@ -83,9 +78,7 @@
<div class="header-icon-item" @click="goToMessages">
<message-emoji class="header-icon" />
<span class="header-label">消息</span>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
@@ -96,9 +89,10 @@
<BaseUserAvatar
class="avatar-img"
:user-id="authState.userId"
:src="authState.avatar"
:disable-link="true"
:src="avatar"
alt="avatar"
:width="32"
:disable-link="true"
/>
<down />
</div>
@@ -111,6 +105,7 @@
</div>
</div>
</ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
</div>
</header>
</template>
@@ -122,7 +117,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { authState, clearToken } from '~/utils/auth'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
@@ -144,11 +139,13 @@ const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const isCopying = ref(false)
const onlineCount = ref(0)
// 心跳检测
@@ -211,7 +208,7 @@ const copyInviteLink = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
isCopying.value = false // 🔥 修复:未登录时立即复原状态
isCopying.value = false // 🔥 修复:未登录时立即复原状态
return
}
try {
@@ -255,7 +252,17 @@ const copyRssLink = async () => {
}
const goToProfile = async () => {
let id = authState.username || authState.id
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
return
}
let id = authState.username || authState.userId
if (!id) {
const user = await loadCurrentUser()
if (user) {
id = user.username || user.id
}
}
if (id) {
navigateTo(`/users/${id}`, { replace: true })
}
@@ -299,6 +306,14 @@ const iconClass = computed(() => {
})
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
if (user && user.avatar) {
avatar.value = user.avatar
}
}
}
const updateUnread = async () => {
if (authState.loggedIn) {
fetchUnreadCount()
@@ -308,8 +323,17 @@ onMounted(async () => {
}
}
await updateAvatar()
await updateUnread()
watch(
() => authState.loggedIn,
async (isLoggedIn) => {
await updateAvatar()
await updateUnread()
},
)
// 新增的在线人数逻辑
sendPing()
fetchCount()
@@ -458,6 +482,7 @@ onMounted(async () => {
cursor: pointer;
}
.invite_text:hover {
opacity: 0.8;
text-decoration: underline;
@@ -518,10 +543,7 @@ onMounted(async () => {
color: var(--primary-color);
cursor: pointer;
position: relative;
transition:
color 0.25s ease,
transform 0.15s ease,
opacity 0.2s ease;
transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease;
}
.header-icon-item:hover {
@@ -543,7 +565,6 @@ onMounted(async () => {
.header-label {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
/* 在线人数的数字文字样式(无背景) */
@@ -551,14 +572,15 @@ onMounted(async () => {
position: absolute;
top: -4px;
right: -6px;
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
line-height: 1;
padding: 0; /* 去掉内边距 */
padding: 0; /* 去掉内边距 */
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);

View File

@@ -3,30 +3,15 @@
<div class="login-overlay-blur"></div>
<div class="login-overlay-content">
<user-icon class="login-overlay-icon" />
<div class="login-overlay-text">{{ props.text }}</div>
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
<div class="login-overlay-text">请先登录点击跳转到登录页面</div>
<div class="login-overlay-button" @click="goLogin">登录</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
text: {
type: String,
default: '请先登录,点击跳转到登录页面',
},
buttonText: {
type: String,
default: '登录',
},
buttonLink: {
type: String,
default: '/login',
},
})
const goLogin = () => {
navigateTo(props.buttonLink, { replace: true })
navigateTo('/login', { replace: true })
}
</script>

View File

@@ -45,7 +45,6 @@ export default {
font-size: 12px;
cursor: pointer;
margin-left: 10px;
white-space: nowrap;
}
.mark-read-button:hover {
@@ -54,7 +53,6 @@ export default {
.has-read-button {
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 768px) {

View File

@@ -4,7 +4,11 @@
<span class="poll-row-title">投票选项</span>
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
<i
v-if="data.options.length > 2"
class="fa-solid fa-xmark remove-option-icon"
@click="removeOption(idx)"
></i>
</div>
<div class="add-option" @click="addOption">添加选项</div>
</div>

View File

@@ -36,19 +36,12 @@
<template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template>
</span>
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
变更了文章可见范围, {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
{{ formatVisibleScope(log.newVisibleScope) }}
</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 === 'DONATE'" class="change-log-content"
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div
@@ -73,17 +66,6 @@ const props = defineProps({
title: String,
})
const VISIBLE_SCOPE_LABELS = {
ALL: '全部可见',
ONLY_ME: '仅自己可见',
ONLY_REGISTER: '仅注册用户可见',
}
const formatVisibleScope = (scope) => {
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
return VISIBLE_SCOPE_LABELS[scope] ?? scope
}
const diffHtml = computed(() => {
// Track theme changes
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'

View File

@@ -2,30 +2,6 @@
<div class="post-poll-container" v-if="poll">
<div class="poll-top-container">
<div class="poll-options-container">
<div class="poll-title-section">
<div class="poll-title-section-row">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else-if="isProposal">
拟议分类{{ poll.proposedName }}
<ToolTip
content="🗳️ 提案提交后将开放3天投票需达到至少60%的赞成率并满10人参与方可通过。"
placement="bottom"
v-if="isProposal"
>
<info-icon class="info-icon" />
</ToolTip>
</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<div class="poll-title-section-row">
<div v-if="poll.description" class="proposal-description">{{ poll.description }}</div>
</div>
</div>
<div v-if="showPollResult || pollEnded || hasVoted">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div class="poll-option-info-container">
@@ -53,6 +29,16 @@
</div>
</div>
<div v-else>
<div class="poll-title-section">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<template v-if="poll.multiple">
<div
v-for="(opt, idx) in poll.options"
@@ -117,6 +103,11 @@
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
<div v-else class="poll-option-hint">
<div>您已投票等待结束查看结果</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
</div>
</div>
@@ -139,9 +130,6 @@ const emit = defineEmits(['refresh'])
const loggedIn = computed(() => authState.loggedIn)
const showPollResult = ref(false)
const isProposal = computed(() =>
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
)
const pollParticipants = computed(() => props.poll?.participants || [])
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
const pollVotes = computed(() => props.poll?.votes || {})
@@ -245,34 +233,6 @@ const submitMultiPoll = async () => {
padding: 10px;
}
.proposal-info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
background-color: var(--background-color);
color: var(--text-color);
}
.proposal-name {
font-weight: 600;
font-size: 16px;
}
.proposal-status {
font-size: 14px;
opacity: 0.8;
}
.proposal-description {
font-size: 16px;
margin-top: 10px;
line-height: 1.5;
white-space: pre-wrap;
opacity: 0.8;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
@@ -425,18 +385,10 @@ const submitMultiPoll = async () => {
}
.poll-title-section {
margin-bottom: 20px;
}
.poll-title-section-row {
display: flex;
align-items: center;
flex-direction: row;
gap: 30px;
}
.info-icon {
margin-right: 20px;
flex-direction: row;
margin-bottom: 20px;
}
.poll-option-title {

View File

@@ -34,7 +34,6 @@ export default {
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
]
}

View File

@@ -1,41 +0,0 @@
<template>
<Dropdown
v-model="selected"
:fetch-options="fetchTypes"
placeholder="选择帖子可见范围"
/>
</template>
<script>
import { computed, ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
export default {
name: 'PostVisibleScopeSelect',
components: { Dropdown },
props: {
modelValue: { type: String, default: 'ALL' },
// options: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const fetchTypes = async () => {
return [
{ id: 'ALL', name: '全部可见', icon: 'communication' },
{ id: 'ONLY_ME', name: '仅自己可见', icon: 'user-icon' },
{ id: 'ONLY_REGISTER', name: '仅注册用户可见', icon: 'peoples-two' },
]
}
const selected = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
return { fetchTypes, selected }
},
}
</script>
<style scoped></style>

View File

@@ -1,77 +0,0 @@
<template>
<div class="proposal-section">
<div class="proposal-row">
<span class="proposal-row-title rule">
<info-icon class="proposal-description-title-icon" />提案规则说明</span
>
<div class="proposal-description-content">
<p>📛 拟议分类名称需保持唯一请勿与现有分类或正在提案中的名称重复</p>
<p>📝 请在下方详细说明提案目的预期价值及补充材料方便大家快速理解</p>
<p>🗳 提案提交后将开放 3 天投票需达到至少 60% 的赞成率并满 10 人参与方可通过</p>
<p>🤝 讨论请遵循社区守则保持礼貌和善欢迎附上相关案例或参考链接</p>
</div>
</div>
<div class="proposal-row">
<span class="proposal-row-title">拟议分类名称</span>
<BaseInput v-model="data.proposedName" placeholder="请输入分类名称" />
</div>
<div class="proposal-row">
<span class="proposal-row-title">提案描述</span>
<BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" />
</div>
</div>
</template>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
defineProps({
data: { type: Object, required: true },
})
</script>
<style scoped>
.proposal-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.proposal-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.proposal-row-title.rule {
margin-bottom: 0;
}
.proposal-row {
display: flex;
flex-direction: column;
}
.proposal-activity {
margin-top: 20px;
padding: 20px;
}
.proposal-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.proposal-description-title-icon {
margin-right: 5px;
}
.proposal-description-content {
font-size: 12px;
opacity: 0.8;
}
</style>

View File

@@ -18,11 +18,9 @@
<div>{{ counts[r.type] }}</div>
</div>
<ToolTip content="发表心情" placement="bottom">
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
</ToolTip>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
</template>
<template v-else-if="displayedReactions.length">
<div
@@ -166,7 +164,7 @@ const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = reactionsPanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
const parentEl = panelEl.closest('.reactions-container')?.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
@@ -322,12 +320,11 @@ onBeforeUnmount(() => {
.reactions-count {
font-size: 16px;
font-weight: bold;
margin-right: 15px;
}
.reactions-panel {
position: absolute;
bottom: 35px;
bottom: 40px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
@@ -360,6 +357,7 @@ onBeforeUnmount(() => {
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;

View File

@@ -17,8 +17,7 @@
<input
class="text-input"
v-model="keyword"
placeholder="键盘点击「/」以触发搜索"
ref="searchInput"
placeholder="Search"
@input="setSearch(keyword)"
/>
</div>
@@ -49,7 +48,7 @@
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
@@ -62,48 +61,8 @@ const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const searchInput = ref(null)
const isMobile = useIsMobile()
const isEditableElement = (el) => {
if (!el) return false
if (el.isContentEditable) return true
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true
}
const role = el.getAttribute ? el.getAttribute('role') : null
return role === 'textbox'
}
const focusSearchInput = () => {
if (!searchInput.value) return
dropdown.value?.openMenu?.()
if (typeof searchInput.value.focus === 'function') {
try {
searchInput.value.focus({ preventScroll: true })
} catch (e) {
searchInput.value.focus()
}
}
}
const handleGlobalSlash = (event) => {
if (event.defaultPrevented) return
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
if (isEditableElement(document.activeElement)) return
event.preventDefault()
focusSearchInput()
}
onMounted(() => {
window.addEventListener('keydown', handleGlobalSlash)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleGlobalSlash)
})
const toggle = () => {
dropdown.value.toggle()
}
@@ -185,7 +144,8 @@ defineExpose({
<style scoped>
.search-dropdown {
width: 300px;
margin-top: 20px;
width: 500px;
}
.search-mobile-trigger {
@@ -194,7 +154,7 @@ defineExpose({
}
.search-input {
padding: 2px 10px;
padding: 10px;
display: flex;
align-items: center;
width: 100%;
@@ -242,7 +202,7 @@ defineExpose({
}
.result-body {
line-height: 1;
line-height: 1;
display: flex;
flex-direction: column;
}
@@ -256,14 +216,4 @@ defineExpose({
font-size: 12px;
color: #666;
}
.search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10000;
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">浏览了文章</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="article-container">
<NuxtLink :to="postLink" class="timeline-article-link">
{{ item.post?.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ strippedSnippet }}
</div>
<div class="article-meta" v-if="hasMeta">
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
<ArticleTags :tags="item.post?.tags" />
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
<comment-one class="article-comment-count-icon" />
<span>{{ item.post?.commentCount }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const postLink = computed(() => {
const id = props.item.post?.id
return id ? `/posts/${id}` : '#'
})
const formattedDate = computed(() =>
TimeManager.format(props.item.lastReadAt ?? props.item.createdAt),
)
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
const hasMeta = computed(() => {
const tags = props.item.post?.tags ?? []
const hasTags = Array.isArray(tags) && tags.length > 0
const hasCategory = !!props.item.post?.category
const hasCommentCount =
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
return hasTags || hasCategory || hasCommentCount
})
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
padding-top: 5px;
gap: 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
}
.article-container {
display: flex;
flex-direction: column;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
padding: 10px;
gap: 6px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--link-color);
text-decoration: none;
}
.timeline-article-link:hover {
text-decoration: underline;
}
.timeline-snippet {
color: var(--timeline-snippet-color, #666);
font-size: 14px;
line-height: 1.6;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.article-tag {
background-color: var(--article-info-background-color);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count-icon {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.timeline-article-link {
font-size: 16px;
}
.timeline-snippet {
font-size: 13px;
}
}
</style>

View File

@@ -1,5 +1,10 @@
<template>
<div class="home-page">
<div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div>
<div class="topic-container">
<div class="topic-item-container">
<div
@@ -67,13 +72,11 @@
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
<lock class="preview-close-icon" v-if="article.isRestricted" />
</NuxtLink>
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
<div v-html="sanitizeDescription(article.description)"></div>
</NuxtLink>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
@@ -113,7 +116,7 @@
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<InfiniteLoadMore
v-if="articles.length > 0"
:key="ioKey"
@@ -140,7 +143,6 @@ import { useIsMobile } from '~/utils/screen'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import TimeManager from '~/utils/time'
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
@@ -296,7 +298,6 @@ const {
comments: p.commentCount,
views: p.views,
rssExcluded: p.rssExcluded || false,
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
@@ -338,7 +339,6 @@ const fetchNextPage = async () => {
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
rssExcluded: p.rssExcluded || false,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
@@ -378,6 +378,28 @@ onBeforeUnmount(() => {
/** 供 InfiniteLoadMore 重建用的 key筛选/Tab 改变即重建内部状态 */
const ioKey = computed(() => asyncKey.value.join('::'))
// 在首页摘要加载贴吧表情包
const sanitizeDescription = (text) => {
if (!text) return ''
// 1⃣ 先把 Markdown 转成纯文本
const plain = stripMarkdown(text)
// 2⃣ 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// 3 可选截断纯文本长度防止撑太长
const truncated = withEmoji.length > 500 ? withEmoji.slice(0, 500) + '…' : withEmoji
return truncated
}
// 页面选项同步到全局状态
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
selectedCategoryGlobal.value = newCategory
@@ -542,14 +564,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.header-item.views {
width: 5%;
justify-content: flex-end;
text-align: right;
text-align: right;
}
.article-time,
.header-item.activity {
width: 10%;
justify-content: flex-end;
text-align: left;
text-align: left;
}
.article-item-title {
@@ -571,7 +593,6 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.pinned-icon,
.lottery-icon,
.featured-icon,
.proposal-icon,
.poll-icon {
margin-right: 4px;
color: var(--primary-color);

View File

@@ -40,7 +40,7 @@
<script setup>
import { toast } from '~/main'
import { setToken } from '~/utils/auth'
import { setToken, loadCurrentUser } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
import { registerPush } from '~/utils/push'
@@ -58,15 +58,13 @@ const submitLogin = async () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json().catch(() => ({}))
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
await navigateTo('/', { replace: true })
} else if (data.reason_code === 'EMAIL_SEND_FAILED') {
const msg = data.error || data.message || res.statusText || '登录失败'
toast.error(`${res.status} ${msg} (${data.reason_code})`)
} else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码')
await navigateTo(
@@ -79,12 +77,10 @@ const submitLogin = async () => {
} else if (data.reason_code === 'NOT_APPROVED') {
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
} else {
const msg = data.error || data.message || res.statusText || '登录失败'
const reason = data.reason_code ? ` (${data.reason_code})` : ''
toast.error(`${res.status} ${msg}${reason}`)
toast.error(data.error || '登录失败')
}
} catch (e) {
toast.error(`登录失败: ${e.message}`)
toast.error('登录失败')
} finally {
isWaitingForLogin.value = false
}

Some files were not shown because too many files have changed in this diff Show More