mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-16 03:50:54 +08:00
Compare commits
19 Commits
codex/add-
...
codex/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b837c9d7f | ||
|
|
017ad5bf54 | ||
|
|
f076b70e9b | ||
|
|
62d12ad2a7 | ||
|
|
923854bbc6 | ||
|
|
9ca5d7b167 | ||
|
|
9c3e1d17f0 | ||
|
|
7906062945 | ||
|
|
785c36d339 | ||
|
|
197cbca99c | ||
|
|
b1076d7256 | ||
|
|
ce94cd7e73 | ||
|
|
90147d6cd9 | ||
|
|
2c187cf2cd | ||
|
|
0b6d4f9709 | ||
|
|
cf3b6d8fc7 | ||
|
|
8d98c876d2 | ||
|
|
df4df1933a | ||
|
|
7507f1bb03 |
@@ -3,6 +3,12 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
|
|||||||
MYSQL_USER=<数据库用户名>
|
MYSQL_USER=<数据库用户名>
|
||||||
MYSQL_PASSWORD=<数据库密码>
|
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 ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
@@ -30,4 +36,4 @@ OPENAI_API_KEY=<你的openai-api-key>
|
|||||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||||
|
|
||||||
# LOG_LEVEL=DEBUG
|
# LOG_LEVEL=DEBUG
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.openisle.repository.ActivityRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -22,5 +24,16 @@ public class ActivityInitializer implements CommandLineRunner {
|
|||||||
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
||||||
activityRepository.save(a);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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,6 +119,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").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/categories/**").hasAuthority("ADMIN")
|
||||||
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
||||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||||
@@ -151,6 +153,7 @@ public class SecurityConfig {
|
|||||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||||
|
uri.startsWith("/api/point-goods") ||
|
||||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||||
|
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class AuthController {
|
|||||||
private final RegisterModeService registerModeService;
|
private final RegisterModeService registerModeService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final InviteService inviteService;
|
||||||
|
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
@@ -45,6 +46,25 @@ public class AuthController {
|
|||||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
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(
|
User user = userService.register(
|
||||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||||
@@ -106,27 +126,42 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
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"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid google token",
|
"error", "Invalid google token",
|
||||||
@@ -165,28 +200,44 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/github")
|
@PostMapping("/github")
|
||||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
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"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid github code",
|
"error", "Invalid github code",
|
||||||
@@ -196,27 +247,43 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/discord")
|
@PostMapping("/discord")
|
||||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
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"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid discord code",
|
"error", "Invalid discord code",
|
||||||
@@ -226,31 +293,44 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/twitter")
|
@PostMapping("/twitter")
|
||||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||||
Optional<User> user = twitterAuthService.authenticate(
|
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(
|
||||||
req.getCode(),
|
req.getCode(),
|
||||||
req.getCodeVerifier(),
|
req.getCodeVerifier(),
|
||||||
registerModeService.getRegisterMode(),
|
registerModeService.getRegisterMode(),
|
||||||
req.getRedirectUri());
|
req.getRedirectUri(),
|
||||||
if (user.isPresent()) {
|
viaInvite);
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
if (resultOpt.isPresent()) {
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
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"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid twitter code",
|
"error", "Invalid twitter code",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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,4 +7,5 @@ import lombok.Data;
|
|||||||
public class DiscordLoginRequest {
|
public class DiscordLoginRequest {
|
||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ import lombok.Data;
|
|||||||
public class GithubLoginRequest {
|
public class GithubLoginRequest {
|
||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class GoogleLoginRequest {
|
public class GoogleLoginRequest {
|
||||||
private String idToken;
|
private String idToken;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/src/main/java/com/openisle/dto/PointGoodDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/PointGoodDto.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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,4 +9,5 @@ public class RegisterRequest {
|
|||||||
private String email;
|
private String email;
|
||||||
private String password;
|
private String password;
|
||||||
private String captcha;
|
private String captcha;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ public class TwitterLoginRequest {
|
|||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
private String codeVerifier;
|
private String codeVerifier;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,5 +3,6 @@ package com.openisle.model;
|
|||||||
/** Activity type enumeration. */
|
/** Activity type enumeration. */
|
||||||
public enum ActivityType {
|
public enum ActivityType {
|
||||||
NORMAL,
|
NORMAL,
|
||||||
MILK_TEA
|
MILK_TEA,
|
||||||
|
INVITE_POINTS
|
||||||
}
|
}
|
||||||
|
|||||||
23
backend/src/main/java/com/openisle/model/InviteToken.java
Normal file
23
backend/src/main/java/com/openisle/model/InviteToken.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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,6 +32,8 @@ public enum NotificationType {
|
|||||||
REGISTER_REQUEST,
|
REGISTER_REQUEST,
|
||||||
/** A user redeemed an activity reward */
|
/** A user redeemed an activity reward */
|
||||||
ACTIVITY_REDEEM,
|
ACTIVITY_REDEEM,
|
||||||
|
/** A user redeemed a point good */
|
||||||
|
POINT_REDEEM,
|
||||||
/** You won a lottery post */
|
/** You won a lottery post */
|
||||||
LOTTERY_WIN,
|
LOTTERY_WIN,
|
||||||
/** Your lottery post was drawn */
|
/** Your lottery post was drawn */
|
||||||
|
|||||||
26
backend/src/main/java/com/openisle/model/PointGood.java
Normal file
26
backend/src/main/java/com/openisle/model/PointGood.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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> {
|
||||||
|
}
|
||||||
12
backend/src/main/java/com/openisle/service/AuthResult.java
Normal file
12
backend/src/main/java/com/openisle/service/AuthResult.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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:}")
|
@Value("${discord.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||||
try {
|
try {
|
||||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -67,13 +67,13 @@ public class DiscordAuthService {
|
|||||||
if (email == null) {
|
if (email == null) {
|
||||||
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
||||||
}
|
}
|
||||||
return Optional.of(processUser(email, username, avatar, mode));
|
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -82,7 +82,7 @@ public class DiscordAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -96,12 +96,12 @@ public class DiscordAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class GithubAuthService {
|
|||||||
@Value("${github.client-secret:}")
|
@Value("${github.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||||
try {
|
try {
|
||||||
String tokenUrl = "https://github.com/login/oauth/access_token";
|
String tokenUrl = "https://github.com/login/oauth/access_token";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -86,13 +86,13 @@ public class GithubAuthService {
|
|||||||
if (email == null) {
|
if (email == null) {
|
||||||
email = username + "@users.noreply.github.com";
|
email = username + "@users.noreply.github.com";
|
||||||
}
|
}
|
||||||
return Optional.of(processUser(email, username, avatarUrl, mode));
|
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -101,7 +101,7 @@ public class GithubAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -115,12 +115,12 @@ public class GithubAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class GoogleAuthService {
|
|||||||
@Value("${google.client-id:}")
|
@Value("${google.client-id:}")
|
||||||
private String clientId;
|
private String clientId;
|
||||||
|
|
||||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
||||||
.setAudience(Collections.singletonList(clientId))
|
.setAudience(Collections.singletonList(clientId))
|
||||||
.build();
|
.build();
|
||||||
@@ -38,13 +38,13 @@ public class GoogleAuthService {
|
|||||||
String email = payload.getEmail();
|
String email = payload.getEmail();
|
||||||
String name = (String) payload.get("name");
|
String name = (String) payload.get("name");
|
||||||
String picture = (String) payload.get("picture");
|
String picture = (String) payload.get("picture");
|
||||||
return Optional.of(processUser(email, name, picture, mode));
|
return Optional.of(processUser(email, name, picture, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -53,8 +53,7 @@ public class GoogleAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
return new AuthResult(user, false);
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
User user = new User();
|
User user = new User();
|
||||||
String baseUsername = email.split("@")[0];
|
String baseUsername = email.split("@")[0];
|
||||||
@@ -68,12 +67,12 @@ public class GoogleAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar(avatarGenerator.generate(username));
|
user.setAvatar(avatarGenerator.generate(username));
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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,6 +24,9 @@ public class JwtService {
|
|||||||
@Value("${app.jwt.reset-secret}")
|
@Value("${app.jwt.reset-secret}")
|
||||||
private String resetSecret;
|
private String resetSecret;
|
||||||
|
|
||||||
|
@Value("${app.jwt.invite-secret}")
|
||||||
|
private String inviteSecret;
|
||||||
|
|
||||||
@Value("${app.jwt.expiration}")
|
@Value("${app.jwt.expiration}")
|
||||||
private long expiration;
|
private long expiration;
|
||||||
|
|
||||||
@@ -70,6 +73,17 @@ public class JwtService {
|
|||||||
.compact();
|
.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) {
|
public String validateAndGetSubject(String token) {
|
||||||
Claims claims = Jwts.parserBuilder()
|
Claims claims = Jwts.parserBuilder()
|
||||||
.setSigningKey(getSigningKeyForSecret(secret))
|
.setSigningKey(getSigningKeyForSecret(secret))
|
||||||
@@ -96,4 +110,13 @@ public class JwtService {
|
|||||||
.getBody();
|
.getBody();
|
||||||
return claims.getSubject();
|
return claims.getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String validateAndGetSubjectForInvite(String token) {
|
||||||
|
Claims claims = Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKeyForSecret(inviteSecret))
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
return claims.getSubject();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,19 @@ 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) {
|
public List<NotificationPreferenceDto> listPreferences(String username) {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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,6 +26,11 @@ public class PointService {
|
|||||||
return addPoint(user, 30);
|
return addPoint(user, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int awardForInvite(String userName) {
|
||||||
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
|
return addPoint(user, 500);
|
||||||
|
}
|
||||||
|
|
||||||
private PointLog getTodayLog(User user) {
|
private PointLog getTodayLog(User user) {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ public class TwitterAuthService {
|
|||||||
@Value("${twitter.client-secret:}")
|
@Value("${twitter.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(
|
public Optional<AuthResult> authenticate(
|
||||||
String code,
|
String code,
|
||||||
String codeVerifier,
|
String codeVerifier,
|
||||||
RegisterMode mode,
|
RegisterMode mode,
|
||||||
String redirectUri) {
|
String redirectUri,
|
||||||
|
boolean viaInvite) {
|
||||||
|
|
||||||
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
||||||
|
|
||||||
@@ -106,10 +107,10 @@ public class TwitterAuthService {
|
|||||||
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
||||||
String email = username + "@twitter.com";
|
String email = username + "@twitter.com";
|
||||||
logger.debug("Processing user {} with email {}", username, email);
|
logger.debug("Processing user {} with email {}", username, email);
|
||||||
return Optional.of(processUser(email, username, avatar, mode));
|
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -119,7 +120,7 @@ public class TwitterAuthService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
logger.debug("Existing user {} authenticated", user.getUsername());
|
logger.debug("Existing user {} authenticated", user.getUsername());
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -133,13 +134,13 @@ public class TwitterAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
||||||
}
|
}
|
||||||
logger.debug("Creating new user {}", finalUsername);
|
logger.debug("Creating new user {}", finalUsername);
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ public class UserService {
|
|||||||
return userRepository.save(user);
|
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() {
|
private String genCode() {
|
||||||
return String.format("%06d", new Random().nextInt(1000000));
|
return String.format("%06d", new Random().nextInt(1000000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||||
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
||||||
|
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
|
||||||
# 30 days
|
# 30 days
|
||||||
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
||||||
# Password strength: LOW, MEDIUM or HIGH
|
# Password strength: LOW, MEDIUM or HIGH
|
||||||
|
|||||||
@@ -144,6 +144,30 @@ class NotificationServiceTest {
|
|||||||
verify(nRepo).save(any(Notification.class));
|
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
|
@Test
|
||||||
void createNotificationSendsEmailForCommentReply() {
|
void createNotificationSendsEmailForCommentReply() {
|
||||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||||
|
|||||||
186
frontend_nuxt/components/InviteCodeActivityComponent.vue
Normal file
186
frontend_nuxt/components/InviteCodeActivityComponent.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<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>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
v-if="authState.loggedIn"
|
v-if="authState.loggedIn"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
exact-active-class="selected"
|
exact-active-class="selected"
|
||||||
to="/about/points"
|
to="/points"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-coins"></i>
|
<i class="menu-item-icon fas fa-coins"></i>
|
||||||
|
|||||||
@@ -40,30 +40,22 @@
|
|||||||
兑换
|
兑换
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="redeem-button disabled">兑换</div>
|
<div v-else class="redeem-button disabled">兑换</div>
|
||||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
<RedeemPopup
|
||||||
<div class="redeem-dialog-content">
|
:visible="dialogVisible"
|
||||||
<BaseInput
|
v-model="contact"
|
||||||
textarea=""
|
:loading="loading"
|
||||||
rows="5"
|
@close="closeDialog"
|
||||||
v-model="contact"
|
@submit="submitRedeem"
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
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 LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import ProgressBar from '~/components/ProgressBar.vue'
|
import ProgressBar from '~/components/ProgressBar.vue'
|
||||||
|
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -185,56 +177,6 @@ const submitRedeem = async () => {
|
|||||||
font-size: 14px;
|
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 {
|
.user-level-text {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -247,9 +189,5 @@ const submitRedeem = async () => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-dialog-content {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
103
frontend_nuxt/components/RedeemPopup.vue
Normal file
103
frontend_nuxt/components/RedeemPopup.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<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>
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="point-mall-page">
|
|
||||||
<p v-if="authState.loggedIn && point !== null">我的积分:{{ point }}</p>
|
|
||||||
<p v-else>请先登录以查看积分</p>
|
|
||||||
|
|
||||||
<section class="rules">
|
|
||||||
<h2>积分规则</h2>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="goods">
|
|
||||||
<h2>积分兑换商品</h2>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(good, idx) in goods" :key="idx">{{ good.name }} - {{ good.cost }} 积分</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
|
||||||
|
|
||||||
const point = ref(null)
|
|
||||||
|
|
||||||
const pointRules = [
|
|
||||||
'发帖:每天前两次,每次 30 积分',
|
|
||||||
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
|
||||||
'帖子被点赞:每次 10 积分',
|
|
||||||
'评论被点赞:每次 10 积分',
|
|
||||||
]
|
|
||||||
|
|
||||||
const goods = [
|
|
||||||
{ name: 'GPT Plus for 1 month', cost: 20000 },
|
|
||||||
{ name: '奶茶', cost: 5000 },
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
const user = await fetchCurrentUser()
|
|
||||||
point.value = user ? user.point : null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.point-mall-page {
|
|
||||||
padding: 20px;
|
|
||||||
max-width: var(--page-max-width);
|
|
||||||
background-color: var(--background-color);
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rules,
|
|
||||||
.goods {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||||
|
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||||
|
import InviteCodeActivityComponent from '~/components/InviteCodeActivityComponent.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ onMounted(async () => {
|
|||||||
background-color: var(--activity-card-background-color);
|
background-color: var(--activity-card-background-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-card-left-avatar-img {
|
.activity-card-left-avatar-img {
|
||||||
@@ -141,6 +144,10 @@ onMounted(async () => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-card-normal-right {
|
||||||
|
width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.activity-card-left-avatar-img {
|
.activity-card-left-avatar-img {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- pages/discord-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
@@ -8,9 +9,30 @@ import { discordExchange } from '~/utils/discord'
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get('code') || ''
|
||||||
const state = url.searchParams.get('state')
|
const stateStr = url.searchParams.get('state') || ''
|
||||||
const result = await discordExchange(code, 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, '')
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- pages/github-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
@@ -8,9 +9,31 @@ import { githubExchange } from '~/utils/github'
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get('code') || ''
|
||||||
const state = url.searchParams.get('state')
|
const state = url.searchParams.get('state') || ''
|
||||||
const result = await githubExchange(code, 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, '')
|
||||||
|
|
||||||
if (result.needReason) {
|
if (result.needReason) {
|
||||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import { googleAuthWithToken } from '~/utils/google'
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const idToken = hash.get('id_token')
|
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') || ''
|
||||||
|
// }
|
||||||
|
|
||||||
if (idToken) {
|
if (idToken) {
|
||||||
await googleAuthWithToken(
|
await googleAuthWithToken(
|
||||||
idToken,
|
idToken,
|
||||||
@@ -18,6 +33,7 @@ onMounted(async () => {
|
|||||||
(token) => {
|
(token) => {
|
||||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||||
},
|
},
|
||||||
|
{ inviteToken },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
|
|||||||
@@ -130,6 +130,12 @@
|
|||||||
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</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">
|
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||||
@@ -610,6 +616,8 @@ const formatType = (t) => {
|
|||||||
return '有人申请注册'
|
return '有人申请注册'
|
||||||
case 'ACTIVITY_REDEEM':
|
case 'ACTIVITY_REDEEM':
|
||||||
return '有人申请兑换奶茶'
|
return '有人申请兑换奶茶'
|
||||||
|
case 'POINT_REDEEM':
|
||||||
|
return '有人申请积分兑换'
|
||||||
case 'LOTTERY_WIN':
|
case 'LOTTERY_WIN':
|
||||||
return '抽奖中奖了'
|
return '抽奖中奖了'
|
||||||
case 'LOTTERY_DRAW':
|
case 'LOTTERY_DRAW':
|
||||||
|
|||||||
229
frontend_nuxt/pages/points.vue
Normal file
229
frontend_nuxt/pages/points.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<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,6 +28,7 @@ const reason = ref('')
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isWaitingForRegister = ref(false)
|
const isWaitingForRegister = ref(false)
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
token.value = route.query.token || ''
|
token.value = route.query.token || ''
|
||||||
@@ -50,8 +51,8 @@ const submit = async () => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: this.token,
|
token: token.value,
|
||||||
reason: this.reason,
|
reason: reason.value,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
isWaitingForRegister.value = false
|
isWaitingForRegister.value = false
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="other-signup-page-content">
|
<div class="other-signup-page-content">
|
||||||
<div class="signup-page-button" @click="googleAuthorize">
|
<div class="signup-page-button" @click="signupWithGoogle">
|
||||||
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
||||||
<div class="signup-page-button-text">Google 注册</div>
|
<div class="signup-page-button-text">Google 注册</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,8 @@ import { discordAuthorize } from '~/utils/discord'
|
|||||||
import { githubAuthorize } from '~/utils/github'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
import { googleAuthorize } from '~/utils/google'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import { twitterAuthorize } from '~/utils/twitter'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const emailStep = ref(0)
|
const emailStep = ref(0)
|
||||||
@@ -109,9 +111,11 @@ const passwordError = ref('')
|
|||||||
const code = ref('')
|
const code = ref('')
|
||||||
const isWaitingForEmailSent = ref(false)
|
const isWaitingForEmailSent = ref(false)
|
||||||
const isWaitingForEmailVerified = ref(false)
|
const isWaitingForEmailVerified = ref(false)
|
||||||
|
const inviteToken = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
username.value = route.query.u || ''
|
username.value = route.query.u || ''
|
||||||
|
inviteToken.value = route.query.invite_token || ''
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -203,14 +207,17 @@ const verifyCode = async () => {
|
|||||||
isWaitingForEmailVerified.value = false
|
isWaitingForEmailVerified.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const signupWithGoogle = () => {
|
||||||
|
googleAuthorize(inviteToken.value)
|
||||||
|
}
|
||||||
const signupWithGithub = () => {
|
const signupWithGithub = () => {
|
||||||
githubAuthorize()
|
githubAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
const signupWithDiscord = () => {
|
const signupWithDiscord = () => {
|
||||||
discordAuthorize()
|
discordAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
const signupWithTwitter = () => {
|
const signupWithTwitter = () => {
|
||||||
twitterAuthorize()
|
twitterAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
|||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(state = '') {
|
export function discordAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const DISCORD_CLIENT_ID = config.public.discordClientId
|
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||||
@@ -10,62 +10,60 @@ export function discordAuthorize(state = '') {
|
|||||||
toast.error('Discord 登录不可用')
|
toast.error('Discord 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||||
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}`
|
// 用 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)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discordExchange(code, state, reason) {
|
export async function discordExchange(code, inviteToken = '', reason = '') {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
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`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
code,
|
|
||||||
redirectUri: `${window.location.origin}/discord-callback`,
|
|
||||||
reason,
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return { success: false, needReason: true, token: data.token }
|
||||||
success: false,
|
|
||||||
needReason: true,
|
|
||||||
token: data.token,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: data.error || '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
|||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(state = '') {
|
export function githubAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const GITHUB_CLIENT_ID = config.public.githubClientId
|
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||||
@@ -10,62 +10,58 @@ export function githubAuthorize(state = '') {
|
|||||||
toast.error('GitHub 登录不可用')
|
toast.error('GitHub 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
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)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function githubExchange(code, state, reason) {
|
export async function githubExchange(code, inviteToken = '', reason = '') {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
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`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
code,
|
|
||||||
redirectUri: `${window.location.origin}/github-callback`,
|
|
||||||
reason,
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return { success: false, needReason: true, token: data.token }
|
||||||
success: false,
|
|
||||||
needReason: true,
|
|
||||||
token: data.token,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: data.error || '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,44 +21,85 @@ export async function googleGetIdToken() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function googleAuthorize() {
|
export function googleAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
|
||||||
if (!GOOGLE_CLIENT_ID) {
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||||
const nonce = Math.random().toString(36).substring(2)
|
const nonce = Math.random().toString(36).slice(2)
|
||||||
const url = `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(推荐;Google 会原样回传)
|
||||||
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
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)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
export async function googleAuthWithToken(
|
||||||
|
idToken,
|
||||||
|
redirect_success,
|
||||||
|
redirect_not_approved,
|
||||||
|
options = {}, // { inviteToken?: string }
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
if (!idToken) {
|
||||||
|
toast.error('缺少 id_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
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`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ idToken }),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok && data.token) {
|
const data = await res.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (res.ok && data && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
if (redirect_success) redirect_success()
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
return
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
|
||||||
if (redirect_not_approved) redirect_not_approved(data.token)
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
|
||||||
toast.info('您的注册理由正在审批中')
|
|
||||||
if (redirect_success) redirect_success()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data && 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') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(data?.message || '登录失败')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const iconMap = {
|
|||||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||||
|
POINT_REDEEM: 'fas fa-gift',
|
||||||
LOTTERY_WIN: 'fas fa-trophy',
|
LOTTERY_WIN: 'fas fa-trophy',
|
||||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||||
MENTION: 'fas fa-at',
|
MENTION: 'fas fa-at',
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ async function generateCodeChallenge(codeVerifier) {
|
|||||||
.replace(/=+$/, '')
|
.replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function twitterAuthorize(state = '') {
|
// 邀请码明文放入 state;同时生成 csrf 放入 state 并在回调校验
|
||||||
|
export async function twitterAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
||||||
@@ -28,17 +29,30 @@ export async function twitterAuthorize(state = '') {
|
|||||||
toast.error('Twitter 登录不可用')
|
toast.error('Twitter 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state === '') {
|
|
||||||
state = Math.random().toString(36).substring(2, 15)
|
|
||||||
}
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
|
||||||
|
|
||||||
|
// PKCE
|
||||||
const codeVerifier = generateCodeVerifier()
|
const codeVerifier = generateCodeVerifier()
|
||||||
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
|
||||||
const codeChallenge = await generateCodeChallenge(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 =
|
const url =
|
||||||
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
|
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(TWITTER_CLIENT_ID)}` +
|
||||||
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
`&scope=${encodeURIComponent('tweet.read users.read')}` +
|
||||||
|
`&state=${encodeURIComponent(state)}` +
|
||||||
|
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
|
||||||
|
`&code_challenge_method=S256`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +60,29 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
// 取出并清理 PKCE/CSRF
|
||||||
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||||
sessionStorage.removeItem('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`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -57,8 +92,10 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
reason,
|
reason,
|
||||||
state,
|
state,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
|
inviteToken,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
@@ -77,6 +114,7 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return { success: false, needReason: false, error: '登录失败' }
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user