mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
37 Commits
codex/shor
...
feature/md
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccdc21568 | ||
|
|
ff63d232a9 | ||
|
|
32a624e62d | ||
|
|
5af0c9dee0 | ||
|
|
edaafdd000 | ||
|
|
24838ab714 | ||
|
|
56a80a184b | ||
|
|
ed24ed174b | ||
|
|
3080acb6e4 | ||
|
|
1856eb191b | ||
|
|
0c2a50d620 | ||
|
|
7562de11a5 | ||
|
|
aaacf4efb1 | ||
|
|
1f30cdfe85 | ||
|
|
8b37cf5abb | ||
|
|
4af19a75c9 | ||
|
|
37ea986389 | ||
|
|
fefd0b3b6c | ||
|
|
a31ed29cfa | ||
|
|
2719819ad7 | ||
|
|
27ff9a9c9b | ||
|
|
18fde1052f | ||
|
|
800970f078 | ||
|
|
cbbd1440a1 | ||
|
|
215616d771 | ||
|
|
575e90e558 | ||
|
|
e63d66806d | ||
|
|
1fc0118c5a | ||
|
|
f3512c1184 | ||
|
|
28842c90b1 | ||
|
|
d67cc326c4 | ||
|
|
27c217a630 | ||
|
|
4e3e5f147c | ||
|
|
8767aa31d6 | ||
|
|
a428f472f2 | ||
|
|
54874cea7a | ||
|
|
098d82a6a0 |
@@ -76,11 +76,13 @@ cp .env.staging.example .env
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
; 开发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=localhost:3000
|
||||
```
|
||||
|
||||
2. 依赖预发环境后台环境
|
||||
|
||||
@@ -28,6 +28,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
|
||||
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
|
||||
DISCORD_CLIENT_ID=<你的discord-client-id>
|
||||
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
|
||||
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
|
||||
|
||||
# === OPENAI ===
|
||||
OPENAI_API_KEY=<你的openai-api-key>
|
||||
|
||||
@@ -26,6 +26,7 @@ public class AuthController {
|
||||
private final GithubAuthService githubAuthService;
|
||||
private final DiscordAuthService discordAuthService;
|
||||
private final TwitterAuthService twitterAuthService;
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
@@ -360,6 +361,51 @@ public class AuthController {
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/telegram")
|
||||
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = telegramAuthService.authenticate(
|
||||
req,
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid telegram data",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<?> checkToken() {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
|
||||
@@ -62,4 +62,14 @@ public class NotificationController {
|
||||
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
|
||||
@GetMapping("/email-prefs")
|
||||
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
|
||||
return notificationService.listEmailPreferences(auth.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/email-prefs")
|
||||
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Request for Telegram login. */
|
||||
@Data
|
||||
public class TelegramLoginRequest {
|
||||
private String id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String username;
|
||||
private String photoUrl;
|
||||
private Long authDate;
|
||||
private String hash;
|
||||
private String inviteToken;
|
||||
}
|
||||
@@ -74,6 +74,12 @@ public class User {
|
||||
NotificationType.USER_ACTIVITY
|
||||
);
|
||||
|
||||
@ElementCollection(targetClass = NotificationType.class)
|
||||
@CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@Column(name = "notification_type")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Set<NotificationType> disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class);
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
@@ -14,6 +15,7 @@ import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -21,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -39,6 +44,7 @@ public class CommentService {
|
||||
private final CommentSubscriptionRepository commentSubscriptionRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final PointService pointService;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Transactional
|
||||
@@ -65,16 +71,19 @@ public class CommentService {
|
||||
log.debug("Comment {} saved for post {}", comment.getId(), postId);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(post.getAuthor().getId())) {
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(postId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, comment);
|
||||
@@ -111,21 +120,25 @@ public class CommentService {
|
||||
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(parent.getAuthor().getId())) {
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
|
||||
comment, null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||
@@ -237,15 +250,33 @@ public class CommentService {
|
||||
for (Comment c : replies) {
|
||||
deleteCommentCascade(c);
|
||||
}
|
||||
// 逻辑删除相关的积分历史记录
|
||||
pointHistoryRepository.findByComment(comment).forEach(pointHistoryRepository::delete);
|
||||
|
||||
// 逻辑删除相关的积分历史记录,并收集受影响的用户
|
||||
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
|
||||
// 收集需要重新计算积分的用户
|
||||
Set<User> usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet());
|
||||
|
||||
// 删除其他相关数据
|
||||
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
|
||||
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
||||
|
||||
// 逻辑删除评论
|
||||
commentRepository.delete(comment);
|
||||
// 删除积分历史
|
||||
pointHistoryRepository.deleteAll(pointHistories);
|
||||
|
||||
// 重新计算受影响用户的积分
|
||||
if (!usersToRecalculate.isEmpty()) {
|
||||
for (User user : usersToRecalculate) {
|
||||
int newPoints = pointService.recalculateUserPoints(user);
|
||||
user.setPoint(newPoints);
|
||||
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
|
||||
}
|
||||
userRepository.saveAll(usersToRecalculate);
|
||||
}
|
||||
|
||||
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.EnumSet;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
@@ -40,6 +41,12 @@ public class NotificationService {
|
||||
|
||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||
|
||||
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
|
||||
NotificationType.COMMENT_REPLY,
|
||||
NotificationType.LOTTERY_WIN,
|
||||
NotificationType.LOTTERY_DRAW
|
||||
);
|
||||
|
||||
private String buildPayload(String body, String url) {
|
||||
// Ensure push notifications contain a link to the related resource so
|
||||
// that verifications can assert its presence and users can navigate
|
||||
@@ -75,7 +82,8 @@ public class NotificationService {
|
||||
n = notificationRepository.save(n);
|
||||
|
||||
// Runnable asyncTask = () -> {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null
|
||||
&& !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) {
|
||||
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
|
||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||
sendCustomPush(user, "有人回复了你", url);
|
||||
@@ -187,6 +195,35 @@ public class NotificationService {
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<NotificationPreferenceDto> listEmailPreferences(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
|
||||
List<NotificationPreferenceDto> prefs = new ArrayList<>();
|
||||
for (NotificationType nt : EMAIL_TYPES) {
|
||||
NotificationPreferenceDto dto = new NotificationPreferenceDto();
|
||||
dto.setType(nt);
|
||||
dto.setEnabled(!disabled.contains(nt));
|
||||
prefs.add(dto);
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
public void updateEmailPreference(String username, NotificationType type, boolean enabled) {
|
||||
if (!EMAIL_TYPES.contains(type)) {
|
||||
return;
|
||||
}
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
|
||||
if (enabled) {
|
||||
disabled.remove(type);
|
||||
} else {
|
||||
disabled.add(type);
|
||||
}
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -219,4 +219,32 @@ public class PointService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算用户的积分总数
|
||||
* 通过累加所有积分历史记录来重新计算用户的当前积分
|
||||
*/
|
||||
public int recalculateUserPoints(User user) {
|
||||
// 获取用户所有的积分历史记录(由于@Where注解,已删除的记录会被自动过滤)
|
||||
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdDesc(user);
|
||||
|
||||
int totalPoints = 0;
|
||||
for (PointHistory history : histories) {
|
||||
totalPoints += history.getAmount();
|
||||
}
|
||||
|
||||
// 更新用户积分
|
||||
user.setPoint(totalPoints);
|
||||
userRepository.save(user);
|
||||
|
||||
return totalPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算用户的积分总数(通过用户名)
|
||||
*/
|
||||
public int recalculateUserPoints(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return recalculateUserPoints(user);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -374,14 +374,16 @@ public class PostService {
|
||||
lp.setWinners(winners);
|
||||
lotteryPostRepository.save(lp);
|
||||
for (User w : winners) {
|
||||
if (w.getEmail() != null) {
|
||||
if (w.getEmail() != null &&
|
||||
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
|
||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
||||
}
|
||||
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||
}
|
||||
if (lp.getAuthor() != null) {
|
||||
if (lp.getAuthor().getEmail() != null) {
|
||||
if (lp.getAuthor().getEmail() != null &&
|
||||
!lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) {
|
||||
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||
}
|
||||
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.dto.TelegramLoginRequest;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TelegramAuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarGenerator avatarGenerator;
|
||||
|
||||
@Value("${telegram.bot-token:}")
|
||||
private String botToken;
|
||||
|
||||
public Optional<AuthResult> authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) {
|
||||
try {
|
||||
if (botToken == null || botToken.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String dataCheckString = buildDataCheckString(req);
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
|
||||
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
|
||||
String hex = bytesToHex(hash);
|
||||
if (!hex.equalsIgnoreCase(req.getHash())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String username = req.getUsername();
|
||||
String email = (username != null ? username : req.getId()) + "@telegram.org";
|
||||
String avatar = req.getPhotoUrl();
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildDataCheckString(TelegramLoginRequest req) {
|
||||
List<String> data = new ArrayList<>();
|
||||
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
|
||||
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
|
||||
if (req.getId() != null) data.add("id=" + req.getId());
|
||||
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
|
||||
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
|
||||
if (req.getUsername() != null) data.add("username=" + req.getUsername());
|
||||
Collections.sort(data);
|
||||
return String.join("\n", data);
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
if (!user.isVerified()) {
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
int suffix = 1;
|
||||
while (userRepository.findByUsername(finalUsername).isPresent()) {
|
||||
finalUsername = baseUsername + suffix++;
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(finalUsername);
|
||||
user.setEmail(email);
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:}
|
||||
# Twitter OAuth configuration
|
||||
twitter.client-id=${TWITTER_CLIENT_ID:}
|
||||
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
|
||||
# Telegram login configuration
|
||||
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
|
||||
# OpenAI configuration
|
||||
openai.api-key=${OPENAI_API_KEY:}
|
||||
openai.model=${OPENAI_MODEL:gpt-4o}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.CommentSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -26,10 +27,11 @@ class CommentServiceTest {
|
||||
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||
|
||||
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
|
||||
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, imageUploader);
|
||||
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, pointService, imageUploader);
|
||||
|
||||
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
|
||||
|
||||
; 预发环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
; 预发环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
|
||||
|
||||
; 预发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
|
||||
@@ -58,6 +58,7 @@ const hideMenu = computed(() => {
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
'/telegram-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
--poll-option-button-background-color: rgb(218, 218, 218);
|
||||
--telegram-bg: #caedff74;
|
||||
--telegram-bg-hover: #67a2c088;
|
||||
--twitter-bg: rgb(68, 68, 68);
|
||||
--twitter-bg-hover: rgb(91, 91, 91);
|
||||
--discord-bg: #5865f258;
|
||||
--discord-bg-hover: #5865f2b1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
@@ -239,6 +245,14 @@ body {
|
||||
overflow-x: auto; /* 小屏可横向滚动 */
|
||||
}
|
||||
|
||||
.info-content-text hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--normal-border-color);
|
||||
padding: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-content-text thead th {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 512"><path fill="#5865F2" d="M105 0h302c57.928.155 104.845 47.072 105 104.996V407c-.155 57.926-47.072 104.844-104.996 104.998L105 512C47.074 511.844.156 464.926.002 407.003L0 105C.156 47.072 47.074.155 104.997 0H105z"/><g data-name="å¾å± 2"><g data-name="Discord Logos"><path fill="#fff" fill-rule="nonzero" d="M368.896 153.381a269.506 269.506 0 00-67.118-20.637 186.88 186.88 0 00-8.57 17.475 250.337 250.337 0 00-37.247-2.8c-12.447 0-24.955.946-37.25 2.776-2.511-5.927-5.427-11.804-8.592-17.454a271.73 271.73 0 00-67.133 20.681c-42.479 62.841-53.991 124.112-48.235 184.513a270.622 270.622 0 0082.308 41.312c6.637-8.959 12.582-18.497 17.63-28.423a173.808 173.808 0 01-27.772-13.253c2.328-1.688 4.605-3.427 6.805-5.117 25.726 12.083 53.836 18.385 82.277 18.385 28.442 0 56.551-6.302 82.279-18.387 2.226 1.817 4.503 3.557 6.805 5.117a175.002 175.002 0 01-27.823 13.289 197.847 197.847 0 0017.631 28.4 269.513 269.513 0 0082.363-41.305l-.007.007c6.754-70.045-11.538-130.753-48.351-184.579zM201.968 300.789c-16.04 0-29.292-14.557-29.292-32.465s12.791-32.592 29.241-32.592 29.599 14.684 29.318 32.592c-.282 17.908-12.919 32.465-29.267 32.465zm108.062 0c-16.066 0-29.267-14.557-29.267-32.465s12.791-32.592 29.267-32.592c16.475 0 29.522 14.684 29.241 32.592-.281 17.908-12.894 32.465-29.241 32.465z" data-name="Discord Logo - Large - White"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
1
frontend_nuxt/assets/icons/telegram.svg
Normal file
1
frontend_nuxt/assets/icons/telegram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><defs><clipPath id="A"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118"/></clipPath><linearGradient x1="133.903" y1="13.999" x2="133.903" y2="249.999" gradientUnits="userSpaceOnUse" spreadMethod="pad" id="B"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient><clipPath id="C"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="D"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="E"><path d="M0 265.9h266.987V0H0z"/></clipPath></defs><g transform="matrix(.271187 0 0 -.271187 -4.312678 67.796339)"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118" fill="url(#B)" clip-path="url(#A)"/><g clip-path="url(#C)"><path d="M95.778 123.374l14-38.75S111.528 81 113.403 81s29.75 29 29.75 29l31 59.875-77.875-36.5z" fill="#c8daea"/></g><g clip-path="url(#D)"><path d="M114.34 113.436l-2.688-28.562s-1.125-8.75 7.625 0 17.125 15.5 17.125 15.5" fill="#a9c6d8"/></g><g clip-path="url(#E)"><path d="M96.03 121.99l-28.795 9.383s-3.437 1.395-2.333 4.562c.228.653.687 1.208 2.062 2.167 6.382 4.447 118.104 44.604 118.104 44.604s3.155 1.062 5.02.356c.852-.323 1.396-.688 1.854-2.02.167-.485.263-1.516.25-2.542-.01-.74-.1-1.425-.166-2.5-.68-10.98-21.04-92.918-21.04-92.918s-1.218-4.795-5.583-4.958c-1.592-.06-3.524.263-5.834 2.25-8.565 7.368-38.172 27.265-44.713 31.64-.37.246-.474.567-.537.88-.092.46.4 1.034.4 1.034s51.552 45.825 52.924 50.633c.106.373-.293.557-.834.396-3.424-1.26-62.78-38.74-69.33-42.88-.383-.242-1.457-.086-1.457-.086" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Twitter icon</title>
|
||||
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.949.555-2.005.959-3.127 1.184-.897-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124-4.083-.205-7.697-2.159-10.126-5.134-.422.722-.666 1.561-.666 2.475 0 1.709.87 3.214 2.188 4.096-.807-.026-1.566-.248-2.229-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.376 4.6 3.416-1.68 1.318-3.808 2.105-6.102 2.105-.39 0-.779-.023-1.17-.069 2.189 1.394 4.768 2.209 7.548 2.209 9.051 0 14.001-7.496 14.001-13.986 0-.21 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-480 -466.815 2160 2160"><circle fill="#444" cx="600" cy="613.185" r="1080"/><path fill="#fff" d="M306.615 79.694H144.011L892.476 1150.3h162.604ZM0 0h357.328l309.814 450.883L1055.03 0h105.86L714.15 519.295 1200 1226.37H842.672L515.493 750.215 105.866 1226.37H0l468.485-544.568Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 335 B |
183
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
183
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="third-party-auth">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.name"
|
||||
class="third-party-button"
|
||||
:class="provider.name"
|
||||
@click="provider.action"
|
||||
>
|
||||
<img
|
||||
class="third-party-button-icon"
|
||||
:class="provider.name"
|
||||
:src="provider.icon"
|
||||
:alt="provider.alt"
|
||||
/>
|
||||
<div class="third-party-button-text" :class="provider.name">
|
||||
{{ provider.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import googleIcon from '~/assets/icons/google.svg'
|
||||
import githubIcon from '~/assets/icons/github.svg'
|
||||
import discordIcon from '~/assets/icons/discord.svg'
|
||||
import twitterIcon from '~/assets/icons/twitter.svg'
|
||||
import telegramIcon from '~/assets/icons/telegram.svg'
|
||||
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import { telegramAuthorize } from '~/utils/telegram'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
},
|
||||
inviteToken: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const actionText = computed(() => (props.mode === 'signup' ? '注册' : '登录'))
|
||||
|
||||
const providers = computed(() => [
|
||||
{
|
||||
name: 'google',
|
||||
icon: googleIcon,
|
||||
action: () => googleAuthorize(props.inviteToken),
|
||||
alt: 'Google Logo',
|
||||
label: `Google ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
icon: githubIcon,
|
||||
action: () => githubAuthorize(props.inviteToken),
|
||||
alt: 'GitHub Logo',
|
||||
label: `GitHub ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
icon: discordIcon,
|
||||
action: () => discordAuthorize(props.inviteToken),
|
||||
alt: 'Discord Logo',
|
||||
label: `Discord ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'twitter',
|
||||
icon: twitterIcon,
|
||||
action: () => twitterAuthorize(props.inviteToken),
|
||||
alt: 'Twitter Logo',
|
||||
label: `X ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'telegram',
|
||||
icon: telegramIcon,
|
||||
action: () => telegramAuthorize(props.inviteToken),
|
||||
alt: 'Telegram Logo',
|
||||
label: `Telegram ${actionText.value}`,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.third-party-auth {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.third-party-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.third-party-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.third-party-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.third-party-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.third-party-button-text.twitter {
|
||||
color: rgb(182, 182, 182);
|
||||
}
|
||||
|
||||
/* Provider specific classes for customization */
|
||||
.third-party-button.google {
|
||||
background-color: var(--google-bg, var(--login-background-color));
|
||||
color: var(--google-color, inherit);
|
||||
}
|
||||
.third-party-button.google:hover {
|
||||
background-color: var(--google-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.github {
|
||||
background-color: var(--github-bg, var(--login-background-color));
|
||||
color: var(--github-color, inherit);
|
||||
}
|
||||
.third-party-button.github:hover {
|
||||
background-color: var(--github-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.discord {
|
||||
background-color: var(--discord-bg, var(--login-background-color));
|
||||
color: var(--discord-color, inherit);
|
||||
}
|
||||
.third-party-button.discord:hover {
|
||||
background-color: var(--discord-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.twitter {
|
||||
background-color: var(--twitter-bg, var(--login-background-color));
|
||||
color: var(--twitter-color, inherit);
|
||||
}
|
||||
.third-party-button.twitter:hover {
|
||||
background-color: var(--twitter-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.telegram {
|
||||
background-color: var(--telegram-bg, var(--login-background-color));
|
||||
color: var(--telegram-color, inherit);
|
||||
}
|
||||
.third-party-button.telegram:hover {
|
||||
background-color: var(--telegram-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.third-party-auth {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.third-party-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ export default defineNuxtConfig({
|
||||
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '',
|
||||
},
|
||||
},
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "frontend_nuxt",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
|
||||
@@ -34,35 +34,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-login-page-content">
|
||||
<div class="login-page-button" @click="loginWithGoogle">
|
||||
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="login-page-button-text">Google 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithGithub">
|
||||
<img class="login-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="login-page-button-text">GitHub 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithDiscord">
|
||||
<img class="login-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="login-page-button-text">Discord 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithTwitter">
|
||||
<img class="login-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="login-page-button-text">Twitter 登录</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThirdPartyAuth mode="login" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||
import { registerPush } from '~/utils/push'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -105,19 +85,6 @@ const submitLogin = async () => {
|
||||
isWaitingForLogin.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loginWithGoogle = () => {
|
||||
googleAuthorize()
|
||||
}
|
||||
const loginWithGithub = () => {
|
||||
githubAuthorize()
|
||||
}
|
||||
const loginWithDiscord = () => {
|
||||
discordAuthorize()
|
||||
}
|
||||
const loginWithTwitter = () => {
|
||||
twitterAuthorize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -190,16 +157,6 @@ const loginWithTwitter = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@@ -229,29 +186,6 @@ const loginWithTwitter = () => {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.login-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -293,16 +227,5 @@ const loginWithTwitter = () => {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -334,6 +334,9 @@ onMounted(async () => {
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
await nextTick()
|
||||
// 初次进入频道时,平滑滚动到底部
|
||||
scrollToBottomSmooth()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
connect(token)
|
||||
@@ -368,11 +371,12 @@ watch(isConnected, (newValue) => {
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
// 返回页面时:刷新数据与已读,不做强制滚动,保持用户当前位置
|
||||
// 返回页面时:刷新数据与已读,并滚动到底部
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
await nextTick()
|
||||
scrollToBottomSmooth()
|
||||
updateNearBottom()
|
||||
if (!isConnected.value) {
|
||||
const token = getToken()
|
||||
|
||||
@@ -14,11 +14,25 @@
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">通知设置</div>
|
||||
<div class="message-control-item-container">
|
||||
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
|
||||
<template v-for="pref in notificationPrefs">
|
||||
<div v-if="canShowNotification(pref.type)" :key="pref.type" class="message-control-item">
|
||||
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
|
||||
<BaseSwitch
|
||||
:model-value="pref.enabled"
|
||||
@update:modelValue="(val) => togglePref(pref, val)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">邮件通知设置</div>
|
||||
<div class="message-control-item-container">
|
||||
<div v-for="pref in emailPrefs" :key="pref.type" class="message-control-item">
|
||||
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
|
||||
<BaseSwitch
|
||||
:model-value="pref.enabled"
|
||||
@update:modelValue="(val) => togglePref(pref, val)"
|
||||
@update:modelValue="(val) => toggleEmailPref(pref, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,6 +593,8 @@ import {
|
||||
hasMore,
|
||||
fetchNotificationPreferences,
|
||||
updateNotificationPreference,
|
||||
fetchEmailNotificationPreferences,
|
||||
updateEmailNotificationPreference,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
@@ -595,6 +611,7 @@ const tabs = [
|
||||
{ key: 'control', label: '消息设置' },
|
||||
]
|
||||
const notificationPrefs = ref([])
|
||||
const emailPrefs = ref([])
|
||||
const page = ref(0)
|
||||
const pageSize = 30
|
||||
|
||||
@@ -619,6 +636,10 @@ const fetchPrefs = async () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
|
||||
const fetchEmailPrefs = async () => {
|
||||
emailPrefs.value = await fetchEmailNotificationPreferences()
|
||||
}
|
||||
|
||||
const togglePref = async (pref, value) => {
|
||||
const ok = await updateNotificationPreference(pref.type, value)
|
||||
if (ok) {
|
||||
@@ -634,6 +655,15 @@ const togglePref = async (pref, value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEmailPref = async (pref, value) => {
|
||||
const ok = await updateEmailNotificationPreference(pref.type, value)
|
||||
if (ok) {
|
||||
pref.enabled = value
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
markNotificationRead(id)
|
||||
if (selectedTab.value === 'unread') {
|
||||
@@ -725,10 +755,19 @@ const formatType = (t) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const needAdminSet = new Set(['POST_REVIEW_REQUEST','REGISTER_REQUEST', 'POINT_REDEEM', 'ACTIVITY_REDEEM'])
|
||||
|
||||
const canShowNotification = (type) => {
|
||||
return !needAdminSet.has(type) || isAdmin.value
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
page.value = 0
|
||||
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
|
||||
fetchPrefs()
|
||||
fetchEmailPrefs()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -68,35 +68,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-signup-page-content">
|
||||
<div class="signup-page-button" @click="signupWithGoogle">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="signup-page-button-text">Google 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithGithub">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="signup-page-button-text">GitHub 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithDiscord">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="signup-page-button-text">Discord 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithTwitter">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="signup-page-button-text">Twitter 注册</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThirdPartyAuth mode="signup" :invite-token="inviteToken" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { toast } from '~/main'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import { loadCurrentUser, setToken } from '~/utils/auth'
|
||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
@@ -216,18 +196,6 @@ const verifyCode = async () => {
|
||||
isWaitingForEmailVerified.value = false
|
||||
}
|
||||
}
|
||||
const signupWithGoogle = () => {
|
||||
googleAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithGithub = () => {
|
||||
githubAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithDiscord = () => {
|
||||
discordAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithTwitter = () => {
|
||||
twitterAuthorize(inviteToken.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -300,16 +268,6 @@ const signupWithTwitter = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-signup-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@@ -339,29 +297,6 @@ const signupWithTwitter = () => {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -411,16 +346,5 @@ const signupWithTwitter = () => {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-signup-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
42
frontend_nuxt/pages/telegram-callback.vue
Normal file
42
frontend_nuxt/pages/telegram-callback.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CallbackPage from '~/components/CallbackPage.vue'
|
||||
import { telegramExchange } from '~/utils/telegram'
|
||||
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const inviteToken =
|
||||
url.searchParams.get('invite_token') || url.searchParams.get('invitetoken') || ''
|
||||
const hash = url.hash.startsWith('#tgAuthResult=') ? url.hash.slice('#tgAuthResult='.length) : ''
|
||||
if (!hash) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
let authData
|
||||
try {
|
||||
const decoded = atob(hash)
|
||||
const parsed = JSON.parse(decoded)
|
||||
authData = {
|
||||
id: String(parsed.id),
|
||||
firstName: parsed.first_name,
|
||||
lastName: parsed.last_name,
|
||||
username: parsed.username,
|
||||
photoUrl: parsed.photo_url,
|
||||
authDate: parsed.auth_date,
|
||||
hash: parsed.hash,
|
||||
}
|
||||
} catch (e) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
const result = await telegramExchange(authData, inviteToken, '')
|
||||
if (result.needReason) {
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
} else {
|
||||
navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -58,7 +58,9 @@
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后发帖时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||
<div class="profile-info-item-value">
|
||||
{{ user.lastPostTime != null ? formatDate(user.lastPostTime) : '暂无帖子' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后评论时间:</div>
|
||||
|
||||
@@ -116,6 +116,43 @@ export async function updateNotificationPreference(type, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEmailNotificationPreferences() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const token = getToken()
|
||||
if (!token) return []
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return await res.json()
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEmailNotificationPreference(type, enabled) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ type, enabled }),
|
||||
})
|
||||
return res.ok
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理信息的高阶函数
|
||||
* @returns
|
||||
|
||||
56
frontend_nuxt/utils/telegram.js
Normal file
56
frontend_nuxt/utils/telegram.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function telegramAuthorize(inviteToken = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const TELEGRAM_BOT_ID = config.public.telegramBotId
|
||||
if (!TELEGRAM_BOT_ID) {
|
||||
toast.error('Telegram 登录不可用')
|
||||
return
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/telegram-callback${inviteToken ? `?invite_token=${encodeURIComponent(inviteToken)}` : ''}`
|
||||
const url =
|
||||
`https://oauth.telegram.org/auth` +
|
||||
`?bot_id=${encodeURIComponent(TELEGRAM_BOT_ID)}` +
|
||||
`&origin=${encodeURIComponent(redirectUri)}` +
|
||||
`&request_access=write`
|
||||
// `&redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function telegramExchange(authData, inviteToken = '', reason = '') {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const payload = { ...authData, reason }
|
||||
if (inviteToken) payload.inviteToken = inviteToken
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/telegram`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
return { success: true, needReason: false }
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user