mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 07:30:54 +08:00
Compare commits
1 Commits
codex/impl
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ae877b3b |
@@ -3,12 +3,6 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
|
||||
# === JWT ===
|
||||
JWT_SECRET=<jwt secret>
|
||||
JWT_REASON_SECRET=<jwt reason secret>
|
||||
JWT_RESET_SECRET=<jwt reset secret>
|
||||
JWT_INVITE_SECRET=<jwt invite secret>
|
||||
JWT_EXPIRATION=2592000000
|
||||
|
||||
# === Resend ===
|
||||
RESEND_API_KEY=<你的resend-api-key>
|
||||
@@ -36,4 +30,4 @@ OPENAI_API_KEY=<你的openai-api-key>
|
||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||
|
||||
# LOG_LEVEL=DEBUG
|
||||
# LOG_LEVEL=DEBUG
|
||||
@@ -6,8 +6,6 @@ import com.openisle.repository.ActivityRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -24,16 +22,5 @@ public class ActivityInitializer implements CommandLineRunner {
|
||||
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
||||
activityRepository.save(a);
|
||||
}
|
||||
|
||||
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
|
||||
Activity a = new Activity();
|
||||
a.setTitle("🎁邀请码送积分活动");
|
||||
a.setType(ActivityType.INVITE_POINTS);
|
||||
a.setIcon("https://img.icons8.com/color/96/gift.png");
|
||||
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
|
||||
a.setStartTime(LocalDateTime.now());
|
||||
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
|
||||
activityRepository.save(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Initialize default point mall goods. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PointGoodInitializer implements CommandLineRunner {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (pointGoodRepository.count() == 0) {
|
||||
PointGood g1 = new PointGood();
|
||||
g1.setName("GPT Plus 1 个月");
|
||||
g1.setCost(20000);
|
||||
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
|
||||
pointGoodRepository.save(g1);
|
||||
|
||||
PointGood g2 = new PointGood();
|
||||
g2.setName("奶茶");
|
||||
g2.setCost(5000);
|
||||
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
|
||||
pointGoodRepository.save(g2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,6 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||
@@ -153,7 +151,6 @@ public class SecurityConfig {
|
||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/point-goods") ||
|
||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
|
||||
@@ -29,7 +29,6 @@ public class AuthController {
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
private final InviteService inviteService;
|
||||
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
@@ -46,25 +45,6 @@ public class AuthController {
|
||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
|
||||
if (!inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
try {
|
||||
User user = userService.registerWithInvite(
|
||||
req.getUsername(), req.getEmail(), req.getPassword());
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(user.getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
} catch (FieldException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"field", e.getField(),
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
User user = userService.register(
|
||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
@@ -126,42 +106,27 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||
req.getIdToken(),
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid google token",
|
||||
@@ -200,44 +165,28 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/github")
|
||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||
req.getCode(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
// 已填写注册理由
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid github code",
|
||||
@@ -247,43 +196,27 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/discord")
|
||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||
req.getCode(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid discord code",
|
||||
@@ -293,44 +226,31 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/twitter")
|
||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||
Optional<User> user = twitterAuthService.authenticate(
|
||||
req.getCode(),
|
||||
req.getCodeVerifier(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid twitter code",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.service.InviteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/invite")
|
||||
@RequiredArgsConstructor
|
||||
public class InviteController {
|
||||
private final InviteService inviteService;
|
||||
|
||||
@PostMapping("/generate")
|
||||
public Map<String, String> generate(Authentication auth) {
|
||||
String token = inviteService.generate(auth.getName());
|
||||
return Map.of("token", token);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.dto.PointRedeemRequest;
|
||||
import com.openisle.mapper.PointGoodMapper;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.service.PointMallService;
|
||||
import com.openisle.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** REST controller for point mall. */
|
||||
@RestController
|
||||
@RequestMapping("/api/point-goods")
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallController {
|
||||
private final PointMallService pointMallService;
|
||||
private final UserService userService;
|
||||
private final PointGoodMapper pointGoodMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<PointGoodDto> list() {
|
||||
return pointMallService.listGoods().stream()
|
||||
.map(pointGoodMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@PostMapping("/redeem")
|
||||
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
|
||||
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
|
||||
return Map.of("point", point);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,4 @@ import lombok.Data;
|
||||
public class DiscordLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ import lombok.Data;
|
||||
public class GithubLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,4 @@ import lombok.Data;
|
||||
@Data
|
||||
public class GoogleLoginRequest {
|
||||
private String idToken;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Point mall good info. */
|
||||
@Data
|
||||
public class PointGoodDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private int cost;
|
||||
private String image;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Request to redeem a point mall good. */
|
||||
@Data
|
||||
public class PointRedeemRequest {
|
||||
private Long goodId;
|
||||
private String contact;
|
||||
}
|
||||
@@ -9,5 +9,4 @@ public class RegisterRequest {
|
||||
private String email;
|
||||
private String password;
|
||||
private String captcha;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -8,5 +8,4 @@ public class TwitterLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String codeVerifier;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.openisle.mapper;
|
||||
|
||||
import com.openisle.dto.PointGoodDto;
|
||||
import com.openisle.model.PointGood;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Mapper for point mall goods. */
|
||||
@Component
|
||||
public class PointGoodMapper {
|
||||
public PointGoodDto toDto(PointGood good) {
|
||||
PointGoodDto dto = new PointGoodDto();
|
||||
dto.setId(good.getId());
|
||||
dto.setName(good.getName());
|
||||
dto.setCost(good.getCost());
|
||||
dto.setImage(good.getImage());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,5 @@ package com.openisle.model;
|
||||
/** Activity type enumeration. */
|
||||
public enum ActivityType {
|
||||
NORMAL,
|
||||
MILK_TEA,
|
||||
INVITE_POINTS
|
||||
MILK_TEA
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Invite token entity tracking usage counts.
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
public class InviteToken {
|
||||
@Id
|
||||
private String token;
|
||||
|
||||
@ManyToOne
|
||||
private User inviter;
|
||||
|
||||
private LocalDate createdDate;
|
||||
|
||||
private int usageCount;
|
||||
}
|
||||
@@ -32,8 +32,6 @@ public enum NotificationType {
|
||||
REGISTER_REQUEST,
|
||||
/** A user redeemed an activity reward */
|
||||
ACTIVITY_REDEEM,
|
||||
/** A user redeemed a point good */
|
||||
POINT_REDEEM,
|
||||
/** You won a lottery post */
|
||||
LOTTERY_WIN,
|
||||
/** Your lottery post was drawn */
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Item available in the point mall. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "point_goods")
|
||||
public class PointGood {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int cost;
|
||||
|
||||
private String image;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.InviteToken;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
||||
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PointGood;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Repository for point mall goods. */
|
||||
public interface PointGoodRepository extends JpaRepository<PointGood, Long> {
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.User;
|
||||
import lombok.Value;
|
||||
|
||||
/** Result for OAuth authentication indicating whether a new user was created. */
|
||||
@Value
|
||||
public class AuthResult {
|
||||
User user;
|
||||
boolean newUser;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class DiscordAuthService {
|
||||
@Value("${discord.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -67,13 +67,13 @@ public class DiscordAuthService {
|
||||
if (email == null) {
|
||||
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
||||
}
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
return Optional.of(processUser(email, username, avatar, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -82,7 +82,7 @@ public class DiscordAuthService {
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
@@ -96,12 +96,12 @@ public class DiscordAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class GithubAuthService {
|
||||
@Value("${github.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://github.com/login/oauth/access_token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -86,13 +86,13 @@ public class GithubAuthService {
|
||||
if (email == null) {
|
||||
email = username + "@users.noreply.github.com";
|
||||
}
|
||||
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
|
||||
return Optional.of(processUser(email, username, avatarUrl, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -101,7 +101,7 @@ public class GithubAuthService {
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
@@ -115,12 +115,12 @@ public class GithubAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public class GoogleAuthService {
|
||||
@Value("${google.client-id:}")
|
||||
private String clientId;
|
||||
|
||||
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
||||
.setAudience(Collections.singletonList(clientId))
|
||||
.build();
|
||||
@@ -38,13 +38,13 @@ public class GoogleAuthService {
|
||||
String email = payload.getEmail();
|
||||
String name = (String) payload.get("name");
|
||||
String picture = (String) payload.get("picture");
|
||||
return Optional.of(processUser(email, name, picture, mode, viaInvite));
|
||||
return Optional.of(processUser(email, name, picture, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -53,7 +53,8 @@ public class GoogleAuthService {
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
|
||||
return user;
|
||||
}
|
||||
User user = new User();
|
||||
String baseUsername = email.split("@")[0];
|
||||
@@ -67,12 +68,12 @@ public class GoogleAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(username));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.InviteToken;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.InviteTokenRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InviteService {
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PointService pointService;
|
||||
|
||||
public String generate(String username) {
|
||||
User inviter = userRepository.findByUsername(username).orElseThrow();
|
||||
LocalDate today = LocalDate.now();
|
||||
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get().getToken();
|
||||
}
|
||||
String token = jwtService.generateInviteToken(username);
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
inviteToken.setToken(token);
|
||||
inviteToken.setInviter(inviter);
|
||||
inviteToken.setCreatedDate(today);
|
||||
inviteToken.setUsageCount(0);
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public boolean validate(String token) {
|
||||
try {
|
||||
jwtService.validateAndGetSubjectForInvite(token);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||
return invite != null && invite.getUsageCount() < 3;
|
||||
}
|
||||
|
||||
public void consume(String token) {
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
|
||||
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||
inviteTokenRepository.save(invite);
|
||||
pointService.awardForInvite(invite.getInviter().getUsername());
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,6 @@ public class JwtService {
|
||||
@Value("${app.jwt.reset-secret}")
|
||||
private String resetSecret;
|
||||
|
||||
@Value("${app.jwt.invite-secret}")
|
||||
private String inviteSecret;
|
||||
|
||||
@Value("${app.jwt.expiration}")
|
||||
private long expiration;
|
||||
|
||||
@@ -73,17 +70,6 @@ public class JwtService {
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateInviteToken(String subject) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
return Jwts.builder()
|
||||
.setSubject(subject)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSigningKeyForSecret(inviteSecret))
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String validateAndGetSubject(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(secret))
|
||||
@@ -110,13 +96,4 @@ public class JwtService {
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
public String validateAndGetSubjectForInvite(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(inviteSecret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,19 +141,6 @@ public class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for all admins when a user redeems a point good.
|
||||
* Old redeem notifications from the same user are removed first.
|
||||
*/
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public void createPointRedeemNotifications(User user, String content) {
|
||||
// notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
|
||||
for (User admin : userRepository.findByRole(Role.ADMIN)) {
|
||||
createNotification(admin, NotificationType.POINT_REDEEM, null, null,
|
||||
null, user, null, content);
|
||||
}
|
||||
}
|
||||
|
||||
public List<NotificationPreferenceDto> listPreferences(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Service for point mall operations. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointMallService {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public List<PointGood> listGoods() {
|
||||
return pointGoodRepository.findAll();
|
||||
}
|
||||
|
||||
public int redeem(User user, Long goodId, String contact) {
|
||||
PointGood good = pointGoodRepository.findById(goodId)
|
||||
.orElseThrow(() -> new NotFoundException("Good not found"));
|
||||
if (user.getPoint() < good.getCost()) {
|
||||
throw new FieldException("point", "Insufficient points");
|
||||
}
|
||||
user.setPoint(user.getPoint() - good.getCost());
|
||||
userRepository.save(user);
|
||||
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
|
||||
return user.getPoint();
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,6 @@ public class PointService {
|
||||
return addPoint(user, 30);
|
||||
}
|
||||
|
||||
public int awardForInvite(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return addPoint(user, 500);
|
||||
}
|
||||
|
||||
private PointLog getTodayLog(User user) {
|
||||
LocalDate today = LocalDate.now();
|
||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||
|
||||
@@ -33,12 +33,11 @@ public class TwitterAuthService {
|
||||
@Value("${twitter.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<AuthResult> authenticate(
|
||||
public Optional<User> authenticate(
|
||||
String code,
|
||||
String codeVerifier,
|
||||
RegisterMode mode,
|
||||
String redirectUri,
|
||||
boolean viaInvite) {
|
||||
String redirectUri) {
|
||||
|
||||
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
||||
|
||||
@@ -107,10 +106,10 @@ public class TwitterAuthService {
|
||||
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
||||
String email = username + "@twitter.com";
|
||||
logger.debug("Processing user {} with email {}", username, email);
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
return Optional.of(processUser(email, username, avatar, mode));
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -120,7 +119,7 @@ public class TwitterAuthService {
|
||||
userRepository.save(user);
|
||||
}
|
||||
logger.debug("Existing user {} authenticated", user.getUsername());
|
||||
return new AuthResult(user, false);
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
@@ -134,13 +133,13 @@ public class TwitterAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
||||
}
|
||||
logger.debug("Creating new user {}", finalUsername);
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,6 @@ public class UserService {
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public User registerWithInvite(String username, String email, String password) {
|
||||
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
private String genCode() {
|
||||
return String.format("%06d", new Random().nextInt(1000000));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ spring.jpa.hibernate.ddl-auto=update
|
||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
||||
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
|
||||
# 30 days
|
||||
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
||||
# Password strength: LOW, MEDIUM or HIGH
|
||||
|
||||
@@ -144,30 +144,6 @@ class NotificationServiceTest {
|
||||
verify(nRepo).save(any(Notification.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPointRedeemNotificationsDeletesOldOnes() {
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
UserRepository uRepo = mock(UserRepository.class);
|
||||
ReactionRepository rRepo = mock(ReactionRepository.class);
|
||||
EmailSender email = mock(EmailSender.class);
|
||||
PushNotificationService push = mock(PushNotificationService.class);
|
||||
Executor executor = Runnable::run;
|
||||
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
|
||||
|
||||
User admin = new User();
|
||||
admin.setId(10L);
|
||||
User user = new User();
|
||||
user.setId(20L);
|
||||
|
||||
when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin));
|
||||
|
||||
service.createPointRedeemNotifications(user, "contact");
|
||||
|
||||
verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
|
||||
verify(nRepo).save(any(Notification.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createNotificationSendsEmailForCommentReply() {
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<div class="invite-code-activity">
|
||||
<div class="invite-code-description">
|
||||
<div class="invite-code-description-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span class="invite-code-description-title-text">邀请规则说明</span>
|
||||
</div>
|
||||
<div class="invite-code-description-content">
|
||||
<p>邀请好友注册并登录,每次可以获得500积分</p>
|
||||
<p>邀请链接的有效期为1个月</p>
|
||||
<p>每一个邀请链接的邀请人数上限为3人</p>
|
||||
<p>通过邀请链接注册,无需注册审核</p>
|
||||
<p>每人每天仅能生产3个邀请链接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="inviteLink" class="invite-code-link-content">
|
||||
<p>
|
||||
邀请链接:{{ inviteLink }}
|
||||
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
|
||||
生成邀请链接
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
|
||||
const user = ref(null)
|
||||
const isLoadingUser = ref(true)
|
||||
const inviteCode = ref('')
|
||||
const loadingInvite = ref(false)
|
||||
|
||||
const inviteLink = computed(() =>
|
||||
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingUser.value = true
|
||||
user.value = await fetchCurrentUser()
|
||||
isLoadingUser.value = false
|
||||
if (user.value) {
|
||||
await fetchInvite(false)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchInvite = async (showToast = true) => {
|
||||
loadingInvite.value = true
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
loadingInvite.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
inviteCode.value = data.token
|
||||
if (showToast) toast.success('邀请链接已生成')
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
toast.error(data.error || '生成邀请链接失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('生成邀请链接失败')
|
||||
} finally {
|
||||
loadingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateInvite = () => fetchInvite(true)
|
||||
|
||||
const copyLink = async () => {
|
||||
if (!inviteLink.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink.value)
|
||||
toast.success('已复制')
|
||||
} catch (e) {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-code-description-title-text {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.invite-code-description-content {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.invite-code-activity {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
margin-top: 20px;
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.generate-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.generate-button.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.generate-button.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.invite-code-status-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.invite-code-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-level-text {
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.invite-code-link-content {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.invite-code-status-container {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -56,19 +56,6 @@
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="authState.loggedIn"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/points"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-coins"></i>
|
||||
<span class="menu-item-text">
|
||||
积分商城
|
||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
@@ -143,7 +130,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||
@@ -160,7 +147,6 @@ const emit = defineEmits(['item-click'])
|
||||
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const myPoint = ref(null)
|
||||
|
||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||
const {
|
||||
@@ -205,15 +191,6 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const loadPoint = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
myPoint.value = user ? user.point : null
|
||||
} else {
|
||||
myPoint.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const updateCount = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
@@ -223,15 +200,9 @@ const updateCount = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([updateCount(), loadPoint()])
|
||||
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
() => {
|
||||
updateCount()
|
||||
loadPoint()
|
||||
},
|
||||
)
|
||||
await updateCount()
|
||||
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
|
||||
watch(() => authState.loggedIn, updateCount)
|
||||
})
|
||||
|
||||
const handleItemClick = () => {
|
||||
@@ -321,12 +292,6 @@ const gotoTag = (t) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.point-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -40,22 +40,30 @@
|
||||
兑换
|
||||
</div>
|
||||
<div v-else class="redeem-button disabled">兑换</div>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeDialog"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea=""
|
||||
rows="5"
|
||||
v-model="contact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import ProgressBar from '~/components/ProgressBar.vue'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -177,6 +185,56 @@ const submitRedeem = async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.user-level-text {
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
@@ -189,5 +247,9 @@ const submitRedeem = async () => {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="onClose">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea
|
||||
rows="5"
|
||||
v-model="innerContact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submit" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="onClose">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'close'])
|
||||
|
||||
const innerContact = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
innerContact.value = v
|
||||
},
|
||||
)
|
||||
watch(innerContact, (v) => emit('update:modelValue', v))
|
||||
|
||||
const submit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
const onClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.redeem-dialog-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.redeem-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redeem-submit-button {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.redeem-submit-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.redeem-submit-button:disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.redeem-cancel-button {
|
||||
color: var(--primary-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.redeem-cancel-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -25,7 +25,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,7 +32,6 @@
|
||||
<script setup>
|
||||
import TimeManager from '~/utils/time'
|
||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||
import InviteCodeActivityComponent from '~/components/InviteCodeActivityComponent.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -77,7 +75,6 @@ onMounted(async () => {
|
||||
background-color: var(--activity-card-background-color);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.activity-card-left-avatar-img {
|
||||
@@ -144,10 +141,6 @@ onMounted(async () => {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.activity-card-normal-right {
|
||||
width: calc(100% - 150px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.activity-card-left-avatar-img {
|
||||
width: 80px;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- pages/discord-callback.vue -->
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
@@ -9,30 +8,9 @@ import { discordExchange } from '~/utils/discord'
|
||||
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code') || ''
|
||||
const stateStr = url.searchParams.get('state') || ''
|
||||
|
||||
// 从 state 解析 invite_token;兜底支持 query ?invite_token=
|
||||
let inviteToken = ''
|
||||
if (stateStr) {
|
||||
try {
|
||||
const s = new URLSearchParams(stateStr)
|
||||
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
|
||||
} catch {}
|
||||
}
|
||||
// if (!inviteToken) {
|
||||
// inviteToken =
|
||||
// url.searchParams.get('invite_token') ||
|
||||
// url.searchParams.get('invitetoken') ||
|
||||
// ''
|
||||
// }
|
||||
|
||||
if (!code) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await discordExchange(code, inviteToken, '')
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await discordExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- pages/github-callback.vue -->
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
@@ -9,31 +8,9 @@ import { githubExchange } from '~/utils/github'
|
||||
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code') || ''
|
||||
const state = url.searchParams.get('state') || ''
|
||||
|
||||
// 从 state 中解析 invite_token(githubAuthorize 已把它放进 state)
|
||||
let inviteToken = ''
|
||||
if (state) {
|
||||
try {
|
||||
const s = new URLSearchParams(state)
|
||||
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
|
||||
} catch {}
|
||||
}
|
||||
// 兜底:也支持直接跟在回调URL的查询参数上
|
||||
// if (!inviteToken) {
|
||||
// inviteToken =
|
||||
// url.searchParams.get('invite_token') ||
|
||||
// url.searchParams.get('invitetoken') ||
|
||||
// ''
|
||||
// }
|
||||
|
||||
if (!code) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await githubExchange(code, inviteToken, '')
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await githubExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
|
||||
@@ -9,21 +9,7 @@ import { googleAuthWithToken } from '~/utils/google'
|
||||
onMounted(async () => {
|
||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||
const idToken = hash.get('id_token')
|
||||
|
||||
// 优先从 state 中解析
|
||||
let inviteToken = ''
|
||||
const stateStr = hash.get('state') || ''
|
||||
if (stateStr) {
|
||||
const state = new URLSearchParams(stateStr)
|
||||
inviteToken = state.get('invite_token') || ''
|
||||
}
|
||||
|
||||
// 兜底:如果之前把 invite_token 放在回调 URL 的查询参数中
|
||||
// if (!inviteToken) {
|
||||
// const query = new URLSearchParams(window.location.search)
|
||||
// inviteToken = query.get('invite_token') || ''
|
||||
// }
|
||||
|
||||
const state = hash.get('state') || ''
|
||||
if (idToken) {
|
||||
await googleAuthWithToken(
|
||||
idToken,
|
||||
@@ -33,7 +19,7 @@ onMounted(async () => {
|
||||
(token) => {
|
||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||
},
|
||||
{ inviteToken },
|
||||
state,
|
||||
)
|
||||
} else {
|
||||
navigateTo('/login', { replace: true })
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<div class="other-login-page-content">
|
||||
<div class="login-page-button" @click="googleAuthorize">
|
||||
<div class="login-page-button" @click="googleAuthorize()">
|
||||
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="login-page-button-text">Google 登录</div>
|
||||
</div>
|
||||
|
||||
@@ -130,12 +130,6 @@
|
||||
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POINT_REDEEM' && !item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span>
|
||||
申请积分兑换,联系方式是:{{ item.content }}
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||
@@ -616,8 +610,6 @@ const formatType = (t) => {
|
||||
return '有人申请注册'
|
||||
case 'ACTIVITY_REDEEM':
|
||||
return '有人申请兑换奶茶'
|
||||
case 'POINT_REDEEM':
|
||||
return '有人申请积分兑换'
|
||||
case 'LOTTERY_WIN':
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<template>
|
||||
<div class="point-mall-page">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span class="point-value">{{
|
||||
point
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const point = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const pointRules = [
|
||||
'发帖:每天前两次,每次 30 积分',
|
||||
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
||||
'帖子被点赞:每次 10 积分',
|
||||
'评论被点赞:每次 10 积分',
|
||||
]
|
||||
|
||||
const goods = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const selectedGood = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
point.value = user ? user.point : null
|
||||
}
|
||||
await loadGoods()
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
const loadGoods = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
|
||||
if (res.ok) {
|
||||
goods.value = await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
const openRedeem = (good) => {
|
||||
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
|
||||
toast.error('积分不足')
|
||||
return
|
||||
}
|
||||
selectedGood.value = good
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const closeRedeem = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const submitRedeem = async () => {
|
||||
if (!selectedGood.value || !contact.value) return
|
||||
loading.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-goods/redeem`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ goodId: selectedGood.value.id, contact: contact.value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
point.value = data.point
|
||||
toast.success('兑换成功!')
|
||||
dialogVisible.value = false
|
||||
contact.value = ''
|
||||
} else {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.point-mall-page {
|
||||
padding-left: 20px;
|
||||
max-width: var(--page-max-width);
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-points-container {
|
||||
margin-top: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.point-info {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.point-value {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.coin-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.rules,
|
||||
.goods {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.goods {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-item-name {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.goods-item-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.goods-item-cost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.goods-item-button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
width: calc(100% - 40px);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.goods-item-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.goods-item-button.disabled,
|
||||
.goods-item-button.disabled:hover {
|
||||
background-color: var(--primary-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -28,7 +28,6 @@ const reason = ref('')
|
||||
const error = ref('')
|
||||
const isWaitingForRegister = ref(false)
|
||||
const token = ref('')
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
token.value = route.query.token || ''
|
||||
@@ -51,8 +50,8 @@ const submit = async () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token.value,
|
||||
reason: reason.value,
|
||||
token: this.token,
|
||||
reason: this.reason,
|
||||
}),
|
||||
})
|
||||
isWaitingForRegister.value = false
|
||||
|
||||
@@ -96,10 +96,9 @@ import { discordAuthorize } from '~/utils/discord'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const route = useRoute()
|
||||
const emailStep = ref(0)
|
||||
const email = ref('')
|
||||
const username = ref('')
|
||||
@@ -160,6 +159,7 @@ const sendVerification = async () => {
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
inviteToken: inviteToken.value,
|
||||
}),
|
||||
})
|
||||
isWaitingForEmailSent.value = false
|
||||
@@ -188,6 +188,7 @@ const verifyCode = async () => {
|
||||
body: JSON.stringify({
|
||||
code: code.value,
|
||||
username: username.value,
|
||||
inviteToken: inviteToken.value,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function discordAuthorize(inviteToken = '') {
|
||||
export function discordAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||
@@ -10,60 +10,62 @@ export function discordAuthorize(inviteToken = '') {
|
||||
toast.error('Discord 登录不可用')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||
// 用 state 明文携带 invite_token(仅用于回传,不再透传给后端)
|
||||
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||
|
||||
const url =
|
||||
`https://discord.com/api/oauth2/authorize` +
|
||||
`?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${encodeURIComponent('identify email')}` +
|
||||
`&state=${encodeURIComponent(state)}`
|
||||
|
||||
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function discordExchange(code, inviteToken = '', reason = '') {
|
||||
export async function discordExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const payload = {
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/discord-callback`,
|
||||
reason,
|
||||
}
|
||||
if (inviteToken) payload.inviteToken = inviteToken // 明文传给后端
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/discord-callback`,
|
||||
reason,
|
||||
state,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
return { success: true, needReason: false }
|
||||
registerPush()
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
return {
|
||||
success: false,
|
||||
needReason: true,
|
||||
token: data.token,
|
||||
}
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: data.error || '登录失败',
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: '登录失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function githubAuthorize(inviteToken = '') {
|
||||
export function githubAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||
@@ -10,58 +10,62 @@ export function githubAuthorize(inviteToken = '') {
|
||||
toast.error('GitHub 登录不可用')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||
|
||||
const url =
|
||||
`https://github.com/login/oauth/authorize` +
|
||||
`?client_id=${encodeURIComponent(GITHUB_CLIENT_ID)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent('user:email')}` +
|
||||
`&state=${encodeURIComponent(state)}`
|
||||
|
||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function githubExchange(code, inviteToken = '', reason = '') {
|
||||
export async function githubExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const payload = {
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/github-callback`,
|
||||
reason,
|
||||
}
|
||||
if (inviteToken) payload.inviteToken = inviteToken
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/github-callback`,
|
||||
reason,
|
||||
state,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
return { success: true, needReason: false }
|
||||
registerPush()
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
return {
|
||||
success: false,
|
||||
needReason: true,
|
||||
token: data.token,
|
||||
}
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: data.error || '登录失败',
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: '登录失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,31 +21,19 @@ export async function googleGetIdToken() {
|
||||
})
|
||||
}
|
||||
|
||||
export function googleAuthorize(inviteToken = '') {
|
||||
export function googleAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||
const nonce = Math.random().toString(36).slice(2)
|
||||
|
||||
// 明文放在 state(推荐;Google 会原样回传)
|
||||
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||
|
||||
const nonce = Math.random().toString(36).substring(2)
|
||||
const url =
|
||||
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||
`?client_id=${encodeURIComponent(GOOGLE_CLIENT_ID)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=id_token` +
|
||||
`&scope=${encodeURIComponent('openid email profile')}` +
|
||||
`&nonce=${encodeURIComponent(nonce)}` +
|
||||
`&state=${encodeURIComponent(state)}`
|
||||
|
||||
`https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}&state=${state}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
@@ -53,53 +41,31 @@ export async function googleAuthWithToken(
|
||||
idToken,
|
||||
redirect_success,
|
||||
redirect_not_approved,
|
||||
options = {}, // { inviteToken?: string }
|
||||
state = '',
|
||||
) {
|
||||
try {
|
||||
if (!idToken) {
|
||||
toast.error('缺少 id_token')
|
||||
return
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const payload = { idToken }
|
||||
if (options && options.inviteToken) {
|
||||
payload.inviteToken = options.inviteToken
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idToken, state }),
|
||||
})
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.ok && data && data.token) {
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
if (typeof redirect_success === 'function') redirect_success()
|
||||
return
|
||||
}
|
||||
|
||||
if (data && data.reason_code === 'NOT_APPROVED') {
|
||||
registerPush()
|
||||
if (redirect_success) redirect_success()
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
if (typeof redirect_not_approved === 'function') redirect_not_approved(data.token)
|
||||
return
|
||||
}
|
||||
|
||||
if (data && data.reason_code === 'IS_APPROVING') {
|
||||
if (redirect_not_approved) redirect_not_approved(data.token)
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
if (typeof redirect_success === 'function') redirect_success()
|
||||
return
|
||||
if (redirect_success) redirect_success()
|
||||
}
|
||||
toast.error(data?.message || '登录失败')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ const iconMap = {
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
POINT_REDEEM: 'fas fa-gift',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
|
||||
@@ -20,8 +20,7 @@ async function generateCodeChallenge(codeVerifier) {
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
// 邀请码明文放入 state;同时生成 csrf 放入 state 并在回调校验
|
||||
export async function twitterAuthorize(inviteToken = '') {
|
||||
export async function twitterAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
||||
@@ -29,30 +28,17 @@ export async function twitterAuthorize(inviteToken = '') {
|
||||
toast.error('Twitter 登录不可用')
|
||||
return
|
||||
}
|
||||
|
||||
if (state === '') {
|
||||
state = Math.random().toString(36).substring(2, 15)
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
||||
|
||||
// PKCE
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
|
||||
// CSRF + 邀请码一起放入 state
|
||||
const csrf = Math.random().toString(36).slice(2)
|
||||
sessionStorage.setItem('twitter_csrf_state', csrf)
|
||||
const state = new URLSearchParams({
|
||||
csrf,
|
||||
invite_token: inviteToken || '',
|
||||
}).toString()
|
||||
|
||||
const url =
|
||||
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(TWITTER_CLIENT_ID)}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent('tweet.read users.read')}` +
|
||||
`&state=${encodeURIComponent(state)}` +
|
||||
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
|
||||
`&code_challenge_method=S256`
|
||||
|
||||
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
|
||||
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
@@ -60,29 +46,8 @@ export async function twitterExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
// 取出并清理 PKCE/CSRF
|
||||
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||
sessionStorage.removeItem('twitter_code_verifier')
|
||||
|
||||
const savedCsrf = sessionStorage.getItem('twitter_csrf_state')
|
||||
sessionStorage.removeItem('twitter_csrf_state')
|
||||
|
||||
// 从 state 解析 csrf 与 invite_token
|
||||
let parsedCsrf = ''
|
||||
let inviteToken = ''
|
||||
try {
|
||||
const sp = new URLSearchParams(state || '')
|
||||
parsedCsrf = sp.get('csrf') || ''
|
||||
inviteToken = sp.get('invite_token') || sp.get('invitetoken') || ''
|
||||
} catch {}
|
||||
|
||||
// 简单 CSRF 校验(存在才校验,避免误杀老会话)
|
||||
if (savedCsrf && parsedCsrf && savedCsrf !== parsedCsrf) {
|
||||
toast.error('登录状态校验失败,请重试')
|
||||
return { success: false, needReason: false, error: 'state mismatch' }
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -92,10 +57,8 @@ export async function twitterExchange(code, state, reason) {
|
||||
reason,
|
||||
state,
|
||||
codeVerifier,
|
||||
inviteToken,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
@@ -114,7 +77,6 @@ export async function twitterExchange(code, state, reason) {
|
||||
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