修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线

This commit is contained in:
yoyuzh
2026-03-20 18:08:59 +08:00
parent 43358e29d7
commit f8ea5a6f85
37 changed files with 1541 additions and 100 deletions

136
.clinerules Normal file
View File

@@ -0,0 +1,136 @@
# Cline Rules — yoyuzh/my_site
本项目包含 Java 后端、Vite/React 前端、`docs/` 文档区和工具脚本。以下规则适用于所有在此仓库中的操作。
---
## 项目结构
- `backend/`Spring Boot 3.3.8Java 17Maven领域包位于 `com.yoyuzh.{auth,cqu,files,config,common,admin,transfer}`。
- `front/`Vite 6React 19TypeScriptTailwind CSS v4路由/页面代码在 `src/pages`,可复用 UI 在 `src/components`,共享逻辑在 `src/lib`。
- `docs/`:实现计划存放于 `docs/superpowers/plans/`。
- `scripts/`:部署、迁移、冒烟测试和本地启动辅助脚本。
---
## 命令来源(只使用已存在的命令)
不得发明未在 `front/package.json`、`backend/pom.xml`、`backend/README.md`、`front/README.md` 或已签入脚本文件中存在的命令。
### 前端命令(在 `front/` 目录下执行)
```
npm run dev
npm run build
npm run preview
npm run clean
npm run lint
npm run test
```
**重要:**
- `npm run lint` 运行 `tsc --noEmit`,这是当前的 TypeScript 类型检查方式。
- 不存在独立的 ESLint 命令,也不存在独立的 `typecheck` 脚本。
- **禁止在仓库根目录运行 `npm` 命令**,根目录有 `package-lock.json` 但没有 `package.json`。
### 后端命令(在 `backend/` 目录下执行)
```
mvn spring-boot:run
mvn spring-boot:run -Dspring-boot.run.profiles=dev
mvn test
mvn package
```
**重要:** 后端没有定义专用的 lint 或 typecheck 命令。如果任务要求 lint/typecheck请明确说明后端当前未定义这些命令。
### 脚本文件(使用已存在的脚本,不创建新的包装命令)
- `scripts/deploy-front-oss.mjs`
- `scripts/migrate-file-storage-to-oss.mjs`
- `scripts/oss-deploy-lib.mjs`
- `scripts/oss-deploy-lib.test.mjs`
- `scripts/local-smoke.ps1`
- `scripts/start-backend-dev.ps1`
- `scripts/start-frontend-dev.ps1`
### 发布和部署命令
- 前端 OSS 发布(从仓库根目录):`node scripts/deploy-front-oss.mjs`
- 前端 OSS 试运行:`node scripts/deploy-front-oss.mjs --dry-run`
- 前端 OSS 发布(跳过构建):`node scripts/deploy-front-oss.mjs --skip-build`
- 后端打包(从 `backend/``mvn package`
**重要:**
- `scripts/deploy-front-oss.mjs` 从环境变量或 `.env.oss.local` 读取 OSS 凭证。
- 仓库中不存在后端部署脚本。后端交付是两步流程:在 `backend/` 打包生成 `backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar`,然后通过 `ssh`/`scp` 上传并重启。
- **禁止发明**远程目录、服务名、进程管理器或重启命令。从服务器发现,或在无法安全发现时询问用户。
---
## 工作流程
1. **分析阶段**:如果任务涉及多个文件、多个层次或同时跨越前后端,先制定计划再动手。
2. **探索阶段**:如果现有行为或所属模块不明显,先调查代码路径、现有行为和相关配置/测试,再实现。
3. **实现阶段**:进行实际代码修改。拥有 `backend/`、`front/`、`scripts/` 或 docs 中的编辑权,实现时可同步更新附近的测试。
4. **验证阶段**:运行已有的仓库支持命令,报告确切的失败或缺失命令。不要重写源文件来"修复"测试失败。
5. **部署阶段**:代码提交或准备好发布后,使用现有命令构建前后端,通过已签入的 OSS 部署脚本发布前端,通过 SSH 处理后端 jar 上传/重启。
---
## 核心规则
### 第一性原理思考
- 从原始需求和问题出发,不要从用户偏好的实现路径假设出发。
- 不要假设用户已经确切知道他们想要什么或如何实现。
- 对动机、目标和范围保持谨慎。如果底层目标或业务目标不清晰,先与用户讨论再实现。
### 解决方案和重构规则
- **不要**提出兼容性补丁式或临时修复式方案。
- **不要**过度设计。使用最短的正确实现路径。
- **不要**添加用户未要求的回退、降级或额外解决方案分支。
- **不要**提出超出用户明确需求且可能改变业务逻辑的任何方案。
- 每个修改或重构方案在呈现前,必须在完整请求路径中进行逻辑验证。
---
## 仓库专项规范
### 前端专项规范(适用于 `front/` 目录操作)
- 路由行为保留在 `src/pages`,共享非 UI 逻辑保留在 `src/lib`。
- 测试文件紧邻其验证的状态/辅助模块,遵循已有的 `*.test.ts` 模式。
- 保留当前的 Vite 别名用法:`@/*` 从 `front/` 目录根解析。
- 如果修改依赖后端 API 行为,修改前先验证 `vite.config.ts` 中的代理配置,不要硬编码 URL。
- 前端 API 代理定义在 `front/vite.config.ts``VITE_BACKEND_URL` 默认值为 `http://localhost:8080`。
- 前端发布使用 `node scripts/deploy-front-oss.mjs`,不要手动上传对象。
- 前端测试已存在于 `front/src/**/*.test.ts`,新测试紧邻其验证的状态或库模块。
### 后端专项规范(适用于 `backend/` 目录操作)
- 包布局:
- `auth`认证、JWT、登录/注册/个人资料 DTO 和服务
- `files`:文件 API 和存储流,包括 `files/storage`
- `cqu`CQU 课程表/成绩聚合
- `config`Spring 和安全配置
- `common`:共享异常和通用工具
- `admin`:管理员功能
- `transfer`:文件传输功能
- 保持 controller、service、DTO、config 和 storage 职责按当前包边界分离。
- 修改 `auth`、`files` 或 `cqu` 时,先检查现有测试包是否已覆盖该区域,再在其他地方添加新文件。
- 遵守 `application-dev.yml` 中的 `dev` profile不要硬编码绕过 H2 或模拟 CQU 行为的假设。
- 后端本地开发行为分布在 `backend/src/main/resources/application.yml` 和 `application-dev.yml` 中;`dev` profile 使用 H2 和模拟 CQU 数据。
- 如果修改影响文件存储行为,注意仓库当前支持本地存储和 OSS 相关迁移/部署脚本。
- 优先使用 Maven 命令进行验证,而不是临时 shell 管道。
- 后端测试已存在于 `backend/src/test/java/com/yoyuzh/...`;优先在匹配的包中添加或更新测试。
- 不要将 `backend/target/` 产物提交到 git除非用户明确要求。
### 文档专项规范(适用于 `docs/` 目录操作)
- 文档应是具体的、与仓库相关的。
- 优先记录 `front/package.json`、`backend/pom.xml`、`backend/README.md`、`front/README.md` 或已签入脚本文件中已存在的命令。
- 不要引入占位符命令,如虚构的根目录 `npm test`、后端 lint 脚本或独立的前端 typecheck 脚本。
- 文档验证时,明确说明差距:后端 lint/typecheck 命令未定义,前端类型检查通过 `npm run lint` 进行。
- 计划或交接文档应绑定到实际仓库路径,如 `backend/...`、`front/...`、`scripts/...` 和 `docs/...`。

View File

@@ -4,6 +4,7 @@ import com.yoyuzh.config.AdminProperties;
import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.CorsProperties;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.config.JwtProperties; import com.yoyuzh.config.JwtProperties;
import com.yoyuzh.config.RegistrationProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -13,7 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
JwtProperties.class, JwtProperties.class,
FileStorageProperties.class, FileStorageProperties.class,
CorsProperties.class, CorsProperties.class,
AdminProperties.class AdminProperties.class,
RegistrationProperties.class
}) })
public class PortalBackendApplication { public class PortalBackendApplication {

View File

@@ -1,6 +1,7 @@
package com.yoyuzh.admin; package com.yoyuzh.admin;
import com.yoyuzh.auth.PasswordPolicy; import com.yoyuzh.auth.PasswordPolicy;
import com.yoyuzh.auth.RegistrationInviteService;
import com.yoyuzh.auth.User; import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRole; import com.yoyuzh.auth.UserRole;
import com.yoyuzh.auth.UserRepository; import com.yoyuzh.auth.UserRepository;
@@ -21,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.List; import java.util.List;
import java.util.UUID;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -31,12 +33,14 @@ public class AdminService {
private final FileService fileService; private final FileService fileService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final RegistrationInviteService registrationInviteService;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
public AdminSummaryResponse getSummary() { public AdminSummaryResponse getSummary() {
return new AdminSummaryResponse( return new AdminSummaryResponse(
userRepository.count(), userRepository.count(),
storedFileRepository.count() storedFileRepository.count(),
registrationInviteService.getCurrentInviteCode()
); );
} }
@@ -82,6 +86,7 @@ public class AdminService {
public AdminUserResponse updateUserBanned(Long userId, boolean banned) { public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
User user = getRequiredUser(userId); User user = getRequiredUser(userId);
user.setBanned(banned); user.setBanned(banned);
user.setActiveSessionId(UUID.randomUUID().toString());
refreshTokenService.revokeAllForUser(user.getId()); refreshTokenService.revokeAllForUser(user.getId());
return toUserResponse(userRepository.save(user)); return toUserResponse(userRepository.save(user));
} }
@@ -93,6 +98,7 @@ public class AdminService {
} }
User user = getRequiredUser(userId); User user = getRequiredUser(userId);
user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setActiveSessionId(UUID.randomUUID().toString());
refreshTokenService.revokeAllForUser(user.getId()); refreshTokenService.revokeAllForUser(user.getId());
return toUserResponse(userRepository.save(user)); return toUserResponse(userRepository.save(user));
} }

View File

@@ -2,6 +2,7 @@ package com.yoyuzh.admin;
public record AdminSummaryResponse( public record AdminSummaryResponse(
long totalUsers, long totalUsers,
long totalFiles long totalFiles,
String inviteCode
) { ) {
} }

View File

@@ -46,6 +46,7 @@ public class AuthService {
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final FileService fileService; private final FileService fileService;
private final FileContentStorage fileContentStorage; private final FileContentStorage fileContentStorage;
private final RegistrationInviteService registrationInviteService;
@Transactional @Transactional
public AuthResponse register(RegisterRequest request) { public AuthResponse register(RegisterRequest request) {
@@ -59,6 +60,8 @@ public class AuthService {
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在"); throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
} }
registrationInviteService.consumeInviteCode(request.inviteCode());
User user = new User(); User user = new User();
user.setUsername(request.username()); user.setUsername(request.username());
user.setDisplayName(request.username()); user.setDisplayName(request.username());
@@ -69,9 +72,10 @@ public class AuthService {
user.setPreferredLanguage("zh-CN"); user.setPreferredLanguage("zh-CN");
User saved = userRepository.save(user); User saved = userRepository.save(user);
fileService.ensureDefaultDirectories(saved); fileService.ensureDefaultDirectories(saved);
return issueTokens(saved); return issueFreshTokens(saved);
} }
@Transactional
public AuthResponse login(LoginRequest request) { public AuthResponse login(LoginRequest request) {
try { try {
authenticationManager.authenticate( authenticationManager.authenticate(
@@ -85,7 +89,7 @@ public class AuthService {
User user = userRepository.findByUsername(request.username()) User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
fileService.ensureDefaultDirectories(user); fileService.ensureDefaultDirectories(user);
return issueTokens(user); return issueFreshTokens(user);
} }
@Transactional @Transactional
@@ -107,7 +111,7 @@ public class AuthService {
return userRepository.save(created); return userRepository.save(created);
}); });
fileService.ensureDefaultDirectories(user); fileService.ensureDefaultDirectories(user);
return issueTokens(user); return issueFreshTokens(user);
} }
@Transactional @Transactional
@@ -154,9 +158,7 @@ public class AuthService {
} }
user.setPasswordHash(passwordEncoder.encode(request.newPassword())); user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
userRepository.save(user); return issueFreshTokens(user);
refreshTokenService.revokeAllForUser(user.getId());
return issueTokens(user);
} }
public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) { public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) {
@@ -263,13 +265,24 @@ public class AuthService {
); );
} }
private AuthResponse issueTokens(User user) { private AuthResponse issueFreshTokens(User user) {
refreshTokenService.revokeAllForUser(user.getId());
return issueTokens(user, refreshTokenService.issueRefreshToken(user)); return issueTokens(user, refreshTokenService.issueRefreshToken(user));
} }
private AuthResponse issueTokens(User user, String refreshToken) { private AuthResponse issueTokens(User user, String refreshToken) {
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername()); User sessionUser = rotateActiveSession(user);
return AuthResponse.issued(accessToken, refreshToken, toProfile(user)); String accessToken = jwtTokenProvider.generateAccessToken(
sessionUser.getId(),
sessionUser.getUsername(),
sessionUser.getActiveSessionId()
);
return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser));
}
private User rotateActiveSession(User user) {
user.setActiveSessionId(UUID.randomUUID().toString());
return userRepository.save(user);
} }
private String normalizeOptionalText(String value) { private String normalizeOptionalText(String value) {

View File

@@ -11,6 +11,7 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.Date;
import org.springframework.util.StringUtils;
@Component @Component
public class JwtTokenProvider { public class JwtTokenProvider {
@@ -39,15 +40,20 @@ public class JwtTokenProvider {
secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
} }
public String generateAccessToken(Long userId, String username) { public String generateAccessToken(Long userId, String username, String sessionId) {
Instant now = Instant.now(); Instant now = Instant.now();
return Jwts.builder() var builder = Jwts.builder()
.subject(username) .subject(username)
.claim("uid", userId) .claim("uid", userId)
.issuedAt(Date.from(now)) .issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds()))) .expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds())))
.signWith(secretKey) .signWith(secretKey);
.compact();
if (StringUtils.hasText(sessionId)) {
builder.claim("sid", sessionId);
}
return builder.compact();
} }
public boolean validateToken(String token) { public boolean validateToken(String token) {
@@ -68,6 +74,21 @@ public class JwtTokenProvider {
return uid == null ? null : Long.parseLong(uid.toString()); return uid == null ? null : Long.parseLong(uid.toString());
} }
public String getSessionId(String token) {
Object sessionId = parseClaims(token).get("sid");
return sessionId == null ? null : sessionId.toString();
}
public boolean hasMatchingSession(String token, String activeSessionId) {
String tokenSessionId = getSessionId(token);
if (!StringUtils.hasText(activeSessionId)) {
return !StringUtils.hasText(tokenSessionId);
}
return activeSessionId.equals(tokenSessionId);
}
private Claims parseClaims(String token) { private Claims parseClaims(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
} }

View File

@@ -55,6 +55,7 @@ public class RefreshTokenService {
User user = existing.getUser(); User user = existing.getUser();
existing.revoke(LocalDateTime.now()); existing.revoke(LocalDateTime.now());
revokeAllForUser(user.getId());
String nextRefreshToken = issueRefreshToken(user); String nextRefreshToken = issueRefreshToken(user);
return new RotatedRefreshToken(user, nextRefreshToken); return new RotatedRefreshToken(user, nextRefreshToken);

View File

@@ -0,0 +1,96 @@
package com.yoyuzh.auth;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.RegistrationProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.SecureRandom;
@Service
@RequiredArgsConstructor
public class RegistrationInviteService {
private static final Long STATE_ID = 1L;
private static final String INVITE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
private static final int INVITE_LENGTH = 16;
private final RegistrationInviteStateRepository registrationInviteStateRepository;
private final RegistrationProperties registrationProperties;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
public String getCurrentInviteCode() {
return ensureCurrentState().getInviteCode();
}
@Transactional
public void consumeInviteCode(String inviteCode) {
RegistrationInviteState state = ensureCurrentStateForUpdate();
String candidateCode = normalize(inviteCode);
if (!state.getInviteCode().equals(candidateCode)) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "邀请码错误");
}
state.setInviteCode(generateNextInviteCode(state.getInviteCode()));
registrationInviteStateRepository.save(state);
}
private RegistrationInviteState ensureCurrentState() {
return registrationInviteStateRepository.findById(STATE_ID)
.orElseGet(this::createInitialState);
}
private RegistrationInviteState ensureCurrentStateForUpdate() {
return registrationInviteStateRepository.findByIdForUpdate(STATE_ID)
.orElseGet(() -> {
createInitialState();
return registrationInviteStateRepository.findByIdForUpdate(STATE_ID)
.orElseThrow(() -> new IllegalStateException("邀请码状态初始化失败"));
});
}
private RegistrationInviteState createInitialState() {
RegistrationInviteState state = new RegistrationInviteState();
state.setId(STATE_ID);
state.setInviteCode(resolveInitialInviteCode());
try {
return registrationInviteStateRepository.saveAndFlush(state);
} catch (DataIntegrityViolationException ignored) {
return registrationInviteStateRepository.findById(STATE_ID)
.orElseThrow(() -> ignored);
}
}
private String resolveInitialInviteCode() {
String configuredInviteCode = normalize(registrationProperties.getInviteCode());
if (StringUtils.hasText(configuredInviteCode)) {
return configuredInviteCode;
}
return generateInviteCode();
}
private String generateNextInviteCode(String currentInviteCode) {
String nextCode = generateInviteCode();
while (nextCode.equals(currentInviteCode)) {
nextCode = generateInviteCode();
}
return nextCode;
}
private String generateInviteCode() {
StringBuilder builder = new StringBuilder(INVITE_LENGTH);
for (int i = 0; i < INVITE_LENGTH; i += 1) {
builder.append(INVITE_CHARS.charAt(secureRandom.nextInt(INVITE_CHARS.length())));
}
return builder.toString();
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,50 @@
package com.yoyuzh.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_registration_invite_state")
public class RegistrationInviteState {
@Id
private Long id;
@Column(name = "invite_code", nullable = false, length = 64)
private String inviteCode;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
public void touch() {
updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getInviteCode() {
return inviteCode;
}
public void setInviteCode(String inviteCode) {
this.inviteCode = inviteCode;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
import java.util.Optional;
public interface RegistrationInviteStateRepository extends JpaRepository<RegistrationInviteState, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select state from RegistrationInviteState state where state.id = :id")
Optional<RegistrationInviteState> findByIdForUpdate(@Param("id") Long id);
}

View File

@@ -58,6 +58,9 @@ public class User {
@Column(name = "avatar_updated_at") @Column(name = "avatar_updated_at")
private LocalDateTime avatarUpdatedAt; private LocalDateTime avatarUpdatedAt;
@Column(name = "active_session_id", length = 64)
private String activeSessionId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32) @Column(nullable = false, length = 32)
private UserRole role; private UserRole role;
@@ -177,6 +180,14 @@ public class User {
this.avatarUpdatedAt = avatarUpdatedAt; this.avatarUpdatedAt = avatarUpdatedAt;
} }
public String getActiveSessionId() {
return activeSessionId;
}
public void setActiveSessionId(String activeSessionId) {
this.activeSessionId = activeSessionId;
}
public UserRole getRole() { public UserRole getRole() {
return role; return role;
} }

View File

@@ -13,11 +13,18 @@ public record RegisterRequest(
@NotBlank @NotBlank
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号") @Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
String phoneNumber, String phoneNumber,
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") String password @NotBlank @Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") String password,
@NotBlank String confirmPassword,
@NotBlank(message = "请输入邀请码") String inviteCode
) { ) {
@AssertTrue(message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") @AssertTrue(message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符")
public boolean isPasswordStrong() { public boolean isPasswordStrong() {
return PasswordPolicy.isStrong(password); return PasswordPolicy.isStrong(password);
} }
@AssertTrue(message = "两次输入的密码不一致")
public boolean isPasswordConfirmed() {
return password != null && password.equals(confirmPassword);
}
} }

View File

@@ -2,6 +2,8 @@ package com.yoyuzh.config;
import com.yoyuzh.auth.CustomUserDetailsService; import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.JwtTokenProvider; import com.yoyuzh.auth.JwtTokenProvider;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -33,6 +35,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (jwtTokenProvider.validateToken(token) if (jwtTokenProvider.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) { && SecurityContextHolder.getContext().getAuthentication() == null) {
String username = jwtTokenProvider.getUsername(token); String username = jwtTokenProvider.getUsername(token);
User domainUser;
try {
domainUser = userDetailsService.loadDomainUser(username);
} catch (BusinessException ex) {
filterChain.doFilter(request, response);
return;
}
if (!jwtTokenProvider.hasMatchingSession(token, domainUser.getActiveSessionId())) {
filterChain.doFilter(request, response);
return;
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username); UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (!userDetails.isEnabled()) { if (!userDetails.isEnabled()) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);

View File

@@ -0,0 +1,17 @@
package com.yoyuzh.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.registration")
public class RegistrationProperties {
private String inviteCode = "";
public String getInviteCode() {
return inviteCode;
}
public void setInviteCode(String inviteCode) {
this.inviteCode = inviteCode;
}
}

View File

@@ -17,3 +17,5 @@ app:
secret: ${APP_JWT_SECRET:} secret: ${APP_JWT_SECRET:}
admin: admin:
usernames: ${APP_ADMIN_USERNAMES:} usernames: ${APP_ADMIN_USERNAMES:}
registration:
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:dev-invite-code}

View File

@@ -29,6 +29,8 @@ app:
refresh-expiration-seconds: 1209600 refresh-expiration-seconds: 1209600
admin: admin:
usernames: ${APP_ADMIN_USERNAMES:} usernames: ${APP_ADMIN_USERNAMES:}
registration:
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
storage: storage:
root-dir: ./storage root-dir: ./storage
max-file-size: 524288000 max-file-size: 524288000

View File

@@ -111,7 +111,8 @@ class AdminControllerIntegrationTest {
mockMvc.perform(get("/api/admin/summary")) mockMvc.perform(get("/api/admin/summary"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalUsers").value(2)) .andExpect(jsonPath("$.data.totalUsers").value(2))
.andExpect(jsonPath("$.data.totalFiles").value(2)); .andExpect(jsonPath("$.data.totalFiles").value(2))
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
} }
@Test @Test

View File

@@ -46,7 +46,9 @@ class AuthControllerValidationTest {
"username": "alice", "username": "alice",
"email": "alice@example.com", "email": "alice@example.com",
"phoneNumber": "13800138000", "phoneNumber": "13800138000",
"password": "weakpass" "password": "weakpass",
"confirmPassword": "weakpass",
"inviteCode": "invite-code"
} }
""")) """))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -63,7 +65,9 @@ class AuthControllerValidationTest {
"username": "alice", "username": "alice",
"email": "alice@example.com", "email": "alice@example.com",
"phoneNumber": "12345", "phoneNumber": "12345",
"password": "StrongPass1!" "password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": "invite-code"
} }
""")) """))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -71,6 +75,44 @@ class AuthControllerValidationTest {
.andExpect(jsonPath("$.msg").value("请输入有效的11位手机号")); .andExpect(jsonPath("$.msg").value("请输入有效的11位手机号"));
} }
@Test
void shouldReturnReadablePasswordConfirmationMessage() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass2!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(1000))
.andExpect(jsonPath("$.msg").value("两次输入的密码不一致"));
}
@Test
void shouldReturnReadableInviteCodeMessage() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": ""
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(1000))
.andExpect(jsonPath("$.msg").value("请输入邀请码"));
}
@Test @Test
void shouldExposeRefreshEndpointContract() throws Exception { void shouldExposeRefreshEndpointContract() throws Exception {
AuthResponse response = AuthResponse.issued( AuthResponse response = AuthResponse.issued(

View File

@@ -0,0 +1,74 @@
package com.yoyuzh.auth;
import com.yoyuzh.PortalBackendApplication;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:auth_invite_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.registration.invite-code=invite-code",
"app.storage.root-dir=./target/test-storage-auth-invite"
}
)
@AutoConfigureMockMvc
class AuthRegistrationInviteIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldRejectReusingInviteCodeAfterSuccessfulRegistration() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType("application/json")
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(post("/api/auth/register")
.contentType("application/json")
.content("""
{
"username": "bob",
"email": "bob@example.com",
"phoneNumber": "13900139000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.msg").value("邀请码错误"));
}
}

View File

@@ -29,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -57,12 +58,22 @@ class AuthServiceTest {
@Mock @Mock
private FileContentStorage fileContentStorage; private FileContentStorage fileContentStorage;
@Mock
private RegistrationInviteService registrationInviteService;
@InjectMocks @InjectMocks
private AuthService authService; private AuthService authService;
@Test @Test
void shouldRegisterUserWithEncryptedPassword() { void shouldRegisterUserWithEncryptedPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!"); RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
when(userRepository.existsByUsername("alice")).thenReturn(false); when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false); when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false); when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false);
@@ -73,7 +84,7 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now()); user.setCreatedAt(LocalDateTime.now());
return user; return user;
}); });
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("access-token"); when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token"); when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
AuthResponse response = authService.register(request); AuthResponse response = authService.register(request);
@@ -83,13 +94,21 @@ class AuthServiceTest {
assertThat(response.refreshToken()).isEqualTo("refresh-token"); assertThat(response.refreshToken()).isEqualTo("refresh-token");
assertThat(response.user().username()).isEqualTo("alice"); assertThat(response.user().username()).isEqualTo("alice");
assertThat(response.user().phoneNumber()).isEqualTo("13800138000"); assertThat(response.user().phoneNumber()).isEqualTo("13800138000");
verify(registrationInviteService).consumeInviteCode("invite-code");
verify(passwordEncoder).encode("StrongPass1!"); verify(passwordEncoder).encode("StrongPass1!");
verify(fileService).ensureDefaultDirectories(any(User.class)); verify(fileService).ensureDefaultDirectories(any(User.class));
} }
@Test @Test
void shouldRejectDuplicateUsernameOnRegister() { void shouldRejectDuplicateUsernameOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!"); RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
when(userRepository.existsByUsername("alice")).thenReturn(true); when(userRepository.existsByUsername("alice")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request)) assertThatThrownBy(() -> authService.register(request))
@@ -99,7 +118,14 @@ class AuthServiceTest {
@Test @Test
void shouldRejectDuplicatePhoneNumberOnRegister() { void shouldRejectDuplicatePhoneNumberOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!"); RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
when(userRepository.existsByUsername("alice")).thenReturn(false); when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false); when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(true); when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(true);
@@ -109,6 +135,26 @@ class AuthServiceTest {
.hasMessageContaining("手机号已存在"); .hasMessageContaining("手机号已存在");
} }
@Test
void shouldRejectInvalidInviteCodeOnRegister() {
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"wrong-code"
);
var invalidInviteCode = new BusinessException(com.yoyuzh.common.ErrorCode.PERMISSION_DENIED, "邀请码错误");
org.mockito.Mockito.doThrow(invalidInviteCode)
.when(registrationInviteService)
.consumeInviteCode("wrong-code");
assertThatThrownBy(() -> authService.register(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("邀请码错误");
}
@Test @Test
void shouldLoginAndReturnToken() { void shouldLoginAndReturnToken() {
LoginRequest request = new LoginRequest("alice", "plain-password"); LoginRequest request = new LoginRequest("alice", "plain-password");
@@ -119,7 +165,8 @@ class AuthServiceTest {
user.setPasswordHash("encoded-password"); user.setPasswordHash("encoded-password");
user.setCreatedAt(LocalDateTime.now()); user.setCreatedAt(LocalDateTime.now());
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("access-token"); when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token"); when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token");
AuthResponse response = authService.login(request); AuthResponse response = authService.login(request);
@@ -142,7 +189,8 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now()); user.setCreatedAt(LocalDateTime.now());
when(refreshTokenService.rotateRefreshToken("old-refresh")) when(refreshTokenService.rotateRefreshToken("old-refresh"))
.thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh")); .thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh"));
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access"); when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access");
AuthResponse response = authService.refresh("old-refresh"); AuthResponse response = authService.refresh("old-refresh");
@@ -184,7 +232,7 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now()); user.setCreatedAt(LocalDateTime.now());
return user; return user;
}); });
when(jwtTokenProvider.generateAccessToken(9L, "demo")).thenReturn("access-token"); when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token"); when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
AuthResponse response = authService.devLogin("demo"); AuthResponse response = authService.devLogin("demo");
@@ -248,7 +296,7 @@ class AuthServiceTest {
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true); when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new"); when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
when(userRepository.save(user)).thenReturn(user); when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access"); when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access");
when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh"); when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh");
AuthResponse response = authService.changePassword("alice", request); AuthResponse response = authService.changePassword("alice", request);

View File

@@ -0,0 +1,100 @@
package com.yoyuzh.auth;
import com.jayway.jsonpath.JsonPath;
import com.yoyuzh.PortalBackendApplication;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:auth_single_device_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.storage.root-dir=./target/test-storage-auth-single-device"
}
)
@AutoConfigureMockMvc
class AuthSingleDeviceIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldInvalidatePreviousAccessTokenAfterLoggingInAgain() throws Exception {
User user = new User();
user.setUsername("alice");
user.setDisplayName("Alice");
user.setEmail("alice@example.com");
user.setPhoneNumber("13800138000");
user.setPasswordHash(passwordEncoder.encode("StrongPass1!"));
user.setPreferredLanguage("zh-CN");
user.setRole(UserRole.USER);
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
String loginRequest = """
{
"username": "alice",
"password": "StrongPass1!"
}
""";
String firstLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
String secondLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
String firstAccessToken = JsonPath.read(firstLoginResponse, "$.data.accessToken");
String secondAccessToken = JsonPath.read(secondLoginResponse, "$.data.accessToken");
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + firstAccessToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(1001));
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + secondAccessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.username").value("alice"));
}
}

View File

@@ -59,7 +59,7 @@ class JwtTokenProviderTest {
JwtTokenProvider provider = new JwtTokenProvider(properties); JwtTokenProvider provider = new JwtTokenProvider(properties);
provider.init(); provider.init();
String token = provider.generateAccessToken(7L, "alice"); String token = provider.generateAccessToken(7L, "alice", "session-1");
SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8)); SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
Instant expiration = Jwts.parser().verifyWith(secretKey).build() Instant expiration = Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token) .parseSignedClaims(token)
@@ -70,6 +70,9 @@ class JwtTokenProviderTest {
assertThat(provider.validateToken(token)).isTrue(); assertThat(provider.validateToken(token)).isTrue();
assertThat(provider.getUsername(token)).isEqualTo("alice"); assertThat(provider.getUsername(token)).isEqualTo("alice");
assertThat(provider.getUserId(token)).isEqualTo(7L); assertThat(provider.getUserId(token)).isEqualTo(7L);
assertThat(provider.getSessionId(token)).isEqualTo("session-1");
assertThat(provider.hasMatchingSession(token, "session-1")).isTrue();
assertThat(provider.hasMatchingSession(token, "session-2")).isFalse();
assertThat(expiration).isAfter(Instant.now().plusSeconds(850)); assertThat(expiration).isAfter(Instant.now().plusSeconds(850));
assertThat(expiration).isBefore(Instant.now().plusSeconds(950)); assertThat(expiration).isBefore(Instant.now().plusSeconds(950));
} }

View File

@@ -13,7 +13,14 @@ class RegisterRequestValidationTest {
@Test @Test
void shouldRejectWeakPassword() { void shouldRejectWeakPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "weakpass"); RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"weakpass",
"weakpass",
"invite-code"
);
var violations = validator.validate(request); var violations = validator.validate(request);
@@ -24,7 +31,14 @@ class RegisterRequestValidationTest {
@Test @Test
void shouldAcceptStrongPassword() { void shouldAcceptStrongPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!"); RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
var violations = validator.validate(request); var violations = validator.validate(request);
@@ -33,7 +47,14 @@ class RegisterRequestValidationTest {
@Test @Test
void shouldRejectInvalidPhoneNumber() { void shouldRejectInvalidPhoneNumber() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "12345", "StrongPass1!"); RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"12345",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
var violations = validator.validate(request); var violations = validator.validate(request);
@@ -41,4 +62,40 @@ class RegisterRequestValidationTest {
.extracting(violation -> violation.getMessage()) .extracting(violation -> violation.getMessage())
.contains("请输入有效的11位手机号"); .contains("请输入有效的11位手机号");
} }
@Test
void shouldRejectMismatchedPasswordConfirmation() {
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass2!",
"invite-code"
);
var violations = validator.validate(request);
assertThat(violations)
.extracting(violation -> violation.getMessage())
.contains("两次输入的密码不一致");
}
@Test
void shouldRejectBlankInviteCode() {
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
""
);
var violations = validator.validate(request);
assertThat(violations)
.extracting(violation -> violation.getMessage())
.contains("请输入邀请码");
}
} }

View File

@@ -0,0 +1,60 @@
# Invite Code Rotation Admin Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make registration invite codes single-use, rotate them after each successful registration, and show the current invite code in the admin dashboard with copy support.
**Architecture:** Persist the current invite code in backend storage instead of relying on a fixed runtime property at validation time. Consume and rotate the code inside the registration transaction, then expose the current code through the existing admin summary API so the admin dashboard can render and copy it without introducing an extra management surface.
**Tech Stack:** Spring Boot 3.3, Spring Data JPA, H2/MySQL, React 19, Vite, MUI, existing `apiRequest` helpers, existing backend/frontend test runners.
---
### Task 1: Persist invite code state on the backend
**Files:**
- Create: `backend/src/main/java/com/yoyuzh/auth/RegistrationInviteState.java`
- Create: `backend/src/main/java/com/yoyuzh/auth/RegistrationInviteStateRepository.java`
- Create: `backend/src/main/java/com/yoyuzh/auth/RegistrationInviteService.java`
- Modify: `backend/src/main/java/com/yoyuzh/auth/AuthService.java`
- Modify: `backend/src/main/java/com/yoyuzh/config/RegistrationProperties.java`
- [ ] Write a failing backend test that proves a successful registration rotates the invite code and invalidates the previous one.
- [ ] Run the backend auth test to verify the new expectation fails for the current static invite code implementation.
- [ ] Implement the persisted invite code state, bootstrap behavior, and transaction-safe consume-and-rotate flow.
- [ ] Re-run the backend auth test until it passes.
### Task 2: Expose the current invite code to admins
**Files:**
- Modify: `backend/src/main/java/com/yoyuzh/admin/AdminService.java`
- Modify: `backend/src/main/java/com/yoyuzh/admin/AdminSummaryResponse.java`
- Modify: `backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java`
- [ ] Write a failing admin integration test that expects the summary API to include the current invite code.
- [ ] Run the admin integration test to verify it fails before the API change.
- [ ] Extend the summary response with the current invite code metadata.
- [ ] Re-run the admin integration test until it passes.
### Task 3: Show and copy the code from the admin dashboard
**Files:**
- Modify: `front/src/lib/types.ts`
- Create: `front/src/admin/dashboard-state.ts`
- Create: `front/src/admin/dashboard-state.test.ts`
- Modify: `front/src/admin/dashboard.tsx`
- [ ] Write a failing frontend test for dashboard helpers that maps the summary payload into invite-code display state.
- [ ] Run the frontend test to verify it fails before the new helper exists.
- [ ] Implement the helper and update the dashboard UI to render the code, refresh the summary, and copy the current code.
- [ ] Re-run the targeted frontend test until it passes.
### Task 4: Verify the full flow
**Files:**
- Modify only if verification reveals gaps.
- [ ] Run `cd backend && mvn test`
- [ ] Run `cd front && npm run test`
- [ ] Run `cd front && npm run lint`
- [ ] Summarize behavior changes and any deployment follow-up needed for the user.

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getInviteCodePanelState } from './dashboard-state';
test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
assert.deepEqual(
getInviteCodePanelState({
totalUsers: 12,
totalFiles: 34,
inviteCode: ' AbCd1234 ',
}),
{
inviteCode: 'AbCd1234',
canCopy: true,
},
);
});
test('getInviteCodePanelState falls back to a placeholder when summary has no invite code', () => {
assert.deepEqual(
getInviteCodePanelState({
totalUsers: 12,
totalFiles: 34,
inviteCode: ' ',
}),
{
inviteCode: '未生成',
canCopy: false,
},
);
});

View File

@@ -0,0 +1,21 @@
import type { AdminSummary } from '@/src/lib/types';
export interface InviteCodePanelState {
inviteCode: string;
canCopy: boolean;
}
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
const inviteCode = summary?.inviteCode?.trim() ?? '';
if (!inviteCode) {
return {
inviteCode: '未生成',
canCopy: false,
};
}
return {
inviteCode,
canCopy: true,
};
}

View File

@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Alert, Button, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { apiRequest } from '@/src/lib/api'; import { apiRequest } from '@/src/lib/api';
import { readStoredSession } from '@/src/lib/session'; import { readStoredSession } from '@/src/lib/session';
import type { AdminSummary } from '@/src/lib/types'; import type { AdminSummary } from '@/src/lib/types';
import { getInviteCodePanelState } from './dashboard-state';
interface DashboardState { interface DashboardState {
summary: AdminSummary | null; summary: AdminSummary | null;
@@ -33,12 +37,31 @@ export function PortalAdminDashboard() {
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [copyMessage, setCopyMessage] = useState('');
const navigate = useNavigate();
const session = readStoredSession(); const session = readStoredSession();
async function loadDashboardData() {
setLoading(true);
setError('');
try {
const summary = await apiRequest<AdminSummary>('/admin/summary');
setState({
summary,
});
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
let active = true; let active = true;
async function loadDashboardData() { void (async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -63,24 +86,52 @@ export function PortalAdminDashboard() {
setLoading(false); setLoading(false);
} }
} }
} })();
loadDashboardData();
return () => { return () => {
active = false; active = false;
}; };
}, []); }, []);
const inviteCodePanel = getInviteCodePanelState(state.summary);
async function handleRefreshInviteCode() {
setCopyMessage('');
await loadDashboardData();
}
async function handleCopyInviteCode() {
if (!inviteCodePanel.canCopy) {
return;
}
if (!navigator.clipboard?.writeText) {
setError('当前浏览器不支持复制邀请码');
return;
}
try {
await navigator.clipboard.writeText(inviteCodePanel.inviteCode);
setCopyMessage('邀请码已复制到剪贴板');
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '复制邀请码失败');
}
}
return ( return (
<Stack spacing={3} sx={{ p: 2 }}> <Stack spacing={3} sx={{ p: 2 }}>
<Stack spacing={1}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
<Typography variant="h4" fontWeight={700}> <Stack spacing={1}>
YOYUZH Admin <Typography variant="h4" fontWeight={700}>
</Typography> YOYUZH Admin
<Typography color="text.secondary"> </Typography>
react-admin `/api/admin/**` <Typography color="text.secondary">
</Typography> react-admin `/api/admin/**`
</Typography>
</Stack>
<Button variant="outlined" onClick={() => navigate('/overview')}>
</Button>
</Stack> </Stack>
{loading && ( {loading && (
@@ -113,7 +164,7 @@ export function PortalAdminDashboard() {
</Grid> </Grid>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined"> <Card variant="outlined">
<CardContent> <CardContent>
<Stack spacing={1}> <Stack spacing={1}>
@@ -134,7 +185,7 @@ export function PortalAdminDashboard() {
</Card> </Card>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined"> <Card variant="outlined">
<CardContent> <CardContent>
<Stack spacing={1}> <Stack spacing={1}>
@@ -151,6 +202,57 @@ export function PortalAdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined">
<CardContent>
<Stack spacing={1.5}>
<Typography variant="h6" fontWeight={600}>
</Typography>
<Typography color="text.secondary">
</Typography>
<Typography
component="code"
sx={{
display: 'inline-block',
width: 'fit-content',
px: 1.5,
py: 1,
borderRadius: 1,
backgroundColor: 'action.hover',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.95rem',
}}
>
{inviteCodePanel.inviteCode}
</Typography>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
size="small"
startIcon={<ContentCopyIcon />}
onClick={() => void handleCopyInviteCode()}
disabled={!inviteCodePanel.canCopy}
>
</Button>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={() => void handleRefreshInviteCode()}
disabled={loading}
>
</Button>
</Stack>
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
</Stack>
</CardContent>
</Card>
</Grid>
</Grid> </Grid>
</Stack> </Stack>
); );

View File

@@ -9,6 +9,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
const request = async () => ({ const request = async () => ({
totalUsers: 1, totalUsers: 1,
totalFiles: 2, totalFiles: 2,
inviteCode: 'invite-code',
}); });
await assert.doesNotReject(async () => { await assert.doesNotReject(async () => {

View File

@@ -16,6 +16,7 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminSummary { export interface AdminSummary {
totalUsers: number; totalUsers: number;
totalFiles: number; totalFiles: number;
inviteCode: string;
} }
export interface AdminUser { export interface AdminUser {

View File

@@ -7,7 +7,6 @@ import {
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
Download, Download,
Monitor,
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
FileUp, FileUp,
@@ -61,19 +60,15 @@ import {
replaceUiFile, replaceUiFile,
syncSelectedFile, syncSelectedFile,
} from './files-state'; } from './files-state';
import {
const QUICK_ACCESS = [ buildDirectoryTree,
{ name: '桌面', icon: Monitor, path: [] as string[] }, createExpandedDirectorySet,
{ name: '下载', icon: Download, path: ['下载'] }, getMissingDirectoryListingPaths,
{ name: '文档', icon: FileText, path: ['文档'] }, mergeDirectoryChildren,
{ name: '图片', icon: ImageIcon, path: ['图片'] }, toDirectoryPath,
]; type DirectoryChildrenMap,
type DirectoryTreeNode,
const DIRECTORIES = [ } from './files-tree';
{ name: '下载', icon: Folder },
{ name: '文档', icon: Folder },
{ name: '图片', icon: Folder },
];
function sleep(ms: number) { function sleep(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -82,7 +77,52 @@ function sleep(ms: number) {
} }
function toBackendPath(pathParts: string[]) { function toBackendPath(pathParts: string[]) {
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`; return toDirectoryPath(pathParts);
}
function DirectoryTreeItem({
node,
onSelect,
onToggle,
}: {
node: DirectoryTreeNode;
onSelect: (path: string[]) => void;
onToggle: (path: string[]) => void;
}) {
return (
<div>
<div
className={cn(
'group flex items-center gap-1 rounded-xl px-2 py-1.5 transition-colors',
node.active ? 'bg-[#336EFF]/15' : 'hover:bg-white/5',
)}
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
>
<button
type="button"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => onToggle(node.path)}
aria-label={`${node.expanded ? '收起' : '展开'} ${node.name}`}
>
{node.expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<button
type="button"
className={cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1 text-left text-sm transition-colors',
node.active ? 'text-[#336EFF]' : 'text-slate-300 hover:text-white',
)}
onClick={() => onSelect(node.path)}
>
<Folder className={cn('h-4 w-4 shrink-0', node.active ? 'text-[#336EFF]' : 'text-slate-500')} />
<span className="truncate">{node.name}</span>
</button>
</div>
{node.expanded ? node.children.map((child) => (
<DirectoryTreeItem key={child.id} node={child} onSelect={onSelect} onToggle={onToggle} />
)) : null}
</div>
);
} }
function formatFileSize(size: number) { function formatFileSize(size: number) {
@@ -138,6 +178,21 @@ export default function Files() {
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>()); const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
const [currentPath, setCurrentPath] = useState<string[]>(initialPath); const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const currentPathRef = useRef(currentPath); const currentPathRef = useRef(currentPath);
const [directoryChildren, setDirectoryChildren] = useState<DirectoryChildrenMap>(() => {
if (initialCachedFiles.length === 0) {
return {};
}
return mergeDirectoryChildren(
{},
toBackendPath(initialPath),
initialCachedFiles.filter((file) => file.directory).map((file) => file.filename),
);
});
const [loadedDirectoryPaths, setLoadedDirectoryPaths] = useState<Set<string>>(
() => new Set(initialCachedFiles.length === 0 ? [] : [toBackendPath(initialPath)]),
);
const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath));
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null); const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile)); const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
const [uploads, setUploads] = useState<UploadTask[]>([]); const [uploads, setUploads] = useState<UploadTask[]>([]);
@@ -155,21 +210,64 @@ export default function Files() {
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [shareStatus, setShareStatus] = useState(''); const [shareStatus, setShareStatus] = useState('');
const recordDirectoryChildren = (pathParts: string[], items: FileMetadata[]) => {
setDirectoryChildren((previous) => {
let next = mergeDirectoryChildren(
previous,
toBackendPath(pathParts),
items.filter((file) => file.directory).map((file) => file.filename),
);
for (let index = 0; index < pathParts.length; index += 1) {
next = mergeDirectoryChildren(
next,
toBackendPath(pathParts.slice(0, index)),
[pathParts[index]],
);
}
return next;
});
};
const markDirectoryLoaded = (pathParts: string[]) => {
const path = toBackendPath(pathParts);
setLoadedDirectoryPaths((previous) => {
if (previous.has(path)) {
return previous;
}
const next = new Set(previous);
next.add(path);
return next;
});
};
const loadCurrentPath = async (pathParts: string[]) => { const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>( const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100` `/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
); );
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items); writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
writeCachedValue(getFilesLastPathCacheKey(), pathParts); writeCachedValue(getFilesLastPathCacheKey(), pathParts);
recordDirectoryChildren(pathParts, response.items);
markDirectoryLoaded(pathParts);
setCurrentFiles(response.items.map(toUiFile)); setCurrentFiles(response.items.map(toUiFile));
}; };
useEffect(() => { useEffect(() => {
currentPathRef.current = currentPath; currentPathRef.current = currentPath;
setExpandedDirectories((previous) => {
const next = new Set(previous);
for (const path of createExpandedDirectorySet(currentPath)) {
next.add(path);
}
return next;
});
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath))); const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
writeCachedValue(getFilesLastPathCacheKey(), currentPath); writeCachedValue(getFilesLastPathCacheKey(), currentPath);
if (cachedFiles) { if (cachedFiles) {
recordDirectoryChildren(currentPath, cachedFiles);
setCurrentFiles(cachedFiles.map(toUiFile)); setCurrentFiles(cachedFiles.map(toUiFile));
} }
@@ -180,6 +278,44 @@ export default function Files() {
}); });
}, [currentPath]); }, [currentPath]);
useEffect(() => {
const missingAncestors = getMissingDirectoryListingPaths(currentPath, loadedDirectoryPaths);
if (missingAncestors.length === 0) {
return;
}
let cancelled = false;
Promise.all(
missingAncestors.map(async (pathParts) => {
const path = toBackendPath(pathParts);
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(path), response.items);
return { pathParts, items: response.items };
}),
)
.then((responses) => {
if (cancelled) {
return;
}
for (const response of responses) {
recordDirectoryChildren(response.pathParts, response.items);
markDirectoryLoaded(response.pathParts);
}
})
.catch(() => {
// The main content area already loaded the current directory; keep the tree best-effort.
});
return () => {
cancelled = true;
};
}, [currentPath, loadedDirectoryPaths]);
useEffect(() => { useEffect(() => {
if (!directoryInputRef.current) { if (!directoryInputRef.current) {
return; return;
@@ -195,6 +331,38 @@ export default function Files() {
setActiveDropdown(null); setActiveDropdown(null);
}; };
const handleDirectoryToggle = async (pathParts: string[]) => {
const path = toBackendPath(pathParts);
let shouldLoadChildren = false;
setExpandedDirectories((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
return next;
}
next.add(path);
shouldLoadChildren = !(path in directoryChildren);
return next;
});
if (!shouldLoadChildren) {
return;
}
try {
const response = await apiRequest<PageResponse<FileMetadata>>(
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
);
writeCachedValue(getFilesListCacheKey(path), response.items);
recordDirectoryChildren(pathParts, response.items);
markDirectoryLoaded(pathParts);
} catch {
// Keep the branch expanded even if lazy loading fails; the main content area remains the source of truth.
}
};
const handleFolderDoubleClick = (file: UiFile) => { const handleFolderDoubleClick = (file: UiFile) => {
if (file.type === 'folder') { if (file.type === 'folder') {
setCurrentPath([...currentPath, file.name]); setCurrentPath([...currentPath, file.name]);
@@ -574,47 +742,38 @@ export default function Files() {
} }
}; };
const directoryTree = buildDirectoryTree(directoryChildren, currentPath, expandedDirectories);
return ( return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]"> <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */} {/* Left Sidebar */}
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto"> <Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto">
<CardContent className="p-4 space-y-6"> <CardContent className="p-4">
<div className="space-y-1"> <div className="space-y-2">
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">访</p> <p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider"></p>
{QUICK_ACCESS.map((item) => ( <div className="rounded-2xl border border-white/5 bg-black/20 p-2">
<button <button
key={item.name} type="button"
onClick={() => handleSidebarClick(item.path)} onClick={() => handleSidebarClick([])}
className={cn( className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', 'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
currentPath.join('/') === item.path.join('/') currentPath.length === 0 ? 'bg-[#336EFF]/15 text-[#336EFF]' : 'text-slate-200 hover:bg-white/5 hover:text-white',
? 'bg-[#336EFF]/20 text-[#336EFF]'
: 'text-slate-300 hover:text-white hover:bg-white/5'
)} )}
> >
<item.icon className={cn('w-4 h-4', currentPath.join('/') === item.path.join('/') ? 'text-[#336EFF]' : 'text-slate-400')} /> <Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
{item.name} <span className="truncate"></span>
</button> </button>
))} <div className="mt-1 space-y-0.5">
</div> {directoryTree.map((node) => (
<DirectoryTreeItem
<div className="space-y-1"> key={node.id}
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2"></p> node={node}
{DIRECTORIES.map((item) => ( onSelect={handleSidebarClick}
<button onToggle={(path) => void handleDirectoryToggle(path)}
key={item.name} />
onClick={() => handleSidebarClick([item.name])} ))}
className={cn( </div>
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', </div>
currentPath.length === 1 && currentPath[0] === item.name
? 'bg-[#336EFF]/20 text-[#336EFF]'
: 'text-slate-300 hover:text-white hover:bg-white/5'
)}
>
<item.icon className={cn('w-4 h-4', currentPath.length === 1 && currentPath[0] === item.name ? 'text-[#336EFF]' : 'text-slate-400')} />
{item.name}
</button>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -11,6 +11,7 @@ import { getPostLoginRedirectPath } from '@/src/lib/file-share';
import { cn } from '@/src/lib/utils'; import { cn } from '@/src/lib/utils';
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session'; import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse } from '@/src/lib/types'; import type { AuthResponse } from '@/src/lib/types';
import { buildRegisterPayload, validateRegisterForm } from './login-state';
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true'; const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
@@ -26,6 +27,8 @@ export default function Login() {
const [registerEmail, setRegisterEmail] = useState(''); const [registerEmail, setRegisterEmail] = useState('');
const [registerPhoneNumber, setRegisterPhoneNumber] = useState(''); const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
const [registerPassword, setRegisterPassword] = useState(''); const [registerPassword, setRegisterPassword] = useState('');
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
const [registerInviteCode, setRegisterInviteCode] = useState('');
function switchMode(nextIsLogin: boolean) { function switchMode(nextIsLogin: boolean) {
setIsLogin(nextIsLogin); setIsLogin(nextIsLogin);
@@ -74,18 +77,33 @@ export default function Login() {
async function handleRegisterSubmit(e: React.FormEvent) { async function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const validationMessage = validateRegisterForm({
username: registerUsername,
email: registerEmail,
phoneNumber: registerPhoneNumber,
password: registerPassword,
confirmPassword: registerConfirmPassword,
inviteCode: registerInviteCode,
});
if (validationMessage) {
setError(validationMessage);
return;
}
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const auth = await apiRequest<AuthResponse>('/auth/register', { const auth = await apiRequest<AuthResponse>('/auth/register', {
method: 'POST', method: 'POST',
body: { body: buildRegisterPayload({
username: registerUsername.trim(), username: registerUsername,
email: registerEmail.trim(), email: registerEmail,
phoneNumber: registerPhoneNumber.trim(), phoneNumber: registerPhoneNumber,
password: registerPassword, password: registerPassword,
}, confirmPassword: registerConfirmPassword,
inviteCode: registerInviteCode,
}),
}); });
saveStoredSession(createSession(auth)); saveStoredSession(createSession(auth));
@@ -321,6 +339,36 @@ export default function Login() {
10 10
</p> </p>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="password"
placeholder="请再次输入密码"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={registerConfirmPassword}
onChange={(event) => setRegisterConfirmPassword(event.target.value)}
required
minLength={10}
maxLength={64}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative">
<UserPlus className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="text"
placeholder="请输入邀请码"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={registerInviteCode}
onChange={(event) => setRegisterInviteCode(event.target.value)}
required
/>
</div>
</div>
</div> </div>
{error && ( {error && (

View File

@@ -592,8 +592,10 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
<Input <Input
value={receiveCode} value={receiveCode}
onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))} onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))}
placeholder="例如: 849201" inputMode="numeric"
className="h-16 bg-black/20 border-white/10 text-center text-3xl tracking-[0.5em] font-mono text-white" aria-label="六位取件码"
placeholder="请输入 6 位取件码"
className="h-14 rounded-2xl border-white/10 bg-white/[0.03] px-4 text-center text-xl font-semibold tracking-[0.28em] text-slate-100 placeholder:text-slate-500 focus-visible:ring-emerald-400/60"
/> />
</div> </div>
<Button <Button

View File

@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildDirectoryTree,
createExpandedDirectorySet,
getMissingDirectoryListingPaths,
mergeDirectoryChildren,
} from './files-tree';
test('createExpandedDirectorySet keeps the root and every ancestor expanded', () => {
assert.deepEqual(
[...createExpandedDirectorySet(['文档', '课程资料', '实验'])],
['/', '/文档', '/文档/课程资料', '/文档/课程资料/实验'],
);
});
test('mergeDirectoryChildren keeps directory names unique while preserving existing order', () => {
assert.deepEqual(
mergeDirectoryChildren(
{
'/': ['图片'],
},
'/',
['下载', '图片', '文档'],
),
{
'/': ['图片', '下载', '文档'],
},
);
});
test('buildDirectoryTree marks the active branch and nested folders correctly', () => {
const tree = buildDirectoryTree(
{
'/': ['下载', '文档'],
'/文档': ['课程资料'],
'/文档/课程资料': ['实验'],
},
['文档', '课程资料'],
createExpandedDirectorySet(['文档', '课程资料']),
);
assert.deepEqual(tree, [
{
id: '/下载',
name: '下载',
path: ['/下载'.replace(/^\//, '')].filter(Boolean),
depth: 0,
active: false,
expanded: false,
children: [],
},
{
id: '/文档',
name: '文档',
path: ['文档'],
depth: 0,
active: false,
expanded: true,
children: [
{
id: '/文档/课程资料',
name: '课程资料',
path: ['文档', '课程资料'],
depth: 1,
active: true,
expanded: true,
children: [
{
id: '/文档/课程资料/实验',
name: '实验',
path: ['文档', '课程资料', '实验'],
depth: 2,
active: false,
expanded: false,
children: [],
},
],
},
],
},
]);
});
test('getMissingDirectoryListingPaths requests any unloaded ancestors for a deep current path', () => {
assert.deepEqual(
getMissingDirectoryListingPaths(
['文档', '课程资料', '实验'],
new Set(['/文档/课程资料/实验']),
),
[[], ['文档'], ['文档', '课程资料']],
);
});
test('getMissingDirectoryListingPaths ignores ancestors that were only inferred by the tree', () => {
assert.deepEqual(
getMissingDirectoryListingPaths(
['文档', '课程资料'],
new Set(['/文档/课程资料']),
),
[[], ['文档']],
);
});

View File

@@ -0,0 +1,97 @@
export interface DirectoryTreeNode {
id: string;
name: string;
path: string[];
depth: number;
active: boolean;
expanded: boolean;
children: DirectoryTreeNode[];
}
export type DirectoryChildrenMap = Record<string, string[]>;
export function toDirectoryPath(pathParts: string[]) {
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
}
export function createExpandedDirectorySet(pathParts: string[]) {
const expandedPaths = new Set<string>(['/']);
const segments: string[] = [];
for (const part of pathParts) {
segments.push(part);
expandedPaths.add(toDirectoryPath(segments));
}
return expandedPaths;
}
export function mergeDirectoryChildren(
directoryChildren: DirectoryChildrenMap,
parentPath: string,
childNames: string[],
) {
const nextNames = new Set(directoryChildren[parentPath] ?? []);
for (const childName of childNames) {
const normalizedName = childName.trim();
if (normalizedName) {
nextNames.add(normalizedName);
}
}
return {
...directoryChildren,
[parentPath]: [...nextNames],
};
}
export function getMissingDirectoryListingPaths(
pathParts: string[],
loadedDirectoryPaths: Set<string>,
) {
const missingPaths: string[][] = [];
for (let depth = 0; depth < pathParts.length; depth += 1) {
const ancestorPath = pathParts.slice(0, depth);
if (!loadedDirectoryPaths.has(toDirectoryPath(ancestorPath))) {
missingPaths.push(ancestorPath);
}
}
return missingPaths;
}
export function buildDirectoryTree(
directoryChildren: DirectoryChildrenMap,
currentPath: string[],
expandedPaths: Set<string>,
): DirectoryTreeNode[] {
function getChildNames(parentPath: string, parentParts: string[]) {
const nextNames = new Set(directoryChildren[parentPath] ?? []);
const currentChild = currentPath[parentParts.length];
if (currentChild) {
nextNames.add(currentChild);
}
return [...nextNames];
}
function buildNodes(parentPath: string, parentParts: string[]): DirectoryTreeNode[] {
return getChildNames(parentPath, parentParts).map((name) => {
const path = [...parentParts, name];
const id = toDirectoryPath(path);
const expanded = expandedPaths.has(id);
return {
id,
name,
path,
depth: parentParts.length,
active: path.join('/') === currentPath.join('/'),
expanded,
children: expanded ? buildNodes(id, path) : [],
};
});
}
return buildNodes('/', []);
}

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRegisterPayload, validateRegisterForm } from './login-state';
test('validateRegisterForm rejects mismatched passwords', () => {
const result = validateRegisterForm({
username: 'alice',
email: 'alice@example.com',
phoneNumber: '13800138000',
password: 'StrongPass1!',
confirmPassword: 'StrongPass2!',
inviteCode: 'invite-code',
});
assert.equal(result, '两次输入的密码不一致');
});
test('validateRegisterForm rejects blank invite code', () => {
const result = validateRegisterForm({
username: 'alice',
email: 'alice@example.com',
phoneNumber: '13800138000',
password: 'StrongPass1!',
confirmPassword: 'StrongPass1!',
inviteCode: ' ',
});
assert.equal(result, '请输入邀请码');
});
test('buildRegisterPayload trims fields and keeps invite code', () => {
const payload = buildRegisterPayload({
username: ' alice ',
email: ' alice@example.com ',
phoneNumber: '13800138000',
password: 'StrongPass1!',
confirmPassword: 'StrongPass1!',
inviteCode: ' invite-code ',
});
assert.deepEqual(payload, {
username: 'alice',
email: 'alice@example.com',
phoneNumber: '13800138000',
password: 'StrongPass1!',
confirmPassword: 'StrongPass1!',
inviteCode: 'invite-code',
});
});

View File

@@ -0,0 +1,40 @@
export interface RegisterFormValues {
username: string;
email: string;
phoneNumber: string;
password: string;
confirmPassword: string;
inviteCode: string;
}
export interface RegisterRequestPayload {
username: string;
email: string;
phoneNumber: string;
password: string;
confirmPassword: string;
inviteCode: string;
}
export function validateRegisterForm(values: RegisterFormValues) {
if (values.password !== values.confirmPassword) {
return '两次输入的密码不一致';
}
if (!values.inviteCode.trim()) {
return '请输入邀请码';
}
return '';
}
export function buildRegisterPayload(values: RegisterFormValues): RegisterRequestPayload {
return {
username: values.username.trim(),
email: values.email.trim(),
phoneNumber: values.phoneNumber.trim(),
password: values.password,
confirmPassword: values.confirmPassword,
inviteCode: values.inviteCode.trim(),
};
}

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}