From 0c1385b7d44d4f06924e9f836c9821a994f05608 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:32:10 +0800 Subject: [PATCH 01/62] docs: add profile account and check-in design --- ...00\345\217\221\350\256\276\350\256\241.md" | 725 ++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 "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" 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..072ff7d --- /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,725 @@ +# 个人主页账户资料与自动打卡开发设计 + +## 1. 文档状态 + +- 设计日期:2026-07-31。 +- 适用仓库:`UniSpeaking`。 +- 定位基线:当前提交 `27e8618`。 +- 当前状态:设计已确认,等待用户明确批准后才允许进入开发。 +- 本文中的行号以当前提交为准;实施时同时使用类名、方法名和代码锚点定位,禁止仅凭行号修改。 +- 本文只定义开发方案,不代表已经修改源码、配置、数据库或依赖。 + +## 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. OSS Access Key、Secret、Bucket 和 Endpoint 只允许由环境变量注入,不得进入 DTO、 + 前端、日志或 Git。 +8. 数据库只保存头像对象 Key,不保存长期签名 URL。 +9. 上传头像必须验证实际内容、大小、图片尺寸和对象归属。 +10. 业务异常使用 `BusinessException` 和稳定错误码,响应使用统一 `ApiResponse`。 +11. 日志不得记录密码、JWT、OSS 密钥、签名 URL 或图片二进制。 +12. 新行为必须有测试,默认测试不得调用真实 OSS 或其他外网服务。 + +`CLAUDE.md` 本身不需要修改,它是本设计的上位约束。 + +## 3. 已确认需求 + +### 3.1 展示昵称 + +- 用户可以修改个人主页的展示昵称。 +- 展示昵称使用现有 `"user".nickname`。 +- `"user".username` 实际是登录邮箱,本次不得修改。 +- 昵称去除首尾空格后长度必须为 1~32 个字符。 + +### 3.2 用户头像 + +- 用户可以上传 JPEG 或 PNG 头像。 +- 头像保存到阿里云 OSS。 +- 后端保存对象 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 保留的现有静态内容 + +以下现有静态内容保持原样,不改为后端统计: + +- “本周学习时长”统计卡; +- “已保存学习资产”统计卡; +- “连续学习天数”统计卡; +- 七日练习节奏柱状图; +- 成就系统及其静态进度。 + +只有学习日历中的打卡日期改为由五维报告动态推导。 + +## 4. 不在本次范围 + +- 不修改登录邮箱。 +- 不增加邮箱验证、找回密码或忘记密码流程。 +- 不增加删除账户功能;现有按钮继续保持未接入状态。 +- 不增加打卡按钮、补签、撤销打卡或管理员改打卡。 +- 不增加 `user_check_in` 表。 +- 不增加 Redis 依赖、Redis 容器或 Redis 配置。 +- 不修改五维评分算法、五维报告字段或评分表结构。 +- 不把原有静态统计、练习节奏或成就系统改为动态数据。 +- 不引入消息队列、CDN 或浏览器直传 OSS。 +- 不在本次工作中重构整个 `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 和 OSS 依赖。 + +## 6. 总体架构 + +```text +ProfileController + ├── AuthService.requireUserId(null) + ├── ProfileOverviewService + │ ├── UserAccountRepository + │ ├── SceneRepository + │ ├── SessionEvaluationRepository + │ └── ObjectStorageProvider + └── ProfileAccountService + ├── UserAccountRepository + ├── AvatarImageProcessor + └── ObjectStorageProvider + +AuthController + └── AuthService.changePassword(...) + ├── UserAccountRepository + └── PasswordEncoder + +ObjectStorageProvider + └── AliyunOssObjectStorageProvider +``` + +边界说明: + +- `ProfileOverviewService` 只负责个人主页读取聚合和打卡日期推导。 +- `ProfileAccountService` 只负责昵称和头像资料写入。 +- `ProfileService` 继续只负责老师、语速、CEFR 和长期资料,不扩大为万能服务。 +- `AuthService` 继续拥有密码与 JWT 失效逻辑。 +- 不创建 `CheckInService`,因为本次没有打卡写操作。 +- 不让 `SessionService` 调用 Profile 模块;评分报告保存完成后无需额外写入。 + +## 7. 数据库设计 + +### 7.1 唯一数据库改动 + +只修改 `"user"` 表,增加 OSS 对象 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` 的类型。 +- 不设置新的数据库外键。 + +### 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 方法。 + +## 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://example.oss-cn-hangzhou.aliyuncs.com/...", + "avatarUrlExpiresAt": "2026-07-31T10:00:00Z" + }, + "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://example.oss-cn-hangzhou.aliyuncs.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. 头像处理与 OSS 设计 + +### 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 OSS 接口 + +`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 + → 尽力删除旧对象 +``` + +失败策略: + +- 图片校验失败:不调用 OSS,不修改数据库。 +- 新对象上传失败:不修改数据库。 +- 新对象上传成功但数据库更新失败:立即尽力删除新对象,然后返回失败。 +- 数据库更新成功但旧对象删除失败:新头像继续生效,只记录脱敏警告日志。 +- 读取资料时签名失败:资料接口仍成功,`avatarUrl` 和 + `avatarUrlExpiresAt` 返回 `null`。 +- 不在请求内重试超过一次,不引入 MQ 或后台重试框架。 + +### 9.4 OSS 配置 + +`application.yaml` 中新增: + +```yaml +profile: + time-zone: ${PROFILE_TIME_ZONE:Asia/Shanghai} + +object-storage: + aliyun: + endpoint: ${OSS_ENDPOINT:} + bucket: ${OSS_BUCKET:} + access-key-id: ${OSS_ACCESS_KEY_ID:} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:} + avatar-prefix: ${OSS_AVATAR_PREFIX:avatars} + signed-url-ttl: ${OSS_SIGNED_URL_TTL:1h} +``` + +`.env.example` 只增加占位值: + +```properties +PROFILE_TIME_ZONE=Asia/Shanghai +OSS_ENDPOINT= +OSS_BUCKET= +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_AVATAR_PREFIX=avatars +OSS_SIGNED_URL_TTL=1h +``` + +实际密钥只由用户写入被 Git 忽略的 `deploy/env/.env`。开发过程不得自动修改或输出该 +文件。 + +### 9.5 依赖 + +`pom.xml` 增加: + +```xml +3.18.5 +``` + +以及: + +```xml + + com.aliyun.oss + aliyun-sdk-oss + ${aliyun-oss.version} + +``` + +Java 21 下如 OSS SDK 依赖树未提供 JAXB,再按阿里云官方 Java 9+ 指引补充 +`jaxb-api 2.3.1`、`activation 1.1.1` 和 `jaxb-runtime 2.3.3`;是否需要以 +`./mvnw dependency:tree` 和编译结果为准,不预先引入重复依赖。 + +参考: + +- [阿里云 OSS Java SDK 安装说明](https://help.aliyun.com/en/oss/developer-reference/oss-java-sdk/) +- [OSS Java SDK 3.18.5 Maven 坐标](https://mvnrepository.com/artifact/com.aliyun.oss/aliyun-sdk-oss/3.18.5) + +## 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/schema.sql` | 当前第 1 行 `"user"` 建表;`ALTER COLUMN username TYPE VARCHAR(254)` 之后 | 增加 `avatar_object_key`、非空白约束和字段注释;不增加其他表或字段 | +| 新建 | `deploy/postgres/profile.sql` | 新文件 | 用 `BEGIN/COMMIT` 包装同一组幂等头像字段迁移 SQL | +| 修改 | `backend/unispeaking-server/src/main/resources/application.yaml` | 当前第 41 行 `auth:` 之前 | 新增 `profile.time-zone` 和 `object-storage.aliyun` 配置 | +| 修改 | `deploy/env/.env.example` | 当前第 12~23 行 PostgreSQL/JWT 配置之后 | 增加 Profile 时区和 OSS 占位变量,不填真实密钥 | +| 修改 | `deploy/nginx/nginx.conf` | 当前第 12 行 `server` 块内、`location /backend/` 之前 | 增加 `client_max_body_size 10m;`,与 Spring 现有 multipart 上限对齐 | +| 不改 | `deploy/env/.env` | 被 Git 忽略 | 真实 OSS 配置由用户自行填写,开发不得读取、输出或提交 | +| 不改 | `deploy/docker-compose.yml` | `backend.env_file` 已存在 | OSS 变量会由现有 `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`、`calendar` | +| 新建 | `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、OSS 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` | 新文件 | 昵称更新、图片规范化、OSS 上传、数据库更新和补偿删除 | +| 新建 | `service/profile/image/AvatarImageProcessor.java` | 新文件 | 纯图片验证和重新编码;不访问数据库或 OSS | +| 不改 | `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` | `completeCustomScene()` | 报告保存逻辑不增加打卡写入 | + +修改密码流程: + +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 集合直接返回空列表 | +| 不改 | `infrastructure/persistence/entity/evaluation/SessionEvaluationEntity.java` | 整个类 | 直接读取现有 `sceneId` 和 `createdAt` | +| 不改 | `infrastructure/persistence/mapper/*` | 所有 Mapper | 现有 BaseMapper 足够,不增加 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 OSS 基础设施 + +| 动作 | 文件 | 当前定位/锚点 | 必须修改的内容 | +| --- | --- | --- | --- | +| 新建 | `infrastructure/storage/ObjectStorageProvider.java` | 新文件 | 稳定的上传、签名和删除接口 | +| 新建 | `infrastructure/storage/UnavailableObjectStorageProvider.java` | 新文件 | OSS 未配置时提供明确业务失败,不让应用启动失败 | +| 新建 | `infrastructure/storage/aliyun/AliyunOssObjectStorageProvider.java` | 新文件 | 唯一允许依赖阿里云 OSS SDK 的实现 | +| 新建 | `infrastructure/config/ObjectStorageProperties.java` | 新文件 | `@ConfigurationProperties("object-storage.aliyun")` | +| 新建 | `infrastructure/config/ObjectStorageConfig.java` | 新文件 | 配置完整时创建 OSS Provider,否则创建不可用 Provider;OSS Client 关闭时调用 `shutdown()` | +| 新建 | `infrastructure/config/ProfileProperties.java` | 新文件 | `@ConfigurationProperties("profile")`,校验 `ZoneId` | +| 修改 | `backend/unispeaking-server/pom.xml` | 当前第 29~32 行 properties;第 63~72 行 PostgreSQL/音频依赖之间 | 增加 OSS 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 | + +不得把 OSS 原始响应、Endpoint、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` | 当前第 1976~1989 行 `learningMonths` | 删除静态月份打卡数据;不得删除静态统计卡、节奏柱状图和成就常量 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2006 行 `LearningCalendar` | 按月份调用后端并渲染 `checkedDates`;月份切换支持历史月份,不允许未来月份 | +| 修改 | `frontend/Unispeaking_fronted/src/App.jsx` | 当前第 2073 行 `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` | 当前第 806 行 profile 区域 | 增加头像编辑、昵称编辑、上传预览、表单错误、提交中状态 | +| 修改 | `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`。 +- 不把 OSS 配置、签名逻辑或密钥放到前端。 + +### 10.9 文档 + +| 动作 | 文件 | 修改位置 | 必须修改的内容 | +| --- | --- | --- | --- | +| 修改 | `docs/frontend-backend-interface-contract.md` | 当前第 113 行“2.3 当前用户”之后 | 增加“2.4 修改密码”;在第 131 行用户资料章节前增加个人主页 overview、昵称和头像接口 | +| 修改 | `docs/deployment.md` | 当前第 26 行“Available settings”中,Realtime 配置之前 | 增加阿里云 OSS 配置、RAM 最小权限、Bucket 私有读和签名 URL 说明 | +| 不改 | `CLAUDE.md` | 整个文件 | 继续作为强制规范,不重复或弱化规则 | +| 不改 | `README.md` | 整个文件 | 本次接口和部署信息由上述专用文档维护 | + +## 11. 测试文件和覆盖范围 + +### 11.1 新建测试 + +| 文件 | 覆盖内容 | +| --- | --- | +| `src/test/java/com/unispeaking/service/profile/ProfileOverviewServiceImplTest.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/aliyun/AliyunOssObjectStorageProviderTest.java` | 使用 Mock OSS Client 验证 Key、Content-Type、签名 TTL、错误转换和删除,不访问外网 | + +### 11.2 修改现有测试 + +| 文件 | 修改位置 | 覆盖内容 | +| --- | --- | --- | +| `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` | 应用启动测试 | OSS 配置为空时应用仍能启动 | + +### 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. 上传合法头像后立即更新,刷新页面后仍通过 OSS 显示。 +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`,没有打卡表、Redis 或评分表改动。 +- 打卡完全由已有五维报告推导,任意持久化报告都算。 +- 静态统计卡、节奏和成就保持不变。 +- 登录邮箱不可修改,昵称可持久化修改。 +- 头像只保存 OSS Key,签名 URL 短期生成,失败补偿完整。 +- 修改密码验证当前密码,并通过 `auth_version` 撤销全部旧 JWT。 +- Controller 无业务逻辑,Service 不写 SQL,Repository 不泄漏 Entity。 +- 新增持久化只使用 MyBatis-Plus Lambda Wrapper。 +- 配置和日志无密码、JWT、OSS 密钥和签名 URL。 +- 所有新增行为有测试,默认测试不访问真实 OSS。 +- `./mvnw test`、`npm run build`、`npm run check:routes` 和 + `npm run check:realtime-events` 全部通过。 From ab28d464d3b04dd15096ed328be1bd10494cfa12 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:34:13 +0800 Subject: [PATCH 02/62] docs: add profile implementation plan --- ...36\346\226\275\350\256\241\345\210\222.md" | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 "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" 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..1553660 --- /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,100 @@ +# 个人主页账户资料与自动打卡实施计划 + +> **执行要求:** 严格按照本计划逐项实施。每项只修改一个文件,验证该文件相关编译后立即单独 commit。用户明确要求不新增或修改测试文件,因此本计划不创建测试代码,使用现有测试套件、前端构建与检查作为回归验证。 + +**目标:** 实现动态报告打卡日历、展示昵称修改、阿里云 OSS 用户头像和全端 JWT 失效的密码修改功能。 + +**架构:** 完整设计以 `docs/个人主页账户资料与自动打卡开发设计.md` 为准。Profile 读取、Profile 写入、Auth 和 OSS 基础设施保持独立;打卡只读取现有 `session_evaluation`,不增加打卡写模型、Redis 或评分表字段。 + +**技术栈:** Java 21、Spring Boot 4、MyBatis-Plus 3.5.17、PostgreSQL、阿里云 OSS Java SDK、React 19、Vite 6。 + +## 全局约束 + +- 开发前遵循仓库根目录 `CLAUDE.md`。 +- 当前分支固定为 `feat/profile-account-checkin`。 +- 每个 commit 只包含一个文件。 +- 不新增或修改 `src/test` 文件,不删除现有测试。 +- Java 中不写原始 SQL,不使用 SQL 注解、Mapper XML 或被禁止的 Wrapper 方法。 +- 用户身份只来自 JWT。 +- 不读取、修改或提交 `deploy/env/.env`。 +- 不新增打卡表、Redis、MQ 或评分表字段。 + +--- + +### Task 1:数据库与依赖 + +- [ ] 修改 `backend/unispeaking-server/src/main/resources/db/schema.sql`:只给 `"user"` 增加 `avatar_object_key VARCHAR(512)`、非空白约束和注释;运行 `./mvnw -q -DskipTests compile`;单文件 commit。 +- [ ] 新建 `deploy/postgres/profile.sql`:用 `BEGIN/COMMIT` 包装同一幂等迁移;单文件 commit。 +- [ ] 修改 `backend/unispeaking-server/pom.xml`:增加 `aliyun-oss.version=3.18.5` 和 `com.aliyun.oss:aliyun-sdk-oss`;运行 `./mvnw -q -DskipTests compile`;单文件 commit。 +- [ ] 修改 `backend/unispeaking-server/src/main/resources/application.yaml`:增加 `profile.time-zone` 与 `object-storage.aliyun`;单文件 commit。 +- [ ] 修改 `deploy/env/.env.example`:增加 Profile/OSS 空占位配置;单文件 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`:绑定 endpoint、bucket、Access Key、前缀和签名 TTL;编译;单文件 commit。 + +### Task 5:OSS 与图片处理 + +- [ ] 新建 `infrastructure/storage/ObjectStorageProvider.java`:定义 `put`、`signGetUrl`、`delete`;编译;单文件 commit。 +- [ ] 新建 `infrastructure/storage/aliyun/AliyunOssObjectStorageProvider.java`:唯一 OSS SDK 适配器,转换供应商异常;编译;单文件 commit。 +- [ ] 新建 `infrastructure/config/ObjectStorageConfig.java`:配置完整时创建 OSS Provider,不完整时提供明确不可用实现,关闭时 shutdown;编译;单文件 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、并发条件和 OSS 补偿;编译;单文件 commit。 +- [ ] 新建 `controller/ProfileController.java`:实现 overview、PATCH profile 和 multipart avatar;编译;单文件 commit。 +- [ ] 修改 `common/exception/GlobalExceptionHandler.java`:增加 Profile/Auth/OSS 错误状态映射;编译;单文件 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`:补充 OSS、私有 Bucket、RAM 最小权限和签名 URL;单文件 commit。 + +### Task 10:最终验证 + +- [ ] 确认 `git status --short` 为空。 +- [ ] 确认从设计文档 commit 之后的每个 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 技术栈。 + From 3e4b88703d166340fd97ee0e613d42af701bb591 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:35:40 +0800 Subject: [PATCH 03/62] feat: add avatar key to user schema --- .../src/main/resources/db/schema.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/unispeaking-server/src/main/resources/db/schema.sql b/backend/unispeaking-server/src/main/resources/db/schema.sql index 0b325a0..87882c4 100644 --- a/backend/unispeaking-server/src/main/resources/db/schema.sql +++ b/backend/unispeaking-server/src/main/resources/db/schema.sql @@ -17,6 +17,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), From 654dd2919c40a576fc6ecdfdb1228a48ba059755 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:35:40 +0800 Subject: [PATCH 04/62] feat: add profile database migration --- deploy/postgres/profile.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 deploy/postgres/profile.sql diff --git a/deploy/postgres/profile.sql b/deploy/postgres/profile.sql new file mode 100644 index 0000000..0c67328 --- /dev/null +++ b/deploy/postgres/profile.sql @@ -0,0 +1,16 @@ +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; From 848255bcdea47f745cc4e4c15ca2a5d156ff69f4 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:35:40 +0800 Subject: [PATCH 05/62] build: add aliyun oss sdk --- backend/unispeaking-server/pom.xml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/unispeaking-server/pom.xml b/backend/unispeaking-server/pom.xml index efeac05..c86ba2c 100644 --- a/backend/unispeaking-server/pom.xml +++ b/backend/unispeaking-server/pom.xml @@ -26,9 +26,10 @@ - - 21 - 3.5.17 + + 21 + 3.5.17 + 3.18.5 @@ -60,11 +61,16 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server - - org.postgresql - postgresql - runtime - + + org.postgresql + postgresql + runtime + + + com.aliyun.oss + aliyun-sdk-oss + ${aliyun-oss.version} + de.sciss jump3r From b8c98178b73bb024aec758ccb300ea1ea4e00643 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:35:40 +0800 Subject: [PATCH 06/62] config: add profile and oss settings --- .../src/main/resources/application.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/unispeaking-server/src/main/resources/application.yaml b/backend/unispeaking-server/src/main/resources/application.yaml index 201dd98..64561c9 100644 --- a/backend/unispeaking-server/src/main/resources/application.yaml +++ b/backend/unispeaking-server/src/main/resources/application.yaml @@ -20,6 +20,18 @@ spring: server: port: ${SERVER_PORT:8080} +profile: + time-zone: ${PROFILE_TIME_ZONE:Asia/Shanghai} + +object-storage: + aliyun: + endpoint: ${OSS_ENDPOINT:} + bucket: ${OSS_BUCKET:} + access-key-id: ${OSS_ACCESS_KEY_ID:} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:} + avatar-prefix: ${OSS_AVATAR_PREFIX:avatars} + signed-url-ttl: ${OSS_SIGNED_URL_TTL:1h} + prompt: templates: directory: ${PROMPT_TEMPLATE_DIR:} From 027fa49560412f7690c986ee0427fa4409256f2d Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:35:40 +0800 Subject: [PATCH 07/62] docs: add profile oss environment placeholders --- deploy/env/.env.example | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/deploy/env/.env.example b/deploy/env/.env.example index ae4e7a6..08c1d60 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 Alibaba Cloud OSS +# ============================================================================= +PROFILE_TIME_ZONE=Asia/Shanghai +OSS_ENDPOINT= +OSS_BUCKET= +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_AVATAR_PREFIX=avatars +OSS_SIGNED_URL_TTL=1h + # ============================================================================= # AI model routes # The first model is primary; later models are fallbacks. From 5a771196797633b0c815baaadbc7c5c7710466c1 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:35:40 +0800 Subject: [PATCH 08/62] config: allow profile avatar uploads --- deploy/nginx/nginx.conf | 1 + 1 file changed, 1 insertion(+) 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/; From 6bbe98f886a32f23555bcddb1048deb161ce8ac9 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:28 +0800 Subject: [PATCH 09/62] feat(profile): extend user account profile fields --- .../domain/po/auth/UserAccount.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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); + } } From 23fbe962bc5b3bfa6509ac57e302675848498114 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:28 +0800 Subject: [PATCH 10/62] feat(profile): map avatar object key --- .../persistence/entity/user/UserAccountEntity.java | 1 + 1 file changed, 1 insertion(+) 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; From 8548f10fdad7d721172239f931155d1da7f3280f Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:28 +0800 Subject: [PATCH 11/62] feat(profile): define account update operations --- .../persistence/repository/user/UserAccountRepository.java | 6 ++++++ 1 file changed, 6 insertions(+) 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); } From de3279b4139f8037701fff989b375f03f0b4fd82 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 12/62] feat(profile): persist account profile changes --- .../user/MybatisUserAccountRepository.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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(), From 31f3700be9fcc95ae26473f0831141319ed1d606 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 13/62] feat(profile): expose user scene ids --- .../persistence/repository/scene/SceneRepository.java | 1 + 1 file changed, 1 insertion(+) 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..300cb22 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,5 @@ SceneGenerationResponse saveCustomScene( Optional findGeneratedById(String sceneId); Optional findCustomDefinitionById(String sceneId); List findAssetsByUserId(String userId); + List findAllIdsByUserId(String userId); } From 359be8e9ad6bbe25f0b61ca1bf76cebd8e9d91c0 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 14/62] feat(profile): query all user scene ids --- .../scene/MybatisSceneRepository.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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..46b117a 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,23 @@ public List findAssetsByUserId(String userId) { .toList(); } + @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(), From e1d8c422d01b89b6333418079d1942d416db9b2d Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 15/62] feat(profile): query report timestamps --- .../SessionEvaluationRepository.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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, From b3acbae49605654aea52ca16e1601e7b155d2e2b Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 16/62] feat(profile): add overview response model --- .../dto/profile/ProfileOverviewResponse.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/ProfileOverviewResponse.java 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..dfc9615 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/ProfileOverviewResponse.java @@ -0,0 +1,26 @@ +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, 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); + } + } +} From b09642ced8700506399c0c5f8eda923b73454b53 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 17/62] feat(profile): add nickname update request --- .../domain/dto/profile/UpdateProfileRequest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileRequest.java 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) { +} From 29258c6edfbf124eca3f43325a3a5b64eeeb2109 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 18/62] feat(profile): add nickname update response --- .../unispeaking/domain/dto/profile/UpdateProfileResponse.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/UpdateProfileResponse.java 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) { +} From 3469ce42e0f3a98953a9b9226408addae8ebf7df Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 19/62] feat(profile): add avatar response --- .../com/unispeaking/domain/dto/profile/AvatarResponse.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/profile/AvatarResponse.java 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) { +} From dcfbac4e27229eea44ecb1d5c6e68b67d84831fa Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 20/62] feat(profile): add profile settings binding --- .../config/ProfileProperties.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ProfileProperties.java 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); + } +} From fc083425d4058b1404d220b792dfaa5057faa583 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 21/62] feat(profile): add object storage settings binding --- .../config/ObjectStorageProperties.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageProperties.java 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..6854e79 --- /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.aliyun") +public class ObjectStorageProperties { + private String endpoint = ""; + private String bucket = ""; + private String accessKeyId = ""; + private String accessKeySecret = ""; + private String avatarPrefix = "avatars"; + private Duration signedUrlTtl = Duration.ofHours(1); + + public boolean configured() { + return !endpoint.isBlank() && !bucket.isBlank() + && !accessKeyId.isBlank() && !accessKeySecret.isBlank(); + } + public String getEndpoint() { return endpoint; } + public void setEndpoint(String value) { endpoint = value == null ? "" : value.trim(); } + public String getBucket() { return bucket; } + public void setBucket(String value) { bucket = value == null ? "" : value.trim(); } + public String getAccessKeyId() { return accessKeyId; } + public void setAccessKeyId(String value) { accessKeyId = value == null ? "" : value.trim(); } + public String getAccessKeySecret() { return accessKeySecret; } + public void setAccessKeySecret(String value) { accessKeySecret = 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; } +} From 515a4b365eb800851378cea683b00f18baaed73c Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 22/62] feat(profile): define object storage provider --- .../infrastructure/storage/ObjectStorageProvider.java | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/ObjectStorageProvider.java 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..2f1a6e6 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/ObjectStorageProvider.java @@ -0,0 +1,11 @@ +package com.unispeaking.infrastructure.storage; + +import java.net.URI; +import java.time.Duration; + +public interface ObjectStorageProvider { + void put(String objectKey, byte[] content, String contentType); + URI signGetUrl(String objectKey, Duration ttl); + void delete(String objectKey); + boolean available(); +} From f3f46994804dcbd53facffc8c36f9dfc82594576 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 23/62] feat(profile): implement Aliyun OSS provider --- .../AliyunOssObjectStorageProvider.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/aliyun/AliyunOssObjectStorageProvider.java diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/aliyun/AliyunOssObjectStorageProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/aliyun/AliyunOssObjectStorageProvider.java new file mode 100644 index 0000000..bc8b8b4 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/storage/aliyun/AliyunOssObjectStorageProvider.java @@ -0,0 +1,68 @@ +package com.unispeaking.infrastructure.storage.aliyun; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.model.ObjectMetadata; +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.infrastructure.config.ObjectStorageProperties; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +public class AliyunOssObjectStorageProvider implements ObjectStorageProvider { + private final OSS client; + private final ObjectStorageProperties properties; + + public AliyunOssObjectStorageProvider(OSS client, ObjectStorageProperties properties) { + this.client = client; + this.properties = properties; + } + + @Override + public void put(String objectKey, byte[] content, String contentType) { + try { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(content.length); + metadata.setContentType(contentType); + client.putObject(properties.getBucket(), objectKey, + new ByteArrayInputStream(content), metadata); + } + catch (RuntimeException exception) { + throw storageFailure(); + } + } + + @Override + public URI signGetUrl(String objectKey, Duration ttl) { + try { + return client.generatePresignedUrl( + properties.getBucket(), + objectKey, + Date.from(Instant.now().plus(ttl))).toURI(); + } + catch (Exception exception) { + throw storageFailure(); + } + } + + @Override + public void delete(String objectKey) { + try { + client.deleteObject(properties.getBucket(), objectKey); + } + catch (RuntimeException exception) { + throw storageFailure(); + } + } + + @Override + public boolean available() { + return true; + } + + private BusinessException storageFailure() { + return new BusinessException("AVATAR_STORAGE_FAILED", "头像存储服务暂时不可用"); + } +} From 59b33b0984529fa9dff5f88a578617c366998fe8 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:29 +0800 Subject: [PATCH 24/62] feat(profile): configure avatar object storage --- .../config/ObjectStorageConfig.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageConfig.java 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..0ea21e5 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/ObjectStorageConfig.java @@ -0,0 +1,55 @@ +package com.unispeaking.infrastructure.config; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import com.unispeaking.infrastructure.storage.aliyun.AliyunOssObjectStorageProvider; +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(destroyMethod = "") + ObjectStorageProvider objectStorageProvider(ObjectStorageProperties properties) { + if (!properties.configured()) { + return new UnavailableObjectStorageProvider(); + } + OSS client = new OSSClientBuilder().build( + properties.getEndpoint(), + properties.getAccessKeyId(), + properties.getAccessKeySecret()); + return new ManagedAliyunProvider(client, properties); + } + + private static final class ManagedAliyunProvider + extends AliyunOssObjectStorageProvider { + private final OSS client; + + private ManagedAliyunProvider(OSS client, ObjectStorageProperties properties) { + super(client, properties); + this.client = client; + } + + @SuppressWarnings("unused") + public void shutdown() { + client.shutdown(); + } + } + + 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", + "头像存储尚未配置"); + } + } +} From 70ac2c3687c0d9fe8c08eed9354918a95aee776e Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 25/62] feat(profile): validate and normalize avatar images --- .../profile/image/AvatarImageProcessor.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/image/AvatarImageProcessor.java 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..664c18a --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/image/AvatarImageProcessor.java @@ -0,0 +1,54 @@ +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 MB"); + } + 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 BusinessException error(String code, String message) { + return new BusinessException(code, message); + } + + public record ProcessedAvatar(byte[] content, String contentType, String extension) { + } +} From a013c0fec27a5a59dfa472f9b8ba48a11ee255cd Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 26/62] feat(profile): define profile overview service --- .../service/profile/ProfileOverviewService.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileOverviewService.java 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); +} From 31afee8edc3b6c83fb71bef997c4955a7a8e364e Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 27/62] feat(profile): derive report based checkins --- .../impl/ProfileOverviewServiceImpl.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImpl.java 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..53e1fef --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileOverviewServiceImpl.java @@ -0,0 +1,112 @@ +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.user.UserAccountRepository; +import com.unispeaking.infrastructure.storage.ObjectStorageProvider; +import com.unispeaking.service.profile.ProfileOverviewService; +import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.UUID; +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 ObjectStorageProvider storage; + private final ObjectStorageProperties storageProperties; + private final ZoneId zoneId; + + public ProfileOverviewServiceImpl( + UserAccountRepository accounts, + SceneRepository scenes, + SessionEvaluationRepository evaluations, + ObjectStorageProvider storage, + ObjectStorageProperties storageProperties, + ProfileProperties profileProperties) { + this.accounts = accounts; + this.scenes = scenes; + this.evaluations = evaluations; + this.storage = storage; + this.storageProperties = storageProperties; + this.zoneId = profileProperties.zoneId(); + } + + @Override + public ProfileOverviewResponse getOverview(String userId, String requestedMonth) { + UUID id = UUID.fromString(userId); + UserAccount user = accounts.findById(id) + .orElseThrow(() -> new BusinessException("USER_NOT_FOUND", "用户不存在")); + YearMonth current = YearMonth.now(zoneId); + 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 dates = evaluations.findCreatedAtBySceneIdsBetween( + scenes.findAllIdsByUserId(userId), + 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); + return new ProfileOverviewResponse( + new ProfileOverviewResponse.Account( + user.id(), user.username(), user.nickname(), displayName, + signed.url(), signed.expiresAt()), + new ProfileOverviewResponse.Calendar( + month.toString(), dates, dates.contains(LocalDate.now(zoneId)))); + } + + 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(), + Instant.now().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) {} +} From c6634aca15c22041c084f7a0993ceda1ba3090d8 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 28/62] feat(profile): define profile account service --- .../service/profile/ProfileAccountService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/ProfileAccountService.java 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); +} From 3762533752484c53a29088a7252c2171d660213b Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 29/62] feat(profile): implement nickname and avatar updates --- .../impl/ProfileAccountServiceImpl.java | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImpl.java 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..c8add83 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/profile/impl/ProfileAccountServiceImpl.java @@ -0,0 +1,83 @@ +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", "头像已发生变化,请重试"); + } + if (user.avatarObjectKey() != null) safeDelete(user.avatarObjectKey()); + URI signed = storage.signGetUrl(key, properties.getSignedUrlTtl()); + return new AvatarResponse( + signed.toString(), + Instant.now().plus(properties.getSignedUrlTtl())); + } + + private void safeDelete(String key) { + try { + storage.delete(key); + } + catch (BusinessException exception) { + LOGGER.warn("avatar object cleanup failed"); + } + } +} From 691056028e9be5364888c40708fc0430e9775698 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 30/62] feat(profile): expose profile account endpoints --- .../controller/ProfileController.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/controller/ProfileController.java 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())); + } +} From 225456470c89491adc546c9cb10167c85107d034 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 31/62] feat(auth): add password change request --- .../domain/dto/auth/ChangePasswordRequest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordRequest.java 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) { +} From c988ff6b1917d7c7a959aa4653044e8b4dc3cd99 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 32/62] feat(auth): add password change response --- .../domain/dto/auth/ChangePasswordResponse.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 backend/unispeaking-server/src/main/java/com/unispeaking/domain/dto/auth/ChangePasswordResponse.java 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); + } +} From 9d49433b62b319fa92c35b0540b713767cb36d2f Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 33/62] feat(auth): define password change operation --- .../main/java/com/unispeaking/service/auth/AuthService.java | 3 +++ 1 file changed, 3 insertions(+) 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..35ed6ae 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,6 @@ public interface AuthService { AuthResponse register(RegisterRequest request); AuthResponse login(LoginRequest request); UserAccountResponse currentUser(); + ChangePasswordResponse changePassword(ChangePasswordRequest request); String requireUserId(String requestedUserId); } From b02326b5db3e26b51973bbc8927035b9b3bd2fd2 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 34/62] feat(auth): implement password rotation --- .../service/auth/impl/AuthServiceImpl.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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(); From e64136a64f3db676a1a0d8bb1a6aff2f19aeb2a3 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:30 +0800 Subject: [PATCH 35/62] feat(auth): expose password change endpoint --- .../java/com/unispeaking/controller/AuthController.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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)); + } } From 6e44687d75a0fdf0db1335d9ca775b302c78d1da Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:41:31 +0800 Subject: [PATCH 36/62] feat(api): map profile operation failures --- .../common/exception/GlobalExceptionHandler.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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) From 793a79ca55b5206ccde7fcbb0eb546bbbf717642 Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:47:25 +0800 Subject: [PATCH 37/62] feat(profile): add profile account API client --- frontend/Unispeaking_fronted/src/apiClient.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/Unispeaking_fronted/src/apiClient.js b/frontend/Unispeaking_fronted/src/apiClient.js index dbbe14f..8c58d11 100644 --- a/frontend/Unispeaking_fronted/src/apiClient.js +++ b/frontend/Unispeaking_fronted/src/apiClient.js @@ -66,6 +66,34 @@ export function getCurrentUser() { return request("/api/auth/me"); } +export function getProfileOverview(month) { + const query = month ? `?month=${encodeURIComponent(month)}` : ""; + return request(`/api/profile/overview${query}`); +} + +export function updateProfile({ nickname }) { + return request("/api/profile", { + method: "PATCH", + body: JSON.stringify({ nickname }), + }); +} + +export function uploadProfileAvatar(file) { + const formData = new FormData(); + formData.append("avatar", file); + return request("/api/profile/avatar", { + method: "POST", + body: formData, + }); +} + +export function changePassword({ currentPassword, newPassword }) { + return request("/api/auth/password", { + method: "PUT", + body: JSON.stringify({ currentPassword, newPassword }), + }); +} + export function getUserPreference() { return request("/api/user-preferences"); } From 9aad901a28f92e0ace21dcd634614a476e0ef1fc Mon Sep 17 00:00:00 2001 From: suerzzh Date: Fri, 31 Jul 2026 15:47:25 +0800 Subject: [PATCH 38/62] feat(profile): connect account and report checkins --- frontend/Unispeaking_fronted/src/App.jsx | 198 +++++++++++++++++------ 1 file changed, 148 insertions(+), 50 deletions(-) diff --git a/frontend/Unispeaking_fronted/src/App.jsx b/frontend/Unispeaking_fronted/src/App.jsx index d217b56..25cb918 100644 --- a/frontend/Unispeaking_fronted/src/App.jsx +++ b/frontend/Unispeaking_fronted/src/App.jsx @@ -59,6 +59,7 @@ import { } from "lucide-react"; import { learningItems, levels, plans, recommendations, teachers } from "./data.js"; import { + changePassword, clearAuthSession, advanceCustomSceneFlow, createCustomSceneFlow, @@ -67,6 +68,7 @@ import { getCurrentUser, getLearningAsset, getLearningAssets, + getProfileOverview, getUserPreference, hasAuthSession, login, @@ -74,7 +76,9 @@ import { synthesizeSpeech, translateSceneText, translateSessionText, + updateProfile, updateUserPreference, + uploadProfileAvatar, } from "./apiClient.js"; import { createPcmWavRecorder } from "./audioRecorder.js"; import { createRealtimeClient } from "./realtimeClient.js"; @@ -569,7 +573,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 }, @@ -582,7 +586,7 @@ function AppShell({ page, setPage, teacher, children }) {
{children}
@@ -1210,7 +1214,7 @@ function ResultModal({ completed, evaluation, onBack, onAssets }) { return (
-

SIMULATION COMPLETE

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

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

+

SIMULATION COMPLETE

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

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

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

{totalScore}/100
@@ -1965,29 +1969,27 @@ 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 Profile({ section, setSection, user, profile, teacher, speed, level, onSettingsChange, onMonthChange, onNicknameChange, onAvatarChange, onPasswordChange, 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 avatarInputRef = useRef(null); + const editNickname = async () => { + const nickname = window.prompt("请输入新的用户名(昵称)", account?.nickname || user?.nickname || ""); + if (nickname === null) return; + await onNicknameChange(nickname.trim()); + }; + const selectAvatar = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) await onAvatarChange(file); + }; return ( -
{section === "profile" && }{section === "membership" && }{section === "settings" && }
+
{section === "profile" && }{section === "membership" && }{section === "settings" && }
); } -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 }, @@ -2003,38 +2005,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 ( -