diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index 699be62e3..2ca192280 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -3,6 +3,12 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes& MYSQL_USER=<数据库用户名> MYSQL_PASSWORD=<数据库密码> +# === JWT === +JWT_SECRET= +JWT_REASON_SECRET= +JWT_RESET_SECRET= +JWT_INVITE_SECRET= +JWT_EXPIRATION=2592000000 # === Resend === RESEND_API_KEY=<你的resend-api-key> @@ -30,4 +36,4 @@ OPENAI_API_KEY=<你的openai-api-key> WEBPUSH_PUBLIC_KEY=<你的webpush-public-key> WEBPUSH_PRIVATE_KEY=<你的webpush-private-key> -# LOG_LEVEL=DEBUG \ No newline at end of file +# LOG_LEVEL=DEBUG diff --git a/backend/src/main/java/com/openisle/config/ActivityInitializer.java b/backend/src/main/java/com/openisle/config/ActivityInitializer.java index 9d2f1b740..229eef3ad 100644 --- a/backend/src/main/java/com/openisle/config/ActivityInitializer.java +++ b/backend/src/main/java/com/openisle/config/ActivityInitializer.java @@ -6,6 +6,8 @@ import com.openisle.repository.ActivityRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.LocalDateTime; @Component @RequiredArgsConstructor @@ -22,5 +24,16 @@ public class ActivityInitializer implements CommandLineRunner { a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯"); activityRepository.save(a); } + + if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) { + Activity a = new Activity(); + a.setTitle("🎁邀请码送积分活动"); + a.setType(ActivityType.INVITE_POINTS); + a.setIcon("https://img.icons8.com/color/96/gift.png"); + a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!"); + a.setStartTime(LocalDateTime.now()); + a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay()); + activityRepository.save(a); + } } } diff --git a/backend/src/main/java/com/openisle/config/PointGoodInitializer.java b/backend/src/main/java/com/openisle/config/PointGoodInitializer.java new file mode 100644 index 000000000..f7f1e4cff --- /dev/null +++ b/backend/src/main/java/com/openisle/config/PointGoodInitializer.java @@ -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); + } + } +} diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index ba982d980..100c0a2e3 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -119,6 +119,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() + .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() + .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN") @@ -151,6 +153,7 @@ public class SecurityConfig { uri.startsWith("/api/search") || uri.startsWith("/api/users") || uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || + uri.startsWith("/api/point-goods") || uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals")); if (authHeader != null && authHeader.startsWith("Bearer ")) { diff --git a/backend/src/main/java/com/openisle/controller/AuthController.java b/backend/src/main/java/com/openisle/controller/AuthController.java index 903975855..bad3abcfe 100644 --- a/backend/src/main/java/com/openisle/controller/AuthController.java +++ b/backend/src/main/java/com/openisle/controller/AuthController.java @@ -29,6 +29,7 @@ public class AuthController { private final RegisterModeService registerModeService; private final NotificationService notificationService; private final UserRepository userRepository; + private final InviteService inviteService; @Value("${app.captcha.enabled:false}") @@ -45,6 +46,26 @@ public class AuthController { if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); } + if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) { + if (!inviteService.validate(req.getInviteToken())) { + return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多")); + } + try { + User user = userService.registerWithInvite( + req.getUsername(), req.getEmail(), req.getPassword()); + inviteService.consume(req.getInviteToken()); + emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode()); + return ResponseEntity.ok(Map.of( + "token", jwtService.generateToken(user.getUsername()), + "reason_code", "INVITE_APPROVED" + )); + } catch (FieldException e) { + return ResponseEntity.badRequest().body(Map.of( + "field", e.getField(), + "error", e.getMessage() + )); + } + } User user = userService.register( req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode()); emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode()); @@ -58,10 +79,26 @@ public class AuthController { public ResponseEntity verify(@RequestBody VerifyRequest req) { boolean ok = userService.verifyCode(req.getUsername(), req.getCode()); if (ok) { - return ResponseEntity.ok(Map.of( - "message", "Verified", - "token", jwtService.generateReasonToken(req.getUsername()) - )); + Optional userOpt = userService.findByUsername(req.getUsername()); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")); + } + + User user = userOpt.get(); + + if (user.isApproved()) { + return ResponseEntity.ok(Map.of( + "message", "Verified and isApproved", + "reason_code", "VERIFIED_AND_APPROVED", + "token", jwtService.generateToken(req.getUsername()) + )); + } else { + return ResponseEntity.ok(Map.of( + "message", "Verified", + "reason_code", "VERIFIED", + "token", jwtService.generateReasonToken(req.getUsername()) + )); + } } return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code")); } @@ -106,27 +143,42 @@ public class AuthController { @PostMapping("/google") public ResponseEntity loginWithGoogle(@RequestBody GoogleLoginRequest req) { - Optional user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); + } + if (!result.getUser().isApproved()) { + if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid google token", @@ -165,28 +217,44 @@ public class AuthController { @PostMapping("/github") public ResponseEntity loginWithGithub(@RequestBody GithubLoginRequest req) { - Optional user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); + } + if (!result.getUser().isApproved()) { + if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { // 已填写注册理由 return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid github code", @@ -196,27 +264,43 @@ public class AuthController { @PostMapping("/discord") public ResponseEntity loginWithDiscord(@RequestBody DiscordLoginRequest req) { - Optional user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); + } + if (!result.getUser().isApproved()) { + if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid discord code", @@ -226,31 +310,44 @@ public class AuthController { @PostMapping("/twitter") public ResponseEntity loginWithTwitter(@RequestBody TwitterLoginRequest req) { - Optional 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 resultOpt = twitterAuthService.authenticate( req.getCode(), req.getCodeVerifier(), registerModeService.getRegisterMode(), - req.getRedirectUri()); - if (user.isPresent()) { - if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { - return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); + 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 (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) { + if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) { + return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername()))); + } + if (!result.getUser().isApproved()) { + if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "reason_code", "IS_APPROVING", - "token", jwtService.generateReasonToken(user.get().getUsername()) + "token", jwtService.generateReasonToken(result.getUser().getUsername()) )); } return ResponseEntity.badRequest().body(Map.of( "error", "Account awaiting approval", "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( "error", "Invalid twitter code", diff --git a/backend/src/main/java/com/openisle/controller/InviteController.java b/backend/src/main/java/com/openisle/controller/InviteController.java new file mode 100644 index 000000000..9c0817dc9 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/InviteController.java @@ -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 generate(Authentication auth) { + String token = inviteService.generate(auth.getName()); + return Map.of("token", token); + } +} diff --git a/backend/src/main/java/com/openisle/controller/PointMallController.java b/backend/src/main/java/com/openisle/controller/PointMallController.java new file mode 100644 index 000000000..eb6066f52 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/PointMallController.java @@ -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 list() { + return pointMallService.listGoods().stream() + .map(pointGoodMapper::toDto) + .collect(Collectors.toList()); + } + + @PostMapping("/redeem") + public Map 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); + } +} diff --git a/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java b/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java index db424288c..90e41163c 100644 --- a/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/DiscordLoginRequest.java @@ -7,4 +7,5 @@ import lombok.Data; public class DiscordLoginRequest { private String code; private String redirectUri; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java b/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java index ad2b55148..dc9edf8c5 100644 --- a/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/GithubLoginRequest.java @@ -7,4 +7,5 @@ import lombok.Data; public class GithubLoginRequest { private String code; private String redirectUri; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java b/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java index b2ea269fe..3159e10dc 100644 --- a/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/GoogleLoginRequest.java @@ -6,4 +6,5 @@ import lombok.Data; @Data public class GoogleLoginRequest { private String idToken; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/PointGoodDto.java b/backend/src/main/java/com/openisle/dto/PointGoodDto.java new file mode 100644 index 000000000..cf7384283 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PointGoodDto.java @@ -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; +} diff --git a/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java b/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java new file mode 100644 index 000000000..6bbefdba6 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PointRedeemRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/openisle/dto/RegisterRequest.java b/backend/src/main/java/com/openisle/dto/RegisterRequest.java index 4ac63c795..66a6e24b2 100644 --- a/backend/src/main/java/com/openisle/dto/RegisterRequest.java +++ b/backend/src/main/java/com/openisle/dto/RegisterRequest.java @@ -9,4 +9,5 @@ public class RegisterRequest { private String email; private String password; private String captcha; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java b/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java index e7e460907..0bd82a956 100644 --- a/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java +++ b/backend/src/main/java/com/openisle/dto/TwitterLoginRequest.java @@ -8,4 +8,5 @@ public class TwitterLoginRequest { private String code; private String redirectUri; private String codeVerifier; + private String inviteToken; } diff --git a/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java b/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java new file mode 100644 index 000000000..f0f2f2770 --- /dev/null +++ b/backend/src/main/java/com/openisle/mapper/PointGoodMapper.java @@ -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; + } +} diff --git a/backend/src/main/java/com/openisle/model/ActivityType.java b/backend/src/main/java/com/openisle/model/ActivityType.java index 8bc8504ae..1312f0d4d 100644 --- a/backend/src/main/java/com/openisle/model/ActivityType.java +++ b/backend/src/main/java/com/openisle/model/ActivityType.java @@ -3,5 +3,6 @@ package com.openisle.model; /** Activity type enumeration. */ public enum ActivityType { NORMAL, - MILK_TEA + MILK_TEA, + INVITE_POINTS } diff --git a/backend/src/main/java/com/openisle/model/InviteToken.java b/backend/src/main/java/com/openisle/model/InviteToken.java new file mode 100644 index 000000000..7db547bcc --- /dev/null +++ b/backend/src/main/java/com/openisle/model/InviteToken.java @@ -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; +} diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index d9735bc29..132af5176 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -32,6 +32,8 @@ public enum NotificationType { REGISTER_REQUEST, /** A user redeemed an activity reward */ ACTIVITY_REDEEM, + /** A user redeemed a point good */ + POINT_REDEEM, /** You won a lottery post */ LOTTERY_WIN, /** Your lottery post was drawn */ diff --git a/backend/src/main/java/com/openisle/model/PointGood.java b/backend/src/main/java/com/openisle/model/PointGood.java new file mode 100644 index 000000000..b93d73d14 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PointGood.java @@ -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; +} diff --git a/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java b/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java new file mode 100644 index 000000000..3257124d8 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/InviteTokenRepository.java @@ -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 { + Optional findByInviterAndCreatedDate(User inviter, LocalDate createdDate); +} diff --git a/backend/src/main/java/com/openisle/repository/PointGoodRepository.java b/backend/src/main/java/com/openisle/repository/PointGoodRepository.java new file mode 100644 index 000000000..b76a62476 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PointGoodRepository.java @@ -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 { +} diff --git a/backend/src/main/java/com/openisle/service/AuthResult.java b/backend/src/main/java/com/openisle/service/AuthResult.java new file mode 100644 index 000000000..74e9d5e34 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/AuthResult.java @@ -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; +} + diff --git a/backend/src/main/java/com/openisle/service/DiscordAuthService.java b/backend/src/main/java/com/openisle/service/DiscordAuthService.java index 670c248dc..3661fbba7 100644 --- a/backend/src/main/java/com/openisle/service/DiscordAuthService.java +++ b/backend/src/main/java/com/openisle/service/DiscordAuthService.java @@ -26,7 +26,7 @@ public class DiscordAuthService { @Value("${discord.client-secret:}") private String clientSecret; - public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { + public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { try { String tokenUrl = "https://discord.com/api/oauth2/token"; HttpHeaders headers = new HttpHeaders(); @@ -67,13 +67,13 @@ public class DiscordAuthService { if (email == null) { email = (username != null ? username : id) + "@users.noreply.discord.com"; } - return Optional.of(processUser(email, username, avatar, mode)); + return Optional.of(processUser(email, username, avatar, mode, viaInvite)); } catch (Exception e) { 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -82,7 +82,7 @@ public class DiscordAuthService { user.setVerificationCode(null); userRepository.save(user); } - return user; + return new AuthResult(user, false); } String baseUsername = username != null ? username : email.split("@")[0]; String finalUsername = baseUsername; @@ -96,12 +96,12 @@ public class DiscordAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png"); } - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/GithubAuthService.java b/backend/src/main/java/com/openisle/service/GithubAuthService.java index 739770944..c43d7069a 100644 --- a/backend/src/main/java/com/openisle/service/GithubAuthService.java +++ b/backend/src/main/java/com/openisle/service/GithubAuthService.java @@ -30,7 +30,7 @@ public class GithubAuthService { @Value("${github.client-secret:}") private String clientSecret; - public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) { + public Optional authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) { try { String tokenUrl = "https://github.com/login/oauth/access_token"; HttpHeaders headers = new HttpHeaders(); @@ -86,13 +86,13 @@ public class GithubAuthService { if (email == null) { email = username + "@users.noreply.github.com"; } - return Optional.of(processUser(email, username, avatarUrl, mode)); + return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite)); } catch (Exception e) { 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -101,7 +101,7 @@ public class GithubAuthService { user.setVerificationCode(null); userRepository.save(user); } - return user; + return new AuthResult(user, false); } String baseUsername = username != null ? username : email.split("@")[0]; String finalUsername = baseUsername; @@ -115,12 +115,12 @@ public class GithubAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar(avatarGenerator.generate(finalUsername)); } - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/GoogleAuthService.java b/backend/src/main/java/com/openisle/service/GoogleAuthService.java index 89cee5951..6dc5bdf3d 100644 --- a/backend/src/main/java/com/openisle/service/GoogleAuthService.java +++ b/backend/src/main/java/com/openisle/service/GoogleAuthService.java @@ -25,7 +25,7 @@ public class GoogleAuthService { @Value("${google.client-id:}") private String clientId; - public Optional authenticate(String idTokenString, com.openisle.model.RegisterMode mode) { + public Optional authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) { GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory()) .setAudience(Collections.singletonList(clientId)) .build(); @@ -38,13 +38,13 @@ public class GoogleAuthService { String email = payload.getEmail(); String name = (String) payload.get("name"); String picture = (String) payload.get("picture"); - return Optional.of(processUser(email, name, picture, mode)); + return Optional.of(processUser(email, name, picture, mode, viaInvite)); } catch (Exception e) { 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -53,8 +53,7 @@ public class GoogleAuthService { user.setVerificationCode(null); userRepository.save(user); } - - return user; + return new AuthResult(user, false); } User user = new User(); String baseUsername = email.split("@")[0]; @@ -68,12 +67,12 @@ public class GoogleAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar(avatarGenerator.generate(username)); } - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/InviteService.java b/backend/src/main/java/com/openisle/service/InviteService.java new file mode 100644 index 000000000..cd0f895a3 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/InviteService.java @@ -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 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()); + } +} diff --git a/backend/src/main/java/com/openisle/service/JwtService.java b/backend/src/main/java/com/openisle/service/JwtService.java index 07d9df4c8..ba7d0bbdd 100644 --- a/backend/src/main/java/com/openisle/service/JwtService.java +++ b/backend/src/main/java/com/openisle/service/JwtService.java @@ -24,6 +24,9 @@ public class JwtService { @Value("${app.jwt.reset-secret}") private String resetSecret; + @Value("${app.jwt.invite-secret}") + private String inviteSecret; + @Value("${app.jwt.expiration}") private long expiration; @@ -70,6 +73,17 @@ public class JwtService { .compact(); } + public String generateInviteToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKeyForSecret(inviteSecret)) + .compact(); + } + public String validateAndGetSubject(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(getSigningKeyForSecret(secret)) @@ -96,4 +110,13 @@ public class JwtService { .getBody(); return claims.getSubject(); } + + public String validateAndGetSubjectForInvite(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKeyForSecret(inviteSecret)) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } } diff --git a/backend/src/main/java/com/openisle/service/NotificationService.java b/backend/src/main/java/com/openisle/service/NotificationService.java index b459456b7..6ecf571e8 100644 --- a/backend/src/main/java/com/openisle/service/NotificationService.java +++ b/backend/src/main/java/com/openisle/service/NotificationService.java @@ -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 listPreferences(String username) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); diff --git a/backend/src/main/java/com/openisle/service/PointMallService.java b/backend/src/main/java/com/openisle/service/PointMallService.java new file mode 100644 index 000000000..c930ca7f6 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/PointMallService.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index b3e7de6b3..be46b1fc6 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -26,6 +26,11 @@ public class PointService { return addPoint(user, 30); } + public int awardForInvite(String userName) { + User user = userRepository.findByUsername(userName).orElseThrow(); + return addPoint(user, 500); + } + private PointLog getTodayLog(User user) { LocalDate today = LocalDate.now(); return pointLogRepository.findByUserAndLogDate(user, today) diff --git a/backend/src/main/java/com/openisle/service/TwitterAuthService.java b/backend/src/main/java/com/openisle/service/TwitterAuthService.java index eb65d0ccf..5d68342fa 100644 --- a/backend/src/main/java/com/openisle/service/TwitterAuthService.java +++ b/backend/src/main/java/com/openisle/service/TwitterAuthService.java @@ -33,11 +33,12 @@ public class TwitterAuthService { @Value("${twitter.client-secret:}") private String clientSecret; - public Optional authenticate( + public Optional authenticate( String code, String codeVerifier, RegisterMode mode, - String redirectUri) { + String redirectUri, + boolean viaInvite) { 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 String email = username + "@twitter.com"; 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 existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -119,7 +120,7 @@ public class TwitterAuthService { userRepository.save(user); } logger.debug("Existing user {} authenticated", user.getUsername()); - return user; + return new AuthResult(user, false); } String baseUsername = username != null ? username : email.split("@")[0]; String finalUsername = baseUsername; @@ -133,13 +134,13 @@ public class TwitterAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); - user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite); if (avatar != null) { user.setAvatar(avatar); } else { user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image"); } logger.debug("Creating new user {}", finalUsername); - return userRepository.save(user); + return new AuthResult(userRepository.save(user), true); } } diff --git a/backend/src/main/java/com/openisle/service/UserService.java b/backend/src/main/java/com/openisle/service/UserService.java index 8e6a1d5ed..8c0dc4432 100644 --- a/backend/src/main/java/com/openisle/service/UserService.java +++ b/backend/src/main/java/com/openisle/service/UserService.java @@ -74,6 +74,13 @@ public class UserService { return userRepository.save(user); } + public User registerWithInvite(String username, String email, String password) { + User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT); + user.setVerified(true); + user.setVerificationCode(genCode()); + return userRepository.save(user); + } + private String genCode() { return String.format("%06d", new Random().nextInt(1000000)); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f4e59995c..27279df17 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -10,6 +10,7 @@ spring.jpa.hibernate.ddl-auto=update app.jwt.secret=${JWT_SECRET:jwt_sec} app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec} app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec} +app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec} # 30 days app.jwt.expiration=${JWT_EXPIRATION:2592000000} # Password strength: LOW, MEDIUM or HIGH diff --git a/backend/src/test/java/com/openisle/service/NotificationServiceTest.java b/backend/src/test/java/com/openisle/service/NotificationServiceTest.java index c4ca007a1..a01d3c95b 100644 --- a/backend/src/test/java/com/openisle/service/NotificationServiceTest.java +++ b/backend/src/test/java/com/openisle/service/NotificationServiceTest.java @@ -144,6 +144,30 @@ class NotificationServiceTest { verify(nRepo).save(any(Notification.class)); } + @Test + void createPointRedeemNotificationsDeletesOldOnes() { + NotificationRepository nRepo = mock(NotificationRepository.class); + UserRepository uRepo = mock(UserRepository.class); + ReactionRepository rRepo = mock(ReactionRepository.class); + EmailSender email = mock(EmailSender.class); + PushNotificationService push = mock(PushNotificationService.class); + Executor executor = Runnable::run; + NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor); + org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com"); + + User admin = new User(); + admin.setId(10L); + User user = new User(); + user.setId(20L); + + when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin)); + + service.createPointRedeemNotifications(user, "contact"); + + verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user); + verify(nRepo).save(any(Notification.class)); + } + @Test void createNotificationSendsEmailForCommentReply() { NotificationRepository nRepo = mock(NotificationRepository.class); diff --git a/frontend_nuxt/components/GlobalPopups.vue b/frontend_nuxt/components/GlobalPopups.vue index 9992889a9..f5d87b2eb 100644 --- a/frontend_nuxt/components/GlobalPopups.vue +++ b/frontend_nuxt/components/GlobalPopups.vue @@ -8,6 +8,13 @@ /> + + @@ -21,7 +28,10 @@ const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl const showMilkTeaPopup = ref(false) +const showInviteCodePopup = ref(false) const milkTeaIcon = ref('') +const inviteCodeIcon = ref('') + const showNotificationPopup = ref(false) const showMedalPopup = ref(false) const newMedals = ref([]) @@ -30,6 +40,9 @@ onMounted(async () => { await checkMilkTeaActivity() if (showMilkTeaPopup.value) return + await checkInviteCodeActivity() + if (showInviteCodePopup.value) return + await checkNotificationSetting() if (showNotificationPopup.value) return @@ -53,12 +66,38 @@ const checkMilkTeaActivity = async () => { // ignore network errors } } + +const checkInviteCodeActivity = async () => { + if (!process.client) return + if (localStorage.getItem('inviteCodeActivityPopupShown')) return + try { + const res = await fetch(`${API_BASE_URL}/api/activities`) + if (res.ok) { + const list = await res.json() + const a = list.find((i) => i.type === 'INVITE_POINTS' && !i.ended) + if (a) { + inviteCodeIcon.value = a.icon + showInviteCodePopup.value = true + } + } + } catch (e) { + // ignore network errors + } +} + +const closeInviteCodePopup = () => { + if (!process.client) return + localStorage.setItem('inviteCodeActivityPopupShown', 'true') + showInviteCodePopup.value = false +} + const closeMilkTeaPopup = () => { if (!process.client) return localStorage.setItem('milkTeaActivityPopupShown', 'true') showMilkTeaPopup.value = false checkNotificationSetting() } + const checkNotificationSetting = async () => { if (!process.client) return if (!authState.loggedIn) return diff --git a/frontend_nuxt/components/InviteCodeActivityComponent.vue b/frontend_nuxt/components/InviteCodeActivityComponent.vue new file mode 100644 index 000000000..bdc778a70 --- /dev/null +++ b/frontend_nuxt/components/InviteCodeActivityComponent.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend_nuxt/components/MenuComponent.vue b/frontend_nuxt/components/MenuComponent.vue index 9f5aa53db..7b52bae54 100644 --- a/frontend_nuxt/components/MenuComponent.vue +++ b/frontend_nuxt/components/MenuComponent.vue @@ -56,6 +56,19 @@ 站点统计 + + + + 积分商城 + {{ myPoint }} + + + @@ -32,6 +33,7 @@ + + diff --git a/frontend_nuxt/pages/signup-reason.vue b/frontend_nuxt/pages/signup-reason.vue index 1498d9ac6..f6c0d1c15 100644 --- a/frontend_nuxt/pages/signup-reason.vue +++ b/frontend_nuxt/pages/signup-reason.vue @@ -51,8 +51,8 @@ const submit = async () => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - token: this.token, - reason: this.reason, + token: token.value, + reason: reason.value, }), }) isWaitingForRegister.value = false diff --git a/frontend_nuxt/pages/signup.vue b/frontend_nuxt/pages/signup.vue index 43c066abb..9158d7387 100644 --- a/frontend_nuxt/pages/signup.vue +++ b/frontend_nuxt/pages/signup.vue @@ -69,7 +69,7 @@