修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线
This commit is contained in:
136
.clinerules
Normal file
136
.clinerules
Normal file
@@ -0,0 +1,136 @@
|
||||
# Cline Rules — yoyuzh/my_site
|
||||
|
||||
本项目包含 Java 后端、Vite/React 前端、`docs/` 文档区和工具脚本。以下规则适用于所有在此仓库中的操作。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
- `backend/`:Spring Boot 3.3.8,Java 17,Maven,领域包位于 `com.yoyuzh.{auth,cqu,files,config,common,admin,transfer}`。
|
||||
- `front/`:Vite 6,React 19,TypeScript,Tailwind 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/...`。
|
||||
@@ -4,6 +4,7 @@ import com.yoyuzh.config.AdminProperties;
|
||||
import com.yoyuzh.config.CorsProperties;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import com.yoyuzh.config.RegistrationProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -13,7 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
JwtProperties.class,
|
||||
FileStorageProperties.class,
|
||||
CorsProperties.class,
|
||||
AdminProperties.class
|
||||
AdminProperties.class,
|
||||
RegistrationProperties.class
|
||||
})
|
||||
public class PortalBackendApplication {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import com.yoyuzh.auth.RegistrationInviteService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
@@ -21,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -31,12 +33,14 @@ public class AdminService {
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count()
|
||||
storedFileRepository.count(),
|
||||
registrationInviteService.getCurrentInviteCode()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +86,7 @@ public class AdminService {
|
||||
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setBanned(banned);
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
@@ -93,6 +98,7 @@ public class AdminService {
|
||||
}
|
||||
User user = getRequiredUser(userId);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSummaryResponse(
|
||||
long totalUsers,
|
||||
long totalFiles
|
||||
long totalFiles,
|
||||
String inviteCode
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class AuthService {
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
@@ -59,6 +60,8 @@ public class AuthService {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
|
||||
}
|
||||
|
||||
registrationInviteService.consumeInviteCode(request.inviteCode());
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setDisplayName(request.username());
|
||||
@@ -69,9 +72,10 @@ public class AuthService {
|
||||
user.setPreferredLanguage("zh-CN");
|
||||
User saved = userRepository.save(user);
|
||||
fileService.ensureDefaultDirectories(saved);
|
||||
return issueTokens(saved);
|
||||
return issueFreshTokens(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
try {
|
||||
authenticationManager.authenticate(
|
||||
@@ -85,7 +89,7 @@ public class AuthService {
|
||||
User user = userRepository.findByUsername(request.username())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
return issueTokens(user);
|
||||
return issueFreshTokens(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -107,7 +111,7 @@ public class AuthService {
|
||||
return userRepository.save(created);
|
||||
});
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
return issueTokens(user);
|
||||
return issueFreshTokens(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -154,9 +158,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
userRepository.save(user);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return issueTokens(user);
|
||||
return issueFreshTokens(user);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private AuthResponse issueTokens(User user, String refreshToken) {
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername());
|
||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(user));
|
||||
User sessionUser = rotateActiveSession(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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
@@ -39,15 +40,20 @@ public class JwtTokenProvider {
|
||||
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();
|
||||
return Jwts.builder()
|
||||
var builder = Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("uid", userId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds())))
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
.signWith(secretKey);
|
||||
|
||||
if (StringUtils.hasText(sessionId)) {
|
||||
builder.claim("sid", sessionId);
|
||||
}
|
||||
|
||||
return builder.compact();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
@@ -68,6 +74,21 @@ public class JwtTokenProvider {
|
||||
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) {
|
||||
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ public class RefreshTokenService {
|
||||
|
||||
User user = existing.getUser();
|
||||
existing.revoke(LocalDateTime.now());
|
||||
revokeAllForUser(user.getId());
|
||||
|
||||
String nextRefreshToken = issueRefreshToken(user);
|
||||
return new RotatedRefreshToken(user, nextRefreshToken);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -58,6 +58,9 @@ public class User {
|
||||
@Column(name = "avatar_updated_at")
|
||||
private LocalDateTime avatarUpdatedAt;
|
||||
|
||||
@Column(name = "active_session_id", length = 64)
|
||||
private String activeSessionId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private UserRole role;
|
||||
@@ -177,6 +180,14 @@ public class User {
|
||||
this.avatarUpdatedAt = avatarUpdatedAt;
|
||||
}
|
||||
|
||||
public String getActiveSessionId() {
|
||||
return activeSessionId;
|
||||
}
|
||||
|
||||
public void setActiveSessionId(String activeSessionId) {
|
||||
this.activeSessionId = activeSessionId;
|
||||
}
|
||||
|
||||
public UserRole getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,18 @@ public record RegisterRequest(
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
|
||||
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位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(password);
|
||||
}
|
||||
|
||||
@AssertTrue(message = "两次输入的密码不一致")
|
||||
public boolean isPasswordConfirmed() {
|
||||
return password != null && password.equals(confirmPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.yoyuzh.config;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.JwtTokenProvider;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -33,6 +35,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
if (jwtTokenProvider.validateToken(token)
|
||||
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
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);
|
||||
if (!userDetails.isEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,5 @@ app:
|
||||
secret: ${APP_JWT_SECRET:}
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
registration:
|
||||
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:dev-invite-code}
|
||||
|
||||
@@ -29,6 +29,8 @@ app:
|
||||
refresh-expiration-seconds: 1209600
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
registration:
|
||||
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 524288000
|
||||
|
||||
@@ -111,7 +111,8 @@ class AdminControllerIntegrationTest {
|
||||
mockMvc.perform(get("/api/admin/summary"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.totalUsers").value(2))
|
||||
.andExpect(jsonPath("$.data.totalFiles").value(2));
|
||||
.andExpect(jsonPath("$.data.totalFiles").value(2))
|
||||
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -46,7 +46,9 @@ class AuthControllerValidationTest {
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"phoneNumber": "13800138000",
|
||||
"password": "weakpass"
|
||||
"password": "weakpass",
|
||||
"confirmPassword": "weakpass",
|
||||
"inviteCode": "invite-code"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -63,7 +65,9 @@ class AuthControllerValidationTest {
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"phoneNumber": "12345",
|
||||
"password": "StrongPass1!"
|
||||
"password": "StrongPass1!",
|
||||
"confirmPassword": "StrongPass1!",
|
||||
"inviteCode": "invite-code"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -71,6 +75,44 @@ class AuthControllerValidationTest {
|
||||
.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
|
||||
void shouldExposeRefreshEndpointContract() throws Exception {
|
||||
AuthResponse response = AuthResponse.issued(
|
||||
|
||||
@@ -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("邀请码错误"));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -57,12 +58,22 @@ class AuthServiceTest {
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
@Mock
|
||||
private RegistrationInviteService registrationInviteService;
|
||||
|
||||
@InjectMocks
|
||||
private AuthService authService;
|
||||
|
||||
@Test
|
||||
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.existsByEmail("alice@example.com")).thenReturn(false);
|
||||
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false);
|
||||
@@ -73,7 +84,7 @@ class AuthServiceTest {
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
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");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
@@ -83,13 +94,21 @@ class AuthServiceTest {
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
||||
assertThat(response.user().username()).isEqualTo("alice");
|
||||
assertThat(response.user().phoneNumber()).isEqualTo("13800138000");
|
||||
verify(registrationInviteService).consumeInviteCode("invite-code");
|
||||
verify(passwordEncoder).encode("StrongPass1!");
|
||||
verify(fileService).ensureDefaultDirectories(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
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);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(request))
|
||||
@@ -99,7 +118,14 @@ class AuthServiceTest {
|
||||
|
||||
@Test
|
||||
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.existsByEmail("alice@example.com")).thenReturn(false);
|
||||
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(true);
|
||||
@@ -109,6 +135,26 @@ class AuthServiceTest {
|
||||
.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
|
||||
void shouldLoginAndReturnToken() {
|
||||
LoginRequest request = new LoginRequest("alice", "plain-password");
|
||||
@@ -119,7 +165,8 @@ class AuthServiceTest {
|
||||
user.setPasswordHash("encoded-password");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
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");
|
||||
|
||||
AuthResponse response = authService.login(request);
|
||||
@@ -142,7 +189,8 @@ class AuthServiceTest {
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
when(refreshTokenService.rotateRefreshToken("old-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");
|
||||
|
||||
@@ -184,7 +232,7 @@ class AuthServiceTest {
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
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");
|
||||
|
||||
AuthResponse response = authService.devLogin("demo");
|
||||
@@ -248,7 +296,7 @@ class AuthServiceTest {
|
||||
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
|
||||
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
|
||||
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");
|
||||
|
||||
AuthResponse response = authService.changePassword("alice", request);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class JwtTokenProviderTest {
|
||||
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
||||
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));
|
||||
Instant expiration = Jwts.parser().verifyWith(secretKey).build()
|
||||
.parseSignedClaims(token)
|
||||
@@ -70,6 +70,9 @@ class JwtTokenProviderTest {
|
||||
assertThat(provider.validateToken(token)).isTrue();
|
||||
assertThat(provider.getUsername(token)).isEqualTo("alice");
|
||||
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).isBefore(Instant.now().plusSeconds(950));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ class RegisterRequestValidationTest {
|
||||
|
||||
@Test
|
||||
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);
|
||||
|
||||
@@ -24,7 +31,14 @@ class RegisterRequestValidationTest {
|
||||
|
||||
@Test
|
||||
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);
|
||||
|
||||
@@ -33,7 +47,14 @@ class RegisterRequestValidationTest {
|
||||
|
||||
@Test
|
||||
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);
|
||||
|
||||
@@ -41,4 +62,40 @@ class RegisterRequestValidationTest {
|
||||
.extracting(violation -> violation.getMessage())
|
||||
.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("请输入邀请码");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
32
front/src/admin/dashboard-state.test.ts
Normal file
32
front/src/admin/dashboard-state.test.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
||||
21
front/src/admin/dashboard-state.ts
Normal file
21
front/src/admin/dashboard-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
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 { readStoredSession } from '@/src/lib/session';
|
||||
import type { AdminSummary } from '@/src/lib/types';
|
||||
import { getInviteCodePanelState } from './dashboard-state';
|
||||
|
||||
interface DashboardState {
|
||||
summary: AdminSummary | null;
|
||||
@@ -33,12 +37,31 @@ export function PortalAdminDashboard() {
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [copyMessage, setCopyMessage] = useState('');
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadDashboardData() {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
@@ -63,17 +86,41 @@ export function PortalAdminDashboard() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboardData();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
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 (
|
||||
<Stack spacing={3} sx={{ p: 2 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
YOYUZH Admin
|
||||
@@ -82,6 +129,10 @@ export function PortalAdminDashboard() {
|
||||
这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button variant="outlined" onClick={() => navigate('/overview')}>
|
||||
返回总览
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
@@ -113,7 +164,7 @@ export function PortalAdminDashboard() {
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
@@ -134,7 +185,7 @@ export function PortalAdminDashboard() {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
@@ -151,6 +202,57 @@ export function PortalAdminDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
|
||||
const request = async () => ({
|
||||
totalUsers: 1,
|
||||
totalFiles: 2,
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
|
||||
await assert.doesNotReject(async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
|
||||
export interface AdminSummary {
|
||||
totalUsers: number;
|
||||
totalFiles: number;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
Monitor,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
FileUp,
|
||||
@@ -61,19 +60,15 @@ import {
|
||||
replaceUiFile,
|
||||
syncSelectedFile,
|
||||
} from './files-state';
|
||||
|
||||
const QUICK_ACCESS = [
|
||||
{ name: '桌面', icon: Monitor, path: [] as string[] },
|
||||
{ name: '下载', icon: Download, path: ['下载'] },
|
||||
{ name: '文档', icon: FileText, path: ['文档'] },
|
||||
{ name: '图片', icon: ImageIcon, path: ['图片'] },
|
||||
];
|
||||
|
||||
const DIRECTORIES = [
|
||||
{ name: '下载', icon: Folder },
|
||||
{ name: '文档', icon: Folder },
|
||||
{ name: '图片', icon: Folder },
|
||||
];
|
||||
import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
mergeDirectoryChildren,
|
||||
toDirectoryPath,
|
||||
type DirectoryChildrenMap,
|
||||
type DirectoryTreeNode,
|
||||
} from './files-tree';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -82,7 +77,52 @@ function sleep(ms: number) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -138,6 +178,21 @@ export default function Files() {
|
||||
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
|
||||
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
|
||||
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 [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
|
||||
const [uploads, setUploads] = useState<UploadTask[]>([]);
|
||||
@@ -155,21 +210,64 @@ export default function Files() {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
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 response = await apiRequest<PageResponse<FileMetadata>>(
|
||||
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
|
||||
);
|
||||
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
|
||||
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
|
||||
recordDirectoryChildren(pathParts, response.items);
|
||||
markDirectoryLoaded(pathParts);
|
||||
setCurrentFiles(response.items.map(toUiFile));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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)));
|
||||
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
|
||||
|
||||
if (cachedFiles) {
|
||||
recordDirectoryChildren(currentPath, cachedFiles);
|
||||
setCurrentFiles(cachedFiles.map(toUiFile));
|
||||
}
|
||||
|
||||
@@ -180,6 +278,44 @@ export default function Files() {
|
||||
});
|
||||
}, [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(() => {
|
||||
if (!directoryInputRef.current) {
|
||||
return;
|
||||
@@ -195,6 +331,38 @@ export default function Files() {
|
||||
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) => {
|
||||
if (file.type === 'folder') {
|
||||
setCurrentPath([...currentPath, file.name]);
|
||||
@@ -574,47 +742,38 @@ export default function Files() {
|
||||
}
|
||||
};
|
||||
|
||||
const directoryTree = buildDirectoryTree(directoryChildren, currentPath, expandedDirectories);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
|
||||
{/* Left Sidebar */}
|
||||
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto">
|
||||
<CardContent className="p-4 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">快速访问</p>
|
||||
{QUICK_ACCESS.map((item) => (
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">网盘目录</p>
|
||||
<div className="rounded-2xl border border-white/5 bg-black/20 p-2">
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => handleSidebarClick(item.path)}
|
||||
type="button"
|
||||
onClick={() => handleSidebarClick([])}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
currentPath.join('/') === item.path.join('/')
|
||||
? 'bg-[#336EFF]/20 text-[#336EFF]'
|
||||
: 'text-slate-300 hover:text-white hover:bg-white/5'
|
||||
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
|
||||
currentPath.length === 0 ? 'bg-[#336EFF]/15 text-[#336EFF]' : 'text-slate-200 hover:bg-white/5 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn('w-4 h-4', currentPath.join('/') === item.path.join('/') ? 'text-[#336EFF]' : 'text-slate-400')} />
|
||||
{item.name}
|
||||
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
|
||||
<span className="truncate">网盘</span>
|
||||
</button>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{directoryTree.map((node) => (
|
||||
<DirectoryTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
onSelect={handleSidebarClick}
|
||||
onToggle={(path) => void handleDirectoryToggle(path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">网盘目录</p>
|
||||
{DIRECTORIES.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => handleSidebarClick([item.name])}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
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>
|
||||
</Card>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getPostLoginRedirectPath } from '@/src/lib/file-share';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
|
||||
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';
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function Login() {
|
||||
const [registerEmail, setRegisterEmail] = useState('');
|
||||
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
|
||||
const [registerPassword, setRegisterPassword] = useState('');
|
||||
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
|
||||
const [registerInviteCode, setRegisterInviteCode] = useState('');
|
||||
|
||||
function switchMode(nextIsLogin: boolean) {
|
||||
setIsLogin(nextIsLogin);
|
||||
@@ -74,18 +77,33 @@ export default function Login() {
|
||||
|
||||
async function handleRegisterSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const validationMessage = validateRegisterForm({
|
||||
username: registerUsername,
|
||||
email: registerEmail,
|
||||
phoneNumber: registerPhoneNumber,
|
||||
password: registerPassword,
|
||||
confirmPassword: registerConfirmPassword,
|
||||
inviteCode: registerInviteCode,
|
||||
});
|
||||
if (validationMessage) {
|
||||
setError(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: registerUsername.trim(),
|
||||
email: registerEmail.trim(),
|
||||
phoneNumber: registerPhoneNumber.trim(),
|
||||
body: buildRegisterPayload({
|
||||
username: registerUsername,
|
||||
email: registerEmail,
|
||||
phoneNumber: registerPhoneNumber,
|
||||
password: registerPassword,
|
||||
},
|
||||
confirmPassword: registerConfirmPassword,
|
||||
inviteCode: registerInviteCode,
|
||||
}),
|
||||
});
|
||||
|
||||
saveStoredSession(createSession(auth));
|
||||
@@ -321,6 +339,36 @@ export default function Login() {
|
||||
至少 10 位,并包含大写字母、小写字母、数字和特殊字符。
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -592,8 +592,10 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<Input
|
||||
value={receiveCode}
|
||||
onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))}
|
||||
placeholder="例如: 849201"
|
||||
className="h-16 bg-black/20 border-white/10 text-center text-3xl tracking-[0.5em] font-mono text-white"
|
||||
inputMode="numeric"
|
||||
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>
|
||||
<Button
|
||||
|
||||
104
front/src/pages/files-tree.test.ts
Normal file
104
front/src/pages/files-tree.test.ts
Normal 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(['/文档/课程资料']),
|
||||
),
|
||||
[[], ['文档']],
|
||||
);
|
||||
});
|
||||
97
front/src/pages/files-tree.ts
Normal file
97
front/src/pages/files-tree.ts
Normal 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('/', []);
|
||||
}
|
||||
50
front/src/pages/login-state.test.ts
Normal file
50
front/src/pages/login-state.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
40
front/src/pages/login-state.ts
Normal file
40
front/src/pages/login-state.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
4
front/test-results/.last-run.json
Normal file
4
front/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user