Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
0c1385b
docs: add profile account and check-in design
Jul 31, 2026
ab28d46
docs: add profile implementation plan
Jul 31, 2026
3e4b887
feat: add avatar key to user schema
Jul 31, 2026
654dd29
feat: add profile database migration
Jul 31, 2026
848255b
build: add aliyun oss sdk
Jul 31, 2026
b8c9817
config: add profile and oss settings
Jul 31, 2026
027fa49
docs: add profile oss environment placeholders
Jul 31, 2026
5a77119
config: allow profile avatar uploads
Jul 31, 2026
6bbe98f
feat(profile): extend user account profile fields
Jul 31, 2026
23fbe96
feat(profile): map avatar object key
Jul 31, 2026
8548f10
feat(profile): define account update operations
Jul 31, 2026
de3279b
feat(profile): persist account profile changes
Jul 31, 2026
31f3700
feat(profile): expose user scene ids
Jul 31, 2026
359be8e
feat(profile): query all user scene ids
Jul 31, 2026
e1d8c42
feat(profile): query report timestamps
Jul 31, 2026
b3acbae
feat(profile): add overview response model
Jul 31, 2026
b09642c
feat(profile): add nickname update request
Jul 31, 2026
29258c6
feat(profile): add nickname update response
Jul 31, 2026
3469ce4
feat(profile): add avatar response
Jul 31, 2026
dcfbac4
feat(profile): add profile settings binding
Jul 31, 2026
fc08342
feat(profile): add object storage settings binding
Jul 31, 2026
515a4b3
feat(profile): define object storage provider
Jul 31, 2026
f3f4699
feat(profile): implement Aliyun OSS provider
Jul 31, 2026
59b33b0
feat(profile): configure avatar object storage
Jul 31, 2026
70ac2c3
feat(profile): validate and normalize avatar images
Jul 31, 2026
a013c0f
feat(profile): define profile overview service
Jul 31, 2026
31afee8
feat(profile): derive report based checkins
Jul 31, 2026
c6634ac
feat(profile): define profile account service
Jul 31, 2026
3762533
feat(profile): implement nickname and avatar updates
Jul 31, 2026
6910560
feat(profile): expose profile account endpoints
Jul 31, 2026
2254564
feat(auth): add password change request
Jul 31, 2026
c988ff6
feat(auth): add password change response
Jul 31, 2026
9d49433
feat(auth): define password change operation
Jul 31, 2026
b02326b
feat(auth): implement password rotation
Jul 31, 2026
e64136a
feat(auth): expose password change endpoint
Jul 31, 2026
6e44687
feat(api): map profile operation failures
Jul 31, 2026
793a79c
feat(profile): add profile account API client
Jul 31, 2026
9aad901
feat(profile): connect account and report checkins
Jul 31, 2026
b4b4f43
style(profile): present account editing controls
Jul 31, 2026
90d5cf1
docs(api): document profile account endpoints
Jul 31, 2026
b35fc03
docs(deploy): document profile storage rollout
Jul 31, 2026
15d0138
fix(profile): make storage lifecycle explicit
Jul 31, 2026
9277ad6
fix(profile): close Aliyun OSS client
Jul 31, 2026
9ff67b6
fix(profile): register storage cleanup
Jul 31, 2026
3c36563
fix(profile): degrade avatar signing failures
Jul 31, 2026
ec1e715
fix(profile): enforce avatar image formats
Jul 31, 2026
7dce165
fix(auth): preserve service adapter compatibility
Jul 31, 2026
11d2200
docs(deploy): align OSS environment names
Jul 31, 2026
c381e3e
merge latest main into feat/profile-account-checkin
Aug 3, 2026
b3f7ea6
功能:将个人资料修改整合到编辑弹窗
Aug 3, 2026
39573b3
重构:将头像对象存储切换为七牛云
Aug 3, 2026
4ab8b28
文档:更新七牛云头像存储说明
Aug 3, 2026
0624e14
feat: 固定个人主页侧栏并调整编辑按钮位置
Aug 3, 2026
7e254a7
feat: 持久化全场景练习会话
Aug 3, 2026
d029888
feat: 接入个人主页真实学习统计
Aug 3, 2026
377ef8d
feat: 优化头像编辑入口和统计展示
Aug 3, 2026
3cb2ed1
feat: 修复个人主页统计服务构造注入
Aug 3, 2026
dfe7bf3
feat: 更新个人主页真实统计文档
Aug 3, 2026
e0e4555
feat: 清理练习统计文件格式
Aug 3, 2026
9280568
feat: 将有效学习时长门槛调整为三十秒
Aug 3, 2026
e06a9ca
feat: 优化个人主页资产跳转与编辑按钮
Aug 3, 2026
ceca16f
feat: 补充个人主页功能测试并满足CI覆盖率
Aug 3, 2026
55465c7
feat: 修复数据库迁移集成测试断言
Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions backend/unispeaking-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@
<tag/>
<url/>
</scm>

<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.17</mybatis-plus.version>
<qiniu-java-sdk.version>7.19.0</qiniu-java-sdk.version>
<jacoco.version>0.8.13</jacoco.version>
<testcontainers.version>2.0.5</testcontainers.version>
<skipUnitTests>false</skipUnitTests>
<skipIntegrationTests>true</skipIntegrationTests>
<jacoco.unit.argLine/>
<jacoco.integration.argLine/>

</properties>
<dependencies>
<dependency>
Expand Down Expand Up @@ -66,11 +69,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
<version>${qiniu-java-sdk.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ public ResponseEntity<ApiResponse<Void>> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -37,4 +40,10 @@ public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request)
public ApiResponse<UserAccountResponse> me() {
return ApiResponse.success(authService.currentUser());
}

@PutMapping("/password")
public ApiResponse<ChangePasswordResponse> changePassword(
@Valid @RequestBody ChangePasswordRequest request) {
return ApiResponse.success(authService.changePassword(request));
}
}
Original file line number Diff line number Diff line change
@@ -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<ProfileOverviewResponse> overview(
@RequestParam(required = false) String month) {
return ApiResponse.success(overviewService.getOverview(
authService.requireUserId(null), month));
}

@PatchMapping
public ApiResponse<UpdateProfileResponse> update(
@Valid @RequestBody UpdateProfileRequest request) {
return ApiResponse.success(accountService.updateNickname(
authService.requireUserId(null), request));
}

@PostMapping("/avatar")
public ApiResponse<AvatarResponse> avatar(
@RequestPart("avatar") MultipartFile avatar) throws IOException {
return ApiResponse.success(accountService.replaceAvatar(
authService.requireUserId(null), avatar.getBytes()));
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.unispeaking.domain.dto.auth;

public record ChangePasswordResponse(boolean reauthenticationRequired) {
public static ChangePasswordResponse required() {
return new ChangePasswordResponse(true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.unispeaking.domain.dto.profile;

import java.time.Instant;

public record AvatarResponse(String avatarUrl, Instant avatarUrlExpiresAt) {
}
Original file line number Diff line number Diff line change
@@ -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<LocalDate> checkedDates,
boolean checkedInToday) {
public Calendar {
checkedDates = List.copyOf(checkedDates);
}
}

public record PracticeStatistics(
long weeklyPracticeSeconds,
long trainingRecordCount,
int consecutiveLearningDays,
List<DailyPractice> lastSevenDays) {
public PracticeStatistics {
lastSevenDays = List.copyOf(lastSevenDays);
}
}

public record DailyPractice(LocalDate date, long practiceSeconds) {
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.unispeaking.domain.dto.profile;

public record UpdateProfileResponse(String nickname, String displayName) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,56 @@ public record UserAccount(
String username,
String passwordHash,
String nickname,
String avatarObjectKey,
UserRole role,
UserStatus status,
long authVersion,
Instant lastLoginAt,
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,
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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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",
"头像存储尚未配置");
}
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading