diff --git a/CLAUDE.md b/CLAUDE.md index 62230e7..a4e436a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,7 +352,7 @@ infrastructure/messaging/{rabbitmq|kafka}/{module} ```text infrastructure/storage ├── ObjectStorageProvider.java -└── aliyun +└── qiniu infrastructure/cdn infrastructure/config/ObjectStorageProperties.java infrastructure/config/CdnProperties.java diff --git a/backend/unispeaking-server/pom.xml b/backend/unispeaking-server/pom.xml index 7ed67e8..2131b88 100644 --- a/backend/unispeaking-server/pom.xml +++ b/backend/unispeaking-server/pom.xml @@ -26,15 +26,18 @@ + 21 3.5.17 + 7.19.0 0.8.13 2.0.5 false true + @@ -66,11 +69,16 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server - - org.postgresql - postgresql - runtime - + + org.postgresql + postgresql + runtime + + + com.qiniu + qiniu-java-sdk + ${qiniu-java-sdk.version} + org.springframework.boot spring-boot-starter-flyway diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/common/exception/GlobalExceptionHandler.java b/backend/unispeaking-server/src/main/java/com/unispeaking/common/exception/GlobalExceptionHandler.java index 8898366..43336fe 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/common/exception/GlobalExceptionHandler.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/common/exception/GlobalExceptionHandler.java @@ -17,7 +17,12 @@ public ResponseEntity> handleBusinessException(BusinessExcepti case "AUTHENTICATION_REQUIRED", "INVALID_ACCESS_TOKEN", "ACCESS_TOKEN_REVOKED", "INVALID_CREDENTIALS", "USER_NOT_ACTIVE" -> HttpStatus.UNAUTHORIZED; case "SESSION_ACCESS_DENIED" -> HttpStatus.FORBIDDEN; - case "USERNAME_ALREADY_EXISTS" -> HttpStatus.CONFLICT; + case "USERNAME_ALREADY_EXISTS", "PASSWORD_UPDATE_CONFLICT", + "PROFILE_UPDATE_CONFLICT" -> HttpStatus.CONFLICT; + case "AVATAR_STORAGE_FAILED" -> HttpStatus.BAD_GATEWAY; + case "AVATAR_STORAGE_UNAVAILABLE" -> HttpStatus.SERVICE_UNAVAILABLE; + case "AVATAR_DIMENSION_INVALID", "AVATAR_CONTENT_INVALID" -> + HttpStatus.UNPROCESSABLE_ENTITY; default -> HttpStatus.BAD_REQUEST; }; return ResponseEntity.status(status) diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/controller/AuthController.java b/backend/unispeaking-server/src/main/java/com/unispeaking/controller/AuthController.java index 4667856..d7863b2 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/controller/AuthController.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/controller/AuthController.java @@ -1,6 +1,8 @@ package com.unispeaking.controller; import com.unispeaking.domain.dto.auth.AuthResponse; +import com.unispeaking.domain.dto.auth.ChangePasswordRequest; +import com.unispeaking.domain.dto.auth.ChangePasswordResponse; import com.unispeaking.domain.dto.auth.LoginRequest; import com.unispeaking.domain.dto.auth.RegisterRequest; import com.unispeaking.domain.dto.auth.UserAccountResponse; @@ -9,6 +11,7 @@ import jakarta.validation.Valid; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -37,4 +40,10 @@ public ApiResponse login(@Valid @RequestBody LoginRequest request) public ApiResponse me() { return ApiResponse.success(authService.currentUser()); } + + @PutMapping("/password") + public ApiResponse changePassword( + @Valid @RequestBody ChangePasswordRequest request) { + return ApiResponse.success(authService.changePassword(request)); + } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/controller/ProfileController.java b/backend/unispeaking-server/src/main/java/com/unispeaking/controller/ProfileController.java new file mode 100644 index 0000000..a5f4040 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/controller/ProfileController.java @@ -0,0 +1,59 @@ +package com.unispeaking.controller; + +import com.unispeaking.common.response.ApiResponse; +import com.unispeaking.domain.dto.profile.AvatarResponse; +import com.unispeaking.domain.dto.profile.ProfileOverviewResponse; +import com.unispeaking.domain.dto.profile.UpdateProfileRequest; +import com.unispeaking.domain.dto.profile.UpdateProfileResponse; +import com.unispeaking.service.auth.AuthService; +import com.unispeaking.service.profile.ProfileAccountService; +import com.unispeaking.service.profile.ProfileOverviewService; +import jakarta.validation.Valid; +import java.io.IOException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/profile") +public class ProfileController { + private final AuthService authService; + private final ProfileOverviewService overviewService; + private final ProfileAccountService accountService; + + public ProfileController( + AuthService authService, + ProfileOverviewService overviewService, + ProfileAccountService accountService) { + this.authService = authService; + this.overviewService = overviewService; + this.accountService = accountService; + } + + @GetMapping("/overview") + public ApiResponse overview( + @RequestParam(required = false) String month) { + return ApiResponse.success(overviewService.getOverview( + authService.requireUserId(null), month)); + } + + @PatchMapping + public ApiResponse update( + @Valid @RequestBody UpdateProfileRequest request) { + return ApiResponse.success(accountService.updateNickname( + authService.requireUserId(null), request)); + } + + @PostMapping("/avatar") + public ApiResponse avatar( + @RequestPart("avatar") MultipartFile avatar) throws IOException { + return ApiResponse.success(accountService.replaceAvatar( + authService.requireUserId(null), avatar.getBytes())); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordRequest.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordRequest.java new file mode 100644 index 0000000..e233d9b --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordRequest.java @@ -0,0 +1,13 @@ +package com.unispeaking.domain.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ChangePasswordRequest( + @NotBlank(message = "不能为空") + @Size(min = 6, max = 72, message = "长度必须为 6 到 72 位") + String currentPassword, + @NotBlank(message = "不能为空") + @Size(min = 6, max = 72, message = "长度必须为 6 到 72 位") + String newPassword) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordResponse.java new file mode 100644 index 0000000..f011244 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordResponse.java @@ -0,0 +1,7 @@ +package com.unispeaking.domain.dto.auth; + +public record ChangePasswordResponse(boolean reauthenticationRequired) { + public static ChangePasswordResponse required() { + return new ChangePasswordResponse(true); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/AvatarResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/AvatarResponse.java new file mode 100644 index 0000000..11713ad --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/AvatarResponse.java @@ -0,0 +1,6 @@ +package com.unispeaking.domain.dto.profile; + +import java.time.Instant; + +public record AvatarResponse(String avatarUrl, Instant avatarUrlExpiresAt) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/ProfileOverviewResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/ProfileOverviewResponse.java new file mode 100644 index 0000000..ba382d4 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/ProfileOverviewResponse.java @@ -0,0 +1,42 @@ +package com.unispeaking.domain.dto.profile; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public record ProfileOverviewResponse( + Account account, + PracticeStatistics statistics, + Calendar calendar) { + public record Account( + UUID userId, + String email, + String nickname, + String displayName, + String avatarUrl, + Instant avatarUrlExpiresAt) { + } + + public record Calendar( + String month, + List checkedDates, + boolean checkedInToday) { + public Calendar { + checkedDates = List.copyOf(checkedDates); + } + } + + public record PracticeStatistics( + long weeklyPracticeSeconds, + long trainingRecordCount, + int consecutiveLearningDays, + List lastSevenDays) { + public PracticeStatistics { + lastSevenDays = List.copyOf(lastSevenDays); + } + } + + public record DailyPractice(LocalDate date, long practiceSeconds) { + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileRequest.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileRequest.java new file mode 100644 index 0000000..f057867 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileRequest.java @@ -0,0 +1,10 @@ +package com.unispeaking.domain.dto.profile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateProfileRequest( + @NotBlank(message = "不能为空") + @Size(max = 32, message = "不能超过 32 个字符") + String nickname) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileResponse.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileResponse.java new file mode 100644 index 0000000..bf57659 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileResponse.java @@ -0,0 +1,4 @@ +package com.unispeaking.domain.dto.profile; + +public record UpdateProfileResponse(String nickname, String displayName) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/auth/UserAccount.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/auth/UserAccount.java index 887f274..454b666 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/auth/UserAccount.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/auth/UserAccount.java @@ -8,6 +8,7 @@ public record UserAccount( String username, String passwordHash, String nickname, + String avatarObjectKey, UserRole role, UserStatus status, long authVersion, @@ -15,12 +16,28 @@ public record UserAccount( Instant createdAt, Instant updatedAt) { + public UserAccount( + UUID id, + String username, + String passwordHash, + String nickname, + UserRole role, + UserStatus status, + long authVersion, + Instant lastLoginAt, + Instant createdAt, + Instant updatedAt) { + this(id, username, passwordHash, nickname, null, role, status, + authVersion, lastLoginAt, createdAt, updatedAt); + } + public UserAccount withLastLoginAt(Instant value) { return new UserAccount( id, username, passwordHash, nickname, + avatarObjectKey, role, status, authVersion, @@ -28,4 +45,19 @@ public UserAccount withLastLoginAt(Instant value) { createdAt, updatedAt); } + + public UserAccount withNickname(String value) { + return new UserAccount(id, username, passwordHash, value, avatarObjectKey, + role, status, authVersion, lastLoginAt, createdAt, updatedAt); + } + + public UserAccount withAvatarObjectKey(String value) { + return new UserAccount(id, username, passwordHash, nickname, value, + role, status, authVersion, lastLoginAt, createdAt, updatedAt); + } + + public UserAccount withPasswordHashAndAuthVersion(String value, long version) { + return new UserAccount(id, username, value, nickname, avatarObjectKey, + role, status, version, lastLoginAt, createdAt, updatedAt); + } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/session/PracticeSessionRecord.java b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/session/PracticeSessionRecord.java new file mode 100644 index 0000000..54cf241 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/session/PracticeSessionRecord.java @@ -0,0 +1,16 @@ +package com.unispeaking.domain.po.session; + +import com.unispeaking.domain.vo.scene.SceneType; +import com.unispeaking.domain.vo.session.SessionStatus; +import java.time.Instant; +import java.util.UUID; + +public record PracticeSessionRecord( + String sessionId, + UUID userId, + String sceneId, + SceneType sceneType, + SessionStatus status, + Instant startedAt, + Instant endedAt) { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageConfig.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageConfig.java new file mode 100644 index 0000000..a91377a --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageConfig.java @@ -0,0 +1,46 @@ +package com.unispeaking.infrastructure.config; + +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import com.unispeaking.infrastructure.storage.qiniu.QiniuObjectStorageProvider; +import java.net.URI; +import java.time.Duration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectStorageConfig { + + @Bean + ObjectStorageProvider objectStorageProvider(ObjectStorageProperties properties) { + if (!properties.configured()) { + return new UnavailableObjectStorageProvider(); + } + com.qiniu.storage.Configuration configuration = + com.qiniu.storage.Configuration.create(); + Auth auth = Auth.create( + properties.getAccessKey(), + properties.getSecretKey()); + return new QiniuObjectStorageProvider( + auth, + new UploadManager(configuration), + new BucketManager(auth, configuration), + properties); + } + + private static final class UnavailableObjectStorageProvider + implements ObjectStorageProvider { + @Override public void put(String key, byte[] content, String type) { throw unavailable(); } + @Override public URI signGetUrl(String key, Duration ttl) { throw unavailable(); } + @Override public void delete(String key) { throw unavailable(); } + @Override public boolean available() { return false; } + private BusinessException unavailable() { + return new BusinessException( + "AVATAR_STORAGE_UNAVAILABLE", + "头像存储尚未配置"); + } + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageProperties.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageProperties.java new file mode 100644 index 0000000..ce89780 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageProperties.java @@ -0,0 +1,31 @@ +package com.unispeaking.infrastructure.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("object-storage.qiniu") +public class ObjectStorageProperties { + private String bucket = ""; + private String accessKey = ""; + private String secretKey = ""; + private String domain = ""; + private String avatarPrefix = "avatars"; + private Duration signedUrlTtl = Duration.ofHours(1); + + public boolean configured() { + return !bucket.isBlank() && !accessKey.isBlank() + && !secretKey.isBlank() && !domain.isBlank(); + } + public String getBucket() { return bucket; } + public void setBucket(String value) { bucket = value == null ? "" : value.trim(); } + public String getAccessKey() { return accessKey; } + public void setAccessKey(String value) { accessKey = value == null ? "" : value.trim(); } + public String getSecretKey() { return secretKey; } + public void setSecretKey(String value) { secretKey = value == null ? "" : value.trim(); } + public String getDomain() { return domain; } + public void setDomain(String value) { domain = value == null ? "" : value.trim(); } + public String getAvatarPrefix() { return avatarPrefix; } + public void setAvatarPrefix(String value) { avatarPrefix = value == null ? "avatars" : value.trim(); } + public Duration getSignedUrlTtl() { return signedUrlTtl; } + public void setSignedUrlTtl(Duration value) { signedUrlTtl = value; } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ProfileProperties.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ProfileProperties.java new file mode 100644 index 0000000..45b28b0 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ProfileProperties.java @@ -0,0 +1,21 @@ +package com.unispeaking.infrastructure.config; + +import java.time.ZoneId; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("profile") +public class ProfileProperties { + private String timeZone = "Asia/Shanghai"; + + public String getTimeZone() { + return timeZone; + } + + public void setTimeZone(String timeZone) { + this.timeZone = timeZone; + } + + public ZoneId zoneId() { + return ZoneId.of(timeZone); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/session/PracticeSessionEntity.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/session/PracticeSessionEntity.java new file mode 100644 index 0000000..34ac05a --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/session/PracticeSessionEntity.java @@ -0,0 +1,31 @@ +package com.unispeaking.infrastructure.persistence.entity.session; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.unispeaking.infrastructure.persistence.typehandler.PostgresUuidTypeHandler; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@TableName(value = "practice_session", autoResultMap = true) +public class PracticeSessionEntity { + + @TableId(value = "session_id", type = IdType.INPUT) + private String sessionId; + @TableField(typeHandler = PostgresUuidTypeHandler.class) + private UUID userId; + private String sceneId; + private String sceneType; + private String status; + private OffsetDateTime startedAt; + private OffsetDateTime endedAt; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/user/UserAccountEntity.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/user/UserAccountEntity.java index b9d84ec..8cd8814 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/user/UserAccountEntity.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/entity/user/UserAccountEntity.java @@ -20,6 +20,7 @@ public class UserAccountEntity { private String username; private String passwordHash; private String nickname; + private String avatarObjectKey; private String role; private String status; private Long authVersion; diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/mapper/session/PracticeSessionMapper.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/mapper/session/PracticeSessionMapper.java new file mode 100644 index 0000000..fb59f29 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/mapper/session/PracticeSessionMapper.java @@ -0,0 +1,7 @@ +package com.unispeaking.infrastructure.persistence.mapper.session; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.unispeaking.infrastructure.persistence.entity.session.PracticeSessionEntity; + +public interface PracticeSessionMapper extends BaseMapper { +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepository.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepository.java index 008ee23..dc86f63 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepository.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepository.java @@ -96,6 +96,24 @@ public List findBySceneId(String sceneId) { } } + public List findCreatedAtBySceneIdsBetween( + List sceneIds, + OffsetDateTime start, + OffsetDateTime end) { + if (sceneIds == null || sceneIds.isEmpty()) { + return List.of(); + } + return mapper.selectList(new LambdaQueryWrapper() + .select(SessionEvaluationEntity::getCreatedAt) + .in(SessionEvaluationEntity::getSceneId, sceneIds) + .ge(SessionEvaluationEntity::getCreatedAt, start) + .lt(SessionEvaluationEntity::getCreatedAt, end) + .orderByAsc(SessionEvaluationEntity::getCreatedAt)) + .stream() + .map(SessionEvaluationEntity::getCreatedAt) + .toList(); + } + private SessionEvaluationEntity toEntity( String sceneId, String sessionId, diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepository.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepository.java index 92f1f8d..2f1ed35 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepository.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepository.java @@ -120,6 +120,37 @@ public List findAssetsByUserId(String userId) { .toList(); } + @Override + public long countActiveByUserId(String userId) { + UUID ownerId; + try { + ownerId = UUID.fromString(userId); + } + catch (IllegalArgumentException exception) { + return 0; + } + return sceneMapper.selectCount(new LambdaQueryWrapper() + .eq(SceneEntity::getUserId, ownerId) + .isNull(SceneEntity::getDeletedAt)); + } + + @Override + public List findAllIdsByUserId(String userId) { + UUID ownerId; + try { + ownerId = UUID.fromString(userId); + } + catch (IllegalArgumentException exception) { + return List.of(); + } + return sceneMapper.selectList(new LambdaQueryWrapper() + .select(SceneEntity::getId) + .eq(SceneEntity::getUserId, ownerId)) + .stream() + .map(SceneEntity::getId) + .toList(); + } + private CustomSceneDefinition toDefinition(SceneEntity scene) { return new CustomSceneDefinition( scene.getId(), diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/SceneRepository.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/SceneRepository.java index 913ab2f..a4498c9 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/SceneRepository.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/scene/SceneRepository.java @@ -16,4 +16,6 @@ SceneGenerationResponse saveCustomScene( Optional findGeneratedById(String sceneId); Optional findCustomDefinitionById(String sceneId); List findAssetsByUserId(String userId); + long countActiveByUserId(String userId); + List findAllIdsByUserId(String userId); } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepository.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepository.java new file mode 100644 index 0000000..79b352a --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepository.java @@ -0,0 +1,167 @@ +package com.unispeaking.infrastructure.persistence.repository.session; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.domain.po.session.PracticeSessionRecord; +import com.unispeaking.domain.vo.scene.SceneType; +import com.unispeaking.domain.vo.session.SessionStatus; +import com.unispeaking.infrastructure.persistence.entity.session.PracticeSessionEntity; +import com.unispeaking.infrastructure.persistence.mapper.session.PracticeSessionMapper; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class PracticeSessionRepository { + + private final PracticeSessionMapper mapper; + + public PracticeSessionRepository(PracticeSessionMapper mapper) { + this.mapper = mapper; + } + + public void create(PracticeSessionRecord record) { + PracticeSessionEntity entity = toEntity(record); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + if (mapper.insert(entity) != 1) { + throw persistenceFailure(); + } + } + catch (BusinessException exception) { + throw exception; + } + catch (RuntimeException exception) { + throw persistenceFailure(); + } + } + + public void complete(String sessionId, UUID userId, Instant endedAt) { + updateTerminalStatus( + sessionId, + userId, + SessionStatus.COMPLETED, + endedAt); + } + + public void fail(String sessionId, UUID userId, Instant endedAt) { + updateTerminalStatus( + sessionId, + userId, + SessionStatus.FAILED, + endedAt); + } + + public List findCompletedOverlapping( + UUID userId, + Instant start, + Instant end) { + try { + return mapper.selectList( + new LambdaQueryWrapper() + .eq(PracticeSessionEntity::getUserId, userId) + .eq( + PracticeSessionEntity::getStatus, + SessionStatus.COMPLETED.name()) + .isNotNull(PracticeSessionEntity::getEndedAt) + .lt( + PracticeSessionEntity::getStartedAt, + atUtc(end)) + .gt( + PracticeSessionEntity::getEndedAt, + atUtc(start)) + .orderByAsc( + PracticeSessionEntity::getStartedAt)) + .stream() + .map(this::toDomain) + .toList(); + } + catch (RuntimeException exception) { + throw persistenceFailure(); + } + } + + private void updateTerminalStatus( + String sessionId, + UUID userId, + SessionStatus status, + Instant endedAt) { + OffsetDateTime end = atUtc(endedAt); + try { + int updated = mapper.update( + null, + new LambdaUpdateWrapper() + .eq(PracticeSessionEntity::getSessionId, sessionId) + .eq(PracticeSessionEntity::getUserId, userId) + .notIn( + PracticeSessionEntity::getStatus, + SessionStatus.COMPLETED.name(), + SessionStatus.FAILED.name()) + .set(PracticeSessionEntity::getStatus, status.name()) + .set(PracticeSessionEntity::getEndedAt, end) + .set(PracticeSessionEntity::getUpdatedAt, end)); + if (updated == 1 || alreadyTerminal(sessionId, userId, status)) { + return; + } + throw persistenceFailure(); + } + catch (BusinessException exception) { + throw exception; + } + catch (RuntimeException exception) { + throw persistenceFailure(); + } + } + + private boolean alreadyTerminal( + String sessionId, + UUID userId, + SessionStatus status) { + return mapper.selectCount( + new LambdaQueryWrapper() + .eq(PracticeSessionEntity::getSessionId, sessionId) + .eq(PracticeSessionEntity::getUserId, userId) + .eq(PracticeSessionEntity::getStatus, status.name())) == 1; + } + + private PracticeSessionEntity toEntity(PracticeSessionRecord record) { + PracticeSessionEntity entity = new PracticeSessionEntity(); + entity.setSessionId(record.sessionId()); + entity.setUserId(record.userId()); + entity.setSceneId(record.sceneId()); + entity.setSceneType(record.sceneType().name()); + entity.setStatus(record.status().name()); + entity.setStartedAt(atUtc(record.startedAt())); + entity.setEndedAt(record.endedAt() == null ? null : atUtc(record.endedAt())); + return entity; + } + + private PracticeSessionRecord toDomain(PracticeSessionEntity entity) { + return new PracticeSessionRecord( + entity.getSessionId(), + entity.getUserId(), + entity.getSceneId(), + SceneType.valueOf(entity.getSceneType()), + SessionStatus.valueOf(entity.getStatus()), + entity.getStartedAt().toInstant(), + entity.getEndedAt() == null + ? null + : entity.getEndedAt().toInstant()); + } + + private OffsetDateTime atUtc(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private BusinessException persistenceFailure() { + return new BusinessException( + "PRACTICE_SESSION_PERSISTENCE_FAILED", + "练习会话记录保存失败"); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepository.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepository.java index 413ad8a..84213d5 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepository.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepository.java @@ -1,6 +1,7 @@ package com.unispeaking.infrastructure.persistence.repository.user; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.unispeaking.domain.po.auth.UserAccount; import com.unispeaking.domain.po.auth.UserRole; import com.unispeaking.domain.po.auth.UserStatus; @@ -53,12 +54,52 @@ public void updateLastLoginAt(UUID id, Instant lastLoginAt) { mapper.updateById(entity); } + @Override + public boolean updateNickname(UUID id, String nickname) { + return mapper.update(null, Wrappers.lambdaUpdate() + .eq(UserAccountEntity::getId, id) + .set(UserAccountEntity::getNickname, nickname) + .set(UserAccountEntity::getUpdatedAt, OffsetDateTime.now(ZoneOffset.UTC))) == 1; + } + + @Override + public boolean updateAvatarObjectKey( + UUID id, + String expectedObjectKey, + String newObjectKey) { + LambdaUpdateWrapper update = Wrappers.lambdaUpdate(); + update.eq(UserAccountEntity::getId, id); + if (expectedObjectKey == null) { + update.isNull(UserAccountEntity::getAvatarObjectKey); + } + else { + update.eq(UserAccountEntity::getAvatarObjectKey, expectedObjectKey); + } + update.set(UserAccountEntity::getAvatarObjectKey, newObjectKey) + .set(UserAccountEntity::getUpdatedAt, OffsetDateTime.now(ZoneOffset.UTC)); + return mapper.update(null, update) == 1; + } + + @Override + public boolean updatePasswordAndAuthVersion( + UUID id, + long expectedAuthVersion, + String passwordHash) { + return mapper.update(null, Wrappers.lambdaUpdate() + .eq(UserAccountEntity::getId, id) + .eq(UserAccountEntity::getAuthVersion, expectedAuthVersion) + .set(UserAccountEntity::getPasswordHash, passwordHash) + .set(UserAccountEntity::getAuthVersion, expectedAuthVersion + 1) + .set(UserAccountEntity::getUpdatedAt, OffsetDateTime.now(ZoneOffset.UTC))) == 1; + } + private UserAccountEntity toEntity(UserAccount user) { UserAccountEntity entity = new UserAccountEntity(); entity.setId(user.id()); entity.setUsername(user.username()); entity.setPasswordHash(user.passwordHash()); entity.setNickname(user.nickname()); + entity.setAvatarObjectKey(user.avatarObjectKey()); entity.setRole(user.role().name()); entity.setStatus(user.status().name()); entity.setAuthVersion(user.authVersion()); @@ -74,6 +115,7 @@ private UserAccount toDomain(UserAccountEntity entity) { entity.getUsername(), entity.getPasswordHash(), entity.getNickname(), + entity.getAvatarObjectKey(), UserRole.valueOf(entity.getRole()), UserStatus.valueOf(entity.getStatus()), entity.getAuthVersion(), diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/UserAccountRepository.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/UserAccountRepository.java index 476a9bf..5444913 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/UserAccountRepository.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/persistence/repository/user/UserAccountRepository.java @@ -10,4 +10,10 @@ public interface UserAccountRepository { Optional findByUsername(String username); UserAccount create(UserAccount user); void updateLastLoginAt(UUID id, Instant lastLoginAt); + boolean updateNickname(UUID id, String nickname); + boolean updateAvatarObjectKey(UUID id, String expectedObjectKey, String newObjectKey); + boolean updatePasswordAndAuthVersion( + UUID id, + long expectedAuthVersion, + String passwordHash); } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/ObjectStorageProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/ObjectStorageProvider.java new file mode 100644 index 0000000..22a81d8 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/ObjectStorageProvider.java @@ -0,0 +1,15 @@ +package com.unispeaking.infrastructure.storage; + +import java.net.URI; +import java.time.Duration; + +public interface ObjectStorageProvider extends AutoCloseable { + void put(String objectKey, byte[] content, String contentType); + URI signGetUrl(String objectKey, Duration ttl); + void delete(String objectKey); + boolean available(); + + @Override + default void close() { + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProvider.java new file mode 100644 index 0000000..d4eb9f0 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProvider.java @@ -0,0 +1,125 @@ +package com.unispeaking.infrastructure.storage.qiniu; + +import com.qiniu.common.QiniuException; +import com.qiniu.http.Response; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.DownloadUrl; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +public class QiniuObjectStorageProvider implements ObjectStorageProvider { + private final Auth auth; + private final UploadManager uploadManager; + private final BucketManager bucketManager; + private final ObjectStorageProperties properties; + private final String downloadDomain; + + public QiniuObjectStorageProvider( + Auth auth, + UploadManager uploadManager, + BucketManager bucketManager, + ObjectStorageProperties properties) { + this.auth = Objects.requireNonNull(auth, "Qiniu auth is required"); + this.uploadManager = Objects.requireNonNull( + uploadManager, "Qiniu upload manager is required"); + this.bucketManager = Objects.requireNonNull( + bucketManager, "Qiniu bucket manager is required"); + this.properties = Objects.requireNonNull( + properties, "Qiniu storage properties are required"); + this.downloadDomain = requireHttpsDomain(properties.getDomain()); + } + + @Override + public void put(String objectKey, byte[] content, String contentType) { + Response response = null; + try { + response = uploadManager.put( + content, + objectKey, + auth.uploadToken(properties.getBucket(), objectKey), + null, + contentType, + false); + } + catch (QiniuException exception) { + throw storageFailure(); + } + finally { + if (response != null) { + response.close(); + } + } + } + + @Override + public URI signGetUrl(String objectKey, Duration ttl) { + if (ttl == null || ttl.isZero() || ttl.isNegative()) { + throw storageFailure(); + } + try { + long deadline = Instant.now().plus(ttl).getEpochSecond(); + String url = new DownloadUrl(downloadDomain, true, objectKey) + .buildURL(auth, deadline); + return URI.create(url); + } + catch (QiniuException | IllegalArgumentException exception) { + throw storageFailure(); + } + } + + @Override + public void delete(String objectKey) { + try { + bucketManager.delete(properties.getBucket(), objectKey); + } + catch (QiniuException exception) { + throw storageFailure(); + } + } + + @Override + public boolean available() { + return true; + } + + private String requireHttpsDomain(String value) { + String configured = value == null ? "" : value.trim(); + URI uri; + try { + uri = URI.create(configured.contains("://") + ? configured + : "https://" + configured); + } + catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "Qiniu download domain is invalid", exception); + } + if (!"https".equalsIgnoreCase(uri.getScheme()) + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || (uri.getPath() != null + && !uri.getPath().isBlank() + && !"/".equals(uri.getPath()))) { + throw new IllegalArgumentException( + "Qiniu download domain must be an HTTPS host"); + } + return uri.getPort() < 0 + ? uri.getHost() + : uri.getHost() + ":" + uri.getPort(); + } + + private BusinessException storageFailure() { + return new BusinessException( + "AVATAR_STORAGE_FAILED", + "头像存储服务暂时不可用"); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/AuthService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/AuthService.java index 0ceb093..80f4afb 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/AuthService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/AuthService.java @@ -1,6 +1,8 @@ package com.unispeaking.service.auth; import com.unispeaking.domain.dto.auth.AuthResponse; +import com.unispeaking.domain.dto.auth.ChangePasswordRequest; +import com.unispeaking.domain.dto.auth.ChangePasswordResponse; import com.unispeaking.domain.dto.auth.LoginRequest; import com.unispeaking.domain.dto.auth.RegisterRequest; import com.unispeaking.domain.dto.auth.UserAccountResponse; @@ -9,5 +11,8 @@ public interface AuthService { AuthResponse register(RegisterRequest request); AuthResponse login(LoginRequest request); UserAccountResponse currentUser(); + default ChangePasswordResponse changePassword(ChangePasswordRequest request) { + throw new UnsupportedOperationException("Password change is not supported"); + } String requireUserId(String requestedUserId); } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/impl/AuthServiceImpl.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/impl/AuthServiceImpl.java index c105fab..3349b9f 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/impl/AuthServiceImpl.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/auth/impl/AuthServiceImpl.java @@ -1,6 +1,8 @@ package com.unispeaking.service.auth.impl; import com.unispeaking.domain.dto.auth.AuthResponse; +import com.unispeaking.domain.dto.auth.ChangePasswordRequest; +import com.unispeaking.domain.dto.auth.ChangePasswordResponse; import com.unispeaking.domain.dto.auth.LoginRequest; import com.unispeaking.domain.dto.auth.RegisterRequest; import com.unispeaking.domain.dto.auth.UserAccountResponse; @@ -98,6 +100,24 @@ public UserAccountResponse currentUser() { return UserAccountResponse.from(requireAuthenticatedUser()); } + @Override + @Transactional + public ChangePasswordResponse changePassword(ChangePasswordRequest request) { + UserAccount user = requireAuthenticatedUser(); + if (!passwordEncoder.matches(request.currentPassword(), user.passwordHash())) { + throw new BusinessException("CURRENT_PASSWORD_INVALID", "当前密码不正确"); + } + if (passwordEncoder.matches(request.newPassword(), user.passwordHash())) { + throw new BusinessException("NEW_PASSWORD_SAME_AS_CURRENT", "新密码不能与当前密码相同"); + } + String encoded = passwordEncoder.encode(request.newPassword()); + if (!userAccountRepository.updatePasswordAndAuthVersion( + user.id(), user.authVersion(), encoded)) { + throw new BusinessException("PASSWORD_UPDATE_CONFLICT", "账号已发生变化,请重新登录后再试"); + } + return ChangePasswordResponse.required(); + } + @Override public String requireUserId(String requestedUserId) { return requireAuthenticatedUser().id().toString(); diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/PracticeDurationCalculator.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/PracticeDurationCalculator.java new file mode 100644 index 0000000..825806f --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/PracticeDurationCalculator.java @@ -0,0 +1,86 @@ +package com.unispeaking.service.profile; + +import com.unispeaking.domain.dto.profile.ProfileOverviewResponse; +import com.unispeaking.domain.po.session.PracticeSessionRecord; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.TemporalAdjusters; +import java.util.List; + +public class PracticeDurationCalculator { + + public static final long MINIMUM_PRACTICE_SECONDS = 30; + + public ProfileOverviewResponse.PracticeStatistics calculate( + List records, + LocalDate today, + Instant now, + ZoneId zoneId, + long trainingRecordCount, + int consecutiveLearningDays) { + Instant weekStart = today + .with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + .atStartOfDay(zoneId) + .toInstant(); + long weeklySeconds = eligible(records).stream() + .mapToLong(record -> overlapSeconds(record, weekStart, now)) + .sum(); + List dailyPractice = + java.util.stream.IntStream.rangeClosed(0, 6) + .mapToObj(offset -> today.minusDays(6L - offset)) + .map(date -> new ProfileOverviewResponse.DailyPractice( + date, + practiceSecondsForDate(records, date, zoneId, now))) + .toList(); + return new ProfileOverviewResponse.PracticeStatistics( + weeklySeconds, + trainingRecordCount, + consecutiveLearningDays, + dailyPractice); + } + + private long practiceSecondsForDate( + List records, + LocalDate date, + ZoneId zoneId, + Instant now) { + Instant start = date.atStartOfDay(zoneId).toInstant(); + Instant nextDay = date.plusDays(1).atStartOfDay(zoneId).toInstant(); + Instant end = nextDay.isBefore(now) ? nextDay : now; + if (!end.isAfter(start)) { + return 0; + } + return eligible(records).stream() + .mapToLong(record -> overlapSeconds(record, start, end)) + .sum(); + } + + private List eligible( + List records) { + if (records == null || records.isEmpty()) { + return List.of(); + } + return records.stream() + .filter(record -> record.startedAt() != null && record.endedAt() != null) + .filter(record -> !record.endedAt().isBefore(record.startedAt())) + .filter(record -> Duration.between( + record.startedAt(), record.endedAt()).getSeconds() + >= MINIMUM_PRACTICE_SECONDS) + .toList(); + } + + private long overlapSeconds( + PracticeSessionRecord record, + Instant rangeStart, + Instant rangeEnd) { + Instant start = record.startedAt().isAfter(rangeStart) + ? record.startedAt() + : rangeStart; + Instant end = record.endedAt().isBefore(rangeEnd) + ? record.endedAt() + : rangeEnd; + return end.isAfter(start) ? Duration.between(start, end).getSeconds() : 0; + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileAccountService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileAccountService.java new file mode 100644 index 0000000..7c70e7a --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileAccountService.java @@ -0,0 +1,10 @@ +package com.unispeaking.service.profile; + +import com.unispeaking.domain.dto.profile.AvatarResponse; +import com.unispeaking.domain.dto.profile.UpdateProfileRequest; +import com.unispeaking.domain.dto.profile.UpdateProfileResponse; + +public interface ProfileAccountService { + UpdateProfileResponse updateNickname(String userId, UpdateProfileRequest request); + AvatarResponse replaceAvatar(String userId, byte[] content); +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileOverviewService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileOverviewService.java new file mode 100644 index 0000000..2e992de --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileOverviewService.java @@ -0,0 +1,7 @@ +package com.unispeaking.service.profile; + +import com.unispeaking.domain.dto.profile.ProfileOverviewResponse; + +public interface ProfileOverviewService { + ProfileOverviewResponse getOverview(String userId, String month); +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/image/AvatarImageProcessor.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/image/AvatarImageProcessor.java new file mode 100644 index 0000000..a758453 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/image/AvatarImageProcessor.java @@ -0,0 +1,73 @@ +package com.unispeaking.service.profile.image; + +import com.unispeaking.common.exception.BusinessException; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.springframework.stereotype.Component; + +@Component +public class AvatarImageProcessor { + private static final int MAX_BYTES = 2 * 1024 * 1024; + private static final int MIN_DIMENSION = 128; + private static final int MAX_DIMENSION = 4096; + + public ProcessedAvatar process(byte[] input) { + if (input == null || input.length == 0) { + throw error("AVATAR_FILE_REQUIRED", "请选择头像文件"); + } + if (input.length > MAX_BYTES) { + throw error("AVATAR_FILE_TOO_LARGE", "头像不能超过 2 MiB"); + } + if (!hasJpegHeader(input) && !hasPngHeader(input)) { + throw error("AVATAR_TYPE_UNSUPPORTED", "仅支持 JPEG 和 PNG 头像"); + } + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(input)); + if (image == null) { + throw error("AVATAR_CONTENT_INVALID", "头像内容无法识别"); + } + if (image.getWidth() < MIN_DIMENSION || image.getHeight() < MIN_DIMENSION + || image.getWidth() > MAX_DIMENSION || image.getHeight() > MAX_DIMENSION) { + throw error("AVATAR_DIMENSION_INVALID", "头像尺寸必须在 128 到 4096 像素之间"); + } + String format = image.getColorModel().hasAlpha() ? "png" : "jpg"; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + if (!ImageIO.write(image, format, output)) { + throw error("AVATAR_TYPE_UNSUPPORTED", "仅支持 JPEG 和 PNG 头像"); + } + return new ProcessedAvatar( + output.toByteArray(), + format.equals("png") ? "image/png" : "image/jpeg", + format); + } + catch (IOException exception) { + throw error("AVATAR_CONTENT_INVALID", "头像内容无法识别"); + } + } + + private boolean hasJpegHeader(byte[] input) { + return input.length >= 3 + && (input[0] & 0xff) == 0xff + && (input[1] & 0xff) == 0xd8 + && (input[2] & 0xff) == 0xff; + } + + private boolean hasPngHeader(byte[] input) { + byte[] signature = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}; + if (input.length < signature.length) return false; + for (int index = 0; index < signature.length; index++) { + if (input[index] != signature[index]) return false; + } + return true; + } + + private BusinessException error(String code, String message) { + return new BusinessException(code, message); + } + + public record ProcessedAvatar(byte[] content, String contentType, String extension) { + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImpl.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImpl.java new file mode 100644 index 0000000..e6aa795 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImpl.java @@ -0,0 +1,93 @@ +package com.unispeaking.service.profile.impl; + +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.domain.dto.profile.AvatarResponse; +import com.unispeaking.domain.dto.profile.UpdateProfileRequest; +import com.unispeaking.domain.dto.profile.UpdateProfileResponse; +import com.unispeaking.domain.po.auth.UserAccount; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import com.unispeaking.infrastructure.persistence.repository.user.UserAccountRepository; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import com.unispeaking.service.profile.ProfileAccountService; +import com.unispeaking.service.profile.image.AvatarImageProcessor; +import java.net.URI; +import java.time.Instant; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class ProfileAccountServiceImpl implements ProfileAccountService { + private static final Logger LOGGER = LoggerFactory.getLogger(ProfileAccountServiceImpl.class); + private final UserAccountRepository accounts; + private final ObjectStorageProvider storage; + private final ObjectStorageProperties properties; + private final AvatarImageProcessor images; + + public ProfileAccountServiceImpl( + UserAccountRepository accounts, + ObjectStorageProvider storage, + ObjectStorageProperties properties, + AvatarImageProcessor images) { + this.accounts = accounts; + this.storage = storage; + this.properties = properties; + this.images = images; + } + + @Override + public UpdateProfileResponse updateNickname(String userId, UpdateProfileRequest request) { + String nickname = request.nickname().trim(); + if (nickname.isEmpty()) { + throw new BusinessException("PROFILE_NICKNAME_REQUIRED", "昵称不能为空"); + } + UUID id = UUID.fromString(userId); + if (!accounts.updateNickname(id, nickname)) { + throw new BusinessException("PROFILE_UPDATE_CONFLICT", "资料已发生变化,请重试"); + } + return new UpdateProfileResponse(nickname, nickname); + } + + @Override + public AvatarResponse replaceAvatar(String userId, byte[] content) { + if (!storage.available()) { + throw new BusinessException("AVATAR_STORAGE_UNAVAILABLE", "头像存储尚未配置"); + } + UUID id = UUID.fromString(userId); + UserAccount user = accounts.findById(id) + .orElseThrow(() -> new BusinessException("USER_NOT_FOUND", "用户不存在")); + var avatar = images.process(content); + String prefix = properties.getAvatarPrefix().replaceAll("^/+|/+$", ""); + String key = prefix + "/" + userId + "/" + UUID.randomUUID() + "." + avatar.extension(); + storage.put(key, avatar.content(), avatar.contentType()); + if (!accounts.updateAvatarObjectKey(id, user.avatarObjectKey(), key)) { + safeDelete(key); + throw new BusinessException("PROFILE_UPDATE_CONFLICT", "头像已发生变化,请重试"); + } + URI signed = signAvatar(key); + if (user.avatarObjectKey() != null) safeDelete(user.avatarObjectKey()); + return new AvatarResponse( + signed == null ? null : signed.toString(), + signed == null ? null : Instant.now().plus(properties.getSignedUrlTtl())); + } + + private URI signAvatar(String key) { + try { + return storage.signGetUrl(key, properties.getSignedUrlTtl()); + } + catch (BusinessException exception) { + LOGGER.warn("avatar url signing failed"); + return null; + } + } + + private void safeDelete(String key) { + try { + storage.delete(key); + } + catch (BusinessException exception) { + LOGGER.warn("avatar object cleanup failed"); + } + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImpl.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImpl.java new file mode 100644 index 0000000..2184a3b --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImpl.java @@ -0,0 +1,192 @@ +package com.unispeaking.service.profile.impl; + +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.domain.dto.profile.ProfileOverviewResponse; +import com.unispeaking.domain.po.auth.UserAccount; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import com.unispeaking.infrastructure.config.ProfileProperties; +import com.unispeaking.infrastructure.persistence.repository.evaluation.SessionEvaluationRepository; +import com.unispeaking.infrastructure.persistence.repository.scene.SceneRepository; +import com.unispeaking.infrastructure.persistence.repository.session.PracticeSessionRepository; +import com.unispeaking.infrastructure.persistence.repository.user.UserAccountRepository; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import com.unispeaking.service.profile.ProfileOverviewService; +import com.unispeaking.service.profile.PracticeDurationCalculator; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAdjusters; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class ProfileOverviewServiceImpl implements ProfileOverviewService { + private final UserAccountRepository accounts; + private final SceneRepository scenes; + private final SessionEvaluationRepository evaluations; + private final PracticeSessionRepository practiceSessions; + private final ObjectStorageProvider storage; + private final ObjectStorageProperties storageProperties; + private final ZoneId zoneId; + private final Clock clock; + private final PracticeDurationCalculator durationCalculator; + + @Autowired + public ProfileOverviewServiceImpl( + UserAccountRepository accounts, + SceneRepository scenes, + SessionEvaluationRepository evaluations, + PracticeSessionRepository practiceSessions, + ObjectStorageProvider storage, + ObjectStorageProperties storageProperties, + ProfileProperties profileProperties) { + this( + accounts, + scenes, + evaluations, + practiceSessions, + storage, + storageProperties, + profileProperties.zoneId(), + Clock.system(profileProperties.zoneId())); + } + + ProfileOverviewServiceImpl( + UserAccountRepository accounts, + SceneRepository scenes, + SessionEvaluationRepository evaluations, + PracticeSessionRepository practiceSessions, + ObjectStorageProvider storage, + ObjectStorageProperties storageProperties, + ZoneId zoneId, + Clock clock) { + this.accounts = accounts; + this.scenes = scenes; + this.evaluations = evaluations; + this.practiceSessions = practiceSessions; + this.storage = storage; + this.storageProperties = storageProperties; + this.zoneId = zoneId; + this.clock = clock; + this.durationCalculator = new PracticeDurationCalculator(); + } + + @Override + public ProfileOverviewResponse getOverview(String userId, String requestedMonth) { + UUID id = UUID.fromString(userId); + UserAccount user = accounts.findById(id) + .orElseThrow(() -> new BusinessException("USER_NOT_FOUND", "用户不存在")); + Instant now = clock.instant(); + LocalDate today = now.atZone(zoneId).toLocalDate(); + YearMonth current = YearMonth.from(today); + YearMonth month = parseMonth(requestedMonth, current); + if (month.isAfter(current)) { + throw new BusinessException("PROFILE_MONTH_INVALID", "不能查看未来月份"); + } + Instant start = month.atDay(1).atStartOfDay(zoneId).toInstant(); + Instant end = month.plusMonths(1).atDay(1).atStartOfDay(zoneId).toInstant(); + List sceneIds = scenes.findAllIdsByUserId(userId); + List dates = evaluations.findCreatedAtBySceneIdsBetween( + sceneIds, + start.atOffset(ZoneOffset.UTC), + end.atOffset(ZoneOffset.UTC)) + .stream() + .map(value -> value.toInstant().atZone(zoneId).toLocalDate()) + .distinct() + .sorted() + .toList(); + SignedAvatar signed = signAvatar(user.avatarObjectKey()); + String displayName = displayName(user); + LocalDate sevenDayStart = today.minusDays(6); + LocalDate weekStart = today.with( + TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); + Instant statisticsStart = (sevenDayStart.isBefore(weekStart) + ? sevenDayStart + : weekStart).atStartOfDay(zoneId).toInstant(); + int consecutiveLearningDays = consecutiveLearningDays( + evaluations.findCreatedAtBySceneIdsBetween( + sceneIds, + Instant.EPOCH.atOffset(ZoneOffset.UTC), + today.plusDays(1).atStartOfDay(zoneId) + .toInstant().atOffset(ZoneOffset.UTC)), + today); + ProfileOverviewResponse.PracticeStatistics statistics = + durationCalculator.calculate( + practiceSessions.findCompletedOverlapping( + id, statisticsStart, now), + today, + now, + zoneId, + scenes.countActiveByUserId(userId), + consecutiveLearningDays); + return new ProfileOverviewResponse( + new ProfileOverviewResponse.Account( + user.id(), user.username(), user.nickname(), displayName, + signed.url(), signed.expiresAt()), + statistics, + new ProfileOverviewResponse.Calendar( + month.toString(), dates, dates.contains(today))); + } + + private int consecutiveLearningDays( + List reportTimes, + LocalDate today) { + Set checkedDates = new HashSet<>(); + for (OffsetDateTime reportTime : reportTimes) { + checkedDates.add(reportTime.toInstant().atZone(zoneId).toLocalDate()); + } + LocalDate cursor = checkedDates.contains(today) + ? today + : today.minusDays(1); + int days = 0; + while (checkedDates.contains(cursor)) { + days++; + cursor = cursor.minusDays(1); + } + return days; + } + + private YearMonth parseMonth(String value, YearMonth current) { + if (value == null || value.isBlank()) return current; + try { + return YearMonth.parse(value.trim()); + } + catch (DateTimeParseException exception) { + throw new BusinessException("PROFILE_MONTH_INVALID", "month 必须使用 yyyy-MM"); + } + } + + private SignedAvatar signAvatar(String objectKey) { + if (objectKey == null || objectKey.isBlank() || !storage.available()) { + return new SignedAvatar(null, null); + } + try { + URI uri = storage.signGetUrl(objectKey, storageProperties.getSignedUrlTtl()); + return new SignedAvatar( + uri.toString(), + clock.instant().plus(storageProperties.getSignedUrlTtl())); + } + catch (BusinessException exception) { + return new SignedAvatar(null, null); + } + } + + private String displayName(UserAccount user) { + if (user.nickname() != null && !user.nickname().isBlank()) return user.nickname(); + String username = user.username() == null ? "" : user.username(); + int at = username.indexOf('@'); + return at > 0 ? username.substring(0, at) : "UniSpeaking User"; + } + + private record SignedAvatar(String url, Instant expiresAt) {} +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/SessionService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/SessionService.java index a791387..079fe88 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/SessionService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/SessionService.java @@ -12,7 +12,10 @@ public interface SessionService { - StartSessionResponse startSession(SceneType sceneType, String prompt); + StartSessionResponse startSession( + SceneType sceneType, + String sceneId, + String prompt); StartSceneSessionResponse startFreeChat(StartFreeChatRequest request); diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/impl/SessionServiceImpl.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/impl/SessionServiceImpl.java index 09a5933..12ab417 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/impl/SessionServiceImpl.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/session/impl/SessionServiceImpl.java @@ -20,6 +20,7 @@ import com.unispeaking.domain.po.session.AbstractSceneSession; import com.unispeaking.domain.po.session.CustomSceneSession; import com.unispeaking.domain.po.session.FreeChatSceneSession; +import com.unispeaking.domain.po.session.PracticeSessionRecord; import com.unispeaking.domain.vo.session.SpeakerType; import com.unispeaking.domain.vo.session.SessionPrompt; import com.unispeaking.domain.vo.provider.ProviderType; @@ -30,6 +31,7 @@ import com.unispeaking.common.exception.BusinessException; import com.unispeaking.common.exception.SessionNotFoundException; import com.unispeaking.infrastructure.persistence.repository.session.SessionMessageRepository; +import com.unispeaking.infrastructure.persistence.repository.session.PracticeSessionRepository; import com.unispeaking.provider.AiProviderRegistry; import com.unispeaking.infrastructure.persistence.repository.scene.SceneRepository; import com.unispeaking.component.session.ActiveSessionRegistry; @@ -44,7 +46,6 @@ import com.unispeaking.service.scene.impl.ScenarioDialogueStateMachine; import com.unispeaking.service.session.SessionService; import java.time.Instant; -import java.time.format.DateTimeParseException; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -58,6 +59,7 @@ public class SessionServiceImpl implements SessionService { private final SceneRepository sceneRepository; private final ActiveSessionRegistry activeSessionRegistry; private final SessionMessageRepository sessionMessageRepository; + private final PracticeSessionRepository practiceSessionRepository; private final RealtimeSdpExchange realtimeSdpExchange; private final EvaluationService evaluationService; private final ScenarioDialogueStateMachine stateMachine; @@ -73,6 +75,7 @@ public SessionServiceImpl( SceneRepository sceneRepository, ActiveSessionRegistry activeSessionRegistry, SessionMessageRepository sessionMessageRepository, + PracticeSessionRepository practiceSessionRepository, RealtimeSdpExchange realtimeSdpExchange, EvaluationService evaluationService, ScenarioDialogueStateMachine stateMachine, @@ -86,6 +89,7 @@ public SessionServiceImpl( this.sceneRepository = sceneRepository; this.activeSessionRegistry = activeSessionRegistry; this.sessionMessageRepository = sessionMessageRepository; + this.practiceSessionRepository = practiceSessionRepository; this.realtimeSdpExchange = realtimeSdpExchange; this.evaluationService = evaluationService; this.stateMachine = stateMachine; @@ -96,7 +100,10 @@ public SessionServiceImpl( } @Override - public StartSessionResponse startSession(SceneType sceneType, String prompt) { + public StartSessionResponse startSession( + SceneType sceneType, + String sceneId, + String prompt) { String userId = authService.requireUserId(null); SceneType type = sceneType == null ? SceneType.FREE_CHAT : sceneType; String sessionId = SessionIdGenerator.generate(type); @@ -104,7 +111,16 @@ public StartSessionResponse startSession(SceneType sceneType, String prompt) { ? new FreeChatSceneSession(sessionId, userId) : new CustomSceneSession(sessionId, userId); session.setSceneType(type); + session.setSceneId(sceneId); session.setPrompt(new SessionPrompt(requirePrompt(prompt))); + practiceSessionRepository.create(new PracticeSessionRecord( + session.getId(), + UUID.fromString(userId), + sceneId, + type, + session.getStatus(), + session.getCreatedAt(), + null)); activeSessionRegistry.save(session); RealtimeFlowLog.info( "session.start sessionId={} userId={} sceneType={} startTime={} prompt={}", @@ -129,6 +145,7 @@ public StartSceneSessionResponse startFreeChat(StartFreeChatRequest request) { SceneFlowResponse flow = sceneFlowService.createFlow(scene.sceneId()); StartSessionResponse started = startSession( SceneType.FREE_CHAT, + scene.sceneId(), scene.scenePrompt()); RealtimeConnectionResult connection = connect( started.sessionId(), @@ -166,6 +183,7 @@ public StartSceneSessionResponse startCustomScene( String basePrompt = resolvePrompt(scene, definition, userId); StartSessionResponse started = startSession( SceneType.CUSTOM_SCENE, + sceneId, basePrompt); stateMachine.start(started.sessionId(), definition); try { @@ -241,7 +259,12 @@ public void addMessage(String userId, String sessionId, Message message) { public void endSession(String userId, String sessionId, String stopTime) { AbstractSceneSession session = requireOwnedSession(userId, sessionId); if (session.getStatus() != SessionStatus.COMPLETED) { - session.complete(parseStopTime(stopTime)); + Instant endedAt = Instant.now(); + practiceSessionRepository.complete( + sessionId, + UUID.fromString(userId), + endedAt); + session.complete(endedAt); activeSessionRegistry.save(session); } RealtimeFlowLog.info( @@ -267,10 +290,8 @@ public CompleteCustomSceneDialogueResponse completeCustomScene( stateMachine.findState(sessionId) .map(ignored -> stateMachine.beginClosing(sessionId)) .orElse(null); - String endedAt = stopTime == null || stopTime.isBlank() - ? Instant.now().toString() - : stopTime.trim(); - endSession(userId, sessionId, endedAt); + endSession(userId, sessionId, stopTime); + String endedAt = session.getEndedAt().toString(); RealtimeFlowLog.info( "evaluation.report.start sceneId={} sessionId={}", sceneId, @@ -417,6 +438,10 @@ private RealtimeConnectionResult connect( } catch (RuntimeException exception) { session.fail("REALTIME_CONNECTION_FAILED", exception.getMessage()); + practiceSessionRepository.fail( + sessionId, + UUID.fromString(session.getUserId()), + session.getEndedAt()); activeSessionRegistry.remove(sessionId); throw exception; } @@ -532,17 +557,4 @@ private String requirePrompt(String prompt) { return prompt; } - private Instant parseStopTime(String stopTime) { - if (stopTime == null || stopTime.isBlank()) { - return Instant.now(); - } - try { - return Instant.parse(stopTime.trim()); - } - catch (DateTimeParseException exception) { - throw new BusinessException( - "INVALID_STOP_TIME", - "stopTime must use ISO-8601 format"); - } - } } diff --git a/backend/unispeaking-server/src/main/resources/application.yaml b/backend/unispeaking-server/src/main/resources/application.yaml index 477fbea..c7ce611 100644 --- a/backend/unispeaking-server/src/main/resources/application.yaml +++ b/backend/unispeaking-server/src/main/resources/application.yaml @@ -25,6 +25,18 @@ spring: server: port: ${SERVER_PORT:8080} +profile: + time-zone: ${PROFILE_TIME_ZONE:Asia/Shanghai} + +object-storage: + qiniu: + access-key: ${QINIU_ACCESS_KEY:} + secret-key: ${QINIU_SECRET_KEY:} + bucket: ${QINIU_BUCKET:} + domain: ${QINIU_DOMAIN:} + avatar-prefix: ${QINIU_AVATAR_PREFIX:avatars} + signed-url-ttl: ${QINIU_SIGNED_URL_TTL:1h} + prompt: templates: directory: ${PROMPT_TEMPLATE_DIR:} diff --git a/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql b/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql index 0fde13a..8c589d0 100644 --- a/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql +++ b/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql @@ -18,6 +18,19 @@ CREATE TABLE IF NOT EXISTS "user" ( ALTER TABLE "user" ALTER COLUMN username TYPE VARCHAR(254); +ALTER TABLE "user" +ADD COLUMN IF NOT EXISTS avatar_object_key VARCHAR(512); + +ALTER TABLE "user" +DROP CONSTRAINT IF EXISTS user_avatar_object_key_check; + +ALTER TABLE "user" +ADD CONSTRAINT user_avatar_object_key_check +CHECK (avatar_object_key IS NULL OR BTRIM(avatar_object_key) <> ''); + +COMMENT ON COLUMN "user".avatar_object_key IS +'用户头像在对象存储中的对象 Key;不保存签名 URL、Bucket 密钥或完整访问地址'; + CREATE TABLE IF NOT EXISTS user_preference ( user_id UUID PRIMARY KEY, preferred_voice VARCHAR(64), diff --git a/backend/unispeaking-server/src/main/resources/db/migration/V2__practice_session.sql b/backend/unispeaking-server/src/main/resources/db/migration/V2__practice_session.sql new file mode 100644 index 0000000..74a7f4e --- /dev/null +++ b/backend/unispeaking-server/src/main/resources/db/migration/V2__practice_session.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS practice_session ( + session_id VARCHAR(64) PRIMARY KEY, + user_id UUID NOT NULL, + scene_id VARCHAR(64), + scene_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT practice_session_scene_type_check + CHECK (scene_type IN ( + 'FREE_CHAT', + 'CUSTOM_SCENE', + 'INTERVIEW_SCENE', + 'IELTS_SCENE' + )), + CONSTRAINT practice_session_status_check + CHECK (status IN ( + 'CREATED', + 'CONNECTING', + 'WAITING_CLIENT', + 'ACTIVE', + 'PAUSED', + 'INTERRUPTED', + 'COMPLETED', + 'FAILED' + )), + CONSTRAINT practice_session_time_check + CHECK (ended_at IS NULL OR ended_at >= started_at) +); + +CREATE INDEX IF NOT EXISTS idx_practice_session_user_started_at +ON practice_session (user_id, started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_practice_session_user_completed_at +ON practice_session (user_id, ended_at DESC) +WHERE status = 'COMPLETED' AND ended_at IS NOT NULL; + +COMMENT ON TABLE practice_session IS +'全场景练习会话事实;学习时长由 started_at 与 ended_at 计算,不保存聚合统计值'; + +COMMENT ON COLUMN practice_session.user_id IS +'逻辑关联 user.id,不设置数据库外键'; + +COMMENT ON COLUMN practice_session.scene_id IS +'场景业务 ID;自由对话和后续无需持久化场景定义的类型也允许记录'; diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepositoryTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepositoryTest.java new file mode 100644 index 0000000..84b8ed1 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/evaluation/SessionEvaluationRepositoryTest.java @@ -0,0 +1,180 @@ +package com.unispeaking.infrastructure.persistence.repository.evaluation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.unispeaking.common.exception.evaluation.EvaluationErrorCode; +import com.unispeaking.common.exception.evaluation.EvaluationException; +import com.unispeaking.domain.dto.asset.SessionEvaluationRecord; +import com.unispeaking.domain.dto.evaluation.DialogueReportResult; +import com.unispeaking.infrastructure.persistence.entity.evaluation.SessionEvaluationEntity; +import com.unispeaking.infrastructure.persistence.mapper.evaluation.SessionEvaluationMapper; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class SessionEvaluationRepositoryTest { + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + SessionEvaluationEntity.class); + } + + @Test + void insertsNewEvaluationWithAllReportFields() { + SessionEvaluationMapper mapper = mock(SessionEvaluationMapper.class); + when(mapper.selectById("session-1")).thenReturn(null); + when(mapper.insert(any(SessionEvaluationEntity.class))).thenReturn(1); + SessionEvaluationRepository repository = new SessionEvaluationRepository(mapper); + + repository.save("scene-1", "session-1", report()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SessionEvaluationEntity.class); + verify(mapper).insert(captor.capture()); + SessionEvaluationEntity saved = captor.getValue(); + assertEquals("scene-1", saved.getSceneId()); + assertEquals(new BigDecimal("91"), saved.getFinalScore()); + assertEquals(List.of("表达清楚"), List.of(saved.getStrengths())); + assertEquals(saved.getCreatedAt(), saved.getUpdatedAt()); + } + + @Test + void updatesExistingEvaluationAndKeepsCreatedTime() { + SessionEvaluationMapper mapper = mock(SessionEvaluationMapper.class); + SessionEvaluationEntity existing = entity("scene-1", "session-1"); + when(mapper.selectById("session-1")).thenReturn(existing); + when(mapper.updateById(any(SessionEvaluationEntity.class))).thenReturn(1); + SessionEvaluationRepository repository = new SessionEvaluationRepository(mapper); + + repository.save("scene-1", "session-1", report()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SessionEvaluationEntity.class); + verify(mapper).updateById(captor.capture()); + assertEquals(existing.getCreatedAt(), captor.getValue().getCreatedAt()); + } + + @Test + void translatesInsertAndDatabaseFailures() { + SessionEvaluationMapper mapper = mock(SessionEvaluationMapper.class); + when(mapper.insert(any(SessionEvaluationEntity.class))).thenReturn(0); + SessionEvaluationRepository repository = new SessionEvaluationRepository(mapper); + + EvaluationException rejected = assertThrows( + EvaluationException.class, + () -> repository.save("scene", "session", report())); + assertEquals(EvaluationErrorCode.PERSISTENCE_FAILED, rejected.errorCode()); + + when(mapper.selectById("broken")).thenThrow(new IllegalStateException("database")); + EvaluationException failed = assertThrows( + EvaluationException.class, + () -> repository.find("broken")); + assertEquals(EvaluationErrorCode.PERSISTENCE_FAILED, failed.errorCode()); + } + + @Test + void findsReportAndHandlesMissingArrays() { + SessionEvaluationMapper mapper = mock(SessionEvaluationMapper.class); + SessionEvaluationEntity entity = entity("scene-1", "session-1"); + entity.setStrengths(null); + entity.setImprovements(null); + when(mapper.selectById("session-1")).thenReturn(entity); + when(mapper.selectById("missing")).thenReturn(null); + SessionEvaluationRepository repository = new SessionEvaluationRepository(mapper); + + DialogueReportResult result = repository.find("session-1").orElseThrow(); + + assertEquals(new BigDecimal("91"), result.finalScore()); + assertTrue(result.strengths().isEmpty()); + assertTrue(result.improvements().isEmpty()); + assertTrue(repository.find("missing").isEmpty()); + } + + @Test + void findsOnlyCompleteEvaluationRecords() { + SessionEvaluationMapper mapper = mock(SessionEvaluationMapper.class); + SessionEvaluationEntity valid = entity("scene-1", "session-1"); + SessionEvaluationEntity blankScene = entity(" ", "session-2"); + when(mapper.selectById("session-1")).thenReturn(valid); + when(mapper.selectById("session-2")).thenReturn(blankScene); + when(mapper.selectById("missing")).thenReturn(null); + SessionEvaluationRepository repository = new SessionEvaluationRepository(mapper); + + SessionEvaluationRecord record = repository.findRecord("session-1").orElseThrow(); + + assertEquals("scene-1", record.sceneId()); + assertEquals("session-1", record.sessionId()); + assertTrue(repository.findRecord("session-2").isEmpty()); + assertTrue(repository.findRecord("missing").isEmpty()); + } + + @Test + void listsSceneRecordsAndCreationDates() { + SessionEvaluationMapper mapper = mock(SessionEvaluationMapper.class); + SessionEvaluationEntity first = entity("scene-1", "session-1"); + SessionEvaluationEntity second = entity("scene-1", "session-2"); + second.setCreatedAt(first.getCreatedAt().plusMinutes(5)); + when(mapper.selectList(any())).thenReturn(List.of(first, second)); + SessionEvaluationRepository repository = new SessionEvaluationRepository(mapper); + + assertEquals(2, repository.findBySceneId("scene-1").size()); + assertEquals( + List.of(first.getCreatedAt(), second.getCreatedAt()), + repository.findCreatedAtBySceneIdsBetween( + List.of("scene-1"), + first.getCreatedAt().minusDays(1), + second.getCreatedAt().plusDays(1))); + assertTrue(repository.findCreatedAtBySceneIdsBetween( + List.of(), first.getCreatedAt(), second.getCreatedAt()).isEmpty()); + assertTrue(repository.findCreatedAtBySceneIdsBetween( + null, first.getCreatedAt(), second.getCreatedAt()).isEmpty()); + } + + private DialogueReportResult report() { + return new DialogueReportResult( + new BigDecimal("90"), + new BigDecimal("89"), + new BigDecimal("88"), + new BigDecimal("87"), + new BigDecimal("86"), + new BigDecimal("91"), + "整体表现良好", + List.of("表达清楚"), + List.of("注意时态")); + } + + private SessionEvaluationEntity entity(String sceneId, String sessionId) { + DialogueReportResult report = report(); + SessionEvaluationEntity entity = new SessionEvaluationEntity(); + entity.setSceneId(sceneId); + entity.setSessionId(sessionId); + entity.setAccuracyScore(report.accuracyScore()); + entity.setFluencyScore(report.fluencyScore()); + entity.setGrammarScore(report.grammarScore()); + entity.setVocabularyScore(report.vocabularyScore()); + entity.setNaturalnessScore(report.naturalnessScore()); + entity.setFinalScore(report.finalScore()); + entity.setSummary(report.summary()); + entity.setStrengths(report.strengths().toArray(String[]::new)); + entity.setImprovements(report.improvements().toArray(String[]::new)); + entity.setCreatedAt(OffsetDateTime.of( + 2026, 8, 3, 3, 0, 0, 0, ZoneOffset.UTC)); + entity.setUpdatedAt(entity.getCreatedAt()); + return entity; + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepositoryTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepositoryTest.java index b624f34..0868a46 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepositoryTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/scene/MybatisSceneRepositoryTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.unispeaking.domain.dto.scene.LearningContentItem; import com.unispeaking.domain.dto.scene.SceneGenerationResponse; @@ -88,6 +89,24 @@ void persistsSceneAndAllGeneratedLearningContent() { && value.getSentenceId().startsWith("sentence_"))); } + @Test + void countsOnlyActiveTrainingRecords() { + SceneMapper sceneMapper = mock(SceneMapper.class); + when(sceneMapper.selectCount(any())).thenReturn(3L); + var repository = new MybatisSceneRepository( + sceneMapper, + mock(SceneWordMapper.class), + mock(ScenePhraseMapper.class), + mock(SceneSentenceMapper.class), + mock(CustomScenePersistence.class)); + + long count = repository.countActiveByUserId( + "11111111-1111-4111-8111-111111111111"); + + assertEquals(3, count); + verify(sceneMapper).selectCount(any()); + } + private Fixture fixture() { String sceneId = "custom_abc123"; List words = items("word", 5); diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepositoryTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepositoryTest.java new file mode 100644 index 0000000..fa38293 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepositoryTest.java @@ -0,0 +1,65 @@ +package com.unispeaking.infrastructure.persistence.repository.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.unispeaking.domain.po.session.PracticeSessionRecord; +import com.unispeaking.domain.vo.scene.SceneType; +import com.unispeaking.domain.vo.session.SessionStatus; +import com.unispeaking.infrastructure.persistence.entity.session.PracticeSessionEntity; +import com.unispeaking.infrastructure.persistence.mapper.session.PracticeSessionMapper; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class PracticeSessionRepositoryTest { + + @Test + void createsPracticeSessionWithBusinessFields() { + PracticeSessionMapper mapper = mock(PracticeSessionMapper.class); + when(mapper.insert(any(PracticeSessionEntity.class))).thenReturn(1); + PracticeSessionRepository repository = + new PracticeSessionRepository(mapper); + UUID userId = UUID.randomUUID(); + + repository.create(new PracticeSessionRecord( + "custom_session_1", + userId, + "custom_scene1", + SceneType.CUSTOM_SCENE, + SessionStatus.CREATED, + Instant.parse("2026-08-03T02:00:00Z"), + null)); + + verify(mapper).insert(any(PracticeSessionEntity.class)); + } + + @Test + void mapsCompletedSessionsFromRequestedWindow() { + PracticeSessionMapper mapper = mock(PracticeSessionMapper.class); + PracticeSessionEntity entity = new PracticeSessionEntity(); + entity.setSessionId("freechat_session_1"); + entity.setUserId(UUID.randomUUID()); + entity.setSceneId("freechat_scene1"); + entity.setSceneType(SceneType.FREE_CHAT.name()); + entity.setStatus(SessionStatus.COMPLETED.name()); + entity.setStartedAt(Instant.parse("2026-08-03T02:00:00Z").atOffset(java.time.ZoneOffset.UTC)); + entity.setEndedAt(Instant.parse("2026-08-03T02:05:00Z").atOffset(java.time.ZoneOffset.UTC)); + when(mapper.selectList(any())).thenReturn(List.of(entity)); + PracticeSessionRepository repository = + new PracticeSessionRepository(mapper); + + List records = repository.findCompletedOverlapping( + entity.getUserId(), + Instant.parse("2026-08-03T00:00:00Z"), + Instant.parse("2026-08-04T00:00:00Z")); + + assertEquals(1, records.size()); + assertEquals(entity.getStartedAt().toInstant(), records.getFirst().startedAt()); + assertEquals(entity.getEndedAt().toInstant(), records.getFirst().endedAt()); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepositoryTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepositoryTest.java new file mode 100644 index 0000000..5ebf1a5 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/persistence/repository/user/MybatisUserAccountRepositoryTest.java @@ -0,0 +1,161 @@ +package com.unispeaking.infrastructure.persistence.repository.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.unispeaking.domain.po.auth.UserAccount; +import com.unispeaking.domain.po.auth.UserRole; +import com.unispeaking.domain.po.auth.UserStatus; +import com.unispeaking.infrastructure.persistence.entity.user.UserAccountEntity; +import com.unispeaking.infrastructure.persistence.mapper.user.UserAccountMapper; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class MybatisUserAccountRepositoryTest { + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + UserAccountEntity.class); + } + + @Test + void mapsEntityToDomainById() { + UserAccountMapper mapper = mock(UserAccountMapper.class); + UUID id = UUID.randomUUID(); + UserAccountEntity entity = entity(id); + when(mapper.selectById(id)).thenReturn(entity); + MybatisUserAccountRepository repository = + new MybatisUserAccountRepository(mapper); + + UserAccount account = repository.findById(id).orElseThrow(); + + assertEquals(id, account.id()); + assertEquals("learner@example.com", account.username()); + assertEquals("avatar/object.png", account.avatarObjectKey()); + assertEquals(UserRole.USER, account.role()); + assertEquals(UserStatus.ACTIVE, account.status()); + assertEquals(entity.getCreatedAt().toInstant(), account.createdAt()); + } + + @Test + void findsUsernameAndReturnsEmptyWhenMissing() { + UserAccountMapper mapper = mock(UserAccountMapper.class); + when(mapper.selectOne(any())) + .thenReturn(entity(UUID.randomUUID())) + .thenReturn(null); + MybatisUserAccountRepository repository = + new MybatisUserAccountRepository(mapper); + + assertTrue(repository.findByUsername("learner@example.com").isPresent()); + assertTrue(repository.findByUsername("missing@example.com").isEmpty()); + } + + @Test + void mapsDomainFieldsWhenCreatingAccount() { + UserAccountMapper mapper = mock(UserAccountMapper.class); + when(mapper.insert(any(UserAccountEntity.class))).thenReturn(1); + MybatisUserAccountRepository repository = + new MybatisUserAccountRepository(mapper); + UUID id = UUID.randomUUID(); + Instant now = Instant.parse("2026-08-03T01:02:03Z"); + UserAccount account = new UserAccount( + id, + "new@example.com", + "hash", + "新用户", + null, + UserRole.ADMIN, + UserStatus.LOCKED, + 3, + null, + now, + now); + + assertEquals(account, repository.create(account)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UserAccountEntity.class); + verify(mapper).insert(captor.capture()); + UserAccountEntity saved = captor.getValue(); + assertEquals(id, saved.getId()); + assertEquals("ADMIN", saved.getRole()); + assertEquals("LOCKED", saved.getStatus()); + assertEquals(3L, saved.getAuthVersion()); + assertNull(saved.getAvatarObjectKey()); + assertNull(saved.getLastLoginAt()); + assertEquals(now, saved.getCreatedAt().toInstant()); + } + + @Test + void updatesLastLoginOnlyForExistingAccount() { + UserAccountMapper mapper = mock(UserAccountMapper.class); + UUID id = UUID.randomUUID(); + UserAccountEntity entity = entity(id); + when(mapper.selectById(id)).thenReturn(entity); + MybatisUserAccountRepository repository = + new MybatisUserAccountRepository(mapper); + Instant loginAt = Instant.parse("2026-08-03T03:00:00Z"); + + repository.updateLastLoginAt(id, loginAt); + + assertEquals(loginAt, entity.getLastLoginAt().toInstant()); + verify(mapper).updateById(entity); + + UUID missingId = UUID.randomUUID(); + when(mapper.selectById(missingId)).thenReturn(null); + repository.updateLastLoginAt(missingId, loginAt); + verify(mapper, times(1)).updateById(any(UserAccountEntity.class)); + } + + @Test + void returnsWhetherProfileAndPasswordUpdatesMatched() { + UserAccountMapper mapper = mock(UserAccountMapper.class); + when(mapper.update(isNull(), any(Wrapper.class))) + .thenReturn(1, 0, 1, 0); + MybatisUserAccountRepository repository = + new MybatisUserAccountRepository(mapper); + UUID id = UUID.randomUUID(); + + assertTrue(repository.updateNickname(id, "新昵称")); + assertFalse(repository.updateAvatarObjectKey(id, null, "new.png")); + assertTrue(repository.updateAvatarObjectKey(id, "old.png", "new.png")); + assertFalse(repository.updatePasswordAndAuthVersion(id, 2, "new-hash")); + } + + private UserAccountEntity entity(UUID id) { + OffsetDateTime now = OffsetDateTime.of( + 2026, 8, 3, 1, 2, 3, 0, ZoneOffset.UTC); + UserAccountEntity entity = new UserAccountEntity(); + entity.setId(id); + entity.setUsername("learner@example.com"); + entity.setPasswordHash("hash"); + entity.setNickname("学习者"); + entity.setAvatarObjectKey("avatar/object.png"); + entity.setRole("USER"); + entity.setStatus("ACTIVE"); + entity.setAuthVersion(2L); + entity.setLastLoginAt(now); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + return entity; + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProviderTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProviderTest.java new file mode 100644 index 0000000..81097fd --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProviderTest.java @@ -0,0 +1,102 @@ +package com.unispeaking.infrastructure.storage.qiniu; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.qiniu.http.Response; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import java.net.URI; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class QiniuObjectStorageProviderTest { + + private static final String BUCKET = "profile-bucket"; + private static final String OBJECT_KEY = "avatars/user/avatar.png"; + private UploadManager uploadManager; + private BucketManager bucketManager; + private QiniuObjectStorageProvider provider; + + @BeforeEach + void setUp() { + ObjectStorageProperties properties = new ObjectStorageProperties(); + properties.setAccessKey("test-access-key"); + properties.setSecretKey("test-secret-key"); + properties.setBucket(BUCKET); + properties.setDomain("https://profile.example.com"); + uploadManager = mock(UploadManager.class); + bucketManager = mock(BucketManager.class); + provider = new QiniuObjectStorageProvider( + Auth.create("test-access-key", "test-secret-key"), + uploadManager, + bucketManager, + properties); + } + + @Test + void uploadsWithAKeyScopedTokenAndClosesTheResponse() throws Exception { + byte[] content = new byte[] {1, 2, 3}; + Response response = mock(Response.class); + when(uploadManager.put( + eq(content), + eq(OBJECT_KEY), + anyString(), + isNull(), + eq("image/png"), + eq(false))).thenReturn(response); + + provider.put(OBJECT_KEY, content, "image/png"); + + verify(uploadManager).put( + eq(content), + eq(OBJECT_KEY), + anyString(), + isNull(), + eq("image/png"), + eq(false)); + verify(response).close(); + } + + @Test + void createsAnHttpsPrivateDownloadUrl() { + URI result = provider.signGetUrl(OBJECT_KEY, Duration.ofMinutes(5)); + + assertEquals("https", result.getScheme()); + assertEquals("profile.example.com", result.getHost()); + assertEquals("/" + OBJECT_KEY, result.getPath()); + assertTrue(result.getQuery().contains("e=")); + assertTrue(result.getQuery().contains("token=test-access-key:")); + } + + @Test + void deletesFromTheConfiguredBucket() throws Exception { + provider.delete(OBJECT_KEY); + + verify(bucketManager).delete(BUCKET, OBJECT_KEY); + } + + @Test + void rejectsAnInsecureDownloadDomain() { + ObjectStorageProperties properties = new ObjectStorageProperties(); + properties.setDomain("http://profile.example.com"); + + assertThrows( + IllegalArgumentException.class, + () -> new QiniuObjectStorageProvider( + Auth.create("access-key", "secret-key"), + uploadManager, + bucketManager, + properties)); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java index fd5d6d6..c95a7c2 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java @@ -129,7 +129,7 @@ void migratesEmptyDatabaseAndRegistersFlywayHistory() { """, String.class); - assertEquals(1, migrationCount); + assertEquals(2, migrationCount); assertEquals("jsonb", successFactorType); } @@ -388,7 +388,7 @@ status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', "SELECT COUNT(*) FROM legacy_ci.\"user\" WHERE username = 'legacy@example.com'", Integer.class)); assertEquals( - List.of("0", "1"), + List.of("0", "1", "2"), jdbcTemplate.queryForList( """ SELECT version diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/PracticeDurationCalculatorTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/PracticeDurationCalculatorTest.java new file mode 100644 index 0000000..c2a79b9 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/PracticeDurationCalculatorTest.java @@ -0,0 +1,61 @@ +package com.unispeaking.service.profile; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.unispeaking.domain.po.session.PracticeSessionRecord; +import com.unispeaking.domain.vo.scene.SceneType; +import com.unispeaking.domain.vo.session.SessionStatus; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class PracticeDurationCalculatorTest { + + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Shanghai"); + private final PracticeDurationCalculator calculator = + new PracticeDurationCalculator(); + + @Test + void excludesSessionsShorterThanThirtySeconds() { + var result = calculate(List.of( + record("2026-08-03T01:00:00Z", "2026-08-03T01:00:29Z"), + record("2026-08-03T02:00:00Z", "2026-08-03T02:00:30Z"))); + + assertEquals(30, result.weeklyPracticeSeconds()); + assertEquals(30, result.lastSevenDays().getLast().practiceSeconds()); + } + + @Test + void splitsEligibleSessionAcrossLocalCalendarDays() { + var result = calculate(List.of( + record("2026-08-02T15:58:00Z", "2026-08-02T16:03:00Z"))); + + assertEquals(120, result.lastSevenDays().get(5).practiceSeconds()); + assertEquals(180, result.lastSevenDays().get(6).practiceSeconds()); + } + + private com.unispeaking.domain.dto.profile.ProfileOverviewResponse + .PracticeStatistics calculate(List records) { + return calculator.calculate( + records, + LocalDate.of(2026, 8, 3), + Instant.parse("2026-08-03T12:00:00Z"), + ZONE_ID, + 4, + 2); + } + + private PracticeSessionRecord record(String start, String end) { + return new PracticeSessionRecord( + "freechat_" + start, + UUID.randomUUID(), + "freechat_scene", + SceneType.FREE_CHAT, + SessionStatus.COMPLETED, + Instant.parse(start), + Instant.parse(end)); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/image/AvatarImageProcessorTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/image/AvatarImageProcessorTest.java new file mode 100644 index 0000000..456d99a --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/image/AvatarImageProcessorTest.java @@ -0,0 +1,94 @@ +package com.unispeaking.service.profile.image; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.unispeaking.common.exception.BusinessException; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class AvatarImageProcessorTest { + + private final AvatarImageProcessor processor = new AvatarImageProcessor(); + + @Test + void preservesAlphaImagesAsPng() throws IOException { + byte[] input = image("png", BufferedImage.TYPE_INT_ARGB, 256, 256); + + var result = processor.process(input); + + assertEquals("png", result.extension()); + assertEquals("image/png", result.contentType()); + assertTrue(result.content().length > 0); + } + + @Test + void convertsOpaqueImagesToJpeg() throws IOException { + byte[] input = image("jpg", BufferedImage.TYPE_INT_RGB, 256, 256); + + var result = processor.process(input); + + assertEquals("jpg", result.extension()); + assertEquals("image/jpeg", result.contentType()); + assertTrue(result.content().length > 0); + } + + @Test + void requiresAvatarContent() { + assertCode("AVATAR_FILE_REQUIRED", () -> processor.process(null)); + assertCode("AVATAR_FILE_REQUIRED", () -> processor.process(new byte[0])); + } + + @Test + void rejectsOversizedAvatar() { + assertCode( + "AVATAR_FILE_TOO_LARGE", + () -> processor.process(new byte[2 * 1024 * 1024 + 1])); + } + + @Test + void rejectsUnsupportedHeader() { + assertCode( + "AVATAR_TYPE_UNSUPPORTED", + () -> processor.process(new byte[] {1, 2, 3, 4})); + } + + @Test + void rejectsUnreadableImageWithSupportedHeader() { + byte[] pngHeader = { + (byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a + }; + + assertCode("AVATAR_CONTENT_INVALID", () -> processor.process(pngHeader)); + } + + @Test + void rejectsImageOutsideAllowedDimensions() throws IOException { + byte[] tooSmall = image("png", BufferedImage.TYPE_INT_ARGB, 127, 128); + + assertCode("AVATAR_DIMENSION_INVALID", () -> processor.process(tooSmall)); + } + + private byte[] image(String format, int type, int width, int height) + throws IOException { + BufferedImage image = new BufferedImage(width, height, type); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(Color.BLUE); + graphics.fillRect(0, 0, width, height); + graphics.dispose(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ImageIO.write(image, format, output); + return output.toByteArray(); + } + + private void assertCode(String code, Runnable action) { + BusinessException exception = assertThrows(BusinessException.class, action::run); + assertEquals(code, exception.code()); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImplTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImplTest.java new file mode 100644 index 0000000..43de747 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImplTest.java @@ -0,0 +1,193 @@ +package com.unispeaking.service.profile.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.domain.dto.profile.AvatarResponse; +import com.unispeaking.domain.dto.profile.UpdateProfileRequest; +import com.unispeaking.domain.po.auth.UserAccount; +import com.unispeaking.domain.po.auth.UserRole; +import com.unispeaking.domain.po.auth.UserStatus; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import com.unispeaking.infrastructure.persistence.repository.user.UserAccountRepository; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import com.unispeaking.service.profile.image.AvatarImageProcessor; +import com.unispeaking.service.profile.image.AvatarImageProcessor.ProcessedAvatar; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ProfileAccountServiceImplTest { + + private final UserAccountRepository accounts = mock(UserAccountRepository.class); + private final ObjectStorageProvider storage = mock(ObjectStorageProvider.class); + private final AvatarImageProcessor images = mock(AvatarImageProcessor.class); + private final ObjectStorageProperties properties = new ObjectStorageProperties(); + private ProfileAccountServiceImpl service; + + @BeforeEach + void setUp() { + properties.setAvatarPrefix("/profile-avatars/"); + properties.setSignedUrlTtl(Duration.ofMinutes(30)); + service = new ProfileAccountServiceImpl(accounts, storage, properties, images); + } + + @Test + void trimsAndUpdatesNickname() { + UUID userId = UUID.randomUUID(); + when(accounts.updateNickname(userId, "新昵称")).thenReturn(true); + + var response = service.updateNickname( + userId.toString(), + new UpdateProfileRequest(" 新昵称 ")); + + assertEquals("新昵称", response.nickname()); + assertEquals("新昵称", response.displayName()); + } + + @Test + void rejectsBlankNickname() { + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.updateNickname( + UUID.randomUUID().toString(), + new UpdateProfileRequest(" "))); + + assertEquals("PROFILE_NICKNAME_REQUIRED", exception.code()); + } + + @Test + void reportsConcurrentNicknameUpdate() { + UUID userId = UUID.randomUUID(); + when(accounts.updateNickname(userId, "昵称")).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.updateNickname( + userId.toString(), + new UpdateProfileRequest("昵称"))); + + assertEquals("PROFILE_UPDATE_CONFLICT", exception.code()); + } + + @Test + void replacesAvatarAndDeletesPreviousObject() { + UUID userId = UUID.randomUUID(); + byte[] original = {1, 2, 3}; + byte[] processed = {4, 5, 6}; + UserAccount user = user(userId, "old/avatar.png"); + when(storage.available()).thenReturn(true); + when(accounts.findById(userId)).thenReturn(Optional.of(user)); + when(images.process(original)) + .thenReturn(new ProcessedAvatar(processed, "image/png", "png")); + when(accounts.updateAvatarObjectKey(eq(userId), eq("old/avatar.png"), any())) + .thenReturn(true); + when(storage.signGetUrl(any(), eq(Duration.ofMinutes(30)))) + .thenReturn(URI.create("https://cdn.example/avatar.png")); + + AvatarResponse response = service.replaceAvatar(userId.toString(), original); + + assertEquals("https://cdn.example/avatar.png", response.avatarUrl()); + assertNotNull(response.avatarUrlExpiresAt()); + verify(storage).put( + org.mockito.ArgumentMatchers.startsWith( + "profile-avatars/" + userId + "/"), + eq(processed), + eq("image/png")); + verify(storage).delete("old/avatar.png"); + } + + @Test + void rejectsAvatarWhenStorageIsUnavailable() { + when(storage.available()).thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.replaceAvatar(UUID.randomUUID().toString(), new byte[] {1})); + + assertEquals("AVATAR_STORAGE_UNAVAILABLE", exception.code()); + verify(accounts, never()).findById(any()); + } + + @Test + void rejectsAvatarForMissingUser() { + UUID userId = UUID.randomUUID(); + when(storage.available()).thenReturn(true); + when(accounts.findById(userId)).thenReturn(Optional.empty()); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.replaceAvatar(userId.toString(), new byte[] {1})); + + assertEquals("USER_NOT_FOUND", exception.code()); + } + + @Test + void deletesUploadedAvatarWhenConcurrentUpdateWins() { + UUID userId = UUID.randomUUID(); + when(storage.available()).thenReturn(true); + when(accounts.findById(userId)).thenReturn(Optional.of(user(userId, null))); + when(images.process(any())) + .thenReturn(new ProcessedAvatar(new byte[] {2}, "image/jpeg", "jpg")); + when(accounts.updateAvatarObjectKey(eq(userId), eq(null), any())) + .thenReturn(false); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.replaceAvatar(userId.toString(), new byte[] {1})); + + assertEquals("PROFILE_UPDATE_CONFLICT", exception.code()); + verify(storage).delete(org.mockito.ArgumentMatchers.contains(userId.toString())); + } + + @Test + void keepsSuccessfulAvatarUpdateWhenSigningAndCleanupFail() { + UUID userId = UUID.randomUUID(); + when(storage.available()).thenReturn(true); + when(accounts.findById(userId)).thenReturn(Optional.of(user(userId, "old.png"))); + when(images.process(any())) + .thenReturn(new ProcessedAvatar(new byte[] {2}, "image/png", "png")); + when(accounts.updateAvatarObjectKey(eq(userId), eq("old.png"), any())) + .thenReturn(true); + when(storage.signGetUrl(any(), any())) + .thenThrow(new BusinessException("SIGN_FAILED", "签名失败")); + doThrow(new BusinessException("DELETE_FAILED", "删除失败")) + .when(storage).delete("old.png"); + + AvatarResponse response = service.replaceAvatar(userId.toString(), new byte[] {1}); + + assertNull(response.avatarUrl()); + assertNull(response.avatarUrlExpiresAt()); + verify(storage).delete("old.png"); + } + + private UserAccount user(UUID id, String avatarObjectKey) { + Instant now = Instant.parse("2026-08-03T00:00:00Z"); + return new UserAccount( + id, + "learner@example.com", + "hash", + "学习者", + avatarObjectKey, + UserRole.USER, + UserStatus.ACTIVE, + 1, + now, + now, + now); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImplTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImplTest.java new file mode 100644 index 0000000..a13738f --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImplTest.java @@ -0,0 +1,92 @@ +package com.unispeaking.service.profile.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.unispeaking.domain.po.auth.UserAccount; +import com.unispeaking.domain.po.auth.UserRole; +import com.unispeaking.domain.po.auth.UserStatus; +import com.unispeaking.domain.po.session.PracticeSessionRecord; +import com.unispeaking.domain.vo.scene.SceneType; +import com.unispeaking.domain.vo.session.SessionStatus; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import com.unispeaking.infrastructure.persistence.repository.evaluation.SessionEvaluationRepository; +import com.unispeaking.infrastructure.persistence.repository.scene.SceneRepository; +import com.unispeaking.infrastructure.persistence.repository.session.PracticeSessionRepository; +import com.unispeaking.infrastructure.persistence.repository.user.UserAccountRepository; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ProfileOverviewServiceImplTest { + + @Test + void returnsRealPracticeAssetsAndCheckInStreak() { + ZoneId zoneId = ZoneId.of("Asia/Shanghai"); + Instant now = Instant.parse("2026-08-03T12:00:00Z"); + UUID userId = UUID.randomUUID(); + UserAccountRepository accounts = mock(UserAccountRepository.class); + SceneRepository scenes = mock(SceneRepository.class); + SessionEvaluationRepository evaluations = + mock(SessionEvaluationRepository.class); + PracticeSessionRepository practiceSessions = + mock(PracticeSessionRepository.class); + ObjectStorageProvider storage = mock(ObjectStorageProvider.class); + when(accounts.findById(userId)).thenReturn(Optional.of(new UserAccount( + userId, + "learner@example.com", + "hash", + "学习者", + UserRole.USER, + UserStatus.ACTIVE, + 0, + null, + now, + now))); + when(scenes.findAllIdsByUserId(userId.toString())) + .thenReturn(List.of("custom_scene1")); + when(scenes.countActiveByUserId(userId.toString())).thenReturn(4L); + List reports = List.of( + OffsetDateTime.parse("2026-08-03T02:00:00Z"), + OffsetDateTime.parse("2026-08-02T02:00:00Z"), + OffsetDateTime.parse("2026-08-01T02:00:00Z")); + when(evaluations.findCreatedAtBySceneIdsBetween(any(), any(), any())) + .thenReturn(reports, reports); + when(practiceSessions.findCompletedOverlapping(any(), any(), any())) + .thenReturn(List.of(new PracticeSessionRecord( + "custom_session1", + userId, + "custom_scene1", + SceneType.CUSTOM_SCENE, + SessionStatus.COMPLETED, + Instant.parse("2026-08-03T01:00:00Z"), + Instant.parse("2026-08-03T01:05:00Z")))); + when(storage.available()).thenReturn(false); + ProfileOverviewServiceImpl service = new ProfileOverviewServiceImpl( + accounts, + scenes, + evaluations, + practiceSessions, + storage, + new ObjectStorageProperties(), + zoneId, + Clock.fixed(now, zoneId)); + + var overview = service.getOverview(userId.toString(), "2026-08"); + + assertEquals(300, overview.statistics().weeklyPracticeSeconds()); + assertEquals(4, overview.statistics().trainingRecordCount()); + assertEquals(3, overview.statistics().consecutiveLearningDays()); + assertEquals(7, overview.statistics().lastSevenDays().size()); + assertEquals(300, + overview.statistics().lastSevenDays().getLast().practiceSeconds()); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/session/SessionServiceImplRepracticeTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/session/SessionServiceImplRepracticeTest.java index 4fa485c..8d904b6 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/service/session/SessionServiceImplRepracticeTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/session/SessionServiceImplRepracticeTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -19,6 +20,7 @@ import com.unispeaking.domain.vo.session.RealtimeConnectionResult; import com.unispeaking.infrastructure.persistence.repository.scene.SceneRepository; import com.unispeaking.infrastructure.persistence.repository.session.SessionMessageRepository; +import com.unispeaking.infrastructure.persistence.repository.session.PracticeSessionRepository; import com.unispeaking.infrastructure.realtime.RealtimeSdpExchange; import com.unispeaking.provider.AiProviderRegistry; import com.unispeaking.service.asset.impl.ObsoleteDialogueCleanup; @@ -38,7 +40,7 @@ class SessionServiceImplRepracticeTest { @Test void repracticeStartsWithoutAnInMemorySceneFlow() { - String userId = "user_1"; + String userId = "f76889ee-7f7c-4dae-bcc2-61b85a63dcec"; String sceneId = "custom_repeat123"; String prompt = "layer 1\n\nlayer 2\n\nlayer 3\n\nlayer 4\n\nlayer 5"; AuthService authService = mock(AuthService.class); @@ -48,6 +50,8 @@ void repracticeStartsWithoutAnInMemorySceneFlow() { ScenarioDialogueStateMachine stateMachine = mock(ScenarioDialogueStateMachine.class); ActiveSessionRegistry sessions = new ActiveSessionRegistry(); + PracticeSessionRepository practiceSessions = + mock(PracticeSessionRepository.class); CustomSceneDefinition definition = new CustomSceneDefinition( sceneId, userId, @@ -89,6 +93,7 @@ void repracticeStartsWithoutAnInMemorySceneFlow() { sceneRepository, sessions, mock(SessionMessageRepository.class), + practiceSessions, realtimeSdpExchange, mock(EvaluationService.class), stateMachine, @@ -110,6 +115,14 @@ void repracticeStartsWithoutAnInMemorySceneFlow() { assertEquals(SceneFlowStage.DIALOGUE, response.currentStage()); assertEquals("answer-sdp", response.answerSdp()); assertNotNull(response.sessionId()); + service.endSession( + userId, + response.sessionId(), + "2000-01-01T00:00:00Z"); + verify(practiceSessions).complete( + eq(response.sessionId()), + eq(java.util.UUID.fromString(userId)), + any(Instant.class)); verify(sceneFlowService, never()).getByCurrentStage( any(), any()); diff --git a/deploy/env/.env.example b/deploy/env/.env.example index ae4e7a6..89da391 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -22,6 +22,17 @@ JWT_SECRET=replace-with-at-least-32-random-bytes-in-base64 JWT_ISSUER=unispeaking JWT_ACCESS_TOKEN_TTL=2h +# ============================================================================= +# Profile and Qiniu Kodo +# ============================================================================= +PROFILE_TIME_ZONE=Asia/Shanghai +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= +QINIU_BUCKET= +QINIU_DOMAIN= +QINIU_AVATAR_PREFIX=avatars +QINIU_SIGNED_URL_TTL=1h + # ============================================================================= # AI model routes # The first model is primary; later models are fallbacks. diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf index a78de36..7be3dc7 100644 --- a/deploy/nginx/nginx.conf +++ b/deploy/nginx/nginx.conf @@ -11,6 +11,7 @@ http { server { listen 80; + client_max_body_size 10m; location /backend/ { proxy_pass http://backend/; diff --git a/deploy/postgres/profile.sql b/deploy/postgres/profile.sql new file mode 100644 index 0000000..a170cb8 --- /dev/null +++ b/deploy/postgres/profile.sql @@ -0,0 +1,53 @@ +BEGIN; + +ALTER TABLE "user" +ADD COLUMN IF NOT EXISTS avatar_object_key VARCHAR(512); + +ALTER TABLE "user" +DROP CONSTRAINT IF EXISTS user_avatar_object_key_check; + +ALTER TABLE "user" +ADD CONSTRAINT user_avatar_object_key_check +CHECK (avatar_object_key IS NULL OR BTRIM(avatar_object_key) <> ''); + +COMMENT ON COLUMN "user".avatar_object_key IS +'用户头像在对象存储中的对象 Key;不保存签名 URL、Bucket 密钥或完整访问地址'; + +COMMIT; + +BEGIN; + +CREATE TABLE IF NOT EXISTS practice_session ( + session_id VARCHAR(64) PRIMARY KEY, + user_id UUID NOT NULL, + scene_id VARCHAR(64), + scene_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT practice_session_scene_type_check + CHECK (scene_type IN ( + 'FREE_CHAT', 'CUSTOM_SCENE', 'INTERVIEW_SCENE', 'IELTS_SCENE' + )), + CONSTRAINT practice_session_status_check + CHECK (status IN ( + 'CREATED', 'CONNECTING', 'WAITING_CLIENT', 'ACTIVE', + 'PAUSED', 'INTERRUPTED', 'COMPLETED', 'FAILED' + )), + CONSTRAINT practice_session_time_check + CHECK (ended_at IS NULL OR ended_at >= started_at) +); + +CREATE INDEX IF NOT EXISTS idx_practice_session_user_started_at +ON practice_session (user_id, started_at DESC); + +CREATE INDEX IF NOT EXISTS idx_practice_session_user_completed_at +ON practice_session (user_id, ended_at DESC) +WHERE status = 'COMPLETED' AND ended_at IS NOT NULL; + +COMMENT ON TABLE practice_session IS +'全场景练习会话事实;学习时长由 started_at 与 ended_at 计算,不保存聚合统计值'; + +COMMIT; diff --git a/docs/deployment.md b/docs/deployment.md index 04bf57a..cd504a9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -12,17 +12,57 @@ The repository contains `deploy/env/.env.example`. The working copy also uses `deploy/env/.env`, which is ignored by Git. Set the following variables in `deploy/env/.env` before starting a realtime -session: +session or using profile avatars: ```properties DASHSCOPE_API_KEY=replace-with-your-real-key BAILIAN_WORKSPACE_ID=replace-with-your-workspace-id BAILIAN_MODEL=qwen3.5-omni-flash-realtime +QINIU_ACCESS_KEY=replace-with-your-access-key +QINIU_SECRET_KEY=replace-with-your-secret-key +QINIU_BUCKET=replace-with-your-private-bucket +QINIU_DOMAIN=https://replace-with-your-https-download-domain +QINIU_AVATAR_PREFIX=avatars +QINIU_SIGNED_URL_TTL=1h +PROFILE_TIME_ZONE=Asia/Shanghai ``` Do not put API keys in a `VITE_` variable because Vite embeds those values in browser assets. +The Qiniu Kodo bucket must remain private. `QINIU_DOMAIN` must be the bucket's +HTTPS download domain and must not contain a path, query, or fragment. The +backend stores only the object key in PostgreSQL and returns a short-lived +signed download URL to the browser. Grant the configured Qiniu key only the +upload, download, and delete permissions required for this bucket. + +## Profile database migration + +Before deploying the profile feature to an existing database, apply: + +```bash +psql "$DATABASE_URL" -f deploy/postgres/profile.sql +``` + +The migration adds `user.avatar_object_key` and the `practice_session` session +fact table. Check-in dates continue to be derived from persisted +`session_evaluation` reports, so no check-in table and no Redis data structure +are required. Learning duration is calculated from completed practice sessions; +sessions shorter than 30 seconds are excluded at query time. New databases use +the Flyway migrations in +`backend/unispeaking-server/src/main/resources/db/migration`. + +Spring Boot runs `V2__practice_session.sql` automatically on startup. For an +existing environment, either let Flyway apply V2 or run `profile.sql` manually; +do not run both concurrently. Verify the migration with: + +```sql +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'practice_session' +ORDER BY ordinal_position; +``` + ## Available settings The Spring Boot defaults live in @@ -41,6 +81,18 @@ realtime: connect-timeout: ${REALTIME_QWEN_CONNECT_TIMEOUT:10s} read-timeout: ${REALTIME_QWEN_READ_TIMEOUT:20s} max-answer-bytes: ${REALTIME_QWEN_MAX_ANSWER_BYTES:1048576} + +profile: + time-zone: ${PROFILE_TIME_ZONE:Asia/Shanghai} + +object-storage: + qiniu: + access-key: ${QINIU_ACCESS_KEY:} + secret-key: ${QINIU_SECRET_KEY:} + bucket: ${QINIU_BUCKET:} + domain: ${QINIU_DOMAIN:} + avatar-prefix: ${QINIU_AVATAR_PREFIX:avatars} + signed-url-ttl: ${QINIU_SIGNED_URL_TTL:1h} ``` The WebRTC SDP endpoint is derived automatically: diff --git a/docs/frontend-backend-interface-contract.md b/docs/frontend-backend-interface-contract.md index c5e7de7..8ba514d 100644 --- a/docs/frontend-backend-interface-contract.md +++ b/docs/frontend-backend-interface-contract.md @@ -2,6 +2,102 @@ 本文档用于前端和后端对齐接口。当前后端已实现登录注册、JWT 鉴权、用户偏好、字幕翻译、自由会话开始、WebSocket 追加完整消息和结束;其他未实现接口会单独标记。 +## 个人主页与账户安全接口 + +以下接口均从 JWT 获取当前用户,不接收客户端传入的 `userId`。 + +### 获取个人主页 + +```text +GET /api/profile/overview?month=2026-07 +Authorization: Bearer +``` + +`month` 可省略,格式为 `yyyy-MM`,省略时按 `Asia/Shanghai` 返回当前月;未来月份不允许查询。日历的打卡依据是该用户场景下已经持久化的五维评分报告,同一天多份报告只返回一个日期。学习时长来自所有场景共用的 `practice_session`,不依赖是否生成评分报告。 + +```json +{ + "account": { + "userId": "11111111-1111-4111-8111-111111111111", + "email": "name@example.com", + "nickname": "Sunny", + "displayName": "Sunny", + "avatarUrl": "https://signed-oss-url.example/avatar.jpg", + "avatarUrlExpiresAt": "2026-07-31T08:00:00Z" + }, + "statistics": { + "weeklyPracticeSeconds": 10980, + "trainingRecordCount": 12, + "consecutiveLearningDays": 7, + "lastSevenDays": [ + {"date": "2026-07-25", "practiceSeconds": 1080}, + {"date": "2026-07-26", "practiceSeconds": 1560}, + {"date": "2026-07-27", "practiceSeconds": 0} + ] + }, + "calendar": { + "month": "2026-07", + "checkedDates": ["2026-07-02", "2026-07-15"], + "checkedInToday": true + } +} +``` + +`avatarUrl` 是短期签名地址,未上传头像或对象存储暂不可用时为 `null`。 + +统计口径: + +- `weeklyPracticeSeconds`:本周一 00:00 至当前时刻的有效学习秒数。 +- `trainingRecordCount`:未软删除训练记录条数,同一记录复练多次仍计为一项。 +- `consecutiveLearningDays`:由五维报告自动打卡日期计算;当天未打卡时从昨天向前计算。 +- `lastSevenDays`:今天及之前六个上海自然日的有效学习秒数,按日期升序返回。 +- 单次完整会话不足 30 秒时不计入任何时长;达到 30 秒时全部计入。 +- 有效会话跨越上海零点时,时长按每个自然日实际覆盖区间拆分。 +- 接口始终返回精确秒数;当前页面对非零有效秒数按分钟向上展示,因此 30~59 秒显示为 1 分钟。 + +### 修改用户名(昵称) + +```text +PATCH /api/profile +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "nickname": "Sunny" +} +``` + +这里只修改展示昵称,不修改登录邮箱。昵称去除首尾空白后长度必须为 1~80。 + +### 上传头像 + +```text +POST /api/profile/avatar +Authorization: Bearer +Content-Type: multipart/form-data +``` + +表单字段名为 `avatar`。仅接受 JPEG/PNG,文件不超过 2 MiB,宽高均为 128~4096 像素。后端会解码并重新编码后写入七牛云 Kodo 私有空间,响应包含一小时有效的签名 URL。 + +### 修改密码 + +```text +PUT /api/auth/password +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "currentPassword": "old-password", + "newPassword": "new-password" +} +``` + +新密码长度为 6~72,且不能与当前密码相同。成功响应中的 `reauthenticationRequired` 为 `true`;服务端同时递增 `auth_version`,使该用户所有现有 JWT(包括当前请求所用 JWT)失效,前端必须清除 Token 并跳转登录页。 + ## 1. 基础约定 ### 1.1 Base URL @@ -266,7 +362,9 @@ Profile 和偏好,固定以 `FREE_CHAT` 调用 `SceneService` 和 `systemPrompt` 是后端在 `SceneService.generateScene(...)` 中完成权限校验、 用户 Profile 注入和用户偏好注入后的完整五层提示词。前端必须将它放入 DataChannel `session.update.session.instructions`,不得使用客户端默认提示词替代。 -`SessionService.startSession(prompt)` 只创建业务会话并记录开始时间。 +`SessionService.startSession(sceneType, sceneId, prompt)` 创建业务会话,同时在 +`practice_session` 中保存服务器开始时间。自由对话和自定义场景已经接入该统一入口; +雅思、面试及后续场景接入时必须复用同一生命周期。 `RealtimeSessionConnector` 使用 `offerSdp/model/voice` 调用 `RealtimeConnectionService`,内部申请短期凭证并交换 Answer SDP。`systemPrompt` 就是 `SceneService` 生成的 `scenePrompt`,启动响应只保留这一个提示词字段。 @@ -355,8 +453,11 @@ HTTP `POST /api/scene-sessions/{sessionId}/end` 目前保留,方便调试和 后端行为: 1. 会话状态变为 `COMPLETED`。 -2. 结算用量。 -3. 保留本次完整消息;不更新用户长期 `memory_text`。 +2. 后端使用服务器当前时间更新 `practice_session.ended_at`;请求中的 `stopTime` + 只为旧客户端兼容,不作为学习时长依据。 +3. 单次会话完整时长达到 30 秒后,才会进入个人主页学习时长与七日节奏统计。 +4. 自由对话不保存消息正文;自定义场景保留本次完整消息;两者都不更新用户长期 + `memory_text`。 ## 6. 自定义场景接口 diff --git "a/docs/\344\270\252\344\272\272\344\270\273\351\241\265\350\264\246\346\210\267\350\265\204\346\226\231\344\270\216\350\207\252\345\212\250\346\211\223\345\215\241\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/\344\270\252\344\272\272\344\270\273\351\241\265\350\264\246\346\210\267\350\265\204\346\226\231\344\270\216\350\207\252\345\212\250\346\211\223\345\215\241\345\256\236\346\226\275\350\256\241\345\210\222.md" new file mode 100644 index 0000000..b6d9bbb --- /dev/null +++ "b/docs/\344\270\252\344\272\272\344\270\273\351\241\265\350\264\246\346\210\267\350\265\204\346\226\231\344\270\216\350\207\252\345\212\250\346\211\223\345\215\241\345\256\236\346\226\275\350\256\241\345\210\222.md" @@ -0,0 +1,128 @@ +# 个人主页账户资料与自动打卡实施计划 + +> **执行要求:** 严格按照本计划逐项实施。按可独立验收的功能大点提交中文 commit。测试保持最小必要范围:复用现有套件,只有供应商适配等 `CLAUDE.md` 明确要求覆盖的新边界才增加定向测试,不扩张前端测试框架。 + +> 下列任务末尾保留的“单文件 commit”是初版计划的历史拆分标记;实际提交粒度以本页最新执行要求和用户后续确认为准。 + +**目标:** 实现动态报告打卡日历、展示昵称修改、七牛云 Kodo 用户头像、全端 JWT 失效的密码修改,以及全场景真实学习时长统计。 + +**架构:** 完整设计以 `docs/个人主页账户资料与自动打卡开发设计.md` 为准。Profile 读取、Profile 写入、Auth 和对象存储基础设施保持独立;打卡只读取现有 `session_evaluation`,不增加打卡写模型、Redis 或评分表字段。 + +**技术栈:** Java 21、Spring Boot 4、MyBatis-Plus 3.5.17、PostgreSQL、七牛云 Java SDK 7.19.0、React 19、Vite 6。 + +## 全局约束 + +- 开发前遵循仓库根目录 `CLAUDE.md`。 +- 当前分支固定为 `feat/profile-account-checkin`。 +- 每个 commit 只包含一个可独立验收的功能大点,提交信息使用中文。 +- 不删除现有测试,不增加与本次需求无关或重复的测试。 +- Java 中不写原始 SQL,不使用 SQL 注解、Mapper XML 或被禁止的 Wrapper 方法。 +- 用户身份只来自 JWT。 +- 不读取、修改或提交 `deploy/env/.env`。 +- 不新增打卡表、Redis、MQ 或评分表字段;允许新增已确认的 `practice_session` 会话事实表。 + +--- + +### Task 1:数据库与依赖 + +- [x] 修改 `backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql`:给 `"user"` 增加 `avatar_object_key VARCHAR(512)`、非空白约束和注释。 +- [ ] 新建 `deploy/postgres/profile.sql`:用 `BEGIN/COMMIT` 包装同一幂等迁移;单文件 commit。 +- [ ] 修改 `backend/unispeaking-server/pom.xml`:增加 `qiniu-java-sdk.version=7.19.0` 和 `com.qiniu:qiniu-java-sdk`;运行 `./mvnw -q -DskipTests compile`;单文件 commit。 +- [ ] 修改 `backend/unispeaking-server/src/main/resources/application.yaml`:增加 `profile.time-zone` 与 `object-storage.qiniu`;单文件 commit。 +- [ ] 修改 `deploy/env/.env.example`:增加 Profile/七牛云空占位配置;单文件 commit。 +- [ ] 修改 `deploy/nginx/nginx.conf`:在 server 块设置 `client_max_body_size 10m`;单文件 commit。 + +### Task 2:账号持久化 + +- [ ] 修改 `domain/po/auth/UserAccount.java`:record 增加 `avatarObjectKey`,增加 `withNickname`、`withAvatarObjectKey`、`withPasswordHashAndAuthVersion`,同步 `withLastLoginAt`;编译;单文件 commit。 +- [ ] 修改 `infrastructure/persistence/entity/user/UserAccountEntity.java`:在 `nickname` 后增加 `avatarObjectKey`;编译;单文件 commit。 +- [ ] 修改 `infrastructure/persistence/repository/user/UserAccountRepository.java`:增加三个原子更新接口,返回 `boolean`;编译;单文件 commit。 +- [ ] 修改 `infrastructure/persistence/repository/user/MybatisUserAccountRepository.java`:补全 Entity 映射并使用 `LambdaUpdateWrapper` 实现昵称、头像和密码版本更新;编译;单文件 commit。 +- [ ] 修改 `service/auth/impl/AuthServiceImpl.java` 中注册用 `UserAccount` 构造参数;编译;单文件 commit。 + +### Task 3:打卡读取数据 + +- [ ] 修改 `infrastructure/persistence/repository/scene/SceneRepository.java`:增加 `findAllIdsByUserId(String userId)`;编译;单文件 commit。 +- [ ] 修改 `infrastructure/persistence/repository/scene/MybatisSceneRepository.java`:只按 `userId` 查询所有场景 ID,包含软删除;编译;单文件 commit。 +- [ ] 修改 `infrastructure/persistence/repository/evaluation/SessionEvaluationRepository.java`:增加 `findCreatedAtBySceneIdsBetween(List, OffsetDateTime, OffsetDateTime)`;编译;单文件 commit。 + +### Task 4:Profile DTO 与配置 + +- [ ] 新建 `domain/dto/profile/ProfileOverviewResponse.java`:包含嵌套 `Account` 和 `Calendar` record;编译;单文件 commit。 +- [ ] 新建 `domain/dto/profile/UpdateProfileRequest.java`:昵称非空且最长 32;编译;单文件 commit。 +- [ ] 新建 `domain/dto/profile/UpdateProfileResponse.java`:返回 nickname/displayName;编译;单文件 commit。 +- [ ] 新建 `domain/dto/profile/AvatarResponse.java`:返回签名 URL 和到期时间;编译;单文件 commit。 +- [ ] 新建 `infrastructure/config/ProfileProperties.java`:绑定 `profile.time-zone` 并提供合法 `ZoneId`;编译;单文件 commit。 +- [ ] 新建 `infrastructure/config/ObjectStorageProperties.java`:绑定 domain、bucket、Access Key、Secret Key、前缀和签名 TTL;编译;单文件 commit。 + +### Task 5:七牛云对象存储与图片处理 + +- [ ] 新建 `infrastructure/storage/ObjectStorageProvider.java`:定义 `put`、`signGetUrl`、`delete`;编译;单文件 commit。 +- [ ] 新建 `infrastructure/storage/qiniu/QiniuObjectStorageProvider.java`:唯一七牛云 SDK 适配器,转换供应商异常;编译;单文件 commit。 +- [ ] 新建 `infrastructure/config/ObjectStorageConfig.java`:配置完整时创建七牛云 Provider,不完整时提供明确不可用实现;编译;单文件 commit。 +- [ ] 新建 `service/profile/image/AvatarImageProcessor.java`:校验 2 MiB、JPEG/PNG、128~4096 像素并重新编码;编译;单文件 commit。 + +### Task 6:Profile 业务与接口 + +- [ ] 新建 `service/profile/ProfileOverviewService.java`:定义 `getOverview(userId, month)`;编译;单文件 commit。 +- [ ] 新建 `service/profile/impl/ProfileOverviewServiceImpl.java`:按上海时区推导日期、生成 displayName、签名头像并对签名失败降级;编译;单文件 commit。 +- [ ] 新建 `service/profile/ProfileAccountService.java`:定义昵称和头像更新接口;编译;单文件 commit。 +- [ ] 新建 `service/profile/impl/ProfileAccountServiceImpl.java`:实现昵称、头像对象 Key、并发条件和对象存储补偿;编译;单文件 commit。 +- [ ] 新建 `controller/ProfileController.java`:实现 overview、PATCH profile 和 multipart avatar;编译;单文件 commit。 +- [ ] 修改 `common/exception/GlobalExceptionHandler.java`:增加 Profile/Auth/对象存储错误状态映射;编译;单文件 commit。 + +### Task 7:修改密码 + +- [ ] 新建 `domain/dto/auth/ChangePasswordRequest.java`:当前密码和新密码均 6~72 位;编译;单文件 commit。 +- [ ] 新建 `domain/dto/auth/ChangePasswordResponse.java`:固定返回 `reauthenticationRequired=true`;编译;单文件 commit。 +- [ ] 修改 `service/auth/AuthService.java`:增加 `changePassword`;编译;单文件 commit。 +- [ ] 修改 `service/auth/impl/AuthServiceImpl.java`:验证当前密码、新旧相同检查、BCrypt 和 authVersion 原子更新;编译;单文件 commit。 +- [ ] 修改 `controller/AuthController.java`:增加 `PUT /api/auth/password`;编译;单文件 commit。 + +### Task 8:前端 + +- [ ] 修改 `frontend/Unispeaking_fronted/src/apiClient.js`:增加 overview、昵称、头像和密码请求;运行 `npm run build`;单文件 commit。 +- [ ] 修改 `frontend/Unispeaking_fronted/src/App.jsx`:增加 Profile 状态、动态日历、昵称/头像/密码交互、结果打卡提示和全局用户头像;运行 `npm run build`;单文件 commit。 +- [ ] 修改 `frontend/Unispeaking_fronted/src/styles.css`:增加资料编辑、上传、密码弹窗和打卡提示样式;运行 `npm run build`;单文件 commit。 + +### Task 9:接口与部署文档 + +- [ ] 修改 `docs/frontend-backend-interface-contract.md`:补充 Profile 和密码接口;单文件 commit。 +- [ ] 修改 `docs/deployment.md`:补充七牛云配置、私有 Bucket、最小权限和签名 URL;单文件 commit。 + +### Task 10:最终验证 + +- [ ] 确认 `git status --short` 为空。 +- [ ] 确认每个开发 commit 都只包含一个可独立验收的功能大点,且提交信息为中文。 +- [ ] 运行 `backend/unispeaking-server/./mvnw test`,期望 0 failures、0 errors。 +- [ ] 运行 `npm run build`,期望成功。 +- [ ] 运行 `npm run check:routes`,期望 32 assertions passed。 +- [ ] 运行 `npm run check:realtime-events`,期望通过。 +- [ ] 运行 `./mvnw dependency:tree`,确认没有第二套 ORM/JSON/HTTP 技术栈。 + +### Task 11:统一练习会话事实 + +- [x] 新建 `backend/unispeaking-server/src/main/resources/db/migration/V2__practice_session.sql`,保存全场景会话开始、结束和状态。 +- [x] 新建 Practice Session Domain、Entity、Mapper 和 Repository,数据库访问只使用 MyBatis-Plus。 +- [x] 修改 `SessionService.startSession/endSession`,结束时间使用服务器时间,Realtime 失败写入 `FAILED`。 +- [x] 更新 `deploy/postgres/profile.sql`,支持现有数据库手工迁移。 + +### Task 12:个人主页真实统计 + +- [x] `ProfileOverviewResponse` 增加 `statistics`。 +- [x] 新增 `PracticeDurationCalculator`,实现最新确认的 30 秒门槛、周统计和跨零点拆分。 +- [x] 训练记录数量直接统计未软删除 `scene` 记录,不累加复练次数。 +- [x] 连续学习天数继续依据五维报告日期计算。 + +### Task 13:前端真实数据和资料区域 + +- [x] 三张统计卡和七日练习节奏读取 `profile.statistics`。 +- [x] 头像调整为圆形并放大,编辑按钮定位到头像右上角。 +- [x] 用户名和邮箱适当放大;左侧资料区及底部退出登录继续固定。 + +### Task 14:本轮提交 + +- [x] `feat: 持久化全场景练习会话` +- [x] `feat: 接入个人主页真实学习统计` +- [x] `feat: 优化头像编辑入口和统计展示` +- [x] `feat: 更新个人主页真实统计文档` diff --git "a/docs/\344\270\252\344\272\272\344\270\273\351\241\265\350\264\246\346\210\267\350\265\204\346\226\231\344\270\216\350\207\252\345\212\250\346\211\223\345\215\241\345\274\200\345\217\221\350\256\276\350\256\241.md" "b/docs/\344\270\252\344\272\272\344\270\273\351\241\265\350\264\246\346\210\267\350\265\204\346\226\231\344\270\216\350\207\252\345\212\250\346\211\223\345\215\241\345\274\200\345\217\221\350\256\276\350\256\241.md" new file mode 100644 index 0000000..72af80c --- /dev/null +++ "b/docs/\344\270\252\344\272\272\344\270\273\351\241\265\350\264\246\346\210\267\350\265\204\346\226\231\344\270\216\350\207\252\345\212\250\346\211\223\345\215\241\345\274\200\345\217\221\350\256\276\350\256\241.md" @@ -0,0 +1,768 @@ +# 个人主页账户资料与自动打卡开发设计 + +## 1. 文档状态 + +- 设计日期:2026-07-31。 +- 适用仓库:`UniSpeaking`。 +- 定位基线:当前提交 `27e8618`。 +- 当前状态:账户资料、七牛云头像、自动打卡和真实学习统计均已按后续确认进入实施。 +- 本文中的行号以当前提交为准;实施时同时使用类名、方法名和代码锚点定位,禁止仅凭行号修改。 +- 本文记录确认后的开发方案及精确修改位置;实际完成情况以 Git 提交和验证结果为准。 + +## 2. 强制开发规范 + +开发前必须完整阅读仓库根目录的 `CLAUDE.md`。本功能必须遵守其中以下规则: + +1. Java 21、Spring Boot 4、PostgreSQL、MyBatis-Plus 3.5.x,不引入第二套 ORM。 +2. Controller 只处理 HTTP 协议、参数校验、认证用户解析和统一响应,不写业务逻辑。 +3. 业务逻辑位于 `service/profile` 或 `service/auth`,数据库读写只能通过 Repository。 +4. Mapper 继承 `BaseMapper`;查询和更新只能使用 MyBatis-Plus Lambda Wrapper。 +5. Java 代码中禁止原始 SQL、SQL 注解、Mapper XML,以及 `.last()`、`.apply()`、 + `.inSql()`、`.notInSql()`、`.setSql()`。 +6. 当前用户 ID 必须来自 JWT;任何个人资料接口都不接收客户端传入的 `userId`。 +7. 七牛 Access Key、Secret Key、Bucket 和下载域名只允许由环境变量注入,不得进入 DTO、 + 前端、日志或 Git。 +8. 数据库只保存头像对象 Key,不保存长期签名 URL。 +9. 上传头像必须验证实际内容、大小、图片尺寸和对象归属。 +10. 业务异常使用 `BusinessException` 和稳定错误码,响应使用统一 `ApiResponse`。 +11. 日志不得记录密码、JWT、对象存储密钥、签名 URL 或图片二进制。 +12. 新行为必须有测试,默认测试不得调用真实七牛云或其他外网服务。 + +`CLAUDE.md` 是本设计的上位约束;仅将其中对象存储供应商目录由 `aliyun` 同步为 +`qiniu`,不改变其分层、安全或测试规则。 + +## 3. 已确认需求 + +### 3.1 展示昵称 + +- 用户可以修改个人主页的展示昵称。 +- 展示昵称使用现有 `"user".nickname`。 +- `"user".username` 实际是登录邮箱,本次不得修改。 +- 昵称去除首尾空格后长度必须为 1~32 个字符。 + +### 3.2 用户头像 + +- 用户可以上传 JPEG 或 PNG 头像。 +- 头像保存到七牛云 Kodo 私有空间。 +- 后端保存对象 Key,并按需生成有效期 1 小时的签名读取 URL。 +- 未设置头像或签名 URL 暂时不可用时,前端显示 + `/brand/unispeaking-mark-user.jpg`。 +- 用户头像不得继续使用当前 AI 老师的图片。 + +### 3.3 修改密码 + +- 用户必须提交当前密码和新密码。 +- 新密码长度沿用注册规则:6~72 位。 +- 新密码不得与当前密码相同。 +- 修改成功后 `"user".auth_version + 1`。 +- 当前设备和其他设备的全部旧 JWT 立即失效。 +- 前端收到成功响应后清除本地 Token,并跳转登录页。 + +### 3.4 自动打卡 + +- 只要当天存在一份已经持久化的五维报告,就算当天完成打卡。 +- 判断依据是现有 `session_evaluation` 记录,不判断分数高低。 +- 正常报告、零分报告和降级报告都算打卡。 +- 同一天存在多份报告,日历仍只显示一次打卡。 +- 打卡日期按 `Asia/Shanghai` 将 `session_evaluation.created_at` 转为业务日期。 +- 不新增打卡表,不修改 `session_evaluation` 表,不引入 Redis。 +- 已软删除场景的历史报告仍须参与打卡计算。 +- 五维结果弹窗拿到报告时显示“今日已自动打卡”。 + +### 3.5 真实学习统计 + +- “本周学习时长”统计所有已正常结束且整场时长不少于 30 秒的会话。 +- 自由对话、自定义场景、雅思、面试以及后续复用统一会话生命周期的场景使用同一口径。 +- 会话从后端创建时间 `started_at` 计算到后端确认结束时间 `ended_at`;客户端时间不作为统计真相。 +- 单次会话不足 30 秒时整场排除;达到 30 秒时全部计入。 +- 跨越上海时区零点的有效会话按各自然日实际覆盖秒数拆分。 +- “已保存学习资产”直接等于未软删除训练记录数量,不累加同一记录的复练次数。 +- “连续学习天数”仍由五维报告打卡日期计算;当日尚未打卡时允许从昨日向前延续。 +- 七日练习节奏使用与本周学习时长完全相同的有效时长口径。 +- 成就系统及其进度暂时保持静态。 + +## 4. 不在本次范围 + +- 不修改登录邮箱。 +- 不增加邮箱验证、找回密码或忘记密码流程。 +- 不增加删除账户功能;现有按钮继续保持未接入状态。 +- 不增加打卡按钮、补签、撤销打卡或管理员改打卡。 +- 不增加 `user_check_in` 表。 +- 不增加 Redis 依赖、Redis 容器或 Redis 配置。 +- 不修改五维评分算法、五维报告字段或评分表结构。 +- 不把成就系统改为动态数据。 +- 不引入消息队列、CDN 或浏览器直传七牛云。 +- 不在本次工作中重构整个 `App.jsx`。 + +## 5. 当前项目事实 + +1. `"user"` 已有 `username`、`password_hash`、`nickname`、`auth_version` 和 + `updated_at`。 +2. `AuthServiceImpl.requireAuthenticatedUser()` 已验证 JWT subject、账号状态和 + `auth_version`。 +3. `session_evaluation` 已有 `scene_id`、五维分数和 `created_at`。 +4. `scene` 已有 `user_id` 和 `deleted_at`,并有 `idx_scene_user_id`。 +5. `session_evaluation` 已有 + `idx_session_evaluation_scene_id (scene_id, created_at DESC)`。 +6. 旧对话清理只删除 `session_message` 和 `turn_evaluation`,不会删除 + `session_evaluation`,因此历史五维报告可以用于推导打卡。 +7. 前端个人主页、学习日历、统计卡和成就目前都在 `src/App.jsx`。 +8. 当前侧边栏和个人主页将 `teacher.image` 错当成用户头像。 +9. “修改密码”按钮目前没有事件处理。 +10. 当前项目不引入 Redis;头像对象存储通过 `ObjectStorageProvider` 抽象,由七牛云 + Java SDK 适配,配置为空时使用不可用实现且不阻止应用启动。 +11. 学习时长需要覆盖不生成五维报告的自由对话,不能由 `session_evaluation` 或 + `session_message` 反推,因此使用 `practice_session` 保存统一会话事实。 + +## 6. 总体架构 + +```text +ProfileController + ├── AuthService.requireUserId(null) + ├── ProfileOverviewService + │ ├── UserAccountRepository + │ ├── SceneRepository + │ ├── SessionEvaluationRepository + │ ├── PracticeSessionRepository + │ ├── PracticeDurationCalculator + │ └── ObjectStorageProvider + └── ProfileAccountService + ├── UserAccountRepository + ├── AvatarImageProcessor + └── ObjectStorageProvider + +AuthController + └── AuthService.changePassword(...) + ├── UserAccountRepository + └── PasswordEncoder + +ObjectStorageProvider + └── QiniuObjectStorageProvider +``` + +边界说明: + +- `ProfileOverviewService` 只负责个人主页读取聚合和打卡日期推导。 +- `ProfileAccountService` 只负责昵称和头像资料写入。 +- `ProfileService` 继续只负责老师、语速、CEFR 和长期资料,不扩大为万能服务。 +- `AuthService` 继续拥有密码与 JWT 失效逻辑。 +- 不创建 `CheckInService`,因为本次没有打卡写操作。 +- 不让 `SessionService` 调用 Profile 模块;评分报告保存完成后无需额外写入。 + +## 7. 数据库设计 + +### 7.1 唯一数据库改动 + +只修改 `"user"` 表,增加对象存储 Key: + +```sql +ALTER TABLE "user" +ADD COLUMN IF NOT EXISTS avatar_object_key VARCHAR(512); + +ALTER TABLE "user" +DROP CONSTRAINT IF EXISTS user_avatar_object_key_check; + +ALTER TABLE "user" +ADD CONSTRAINT user_avatar_object_key_check +CHECK ( + avatar_object_key IS NULL + OR BTRIM(avatar_object_key) <> '' +); + +COMMENT ON COLUMN "user".avatar_object_key IS +'用户头像在对象存储中的对象 Key;不保存签名 URL、Bucket 密钥或完整访问地址'; +``` + +对象 Key 格式: + +```text +avatars/{userId}/{uuid}.{jpg|png} +``` + +示例: + +```text +avatars/22222222-2222-4222-8222-222222222222/550e8400-e29b-41d4-a716-446655440000.jpg +``` + +### 7.2 明确禁止的数据库改动 + +- 不增加 `user_check_in`。 +- 不给 `session_evaluation` 增加可用状态。 +- 不在数据库保存头像签名 URL。 +- 不在数据库保存头像二进制。 +- 不修改 `username`、`nickname`、`password_hash` 或 `auth_version` 的类型。 +- 不设置新的数据库外键。 + +允许新增 `practice_session` 作为统一会话事实表。它不是打卡表或统计汇总表,只保存 +`session_id`、`user_id`、`scene_id`、`scene_type`、`status`、`started_at` 和 +`ended_at` 等会话原始事实。Redis 不得替代该表。 + +### 7.3 打卡读取算法 + +1. 从 JWT 获取用户 UUID。 +2. 读取该用户所有场景 ID,包含软删除场景。 +3. 将请求月份在 `Asia/Shanghai` 的月初和下月月初转换为两个 UTC Instant。 +4. 查询这些场景在 `[monthStart, nextMonthStart)` 范围内的 + `session_evaluation.created_at`。 +5. 将每个时间转换为 `Asia/Shanghai` 的 `LocalDate`。 +6. `distinct + sort` 后返回 `checkedDates`。 +7. 使用同一查询结果判断 `checkedInToday`。 + +Repository 查询必须使用: + +```java +LambdaQueryWrapper +LambdaQueryWrapper +``` + +允许 `.eq()`、`.in()`、`.ge()`、`.lt()`、`.select()` 和 `.orderByAsc()`; +禁止 SQL 拼接和 `CLAUDE.md` 列出的所有危险 Wrapper 方法。 + +### 7.4 学习时长算法 + +1. `SessionService.startSession(...)` 在保存进程内会话前插入一条 `CREATED` 记录。 +2. Realtime 连接失败时将记录更新为 `FAILED` 并写入服务器结束时间。 +3. `SessionService.endSession(...)` 使用服务器 `Instant.now()` 将记录幂等更新为 + `COMPLETED`,同时结束进程内会话。 +4. 统计只读取与目标时间范围重叠的 `COMPLETED` 记录。 +5. 先按完整会话判断 `ended_at - started_at >= 30 秒`,再与周区间或自然日区间求交集。 +6. 本周从 `Asia/Shanghai` 周一 00:00 开始;七日节奏从今天向前取六个自然日。 +7. 后端返回秒数;前端对非零有效时长按分钟向上展示,确保 30~59 秒显示为 1 分钟;数据库不保存分钟或累计值。 + +## 8. HTTP 接口 + +所有接口都必须携带 JWT,响应继续使用统一 `ApiResponse`。 + +### 8.1 获取个人主页 + +```http +GET /api/profile/overview?month=2026-07 +Authorization: Bearer +``` + +响应: + +```json +{ + "success": true, + "code": "OK", + "message": "success", + "data": { + "account": { + "userId": "22222222-2222-4222-8222-222222222222", + "email": "learner@example.com", + "nickname": "Sunny", + "displayName": "Sunny", + "avatarUrl": "https://profile.example.com/...", + "avatarUrlExpiresAt": "2026-07-31T10:00:00Z" + }, + "statistics": { + "weeklyPracticeSeconds": 10980, + "trainingRecordCount": 12, + "consecutiveLearningDays": 7, + "lastSevenDays": [ + {"date": "2026-07-25", "practiceSeconds": 1080}, + {"date": "2026-07-26", "practiceSeconds": 1560} + ] + }, + "calendar": { + "month": "2026-07", + "checkedDates": [ + "2026-07-01", + "2026-07-02", + "2026-07-04" + ], + "checkedInToday": true + } + } +} +``` + +`displayName` 规则: + +1. 有非空 `nickname` 时使用 `nickname`; +2. 否则使用登录邮箱 `@` 前的部分; +3. 仍为空时使用 `UniSpeaking User`。 + +`month` 必须是 `yyyy-MM`;省略时使用 `Asia/Shanghai` 当前月份;未来月份返回 +`PROFILE_MONTH_INVALID`。 + +### 8.2 修改昵称 + +```http +PATCH /api/profile +Authorization: Bearer +Content-Type: application/json +``` + +请求: + +```json +{ + "nickname": "Sunny" +} +``` + +响应数据: + +```json +{ + "nickname": "Sunny", + "displayName": "Sunny" +} +``` + +### 8.3 上传头像 + +```http +POST /api/profile/avatar +Authorization: Bearer +Content-Type: multipart/form-data +``` + +表单字段: + +```text +avatar= +``` + +响应数据: + +```json +{ + "avatarUrl": "https://profile.example.com/...", + "avatarUrlExpiresAt": "2026-07-31T10:00:00Z" +} +``` + +### 8.4 修改密码 + +```http +PUT /api/auth/password +Authorization: Bearer +Content-Type: application/json +``` + +请求: + +```json +{ + "currentPassword": "old-password", + "newPassword": "new-password" +} +``` + +响应数据: + +```json +{ + "reauthenticationRequired": true +} +``` + +前端确认密码只在浏览器本地校验,不发送 `confirmPassword`。 + +## 9. 头像处理与七牛云对象存储设计 + +### 9.1 文件规则 + +- 最大文件大小:2 MiB,即 `2097152` bytes。 +- 允许格式:JPEG、PNG。 +- 最小宽高:128×128。 +- 最大宽高:4096×4096。 +- 不信任文件扩展名和客户端 `Content-Type`。 +- 使用 Java `ImageIO` 解码实际内容。 +- 解码后重新编码为 JPEG 或 PNG,移除 EXIF 和其他原始元数据。 +- 图片不强制裁剪;前端继续使用 `object-fit: cover`。 + +Spring 全局 multipart 上限继续保持 10 MB,避免影响现有音频接口;头像 2 MiB 限制在 +`AvatarImageProcessor` 中执行。 + +### 9.2 对象存储接口 + +`ObjectStorageProvider` 定义以下稳定接口: + +```java +void put(String objectKey, byte[] content, String contentType); +URI signGetUrl(String objectKey, Duration ttl); +void delete(String objectKey); +``` + +业务代码只能依赖该接口,不得依赖七牛云 SDK 类。 + +### 9.3 上传补偿顺序 + +```text +认证用户 + → 校验并规范化图片 + → 生成归属当前 userId 的对象 Key + → 上传新对象 + → 更新 user.avatar_object_key + → 生成新签名 URL + → 尽力删除旧对象 +``` + +失败策略: + +- 图片校验失败:不调用对象存储,不修改数据库。 +- 新对象上传失败:不修改数据库。 +- 新对象上传成功但数据库更新失败:立即尽力删除新对象,然后返回失败。 +- 数据库更新成功但旧对象删除失败:新头像继续生效,只记录脱敏警告日志。 +- 读取资料时签名失败:资料接口仍成功,`avatarUrl` 和 + `avatarUrlExpiresAt` 返回 `null`。 +- 不在请求内重试超过一次,不引入 MQ 或后台重试框架。 + +### 9.4 七牛云配置 + +`application.yaml` 中新增: + +```yaml +profile: + time-zone: ${PROFILE_TIME_ZONE:Asia/Shanghai} + +object-storage: + qiniu: + access-key: ${QINIU_ACCESS_KEY:} + secret-key: ${QINIU_SECRET_KEY:} + bucket: ${QINIU_BUCKET:} + domain: ${QINIU_DOMAIN:} + avatar-prefix: ${QINIU_AVATAR_PREFIX:avatars} + signed-url-ttl: ${QINIU_SIGNED_URL_TTL:1h} +``` + +`.env.example` 只增加占位值: + +```properties +PROFILE_TIME_ZONE=Asia/Shanghai +QINIU_ACCESS_KEY= +QINIU_SECRET_KEY= +QINIU_BUCKET= +QINIU_DOMAIN= +QINIU_AVATAR_PREFIX=avatars +QINIU_SIGNED_URL_TTL=1h +``` + +实际密钥只由用户写入被 Git 忽略的 `deploy/env/.env`。开发过程不得自动修改或输出该 +文件。`QINIU_DOMAIN` 必须填写对应私有空间的 HTTPS 下载域名,只允许主机及可选端口, +不得带路径、查询参数或片段。 + +### 9.5 依赖 + +`pom.xml` 增加: + +```xml +7.19.0 +``` + +以及: + +```xml + + com.qiniu + qiniu-java-sdk + ${qiniu-java-sdk.version} + +``` + +版本固定为 `7.19.0`,避免 Maven 版本范围导致构建结果漂移。上传使用对象 Key 受限的 +Upload Token;私有下载使用配置的 HTTPS 域名和短期签名 URL。 + +参考: + +- [七牛云 Java SDK 文档](https://developer.qiniu.com/kodo/1239/java) +- [七牛云 Java SDK 官方仓库](https://github.com/qiniu/java-sdk) + +## 10. 具体文件和修改位置 + +本节中的 Java 主代码短路径统一相对于: + +```text +backend/unispeaking-server/src/main/java/com/unispeaking/ +``` + +例如 `domain/po/auth/UserAccount.java` 的完整仓库路径是 +`backend/unispeaking-server/src/main/java/com/unispeaking/domain/po/auth/UserAccount.java`。 +第 11 节中的后端测试短路径统一相对于 +`backend/unispeaking-server/src/test/java/com/unispeaking/`。其余文件均写完整仓库 +相对路径。 + +### 10.1 数据库与部署 + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 修改 | `backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql` | `"user"` 建表及头像字段迁移 | 增加 `avatar_object_key`、非空白约束和字段注释 | +| 新建 | `backend/unispeaking-server/src/main/resources/db/migration/V2__practice_session.sql` | 新文件 | 新增统一练习会话事实表、时间约束及用户时间索引 | +| 修改 | `deploy/postgres/profile.sql` | 头像字段迁移之后 | 补充可手工执行的幂等 `practice_session` 建表和索引 | +| 修改 | `backend/unispeaking-server/src/main/resources/application.yaml` | 顶层 `profile:` 之后、`prompt:` 之前 | 新增 `object-storage.qiniu`,绑定 Access Key、Secret Key、Bucket、HTTPS 下载域名、头像前缀和签名 TTL | +| 修改 | `deploy/env/.env.example` | `# Profile and Qiniu Kodo` 段 | 增加 `QINIU_ACCESS_KEY`、`QINIU_SECRET_KEY`、`QINIU_BUCKET`、`QINIU_DOMAIN`、头像前缀和签名 TTL 占位值,不填真实密钥 | +| 修改 | `deploy/nginx/nginx.conf` | 当前第 12 行 `server` 块内、`location /backend/` 之前 | 增加 `client_max_body_size 10m;`,与 Spring 现有 multipart 上限对齐 | +| 不改 | `deploy/env/.env` | 被 Git 忽略 | 真实七牛云配置由用户自行填写,开发不得读取、输出或提交 | +| 不改 | `deploy/docker-compose.yml` | `backend.env_file` 已存在 | 七牛云变量会由现有 `env_file` 自动传入,不新增 Redis 或其他服务 | + +### 10.2 Domain 和 DTO + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 修改 | `domain/po/auth/UserAccount.java` | record 参数中 `nickname` 之后;`withLastLoginAt()` | 增加 `avatarObjectKey`;同步所有复制方法和构造调用 | +| 不改 | `domain/dto/auth/UserAccountResponse.java` | 当前 `from(UserAccount)` | `/api/auth/me` 保持现有契约;签名 URL 不进入 Auth DTO | +| 新建 | `domain/dto/auth/ChangePasswordRequest.java` | 新文件 | `currentPassword`、`newPassword`,均 `@NotBlank`、`@Size(min=6,max=72)` | +| 新建 | `domain/dto/auth/ChangePasswordResponse.java` | 新文件 | 单字段 `reauthenticationRequired` | +| 新建 | `domain/dto/profile/UpdateProfileRequest.java` | 新文件 | `nickname`,`@NotBlank`、`@Size(max=32)` | +| 新建 | `domain/dto/profile/UpdateProfileResponse.java` | 新文件 | `nickname`、`displayName` | +| 新建 | `domain/dto/profile/ProfileAccountResponse.java` | 新文件 | `userId`、`email`、`nickname`、`displayName`、`avatarUrl`、`avatarUrlExpiresAt` | +| 新建 | `domain/dto/profile/ProfileCalendarResponse.java` | 新文件 | `month`、`List checkedDates`、`checkedInToday` | +| 新建 | `domain/dto/profile/ProfileOverviewResponse.java` | 新文件 | `account`、`statistics`、`calendar`;statistics 包含周时长、训练记录数、连续天数和七日时长 | +| 新建 | `domain/dto/profile/AvatarResponse.java` | 新文件 | `avatarUrl`、`avatarUrlExpiresAt` | +| 不改 | `domain/dto/evaluation/DialogueReportResult.java` | 整个 record | 任意已保存报告都算打卡,不新增状态字段 | + +### 10.3 Controller + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 新建 | `controller/ProfileController.java` | 新文件,`@RequestMapping("/api/profile")` | `GET /overview`、`PATCH /api/profile`、`POST /avatar`;每个方法先通过 `AuthService.requireUserId(null)` 获取身份 | +| 修改 | `controller/AuthController.java` | 当前第 36 行 `me()` 之后 | 增加 `@PutMapping("/password")`,只转发 `@Valid ChangePasswordRequest` | +| 不改 | `controller/UserPreferenceController.java` | 整个文件 | 老师、语速和 CEFR 接口继续独立 | +| 不改 | `controller/CustomSceneController.java` | 五维评分完成接口 | 不写打卡,不新增打卡调用 | + +Controller 禁止: + +- 接收 `userId`; +- 直接访问 Repository、Mapper、七牛云 SDK; +- 计算月份边界或拼对象 Key; +- 校验当前密码; +- 编写上传补偿逻辑。 + +### 10.4 Service + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 新建 | `service/profile/ProfileOverviewService.java` | 新文件 | 定义 `getOverview(String userId, String month)` | +| 新建 | `service/profile/impl/ProfileOverviewServiceImpl.java` | 新文件 | 查询账户、全部场景 ID、月份报告时间,转换业务日期并签名头像 URL | +| 新建 | `service/profile/ProfileAccountService.java` | 新文件 | 定义 `updateNickname(...)`、`replaceAvatar(...)` | +| 新建 | `service/profile/impl/ProfileAccountServiceImpl.java` | 新文件 | 昵称更新、图片规范化、七牛云上传、数据库更新和补偿删除 | +| 新建 | `service/profile/image/AvatarImageProcessor.java` | 新文件 | 纯图片验证和重新编码;不访问数据库或对象存储 | +| 不改 | `service/profile/ProfileService.java` | 当前第 7 行接口 | 继续只处理用户偏好 | +| 不改 | `service/profile/impl/ProfileServiceImpl.java` | 整个文件 | 不把资料、头像和打卡逻辑塞入现有偏好服务 | +| 修改 | `service/auth/AuthService.java` | 当前第 11 行 `currentUser()` 后 | 增加 `changePassword(ChangePasswordRequest)` | +| 修改 | `service/auth/impl/AuthServiceImpl.java` | 当前第 97 行 `currentUser()` 后 | 增加 `@Transactional changePassword`,复用 `requireAuthenticatedUser()` | +| 修改 | `service/session/impl/SessionServiceImpl.java` | `startSession()`、`endSession()`、Realtime 连接失败分支 | 创建、完成或失败时同步持久化 `practice_session`;不增加打卡写入 | +| 新建 | `service/profile/PracticeDurationCalculator.java` | 新文件 | 处理 30 秒门槛、周区间求交和跨自然日拆分 | + +修改密码流程: + +1. `requireAuthenticatedUser()` 获取数据库中的当前账号。 +2. `passwordEncoder.matches(currentPassword, passwordHash)`。 +3. 检查新密码是否与当前密码相同。 +4. `passwordEncoder.encode(newPassword)`。 +5. Repository 使用 `id + expectedAuthVersion` 条件原子更新哈希和 + `authVersion + 1`。 +6. 更新行数不是 1 时抛出 `PASSWORD_UPDATE_CONFLICT`。 +7. 返回 `reauthenticationRequired=true`。 + +### 10.5 持久化 + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 修改 | `infrastructure/persistence/entity/user/UserAccountEntity.java` | 当前第 22 行 `nickname` 后 | 增加 `String avatarObjectKey` | +| 修改 | `infrastructure/persistence/repository/user/UserAccountRepository.java` | 当前第 8 行接口 | 增加 `updateNickname`、`updateAvatarObjectKey`、`updatePasswordAndAuthVersion` | +| 修改 | `infrastructure/persistence/repository/user/MybatisUserAccountRepository.java` | 当前第 27~79 行 | 更新 Entity/Domain 映射;新增三个 LambdaUpdateWrapper 更新方法 | +| 修改 | `infrastructure/persistence/repository/scene/SceneRepository.java` | 当前第 18 行 `findAssetsByUserId` 附近 | 增加 `findAllIdsByUserId(String userId)`,语义明确包含软删除场景 | +| 修改 | `infrastructure/persistence/repository/scene/MybatisSceneRepository.java` | 当前第 102 行 `findAssetsByUserId` 前 | 实现只按 `userId` 查询 ID,不加 `deletedAt IS NULL` | +| 修改 | `infrastructure/persistence/repository/evaluation/SessionEvaluationRepository.java` | 当前第 83 行 `findBySceneId` 后 | 增加 `findCreatedAtBySceneIdsBetween(sceneIds,start,end)`,空 ID 集合直接返回空列表 | +| 新建 | `domain/po/session/PracticeSessionRecord.java` | 新文件 | 向 Service 暴露会话事实,不泄漏数据库 Entity | +| 新建 | `infrastructure/persistence/entity/session/PracticeSessionEntity.java` | 新文件 | 映射 `practice_session`,`session_id` 使用输入主键 | +| 新建 | `infrastructure/persistence/mapper/session/PracticeSessionMapper.java` | 新文件 | 仅继承 `BaseMapper`,不写 SQL/XML | +| 新建 | `infrastructure/persistence/repository/session/PracticeSessionRepository.java` | 新文件 | 封装创建、终态幂等更新和时间范围重叠查询 | +| 修改 | `infrastructure/persistence/repository/scene/SceneRepository.java` | `findAssetsByUserId` 附近 | 增加 `countActiveByUserId`,语义为未软删除训练记录数 | +| 修改 | `infrastructure/persistence/repository/scene/MybatisSceneRepository.java` | `findAssetsByUserId` 后 | 使用 `selectCount` 实现训练记录计数,不加载学习内容详情 | +| 不改 | `infrastructure/persistence/entity/evaluation/SessionEvaluationEntity.java` | 整个类 | 直接读取现有 `sceneId` 和 `createdAt` | +| 不改 | 其他 `infrastructure/persistence/mapper/*` | 其他 Mapper | 不增加 SQL/XML | + +账户更新方法的契约: + +```java +boolean updateNickname(UUID userId, String nickname); +boolean updateAvatarObjectKey( + UUID userId, + String expectedObjectKey, + String newObjectKey); +boolean updatePasswordAndAuthVersion( + UUID userId, + long expectedAuthVersion, + String passwordHash); +``` + +头像更新必须以 `expectedObjectKey` 作为并发条件,避免两个并发上传互相删除正在使用的 +对象。密码更新必须以 `expectedAuthVersion` 作为并发条件。 + +### 10.6 七牛云对象存储基础设施 + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 新建 | `infrastructure/storage/ObjectStorageProvider.java` | 新文件 | 稳定的上传、签名和删除接口 | +| 内嵌 | `infrastructure/config/ObjectStorageConfig.java` 的 `UnavailableObjectStorageProvider` | 配置类末尾的私有静态类 | 七牛云未配置时提供明确业务失败,不让应用启动失败 | +| 新建 | `infrastructure/storage/qiniu/QiniuObjectStorageProvider.java` | 新文件 | 唯一允许依赖七牛云 SDK 的实现;负责上传、私有下载签名、删除和供应商异常转换 | +| 新建 | `infrastructure/config/ObjectStorageProperties.java` | 新文件 | `@ConfigurationProperties("object-storage.qiniu")`,包含 `accessKey`、`secretKey`、`bucket`、`domain`、前缀和 TTL | +| 新建 | `infrastructure/config/ObjectStorageConfig.java` | 新文件 | `objectStorageProvider(...)` 中配置完整时创建 `Auth`、`UploadManager`、`BucketManager` 和七牛 Provider,否则创建不可用 Provider | +| 新建 | `infrastructure/config/ProfileProperties.java` | 新文件 | `@ConfigurationProperties("profile")`,校验 `ZoneId` | +| 修改 | `backend/unispeaking-server/pom.xml` | `` 与 PostgreSQL 依赖之后 | 增加 `qiniu-java-sdk.version=7.19.0` 和 `com.qiniu:qiniu-java-sdk` 依赖 | + +### 10.7 异常处理 + +修改 `common/exception/GlobalExceptionHandler.java` 当前第 16~22 行状态映射: + +| 错误码 | HTTP | +| --- | --- | +| `PROFILE_MONTH_INVALID` | 400 | +| `PROFILE_NICKNAME_REQUIRED` | 400 | +| `PROFILE_NICKNAME_TOO_LONG` | 400 | +| `AVATAR_FILE_REQUIRED` | 400 | +| `AVATAR_FILE_TOO_LARGE` | 400 | +| `AVATAR_TYPE_UNSUPPORTED` | 400 | +| `AVATAR_DIMENSION_INVALID` | 422 | +| `AVATAR_CONTENT_INVALID` | 422 | +| `CURRENT_PASSWORD_INVALID` | 400 | +| `NEW_PASSWORD_SAME_AS_CURRENT` | 400 | +| `PASSWORD_UPDATE_CONFLICT` | 409 | +| `PROFILE_UPDATE_CONFLICT` | 409 | +| `AVATAR_STORAGE_UNAVAILABLE` | 503 | +| `AVATAR_STORAGE_FAILED` | 502 | + +不得把七牛云原始响应、下载域名、Bucket、对象签名 URL 或异常堆栈返回给客户端。 + +### 10.8 前端 + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 修改 | `frontend/Unispeaking_fronted/src/apiClient.js` | 当前第 65 行 `getCurrentUser()` 和第 69 行偏好接口之间 | 增加 `getProfileOverview`、`updateProfile`、`uploadAvatar`、`changePassword` | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 572 行 `AppShell` | 新增 `avatarUrl` 参数;第 585 行用用户头像或默认头像替代 `teacher.image` | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 1194 行 `ResultModal` | `evaluation` 存在时展示“今日已自动打卡”状态 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 1968 行 `Profile` | 使用 Profile account;增加昵称编辑入口和头像上传入口 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | `Profile` 和 `Overview` 组件 | 删除统计卡和节奏硬编码,读取 overview.statistics;成就常量保持不变 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2006 行 `LearningCalendar` | 按月份调用后端并渲染 `checkedDates`;月份切换支持历史月份,不允许未来月份 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | `Overview` | 周时长、训练记录数、连续天数和七日节奏全部渲染真实数据 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2082 行 `Settings` 的“账户与隐私” | 给“修改密码”接入弹窗;“删除账户”保持不变 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2096 行 `App` 状态与第 2175 行认证启动 | 增加 profile 状态;认证启动并行加载当前月份 overview;资料更新后同步状态 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2287 行 `logout()` 附近 | 修改密码成功后复用清理 Token 和跳转登录逻辑 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2373~2374 行 Profile/AppShell 渲染 | 向 Profile 和 AppShell 传 account、avatarUrl 及更新回调 | +| 修改 | `frontend/Unispeaking_fronted/src/styles.css` | 当前第 256~258 行 sidebar avatar | 保持头像裁切;增加加载失败/默认头像状态 | +| 修改 | `frontend/Unispeaking_fronted/src/styles.css` | `.profile-user`、`.profile-user__avatar`、`.profile-user__edit`、`.bars` | 放大圆形头像和账户文字;编辑按钮放到头像右上角;增加真实柱状图零值和日期样式 | +| 修改 | `frontend/Unispeaking_fronted/src/styles.css` | 当前第 810~838 行日历 | 保持现有视觉,只将 `is-practiced` 数据源改为报告日期 | +| 修改 | `frontend/Unispeaking_fronted/src/styles.css` | 当前第 879 行设置区域 | 增加修改密码弹窗状态样式 | + +前端交互要求: + +- 用户进入应用时获取当前月份 Profile Overview,以便侧边栏立即显示用户头像。 +- Profile 请求失败不得清除 JWT;只有认证接口返回 401 才清理登录状态。 +- 头像签名 URL 加载失败时只允许自动刷新 Profile 一次,避免无限请求。 +- 头像上传中禁止重复提交。 +- 昵称和头像成功后同步更新 Profile 和 AppShell,不刷新整页。 +- 修改密码成功后立即清除 `unispeaking.accessToken`,跳转 `/auth/login`。 +- 不把七牛云配置、签名逻辑或密钥放到前端。 + +### 10.9 文档 + +| 动作 | 文件 | 修改位置 | 必须修改的内容 | +| --- | --- | --- | --- | +| 修改 | `docs/frontend-backend-interface-contract.md` | 当前第 113 行“2.3 当前用户”之后 | 增加“2.4 修改密码”;在第 131 行用户资料章节前增加个人主页 overview、昵称和头像接口 | +| 修改 | `docs/deployment.md` | “Secret file”变量清单与“Available settings”中的 `object-storage` 段 | 增加七牛云 Kodo 配置、私有 Bucket、HTTPS 下载域名、最小权限和签名 URL 说明 | +| 修改 | `CLAUDE.md` | 第 9 节对象存储目录树 | 将供应商目录从 `aliyun` 同步为 `qiniu`,其余强制规范不变 | +| 不改 | `README.md` | 整个文件 | 本次接口和部署信息由上述专用文档维护 | + +## 11. 测试文件和覆盖范围 + +### 11.1 新建测试 + +| 文件 | 覆盖内容 | +| --- | --- | +| `src/test/java/com/unispeaking/service/profile/ProfileOverviewServiceImplTest.java` | 同日多报告去重、跨日、跨月、上海零点边界、软删除场景、无场景、无头像和签名失败降级 | +| `src/test/java/com/unispeaking/service/profile/PracticeDurationCalculatorTest.java` | 30 秒临界值、不足 30 秒排除、跨上海零点拆分 | +| `src/test/java/com/unispeaking/infrastructure/persistence/repository/session/PracticeSessionRepositoryTest.java` | 会话创建映射和完成记录时间范围查询 | +| `src/test/java/com/unispeaking/service/profile/ProfileAccountServiceImplTest.java` | 昵称规范化、邮箱不变、头像上传补偿、并发更新冲突、旧对象删除失败 | +| `src/test/java/com/unispeaking/service/profile/image/AvatarImageProcessorTest.java` | JPEG/PNG、伪造扩展名、非图片、2 MiB 上限、尺寸上下限、重新编码 | +| `src/test/java/com/unispeaking/controller/ProfileControllerTest.java` | JWT 身份、月份参数、统一响应、multipart、禁止客户端 userId | +| `src/test/java/com/unispeaking/infrastructure/storage/qiniu/QiniuObjectStorageProviderTest.java` | 使用 Mock 七牛客户端验证 Key、Content-Type、私有签名 URL、HTTPS 域名和删除,不访问外网 | + +### 11.2 修改现有测试 + +- `SessionServiceImplRepracticeTest`:同步构造参数,并验证结束时间写入 Practice Session Repository。 +- `MybatisSceneRepositoryTest`:验证未软删除训练记录使用数据库计数查询。 + +| 文件 | 修改位置 | 覆盖内容 | +| --- | --- | --- | +| `src/test/java/com/unispeaking/service/auth/AuthServiceImplTest.java` | 当前第 84 行 JWT 测试之后;所有 `UserAccount` 构造器 | 当前密码错误、新旧密码相同、BCrypt 编码、authVersion 增加、并发冲突、旧 JWT 被撤销 | +| `src/test/java/com/unispeaking/infrastructure/persistence/PersistenceArchitectureTest.java` | 当前第 17~19 行禁止 API 正则及现有测试 | 确保新增 Repository 未使用 SQL 注解和禁止的 Wrapper 方法 | +| `src/test/java/com/unispeaking/UniSpeakingApplicationTests.java` | 应用启动测试 | 七牛云配置为空时应用仍能启动 | + +### 11.3 前端验证 + +当前前端没有自动化组件测试框架,本次不为单一功能引入第二套测试框架。必须执行: + +```bash +cd frontend/Unispeaking_fronted +npm run build +npm run check:routes +npm run check:realtime-events +``` + +手工验收: + +1. 生成任意一份五维报告后,结果弹窗显示“今日已自动打卡”。 +2. 进入个人主页,当天显示为已打卡。 +3. 同一天生成多份报告,日历仍只有一个打卡标记。 +4. 跨上海时区零点生成报告,日期归属正确。 +5. 三张统计卡和七日节奏来自后端真实数据;成就系统内容与开发前一致。 +6. 修改昵称后,个人主页和侧边栏名称立即更新;重新登录后仍保留。 +7. 登录邮箱不因昵称修改而变化。 +8. 上传合法头像后立即更新,刷新页面后仍通过七牛云签名 URL 显示。 +9. 非图片、伪造扩展名、超限图片和异常尺寸被拒绝,旧头像不变。 +10. 修改密码成功后自动返回登录页,旧 Token 请求 `/api/auth/me` 得到 401。 + +### 11.4 后端验证 + +```bash +cd backend/unispeaking-server +./mvnw test +``` + +还必须检查: + +```bash +./mvnw dependency:tree +``` + +确认没有重复 JSON/HTTP 技术栈、第二套 ORM 或不必要的 JAXB 重复依赖。 + +## 12. 实施顺序 + +本节只定义顺序,不授权开始开发。 + +1. 数据库头像字段及 Domain/Entity 映射。 +2. Repository 原子更新和报告日期查询。 +3. Profile DTO、月份读取服务及其测试。 +4. 图片处理器及其测试。 +5. ObjectStorageProvider、七牛云实现、配置和 Mock 测试。 +6. Profile 资料写服务、补偿逻辑及测试。 +7. 修改密码 DTO、Service、Repository 和测试。 +8. ProfileController、AuthController 与 Controller 测试。 +9. 前端 API、Profile 状态、动态日历、昵称、头像和密码交互。 +10. Nginx 上传上限、环境变量示例和部署文档。 +11. 完整后端测试、前端构建、路由/实时事件检查和手工验收。 + +## 13. 完成标准 + +只有同时满足以下条件才算完成: + +- `"user"` 表新增 `avatar_object_key`,并新增 `practice_session` 会话事实表;没有打卡表、Redis 或评分表改动。 +- 打卡完全由已有五维报告推导,任意持久化报告都算。 +- 统计卡和七日节奏使用真实数据,成就保持不变。 +- 登录邮箱不可修改,昵称可持久化修改。 +- 头像只保存对象 Key,签名 URL 短期生成,失败补偿完整。 +- 修改密码验证当前密码,并通过 `auth_version` 撤销全部旧 JWT。 +- Controller 无业务逻辑,Service 不写 SQL,Repository 不泄漏 Entity。 +- 新增持久化只使用 MyBatis-Plus Lambda Wrapper。 +- 配置和日志无密码、JWT、七牛云密钥和签名 URL。 +- 所有新增行为有测试,默认测试不访问真实七牛云。 +- `./mvnw test`、`npm run build`、`npm run check:routes` 和 + `npm run check:realtime-events` 全部通过。 diff --git a/frontend/Unispeaking_fronted/src/App.jsx b/frontend/Unispeaking_fronted/src/App.jsx index b446d9d..5d6c89f 100644 --- a/frontend/Unispeaking_fronted/src/App.jsx +++ b/frontend/Unispeaking_fronted/src/App.jsx @@ -25,6 +25,7 @@ import { PaperPlaneTilt, Pause, Password, + PencilSimple, PhoneDisconnect, Play, Plus, @@ -59,6 +60,7 @@ import { } from "lucide-react"; import { learningItems, levels, plans, recommendations, teachers } from "./data.js"; import { + changePassword, clearAuthSession, advanceCustomSceneFlow, createCustomSceneFlow, @@ -67,6 +69,7 @@ import { getCurrentUser, getLearningAsset, getLearningAssets, + getProfileOverview, getUserPreference, hasAuthSession, login, @@ -74,7 +77,9 @@ import { synthesizeSpeech, translateSceneText, translateSessionText, + updateProfile, updateUserPreference, + uploadProfileAvatar, } from "./apiClient.js"; import { createPcmWavRecorder } from "./audioRecorder.js"; import { createRealtimeClient } from "./realtimeClient.js"; @@ -665,7 +670,7 @@ function TeacherSetup({ selectedId, onSelect, onFinish }) { ); } -function AppShell({ page, setPage, teacher, children }) { +function AppShell({ page, setPage, teacher, avatarUrl, children }) { const [sidebarOpen, setSidebarOpen] = useState(false); const items = [ { id: "conversation", label: "自由对话", icon: Waveform }, @@ -678,7 +683,7 @@ function AppShell({ page, setPage, teacher, children }) {
{children}
@@ -1284,7 +1289,7 @@ function ResultModal({ completed, evaluation, onBack, onAssets }) { return (
-

SIMULATION COMPLETE

{completed ? "模拟完成" : "本次模拟已结束"}

{evaluation?.summary || "会话已经结束,但评分报告暂未返回。请稍后在学习资产中查看。"}

+

SIMULATION COMPLETE

{completed ? "模拟完成" : "本次模拟已结束"}

{evaluation && 今日已自动打卡}

{evaluation?.summary || "会话已经结束,但评分报告暂未返回。请稍后在学习资产中查看。"}

{totalScore}/100
@@ -2059,29 +2064,77 @@ function Assets({ sceneId, onPractice, onRestart, onIelts, onInterview, onOpenRe ); } -function Profile({ section, setSection, user, teacher, speed, level, onSettingsChange, onLogout }) { - const displayName = user?.nickname || user?.username?.split("@")[0] || "UniSpeaking User"; - const email = user?.username || ""; +function ProfileEditModal({ account, user, avatarUrl, onClose, onNicknameChange, onAvatarChange }) { + const currentNickname = account?.nickname || user?.nickname || ""; + const [nickname, setNickname] = useState(currentNickname); + const [avatar, setAvatar] = useState(null); + const [previewUrl, setPreviewUrl] = useState(avatarUrl); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!avatar) { + setPreviewUrl(avatarUrl); + return undefined; + } + const objectUrl = URL.createObjectURL(avatar); + setPreviewUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [avatar, avatarUrl]); + + const selectAvatar = (event) => { + const file = event.target.files?.[0] || null; + event.target.value = ""; + if (!file) return; + if (!["image/jpeg", "image/png"].includes(file.type) || file.size > 2 * 1024 * 1024) { + setError("请选择不超过 2 MiB 的 JPEG 或 PNG 图片"); + return; + } + setError(""); + setAvatar(file); + }; + + const submit = async (event) => { + event.preventDefault(); + const normalizedNickname = nickname.trim(); + if (!normalizedNickname) { + setError("用户名不能为空"); + return; + } + const nicknameChanged = normalizedNickname !== currentNickname; + if (!nicknameChanged && !avatar) { + onClose(); + return; + } + setSubmitting(true); + setError(""); + if (nicknameChanged && !(await onNicknameChange(normalizedNickname))) { + setError("用户名修改失败,请稍后重试"); + setSubmitting(false); + return; + } + if (avatar && !(await onAvatarChange(avatar))) { + setError("头像修改失败,请稍后重试"); + setSubmitting(false); + return; + } + onClose(); + }; + + return

EDIT PROFILE

编辑个人资料

修改你的展示用户名或个人头像。

头像预览
个人头像支持 JPEG、PNG,文件不超过 2 MiB
{error &&

{error}

}
; +} + +function Profile({ section, setSection, user, profile, teacher, speed, level, onSettingsChange, onMonthChange, onNicknameChange, onAvatarChange, onPasswordChange, onAssets, onLogout }) { + const account = profile?.account; + const displayName = account?.displayName || user?.nickname || user?.username?.split("@")[0] || "UniSpeaking User"; + const email = account?.email || user?.username || ""; + const avatarUrl = account?.avatarUrl || teacher.image; + const [profileEditOpen, setProfileEditOpen] = useState(false); return ( -
{section === "profile" && }{section === "membership" && }{section === "settings" && }
+
{section === "profile" && }{section === "membership" && }{section === "settings" && }
{profileEditOpen && setProfileEditOpen(false)} onNicknameChange={onNicknameChange} onAvatarChange={onAvatarChange} />}
); } -const learningMonths = [ - { - key: "2026-05", year: 2026, month: 4, label: "2026 年 5 月", - records: { 2: [14, 1, 2], 5: [22, 2, 3], 8: [18, 1, 2], 12: [31, 2, 4], 13: [12, 1, 1], 17: [26, 2, 3], 20: [35, 3, 5], 24: [19, 1, 2], 27: [28, 2, 4], 30: [16, 1, 2] }, - }, - { - key: "2026-06", year: 2026, month: 5, label: "2026 年 6 月", - records: { 1: [20, 1, 2], 3: [28, 2, 3], 4: [16, 1, 2], 7: [34, 2, 4], 9: [15, 1, 2], 10: [25, 2, 3], 14: [38, 3, 5], 15: [18, 1, 2], 18: [31, 2, 4], 19: [12, 1, 1], 21: [27, 2, 3], 23: [35, 3, 5], 24: [22, 2, 3], 28: [29, 2, 4], 30: [17, 1, 2] }, - }, - { - key: "2026-07", year: 2026, month: 6, label: "2026 年 7 月", - records: { 1: [18, 1, 2], 2: [26, 2, 3], 4: [34, 2, 4], 5: [12, 1, 1], 7: [40, 3, 5], 8: [31, 2, 4], 9: [22, 2, 3], 11: [24, 2, 3], 12: [16, 1, 2], 14: [33, 2, 4], 15: [20, 1, 2], 16: [29, 2, 4], 17: [37, 3, 5], 18: [34, 2, 4], 19: [18, 1, 2], 20: [28, 2, 4] }, - }, -]; - const achievements = [ { id: "first-talk", title: "初次开口", desc: "完成首次自由对话", category: "开口", progress: 1, total: 1, icon: MessageCircleMore }, { id: "seven-days", title: "七日同行", desc: "连续学习 7 天", category: "连续", progress: 7, total: 7, icon: Footprints }, @@ -2097,38 +2150,45 @@ const achievements = [ { id: "speaking-master", title: "口语大师", desc: "解锁其余全部成就", category: "场景", progress: 4, total: 11, icon: Trophy }, ]; -function LearningCalendar() { - const [monthIndex, setMonthIndex] = useState(learningMonths.length - 1); - const [selectedDay, setSelectedDay] = useState(20); - const month = learningMonths[monthIndex]; - const daysInMonth = new Date(month.year, month.month + 1, 0).getDate(); - const leadingDays = (new Date(month.year, month.month, 1).getDay() + 6) % 7; - const selectedRecord = month.records[selectedDay]; - const changeMonth = (nextIndex) => { - const nextMonth = learningMonths[nextIndex]; - const latestRecordedDay = Math.max(...Object.keys(nextMonth.records).map(Number)); - setMonthIndex(nextIndex); - setSelectedDay(latestRecordedDay); +function LearningCalendar({ calendar, onMonthChange }) { + const monthKey = calendar?.month || new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Shanghai" }).slice(0, 7); + const [year, monthNumber] = monthKey.split("-").map(Number); + const checkedDays = new Set((calendar?.checkedDates || []).map((date) => Number(date.slice(-2)))); + const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Shanghai" }); + const todayDay = today.startsWith(monthKey) ? Number(today.slice(-2)) : null; + const latestCheckedDay = Math.max(0, ...checkedDays); + const [selectedDay, setSelectedDay] = useState(todayDay || latestCheckedDay || 1); + useEffect(() => { + setSelectedDay(todayDay || latestCheckedDay || 1); + }, [monthKey]); + const daysInMonth = new Date(year, monthNumber, 0).getDate(); + const leadingDays = (new Date(year, monthNumber - 1, 1).getDay() + 6) % 7; + const label = `${year} 年 ${monthNumber} 月`; + const selectedRecord = checkedDays.has(selectedDay); + const currentMonth = today.slice(0, 7); + const shiftMonth = (offset) => { + const shifted = new Date(Date.UTC(year, monthNumber - 1 + offset, 1)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}`; }; return (

LEARNING CALENDAR

学习日历

- - {month.label} - + + {label} +
-
+
{Array.from({ length: leadingDays }, (_, index) => )} {Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; - const record = month.records[day]; - const isToday = month.key === "2026-07" && day === 20; + const record = checkedDays.has(day); + const isToday = todayDay === day; return ( -