From ad5e305ad433cd4fe099a736e19c0766da091759 Mon Sep 17 00:00:00 2001 From: zhouzhibo Date: Thu, 15 Oct 2026 21:28:05 +0800 Subject: [PATCH] feat: integrate COS and unify user persistence --- .gitignore | 1 + README.md | 58 ++++++- deploy/nginx-file.conf.example | 11 ++ pom.xml | 10 ++ .../java/com/hongzhen/hera/auth/AuthDtos.java | 13 ++ .../com/hongzhen/hera/auth/AuthService.java | 80 ++++++--- .../hera/auth/LoginSessionMapper.java | 3 + .../com/hongzhen/hera/auth/SmsService.java | 4 +- .../hongzhen/hera/daily/DailyController.java | 7 - .../com/hongzhen/hera/daily/DailyDtos.java | 18 +-- .../com/hongzhen/hera/daily/DailyService.java | 35 +--- .../hongzhen/hera/data/UserAccountMapper.java | 15 ++ .../com/hongzhen/hera/data/UserProfile.java | 26 +++ .../hongzhen/hera/data/UserProfileMapper.java | 9 ++ .../hera/points/PointsController.java | 31 ---- .../hongzhen/hera/storage/CosProperties.java | 29 ++++ .../hera/storage/CosStorageService.java | 127 +++++++++++++++ .../hera/storage/FileAccessController.java | 35 ++++ .../hera/storage/FileAccessService.java | 69 ++++++++ .../hongzhen/hera/storage/FileController.java | 49 ++++++ .../hera/user/AvatarStorageService.java | 31 +--- .../com/hongzhen/hera/user/ProfileDtos.java | 15 +- .../java/com/hongzhen/hera/user/User.java | 68 ++++---- .../com/hongzhen/hera/user/UserMapper.java | 3 - .../com/hongzhen/hera/user/UserService.java | 107 +++++++++--- .../hera/user/UserTableMigration.java | 114 +++++++++++++ src/main/resources/application.yml | 15 +- .../resources/mapper/LoginSessionMapper.xml | 3 +- src/main/resources/mapper/SmsCodeMapper.xml | 2 +- .../resources/mapper/UserAccountMapper.xml | 22 +++ src/main/resources/mapper/UserMapper.xml | 54 ++----- .../resources/mapper/UserProfileMapper.xml | 20 ++- src/main/resources/schema.sql | 26 +-- .../hera/storage/CosIntegrationTest.java | 133 +++++++++++++++ .../hera/storage/CosStorageServiceTest.java | 77 +++++++++ .../hera/storage/FileAccessServiceTest.java | 52 ++++++ .../hera/user/UserTableMigrationTest.java | 87 ++++++++++ .../hera/user/UserUnificationTest.java | 153 ++++++++++++++++++ src/test/resources/logback-test.xml | 12 ++ 39 files changed, 1355 insertions(+), 269 deletions(-) create mode 100644 deploy/nginx-file.conf.example delete mode 100644 src/main/java/com/hongzhen/hera/points/PointsController.java create mode 100644 src/main/java/com/hongzhen/hera/storage/CosProperties.java create mode 100644 src/main/java/com/hongzhen/hera/storage/CosStorageService.java create mode 100644 src/main/java/com/hongzhen/hera/storage/FileAccessController.java create mode 100644 src/main/java/com/hongzhen/hera/storage/FileAccessService.java create mode 100644 src/main/java/com/hongzhen/hera/storage/FileController.java create mode 100644 src/main/java/com/hongzhen/hera/user/UserTableMigration.java create mode 100644 src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java create mode 100644 src/test/java/com/hongzhen/hera/storage/CosStorageServiceTest.java create mode 100644 src/test/java/com/hongzhen/hera/storage/FileAccessServiceTest.java create mode 100644 src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java create mode 100644 src/test/java/com/hongzhen/hera/user/UserUnificationTest.java create mode 100644 src/test/resources/logback-test.xml diff --git a/.gitignore b/.gitignore index 991728c..68b64eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ target/ *.iml +.hera-local.yml diff --git a/README.md b/README.md index ba4cc65..cf5428b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Hera 后端 -Spring Boot 后端,提供微信小程序登录、手机号验证码绑定、用户资料、协议、关注我们和吉分兑换状态接口。 +Spring Boot 后端,提供微信小程序登录、手机号验证码绑定、用户资料、协议和关注我们接口。 命理计算使用 `cn.6tail:lunar` 根据公历生日和时辰生成四柱八字,再计算五行分布、日主、喜用和忌用。 -元宝页提供今日卡牌、每日生活小贴士、卡牌置顶等接口。 +元宝页提供今日卡牌和每日生活小贴士接口。 ## 本地启动 ```bash export WECHAT_APP_ID=wxa76a2ef9e8ba612e -export WECHAT_APP_SECRET=266bb34a7186a4b835186141516efb78 +export WECHAT_APP_SECRET='set-via-environment' export DB_URL='jdbc:mysql://101.43.139.56:3306/hera?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true' export DB_USERNAME=hera_user export DB_PASSWORD='请通过部署环境配置' @@ -19,6 +19,42 @@ mvn spring-boot:run 默认地址:`http://localhost:8080` +本机敏感配置可放在仓库根目录的 `.hera-local.yml`,使用与 `application.yml` 相同的 YAML +层级。后端从项目根目录启动时会自动加载,此文件已加入 Git 忽略清单;服务器使用环境变量配置。 + +## 腾讯云 COS 文件存储 + +存储桶为 `hera-1319913268`,地域为 `ap-beijing`,桶权限保持私有读。 +运行环境必须配置 `TENCENT_COS_SECRET_ID`、`TENCENT_COS_SECRET_KEY`。 +推荐仅授权该桶 `hera/avatar/*`、`hera/files/*` 路径的 `cos:PutObject`、`cos:GetObject` 权限。 +密钥不写入配置文件或 Git。未配置密钥时允许服务启动,文件操作会返回配置错误。 + +- `POST /api/user/avatar`:上传 JPG、PNG、WebP 头像,最大 5MB。 +- `POST /api/files`:通过 multipart 字段 `file` 上传普通文件,最大 5MB。 +- `GET /api/files/url?reference=cos%3A%2F%2Fhera%2Ffiles%2F...`:为本人文件续签。 + +所有上述接口需要 Bearer JWT。对象分别存储在 `hera/avatar/{userId}/`、 +`hera/files/{userId}/`,数据库保存 `cos://` 对象标识;不要保存临时签名 URL。 +资料查询返回的头像 URL 和文件上传返回的 URL 有效期为 900 秒;过期后重新获取。 +历史 `/uploads/avatars/` 路径继续按原有本地静态资源配置访问,旧文件没有自动迁移。 + +访问入口默认使用 `http://101.43.139.56/file/access?token=...`,Nginx 保留路径和查询参数 +转发至后端;参考 `deploy/nginx-file.conf.example`。后端校验文件专用短期凭证后, +302 跳转至 COS 官方 HTTPS 签名 URL。入口不要求登录头,必须携带有效文件凭证。 +文件链接属于临时访问凭据,不应写入日志或分享给无权访问的人。 +部署时通过 `HERA_JWT_SECRET` 配置随机签名密钥,并通过 `TENCENT_COS_PUBLIC_BASE_URL` +配置生产 HTTPS 域名下的 `/file/` 地址。小程序需配置入口和跳转目标的 HTTPS 合法域名。 +当前 HTTP IP 地址供联调使用,不满足正式小程序的 HTTPS 域名要求。 + +### COS 验证 + +`mvn test` 执行本地签名、目录归属和模拟上传测试,不访问真实 COS。 +`mvn test -Dhera.cos.integration=true` 额外启用真实 COS 联调:从运行配置读取密钥, +在 `hera/avatar/9223372036854775807/` 和 `hera/files/9223372036854775807/` +创建本次随机命名的测试对象,校验匿名访问为 403、签名下载内容一致,以及后端文件入口的跳转。 +测试结束后删除本次创建的对象,因此联调账号还需要这两个测试目录的 `cos:DeleteObject` 权限。 +此测试不会操作数据库,也不会验证远端 Nginx 是否已部署。 + ## 开发说明 - 当前已默认关闭微信 mock,并接入微信 `jscode2session`。 @@ -30,6 +66,22 @@ mvn spring-boot:run ## 数据表 CRUD +小程序用户业务已统一到 `user_account` 与 `user_profile`:微信身份、昵称和手机号 +保存在账号表;性别、生日、出生时辰、头像、现居住地区与资料修改额度保存在资料表。 +`User` 仅作为联合查询模型;业务接口不再读写 `hera_user`。 + +首次启动会由 `UserTableMigration` 为既有正式表补齐缺失字段,并将 `password_hash` 调整为 +可空(纯微信用户未设置密码),因此数据库账号需要相应的建表及 ALTER 权限。 +如发现旧表 `hera_user`,会保留用户 ID,将旧用户和资料迁入正式表,同时保留原表。 +迁移使用数据库锁、事务及版本记录;身份或手机号冲突时停止迁移,不覆盖冲突账号。 +旧用户的资料修改周期以原 `updated_at` 为起点;新周期为首次修改起六个月,最多五次。 +重启前应备份数据库;本地 H2 测试不代表已执行远端 MySQL 迁移。 + +`POST /api/auth/wx-login` 对已绑定手机号的用户额外返回 `token` 和 `user`;未绑定时, +继续通过 `sessionKey` 和 mock 短信验证码调用 `/api/auth/bind-phone` 完成注册或绑定。 +绑定成功会核销会话与验证码。禁用账号不能登录或使用个人资料接口。 +个人资料接口的字段名保持不变;资料尚未填写时,生日、时辰及五行报告可能为空,不填充虚构资料。 + 以下接口均需要携带 `Authorization: Bearer `,并支持统一操作: - `GET /api/data/{resource}/{id}`:按主键查询 diff --git a/deploy/nginx-file.conf.example b/deploy/nginx-file.conf.example new file mode 100644 index 0000000..c7b7d4f --- /dev/null +++ b/deploy/nginx-file.conf.example @@ -0,0 +1,11 @@ +# Include this location in the server block for 101.43.139.56. +# Preserve /file/access and the token query when forwarding. +location /file/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # File tokens are temporary credentials; omit query strings from access logs. + access_log off; +} diff --git a/pom.xml b/pom.xml index 9f7b22a..c4b4335 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,11 @@ 1.8 + + com.qcloud + cos_api + 5.6.227 + org.springframework.boot spring-boot-starter-web @@ -51,6 +56,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + diff --git a/src/main/java/com/hongzhen/hera/auth/AuthDtos.java b/src/main/java/com/hongzhen/hera/auth/AuthDtos.java index 38dc3d2..04629de 100644 --- a/src/main/java/com/hongzhen/hera/auth/AuthDtos.java +++ b/src/main/java/com/hongzhen/hera/auth/AuthDtos.java @@ -18,12 +18,23 @@ public class AuthDtos { public static class WxLoginResponse { private final String sessionKey; private final boolean phoneBound; + private final String token; + private final ProfileResponse user; public WxLoginResponse(String sessionKey, boolean phoneBound) { + this(sessionKey, phoneBound, null, null); + } + + public WxLoginResponse(String sessionKey, boolean phoneBound, String token, ProfileResponse user) { this.sessionKey = sessionKey; this.phoneBound = phoneBound; + this.token = token; + this.user = user; } + public String getToken() { return token; } + public ProfileResponse getUser() { return user; } + public String getSessionKey() { return sessionKey; } @@ -34,6 +45,7 @@ public class AuthDtos { } public static class SendSmsRequest { + @NotBlank @Pattern(regexp = "^1\\d{10}$") private String phone; @@ -45,6 +57,7 @@ public class AuthDtos { public static class BindPhoneRequest { @NotBlank private String sessionKey; + @NotBlank @Pattern(regexp = "^1\\d{10}$") private String phone; @NotBlank diff --git a/src/main/java/com/hongzhen/hera/auth/AuthService.java b/src/main/java/com/hongzhen/hera/auth/AuthService.java index 5f8a3ee..4214d8e 100644 --- a/src/main/java/com/hongzhen/hera/auth/AuthService.java +++ b/src/main/java/com/hongzhen/hera/auth/AuthService.java @@ -5,20 +5,25 @@ import com.hongzhen.hera.auth.AuthDtos.LoginResponse; import com.hongzhen.hera.auth.AuthDtos.WxLoginResponse; import com.hongzhen.hera.common.BusinessException; import com.hongzhen.hera.security.JwtService; -import com.hongzhen.hera.user.User; -import com.hongzhen.hera.user.UserMapper; +import com.hongzhen.hera.data.UserAccount; +import com.hongzhen.hera.data.UserAccountMapper; +import com.hongzhen.hera.data.UserProfileMapper; import com.hongzhen.hera.user.UserService; import java.time.LocalDateTime; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; @Service public class AuthService { + private static final int ACTIVE_STATUS = 1; + private final WechatClient wechatClient; private final LoginSessionMapper loginSessionMapper; - private final UserMapper userMapper; + private final UserAccountMapper accountMapper; + private final UserProfileMapper profileMapper; private final SmsService smsService; private final JwtService jwtService; private final UserService userService; @@ -26,14 +31,16 @@ public class AuthService { public AuthService( WechatClient wechatClient, LoginSessionMapper loginSessionMapper, - UserMapper userMapper, + UserAccountMapper accountMapper, + UserProfileMapper profileMapper, SmsService smsService, JwtService jwtService, UserService userService ) { this.wechatClient = wechatClient; this.loginSessionMapper = loginSessionMapper; - this.userMapper = userMapper; + this.accountMapper = accountMapper; + this.profileMapper = profileMapper; this.smsService = smsService; this.jwtService = jwtService; this.userService = userService; @@ -49,30 +56,63 @@ public class AuthService { loginSession.setUnionId(wxSession.getUnionId()); loginSession.setExpiresAt(LocalDateTime.now().plusMinutes(15)); loginSessionMapper.insert(loginSession); - boolean phoneBound = userMapper.findByOpenId(wxSession.getOpenId()) - .map(user -> user.getPhone() != null) - .orElse(false); - return new WxLoginResponse(sessionKey, phoneBound); + UserAccount account = accountMapper.findByOpenId(wxSession.getOpenId()).orElse(null); + if (account != null) { + requireActive(account); + if (StringUtils.hasText(account.getMobile())) { + profileMapper.ensureProfile(account.getId()); + return new WxLoginResponse(sessionKey, true, jwtService.issue(account.getId()), + userService.profile(account.getId())); + } + } + return new WxLoginResponse(sessionKey, false); } @Transactional public LoginResponse bindPhone(BindPhoneRequest request) { - smsService.verify(request.getPhone(), request.getCaptcha()); LoginSession session = loginSessionMapper.findBySessionKey(request.getSessionKey()) .orElseThrow(() -> new BusinessException("登录会话不存在")); if (session.getExpiresAt().isBefore(LocalDateTime.now())) { throw new BusinessException("登录会话已过期"); } - User user = userMapper.findByOpenId(session.getOpenId()).orElseGet(User::new); - user.setOpenId(session.getOpenId()); - user.setUnionId(session.getUnionId()); - user.setPhone(request.getPhone()); - user.touch(); - if (user.getId() == null) { - userMapper.insert(user); - } else { - userMapper.update(user); + smsService.verify(request.getPhone(), request.getCaptcha()); + UserAccount account = accountMapper.findByOpenId(session.getOpenId()).orElse(null); + UserAccount phoneAccount = accountMapper.findByMobile(request.getPhone()).orElse(null); + if (account == null) { + account = phoneAccount; + } else if (phoneAccount != null && !account.getId().equals(phoneAccount.getId())) { + throw new BusinessException("手机号已绑定其他账号"); + } + if (account == null) { + account = new UserAccount(); + account.setUsername("wx_" + UUID.randomUUID().toString().replace("-", "")); + account.setNickname("微信用户"); + account.setStatus(ACTIVE_STATUS); + account.setMobile(request.getPhone()); + account.setWechatOpenid(session.getOpenId()); + account.setWechatUnionid(session.getUnionId()); + accountMapper.insert(account); + } else { + account = accountMapper.findByIdForUpdate(account.getId()) + .orElseThrow(() -> new BusinessException("账号不存在")); + requireActive(account); + if (StringUtils.hasText(account.getWechatOpenid()) + && !session.getOpenId().equals(account.getWechatOpenid())) { + throw new BusinessException("手机号已绑定其他微信账号"); + } + account.setMobile(request.getPhone()); + account.setWechatOpenid(session.getOpenId()); + account.setWechatUnionid(session.getUnionId()); + accountMapper.bindWechat(account); + } + profileMapper.ensureProfile(account.getId()); + loginSessionMapper.deleteById(session.getId()); + return new LoginResponse(jwtService.issue(account.getId()), userService.profile(account.getId())); + } + + private void requireActive(UserAccount account) { + if (!Integer.valueOf(ACTIVE_STATUS).equals(account.getStatus())) { + throw new BusinessException("账号已禁用"); } - return new LoginResponse(jwtService.issue(user.getId()), userService.toProfile(user)); } } diff --git a/src/main/java/com/hongzhen/hera/auth/LoginSessionMapper.java b/src/main/java/com/hongzhen/hera/auth/LoginSessionMapper.java index 47fbb03..f658394 100644 --- a/src/main/java/com/hongzhen/hera/auth/LoginSessionMapper.java +++ b/src/main/java/com/hongzhen/hera/auth/LoginSessionMapper.java @@ -9,4 +9,7 @@ public interface LoginSessionMapper { int insert(LoginSession loginSession); Optional findBySessionKey(String sessionKey); + + /** 核销本次登录会话。 */ + int deleteById(Long id); } diff --git a/src/main/java/com/hongzhen/hera/auth/SmsService.java b/src/main/java/com/hongzhen/hera/auth/SmsService.java index 1b9e6e8..8d76182 100644 --- a/src/main/java/com/hongzhen/hera/auth/SmsService.java +++ b/src/main/java/com/hongzhen/hera/auth/SmsService.java @@ -35,6 +35,8 @@ public class SmsService { throw new BusinessException("验证码已过期"); } smsCode.setUsed(true); - smsCodeMapper.markUsed(smsCode.getId()); + if (smsCodeMapper.markUsed(smsCode.getId()) != 1) { + throw new BusinessException("验证码已使用"); + } } } diff --git a/src/main/java/com/hongzhen/hera/daily/DailyController.java b/src/main/java/com/hongzhen/hera/daily/DailyController.java index 1b8af61..e82de96 100644 --- a/src/main/java/com/hongzhen/hera/daily/DailyController.java +++ b/src/main/java/com/hongzhen/hera/daily/DailyController.java @@ -2,11 +2,8 @@ package com.hongzhen.hera.daily; import com.hongzhen.hera.common.ApiResponse; import com.hongzhen.hera.daily.DailyDtos.DailyHomeResponse; -import com.hongzhen.hera.daily.DailyDtos.PinCardResponse; import com.hongzhen.hera.security.CurrentUser; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,8 +22,4 @@ public class DailyController { return ApiResponse.ok(dailyService.home(CurrentUser.id())); } - @PostMapping("/cards/{cardId}/pin") - public ApiResponse pin(@PathVariable String cardId) { - return ApiResponse.ok(new PinCardResponse(dailyService.pin(CurrentUser.id(), cardId))); - } } diff --git a/src/main/java/com/hongzhen/hera/daily/DailyDtos.java b/src/main/java/com/hongzhen/hera/daily/DailyDtos.java index 2c74b5e..c0101cf 100644 --- a/src/main/java/com/hongzhen/hera/daily/DailyDtos.java +++ b/src/main/java/com/hongzhen/hera/daily/DailyDtos.java @@ -16,11 +16,10 @@ public class DailyDtos { private List luckyPanels; private List cards; private List lifeTips; - private String pinnedCardId; public DailyHomeResponse(LocalDate date, String lunarDate, String weekName, String fortuneLevel, String greeting, String dailySummary, List calendarDays, List luckyPanels, List cards, - List lifeTips, String pinnedCardId) { + List lifeTips) { this.date = date; this.lunarDate = lunarDate; this.weekName = weekName; @@ -31,7 +30,6 @@ public class DailyDtos { this.luckyPanels = luckyPanels; this.cards = cards; this.lifeTips = lifeTips; - this.pinnedCardId = pinnedCardId; } public LocalDate getDate() { @@ -74,9 +72,6 @@ public class DailyDtos { return lifeTips; } - public String getPinnedCardId() { - return pinnedCardId; - } } public static class CalendarDay { @@ -242,15 +237,4 @@ public class DailyDtos { } } - public static class PinCardResponse { - private String pinnedCardId; - - public PinCardResponse(String pinnedCardId) { - this.pinnedCardId = pinnedCardId; - } - - public String getPinnedCardId() { - return pinnedCardId; - } - } } diff --git a/src/main/java/com/hongzhen/hera/daily/DailyService.java b/src/main/java/com/hongzhen/hera/daily/DailyService.java index 2bcc663..fd07713 100644 --- a/src/main/java/com/hongzhen/hera/daily/DailyService.java +++ b/src/main/java/com/hongzhen/hera/daily/DailyService.java @@ -1,28 +1,26 @@ package com.hongzhen.hera.daily; -import com.hongzhen.hera.common.BusinessException; import com.hongzhen.hera.daily.DailyDtos.CalendarDay; import com.hongzhen.hera.daily.DailyDtos.DailyHomeResponse; import com.hongzhen.hera.daily.DailyDtos.FortuneCard; import com.hongzhen.hera.daily.DailyDtos.LifeTip; import com.hongzhen.hera.daily.DailyDtos.LuckyPanel; import com.hongzhen.hera.user.User; -import com.hongzhen.hera.user.UserMapper; +import com.hongzhen.hera.user.UserService; import com.nlf.calendar.Solar; import java.time.DayOfWeek; import java.time.LocalDate; import java.util.Arrays; import java.util.List; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service public class DailyService { - private final UserMapper userMapper; + private final UserService userService; - public DailyService(UserMapper userMapper) { - this.userMapper = userMapper; + public DailyService(UserService userService) { + this.userService = userService; } public DailyHomeResponse home(Long userId) { @@ -38,33 +36,12 @@ public class DailyService { calendarDays(today), luckyPanels(), cards(), - lifeTips(), - user.getPinnedDailyCardId() + lifeTips() ); } - @Transactional - public String pin(Long userId, String cardId) { - boolean exists = false; - for (FortuneCard card : cards()) { - if (card.getId().equals(cardId)) { - exists = true; - break; - } - } - if (!exists) { - throw new BusinessException("卡牌不存在"); - } - User user = user(userId); - String nextPinnedCardId = cardId.equals(user.getPinnedDailyCardId()) ? null : cardId; - user.setPinnedDailyCardId(nextPinnedCardId); - user.touch(); - userMapper.update(user); - return nextPinnedCardId; - } - private User user(Long userId) { - return userMapper.findById(userId).orElseThrow(() -> new BusinessException("用户不存在")); + return userService.get(userId); } private String lunarDate(LocalDate date) { diff --git a/src/main/java/com/hongzhen/hera/data/UserAccountMapper.java b/src/main/java/com/hongzhen/hera/data/UserAccountMapper.java index 4e77ca0..44109b7 100644 --- a/src/main/java/com/hongzhen/hera/data/UserAccountMapper.java +++ b/src/main/java/com/hongzhen/hera/data/UserAccountMapper.java @@ -9,6 +9,21 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface UserAccountMapper extends CrudMapper { + /** 按微信身份查询正式账号。 */ + Optional findByOpenId(String openId); + + /** 按手机号查询正式账号。 */ + Optional findByMobile(String mobile); + + /** 锁定账号行,串行化当前用户的写操作。 */ + Optional findByIdForUpdate(Long id); + + /** 绑定微信和手机号,保留其他账号字段。 */ + int bindWechat(UserAccount entity); + + /** 修改显示昵称。 */ + int updateNickname(@Param("id") Long id, @Param("nickname") String nickname); + @Override Optional findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/data/UserProfile.java b/src/main/java/com/hongzhen/hera/data/UserProfile.java index 7c79d94..02c4ab9 100644 --- a/src/main/java/com/hongzhen/hera/data/UserProfile.java +++ b/src/main/java/com/hongzhen/hera/data/UserProfile.java @@ -105,6 +105,32 @@ public class UserProfile extends BaseEntity { */ private String signature; + /** + * 当前半年周期内的资料修改次数。 + */ + private Integer profileEditCount; + + /** + * 资料修改额度周期起始时间。 + */ + private LocalDateTime profileEditWindowStart; + + public Integer getProfileEditCount() { + return profileEditCount; + } + + public void setProfileEditCount(Integer profileEditCount) { + this.profileEditCount = profileEditCount; + } + + public LocalDateTime getProfileEditWindowStart() { + return profileEditWindowStart; + } + + public void setProfileEditWindowStart(LocalDateTime profileEditWindowStart) { + this.profileEditWindowStart = profileEditWindowStart; + } + public Long getUserId() { return userId; } diff --git a/src/main/java/com/hongzhen/hera/data/UserProfileMapper.java b/src/main/java/com/hongzhen/hera/data/UserProfileMapper.java index d3c10d3..d3890e2 100644 --- a/src/main/java/com/hongzhen/hera/data/UserProfileMapper.java +++ b/src/main/java/com/hongzhen/hera/data/UserProfileMapper.java @@ -9,6 +9,15 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface UserProfileMapper extends CrudMapper { + /** 补齐账号的一对一资料行,不覆盖已有资料。 */ + int ensureProfile(Long userId); + + /** 更新用户可编辑资料,不修改实名认证等后台字段。 */ + int updatePersonal(UserProfile entity); + + /** 按账号 ID 更新头像对象标识。 */ + int updateAvatar(@Param("userId") Long userId, @Param("avatarUrl") String avatarUrl); + @Override Optional findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/points/PointsController.java b/src/main/java/com/hongzhen/hera/points/PointsController.java deleted file mode 100644 index 02f36cb..0000000 --- a/src/main/java/com/hongzhen/hera/points/PointsController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.hongzhen.hera.points; - -import com.hongzhen.hera.common.ApiResponse; -import com.hongzhen.hera.security.CurrentUser; -import com.hongzhen.hera.user.UserService; -import java.util.HashMap; -import java.util.Map; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/points") -public class PointsController { - - private final UserService userService; - - public PointsController(UserService userService) { - this.userService = userService; - } - - @GetMapping("/exchange-status") - public ApiResponse> exchangeStatus() { - int points = userService.get(CurrentUser.id()).getPoints(); - Map data = new HashMap(); - data.put("points", points); - data.put("available", false); - data.put("message", "你当前拥有 " + points + " 吉分,兑换功能即将开放,敬请期待!"); - return ApiResponse.ok(data); - } -} diff --git a/src/main/java/com/hongzhen/hera/storage/CosProperties.java b/src/main/java/com/hongzhen/hera/storage/CosProperties.java new file mode 100644 index 0000000..d622fb9 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/storage/CosProperties.java @@ -0,0 +1,29 @@ +package com.hongzhen.hera.storage; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 腾讯云 COS 连接和私有文件访问配置。 + */ +@ConfigurationProperties(prefix = "hera.cos") +public class CosProperties { + private String secretId; + private String secretKey; + private String bucket; + private String region; + private int signedUrlSeconds = 900; + private String publicBaseUrl; + + public String getSecretId() { return secretId; } + public void setSecretId(String secretId) { this.secretId = secretId; } + public String getSecretKey() { return secretKey; } + public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + public String getBucket() { return bucket; } + public void setBucket(String bucket) { this.bucket = bucket; } + public String getRegion() { return region; } + public void setRegion(String region) { this.region = region; } + public int getSignedUrlSeconds() { return signedUrlSeconds; } + public void setSignedUrlSeconds(int signedUrlSeconds) { this.signedUrlSeconds = signedUrlSeconds; } + public String getPublicBaseUrl() { return publicBaseUrl; } + public void setPublicBaseUrl(String publicBaseUrl) { this.publicBaseUrl = publicBaseUrl; } +} diff --git a/src/main/java/com/hongzhen/hera/storage/CosStorageService.java b/src/main/java/com/hongzhen/hera/storage/CosStorageService.java new file mode 100644 index 0000000..c148e2f --- /dev/null +++ b/src/main/java/com/hongzhen/hera/storage/CosStorageService.java @@ -0,0 +1,127 @@ +package com.hongzhen.hera.storage; + +import com.hongzhen.hera.common.BusinessException; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.http.HttpMethodName; +import com.qcloud.cos.http.HttpProtocol; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import com.qcloud.cos.region.Region; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; +import javax.annotation.PreDestroy; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +/** + * 私有 COS 文件上传及临时签名访问服务。 + */ +@Service +@EnableConfigurationProperties(CosProperties.class) +public class CosStorageService { + private static final long MAX_FILE_SIZE = 5L * 1024L * 1024L; + private static final String OBJECT_REFERENCE_PREFIX = "cos://"; + private final CosProperties properties; + private COSClient client; + + public CosStorageService(CosProperties properties) { + this.properties = properties; + } + + /** + * 上传至指定用户目录,返回可持久化的对象标识。 + */ + public String upload(Long userId, MultipartFile file, boolean avatar, String extension) { + if (userId == null || userId <= 0) { + throw new BusinessException("请先登录"); + } + if (file == null || file.isEmpty() || file.getSize() > MAX_FILE_SIZE) { + throw new BusinessException("请选择不超过5MB的文件"); + } + String key = (avatar ? "hera/avatar/" : "hera/files/") + userId + "/" + + UUID.randomUUID().toString().replace("-", "") + extension; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(avatar ? file.getContentType() : "application/octet-stream"); + try (InputStream input = file.getInputStream()) { + client().putObject(new PutObjectRequest(properties.getBucket(), key, input, metadata)); + } catch (IOException | com.qcloud.cos.exception.CosClientException exception) { + throw new BusinessException("文件上传失败,请稍后重试"); + } + return OBJECT_REFERENCE_PREFIX + key; + } + + /** + * 将持久化对象标识转换为临时签名链接,兼容原有本地头像路径。 + */ + public String resolveUrl(String reference) { + if (reference == null || !reference.startsWith(OBJECT_REFERENCE_PREFIX)) { + return reference; + } + String key = reference.substring(OBJECT_REFERENCE_PREFIX.length()); + int seconds = properties.getSignedUrlSeconds(); + if (seconds < 60 || seconds > 3600) { + throw new BusinessException("文件签名有效期应在60至3600秒之间"); + } + try { + return client().generatePresignedUrl(properties.getBucket(), key, + Date.from(Instant.now().plusSeconds(seconds)), HttpMethodName.GET).toString(); + } catch (com.qcloud.cos.exception.CosClientException exception) { + throw new BusinessException("文件访问链接生成失败"); + } + } + + /** + * 仅为当前用户自己的文件续签。 + */ + public String resolveOwnedUrl(Long userId, String reference) { + validateOwnership(userId, reference); + return resolveUrl(reference); + } + + /** + * 校验对象标识属于当前用户目录。 + */ + public void validateOwnership(Long userId, String reference) { + if (reference == null || !(reference.startsWith("cos://hera/avatar/" + userId + "/") + || reference.startsWith("cos://hera/files/" + userId + "/")) + || reference.contains("..") || reference.contains("\\")) { + throw new BusinessException("文件标识不合法"); + } + } + + private synchronized COSClient client() { + if (client == null) { + if (!StringUtils.hasText(properties.getSecretId()) + || !StringUtils.hasText(properties.getSecretKey()) + || !StringUtils.hasText(properties.getBucket()) + || !StringUtils.hasText(properties.getRegion())) { + throw new BusinessException("请先配置腾讯云COS密钥、存储桶和地域"); + } + ClientConfig config = new ClientConfig(new Region(properties.getRegion())); + config.setHttpProtocol(HttpProtocol.https); + config.setConnectionTimeout(10000); + config.setSocketTimeout(30000); + client = new COSClient(new BasicCOSCredentials(properties.getSecretId(), properties.getSecretKey()), config); + } + return client; + } + + /** + * 关闭 SDK 网络连接池。 + */ + @PreDestroy + public synchronized void close() { + if (client != null) { + client.shutdown(); + client = null; + } + } +} diff --git a/src/main/java/com/hongzhen/hera/storage/FileAccessController.java b/src/main/java/com/hongzhen/hera/storage/FileAccessController.java new file mode 100644 index 0000000..b8450ca --- /dev/null +++ b/src/main/java/com/hongzhen/hera/storage/FileAccessController.java @@ -0,0 +1,35 @@ +package com.hongzhen.hera.storage; + +import java.net.URI; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 文件访问入口,使用文件专用短期凭证代替登录请求头。 + */ +@RestController +@RequestMapping("/file") +public class FileAccessController { + private final FileAccessService accessService; + + public FileAccessController(FileAccessService accessService) { + this.accessService = accessService; + } + + /** + * 校验访问凭证并跳转至私有 COS 签名 URL。 + */ + @GetMapping("/access") + public ResponseEntity access(@RequestParam("token") String token) { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(accessService.redirectUrl(token))) + .cacheControl(CacheControl.noStore()) + .header("Referrer-Policy", "no-referrer") + .build(); + } +} diff --git a/src/main/java/com/hongzhen/hera/storage/FileAccessService.java b/src/main/java/com/hongzhen/hera/storage/FileAccessService.java new file mode 100644 index 0000000..3e586e8 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/storage/FileAccessService.java @@ -0,0 +1,69 @@ +package com.hongzhen.hera.storage; + +import com.hongzhen.hera.common.BusinessException; +import com.hongzhen.hera.config.HeraProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * 生成和校验文件专用的短期访问凭证。 + */ +@Service +public class FileAccessService { + private static final String FILE_AUDIENCE = "hera-file"; + private final CosStorageService storageService; + private final CosProperties properties; + private final byte[] key; + + public FileAccessService(CosStorageService storageService, CosProperties properties, + HeraProperties heraProperties) { + this.storageService = storageService; + this.properties = properties; + this.key = heraProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8); + } + + /** + * 为 COS 对象生成经 Nginx 转发的短期访问入口。 + */ + public String resolveUrl(String reference) { + if (reference == null || !reference.startsWith("cos://")) { + return reference; + } + if (!StringUtils.hasText(properties.getPublicBaseUrl())) { + return storageService.resolveUrl(reference); + } + int seconds = properties.getSignedUrlSeconds(); + if (seconds < 60 || seconds > 3600) { + throw new BusinessException("文件签名有效期应在60至3600秒之间"); + } + String token = Jwts.builder().setAudience(FILE_AUDIENCE).setSubject(reference) + .setExpiration(Date.from(Instant.now().plusSeconds(seconds))) + .signWith(SignatureAlgorithm.HS256, key).compact(); + return properties.getPublicBaseUrl().replaceAll("/+$", "") + "/access?token=" + token; + } + + /** + * 校验专用于文件访问的凭证,返回 COS 临时签名地址。 + */ + public String redirectUrl(String token) { + try { + Claims claims = Jwts.parser().setSigningKey(key).requireAudience(FILE_AUDIENCE) + .parseClaimsJws(token).getBody(); + if (claims.getExpiration() == null || claims.getSubject() == null + || !(claims.getSubject().startsWith("cos://hera/avatar/") + || claims.getSubject().startsWith("cos://hera/files/"))) { + throw new BusinessException("文件访问凭证不合法"); + } + return storageService.resolveUrl(claims.getSubject()); + } catch (JwtException | IllegalArgumentException exception) { + throw new BusinessException("文件访问链接无效或已过期"); + } + } +} diff --git a/src/main/java/com/hongzhen/hera/storage/FileController.java b/src/main/java/com/hongzhen/hera/storage/FileController.java new file mode 100644 index 0000000..bf996c3 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/storage/FileController.java @@ -0,0 +1,49 @@ +package com.hongzhen.hera.storage; + +import com.hongzhen.hera.common.ApiResponse; +import com.hongzhen.hera.security.CurrentUser; +import java.util.HashMap; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * 当前登录用户的私有文件上传和访问接口。 + */ +@RestController +@RequestMapping("/api/files") +public class FileController { + private final CosStorageService storageService; + private final FileAccessService accessService; + + public FileController(CosStorageService storageService, FileAccessService accessService) { + this.storageService = storageService; + this.accessService = accessService; + } + + /** + * 上传普通文件,返回持久化标识及临时访问链接。 + */ + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse> upload(@RequestParam("file") MultipartFile file) { + String reference = storageService.upload(CurrentUser.id(), file, false, ""); + Map result = new HashMap(); + result.put("reference", reference); + result.put("url", accessService.resolveUrl(reference)); + return ApiResponse.ok(result); + } + + /** + * 为当前用户拥有的文件重新生成临时访问链接。 + */ + @GetMapping("/url") + public ApiResponse url(@RequestParam("reference") String reference) { + storageService.validateOwnership(CurrentUser.id(), reference); + return ApiResponse.ok(accessService.resolveUrl(reference)); + } +} diff --git a/src/main/java/com/hongzhen/hera/user/AvatarStorageService.java b/src/main/java/com/hongzhen/hera/user/AvatarStorageService.java index 23cb7e4..693cd9f 100644 --- a/src/main/java/com/hongzhen/hera/user/AvatarStorageService.java +++ b/src/main/java/com/hongzhen/hera/user/AvatarStorageService.java @@ -1,15 +1,8 @@ package com.hongzhen.hera.user; import com.hongzhen.hera.common.BusinessException; -import com.hongzhen.hera.config.HeraProperties; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; +import com.hongzhen.hera.storage.CosStorageService; import java.util.Locale; -import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -17,10 +10,10 @@ import org.springframework.web.multipart.MultipartFile; public class AvatarStorageService { private static final long MAX_AVATAR_SIZE = 5L * 1024L * 1024L; - private final Path avatarDirectory; + private final CosStorageService cosStorageService; - public AvatarStorageService(HeraProperties properties) { - this.avatarDirectory = Paths.get(properties.getUploadDir(), "avatars").toAbsolutePath().normalize(); + public AvatarStorageService(CosStorageService cosStorageService) { + this.cosStorageService = cosStorageService; } public String store(Long userId, MultipartFile file) { @@ -43,20 +36,6 @@ public class AvatarStorageService { throw new BusinessException("仅支持 JPG、PNG 或 WebP 头像"); } - String filename = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension; - Path target = avatarDirectory.resolve(filename).normalize(); - if (!target.startsWith(avatarDirectory)) { - throw new BusinessException("头像文件名不合法"); - } - - try { - Files.createDirectories(avatarDirectory); - try (InputStream input = file.getInputStream()) { - Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException exception) { - throw new BusinessException("头像保存失败,请稍后重试"); - } - return "/uploads/avatars/" + filename; + return cosStorageService.upload(userId, file, true, extension); } } diff --git a/src/main/java/com/hongzhen/hera/user/ProfileDtos.java b/src/main/java/com/hongzhen/hera/user/ProfileDtos.java index 6d8a070..7bee4e1 100644 --- a/src/main/java/com/hongzhen/hera/user/ProfileDtos.java +++ b/src/main/java/com/hongzhen/hera/user/ProfileDtos.java @@ -4,6 +4,8 @@ import java.time.LocalDate; import java.util.List; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; +import javax.validation.constraints.PastOrPresent; +import javax.validation.constraints.Size; public class ProfileDtos { @@ -18,12 +20,11 @@ public class ProfileDtos { private String province; private String city; private String district; - private int points; private int remainingEdits; private ElementReport elementReport; public ProfileResponse(Long id, String nickname, String phone, String avatarUrl, String gender, LocalDate birthday, String birthHour, - String province, String city, String district, int points, int remainingEdits, ElementReport elementReport) { + String province, String city, String district, int remainingEdits, ElementReport elementReport) { this.id = id; this.nickname = nickname; this.phone = phone; @@ -34,7 +35,6 @@ public class ProfileDtos { this.province = province; this.city = city; this.district = district; - this.points = points; this.remainingEdits = remainingEdits; this.elementReport = elementReport; } @@ -79,10 +79,6 @@ public class ProfileDtos { return district; } - public int getPoints() { - return points; - } - public int getRemainingEdits() { return remainingEdits; } @@ -179,18 +175,23 @@ public class ProfileDtos { public static class UpdateProfileRequest { @NotBlank + @Size(max = 50) private String nickname; @NotBlank private String gender; @NotNull + @PastOrPresent private LocalDate birthday; @NotBlank private String birthHour; @NotBlank + @Size(max = 50) private String province; @NotBlank + @Size(max = 50) private String city; @NotBlank + @Size(max = 50) private String district; public String getNickname() { diff --git a/src/main/java/com/hongzhen/hera/user/User.java b/src/main/java/com/hongzhen/hera/user/User.java index 04e5134..cd1a631 100644 --- a/src/main/java/com/hongzhen/hera/user/User.java +++ b/src/main/java/com/hongzhen/hera/user/User.java @@ -4,7 +4,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; /** - * 小程序业务用户实体。 + * 账号表与资料表的联合查询模型,不再独立持久化。 */ public class User { @@ -36,47 +36,37 @@ public class User { /** * 用户昵称。 */ - private String nickname = "道道3338"; + private String nickname; /** * 用户性别。 */ - private String gender = "男"; + private String gender; /** * 出生日期。 */ - private LocalDate birthday = LocalDate.of(2008, 12, 28); + private LocalDate birthday; /** * 出生时辰。 */ - private String birthHour = "寅时"; + private String birthHour; /** * 所在省份。 */ - private String province = "河南省"; + private String province; /** * 所在城市。 */ - private String city = "洛阳市"; + private String city; /** * 所在区县。 */ - private String district = "老城区"; - - /** - * 当前吉分。 - */ - private int points = 0; - - /** - * 已置顶的每日卡片 ID。 - */ - private String pinnedDailyCardId; + private String district; /** * 当前周期内资料修改次数。 @@ -93,6 +83,32 @@ public class User { */ private LocalDateTime updatedAt = LocalDateTime.now(); + /** + * 账号状态,1 为正常。 + */ + private Integer status; + + /** + * 资料修改额度周期起始时间。 + */ + private LocalDateTime profileEditWindowStart; + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public LocalDateTime getProfileEditWindowStart() { + return profileEditWindowStart; + } + + public void setProfileEditWindowStart(LocalDateTime profileEditWindowStart) { + this.profileEditWindowStart = profileEditWindowStart; + } + public Long getId() { return id; } @@ -189,22 +205,6 @@ public class User { this.district = district; } - public int getPoints() { - return points; - } - - public void setPoints(int points) { - this.points = points; - } - - public String getPinnedDailyCardId() { - return pinnedDailyCardId; - } - - public void setPinnedDailyCardId(String pinnedDailyCardId) { - this.pinnedDailyCardId = pinnedDailyCardId; - } - public int getProfileEditCount() { return profileEditCount; } diff --git a/src/main/java/com/hongzhen/hera/user/UserMapper.java b/src/main/java/com/hongzhen/hera/user/UserMapper.java index 16317ba..9e2274a 100644 --- a/src/main/java/com/hongzhen/hera/user/UserMapper.java +++ b/src/main/java/com/hongzhen/hera/user/UserMapper.java @@ -10,7 +10,4 @@ public interface UserMapper { Optional findByOpenId(String openId); - int insert(User user); - - int update(User user); } diff --git a/src/main/java/com/hongzhen/hera/user/UserService.java b/src/main/java/com/hongzhen/hera/user/UserService.java index 62674a0..4c5e52f 100644 --- a/src/main/java/com/hongzhen/hera/user/UserService.java +++ b/src/main/java/com/hongzhen/hera/user/UserService.java @@ -1,26 +1,52 @@ package com.hongzhen.hera.user; import com.hongzhen.hera.common.BusinessException; +import com.hongzhen.hera.data.UserAccount; +import com.hongzhen.hera.data.UserAccountMapper; +import com.hongzhen.hera.data.UserProfile; +import com.hongzhen.hera.data.UserProfileMapper; import com.hongzhen.hera.fortune.FortuneService; +import com.hongzhen.hera.storage.FileAccessService; import com.hongzhen.hera.user.ProfileDtos.ElementReport; import com.hongzhen.hera.user.ProfileDtos.ProfileResponse; import com.hongzhen.hera.user.ProfileDtos.UpdateProfileRequest; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service +@DependsOn("userTableMigration") public class UserService { + private static final int MAX_PROFILE_EDITS = 5; + private static final int EDIT_WINDOW_MONTHS = 6; + private static final int ACTIVE_STATUS = 1; + private static final List SHICHEN = Arrays.asList( + "子时", "丑时", "寅时", "卯时", "辰时", "巳时", "午时", "未时", "申时", "酉时", "戌时", "亥时"); private final UserMapper userMapper; + private final UserAccountMapper accountMapper; + private final UserProfileMapper profileMapper; private final FortuneService fortuneService; + private final FileAccessService fileAccessService; - public UserService(UserMapper userMapper, FortuneService fortuneService) { + public UserService(UserMapper userMapper, UserAccountMapper accountMapper, UserProfileMapper profileMapper, + FortuneService fortuneService, FileAccessService fileAccessService) { this.userMapper = userMapper; + this.accountMapper = accountMapper; + this.profileMapper = profileMapper; this.fortuneService = fortuneService; + this.fileAccessService = fileAccessService; } public User get(Long userId) { - return userMapper.findById(userId).orElseThrow(() -> new BusinessException("用户不存在")); + User user = userMapper.findById(userId).orElseThrow(() -> new BusinessException("用户不存在")); + if (!Integer.valueOf(ACTIVE_STATUS).equals(user.getStatus())) { + throw new BusinessException("账号已禁用"); + } + return user; } public ProfileResponse profile(Long userId) { @@ -29,47 +55,82 @@ public class UserService { @Transactional public ProfileResponse updateProfile(Long userId, UpdateProfileRequest request) { + lockAccount(userId); + profileMapper.ensureProfile(userId); User user = get(userId); - if (user.getProfileEditCount() >= 5) { + LocalDateTime now = LocalDateTime.now(); + int used = currentEditCount(user, now); + if (used >= MAX_PROFILE_EDITS) { throw new BusinessException("半年内只能修改五次哦"); } - user.setNickname(request.getNickname()); - user.setGender(request.getGender()); - user.setBirthday(request.getBirthday()); - user.setBirthHour(request.getBirthHour()); - user.setProvince(request.getProvince()); - user.setCity(request.getCity()); - user.setDistrict(request.getDistrict()); - user.setProfileEditCount(user.getProfileEditCount() + 1); - user.touch(); - userMapper.update(user); - return toProfile(user); + int shichen = SHICHEN.indexOf(request.getBirthHour()); + if (shichen < 0) { + throw new BusinessException("出生时辰不正确"); + } + Integer gender; + if ("男".equals(request.getGender())) { + gender = 1; + } else if ("女".equals(request.getGender())) { + gender = 2; + } else if ("未知".equals(request.getGender())) { + gender = 0; + } else { + throw new BusinessException("性别不正确"); + } + UserProfile profile = new UserProfile(); + profile.setUserId(userId); + profile.setGender(gender); + profile.setBirthday(request.getBirthday()); + profile.setBirthShichen(shichen); + profile.setResidenceProvince(request.getProvince()); + profile.setResidenceCity(request.getCity()); + profile.setResidenceDistrict(request.getDistrict()); + profile.setProfileEditCount(used + 1); + LocalDateTime start = user.getProfileEditWindowStart(); + profile.setProfileEditWindowStart(start == null || !now.isBefore(start.plusMonths(EDIT_WINDOW_MONTHS)) + ? now : start); + accountMapper.updateNickname(userId, request.getNickname()); + profileMapper.updatePersonal(profile); + return profile(userId); } @Transactional public ProfileResponse updateAvatar(Long userId, String avatarUrl) { - User user = get(userId); - user.setAvatarUrl(avatarUrl); - user.touch(); - userMapper.update(user); - return toProfile(user); + lockAccount(userId); + profileMapper.ensureProfile(userId); + profileMapper.updateAvatar(userId, avatarUrl); + return profile(userId); + } + + private void lockAccount(Long userId) { + UserAccount account = accountMapper.findByIdForUpdate(userId) + .orElseThrow(() -> new BusinessException("用户不存在")); + if (!Integer.valueOf(ACTIVE_STATUS).equals(account.getStatus())) { + throw new BusinessException("账号已禁用"); + } + } + + private int currentEditCount(User user, LocalDateTime now) { + LocalDateTime start = user.getProfileEditWindowStart(); + return start != null && !now.isBefore(start.plusMonths(EDIT_WINDOW_MONTHS)) + ? 0 : user.getProfileEditCount(); } public ProfileResponse toProfile(User user) { - ElementReport report = fortuneService.calculate(user.getBirthday(), user.getBirthHour()); + ElementReport report = user.getBirthday() == null || user.getBirthHour() == null + ? null : fortuneService.calculate(user.getBirthday(), user.getBirthHour()); return new ProfileResponse( user.getId(), user.getNickname(), user.getPhone(), - user.getAvatarUrl(), + fileAccessService.resolveUrl(user.getAvatarUrl()), user.getGender(), user.getBirthday(), user.getBirthHour(), user.getProvince(), user.getCity(), user.getDistrict(), - user.getPoints(), - Math.max(0, 5 - user.getProfileEditCount()), + Math.max(0, MAX_PROFILE_EDITS - currentEditCount(user, LocalDateTime.now())), report ); } diff --git a/src/main/java/com/hongzhen/hera/user/UserTableMigration.java b/src/main/java/com/hongzhen/hera/user/UserTableMigration.java new file mode 100644 index 0000000..e3bf499 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/user/UserTableMigration.java @@ -0,0 +1,114 @@ +package com.hongzhen.hera.user; + +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.DependsOn; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.SingleConnectionDataSource; +import org.springframework.stereotype.Component; + +/** + * 兼容既有 MySQL 用户表;保留旧表并将旧账号一次性迁入正式用户表。 + */ +@Component +@DependsOn("dataSourceScriptDatabaseInitializer") +public class UserTableMigration implements InitializingBean { + private static final String MIGRATION = "unify_user_tables_v1"; + private final DataSource dataSource; + + public UserTableMigration(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public void afterPropertiesSet() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + JdbcTemplate jdbc = new JdbcTemplate(new SingleConnectionDataSource(connection, true)); + String lock = "hera_users_" + connection.getCatalog(); + Integer acquired = jdbc.queryForObject("SELECT GET_LOCK(?, 30)", Integer.class, lock); + if (!Integer.valueOf(1).equals(acquired)) { + throw new IllegalStateException("无法获取用户表迁移锁,请稍后重启"); + } + try { + migrate(jdbc, connection); + } finally { + jdbc.queryForObject("SELECT RELEASE_LOCK(?)", Integer.class, lock); + } + } + } + + private void migrate(JdbcTemplate jdbc, Connection connection) throws SQLException { + // MySQL DDL 会隐式提交,先完成可重复执行的加字段,再事务化迁移用户数据。 + ensureColumn(jdbc, "user_profile", "profile_edit_count", "INT NOT NULL DEFAULT 0 COMMENT '资料修改次数'"); + ensureColumn(jdbc, "user_profile", "profile_edit_window_start", "DATETIME NULL COMMENT '修改额度周期开始时间'"); + String nullable = jdbc.queryForObject("SELECT IS_NULLABLE FROM information_schema.COLUMNS " + + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_account' " + + "AND COLUMN_NAME = 'password_hash'", String.class); + if (!"YES".equals(nullable)) { + jdbc.execute("ALTER TABLE user_account MODIFY COLUMN password_hash VARCHAR(100) NULL " + + "COMMENT '密码哈希;纯微信账号可为空'"); + } + jdbc.execute("CREATE TABLE IF NOT EXISTS hera_schema_migration (" + + "version VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '迁移版本', " + + "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间') ENGINE=InnoDB"); + if (jdbc.queryForObject("SELECT COUNT(*) FROM hera_schema_migration WHERE version = ?", + Integer.class, MIGRATION) > 0) { + return; + } + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + if (tableExists(jdbc, "hera_user")) { + migrateLegacyUsers(jdbc); + } + jdbc.update("INSERT INTO hera_schema_migration (version) VALUES (?)", MIGRATION); + connection.commit(); + } catch (SQLException | RuntimeException exception) { + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(autoCommit); + } + } + + private void ensureColumn(JdbcTemplate jdbc, String table, String column, String definition) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.COLUMNS " + + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + Integer.class, table, column); + if (Integer.valueOf(0).equals(count)) { + // 标识符和定义仅来自本类常量,不接受外部输入。 + jdbc.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); + } + } + + private boolean tableExists(JdbcTemplate jdbc, String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.TABLES " + + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", Integer.class, table) > 0; + } + + void migrateLegacyUsers(JdbcTemplate jdbc) { + Integer conflicts = jdbc.queryForObject("SELECT COUNT(*) FROM hera_user h JOIN user_account a " + + "ON a.id = h.id OR a.wechat_openid = h.open_id OR (h.phone IS NOT NULL AND a.mobile = h.phone) " + + "WHERE a.id != h.id OR a.wechat_openid IS NULL OR a.wechat_openid != h.open_id", Integer.class); + Integer duplicatePhones = jdbc.queryForObject("SELECT COUNT(*) FROM (SELECT phone FROM hera_user " + + "WHERE phone IS NOT NULL GROUP BY phone HAVING COUNT(*) > 1) duplicates", Integer.class); + if (conflicts > 0 || duplicatePhones > 0) { + throw new IllegalStateException("旧用户与正式账号存在ID、OpenID或手机号冲突,迁移已停止,请先核对数据"); + } + jdbc.update("INSERT INTO user_account (id, username, nickname, password_hash, mobile, " + + "wechat_openid, wechat_unionid, status, created_at, updated_at) " + + "SELECT h.id, CONCAT('legacy_wx_', h.id), h.nickname, NULL, h.phone, h.open_id, h.union_id, " + + "1, h.created_at, h.updated_at FROM hera_user h " + + "WHERE NOT EXISTS (SELECT 1 FROM user_account a WHERE a.id = h.id)"); + jdbc.update("INSERT INTO user_profile (user_id, gender, birthday, birth_shichen, avatar_url, " + + "residence_province, residence_city, residence_district, " + + "profile_edit_count, profile_edit_window_start, created_at, updated_at) " + + "SELECT h.id, CASE h.gender WHEN '男' THEN 1 WHEN '女' THEN 2 ELSE 0 END, h.birthday, " + + "NULLIF(FIELD(h.birth_hour, '子时','丑时','寅时','卯时','辰时','巳时','午时','未时','申时','酉时','戌时','亥时'), 0) - 1, " + + "h.avatar_url, h.province, h.city, h.district, h.profile_edit_count, " + + "h.updated_at, h.created_at, h.updated_at FROM hera_user h " + + "WHERE NOT EXISTS (SELECT 1 FROM user_profile p WHERE p.user_id = h.id)"); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 04dbcde..f5809eb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,6 @@ +--- server: port: 8080 - spring: application: name: hera @@ -17,14 +17,21 @@ spring: multipart: max-file-size: 5MB max-request-size: 5MB - + config: + import: optional:file:./.hera-local.yml mybatis: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true - hera: - jwt-secret: "replace-this-development-secret-replace-this-development-secret" + cos: + secret-id: "${TENCENT_COS_SECRET_ID:}" + secret-key: "${TENCENT_COS_SECRET_KEY:}" + bucket: "${TENCENT_COS_BUCKET:hera-1319913268}" + region: "${TENCENT_COS_REGION:ap-beijing}" + signed-url-seconds: 900 + public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/file/}" + jwt-secret: "${HERA_JWT_SECRET:replace-this-development-secret-replace-this-development-secret}" upload-dir: "${HERA_UPLOAD_DIR:./uploads}" wx: app-id: "${WECHAT_APP_ID:wxa76a2ef9e8ba612e}" diff --git a/src/main/resources/mapper/LoginSessionMapper.xml b/src/main/resources/mapper/LoginSessionMapper.xml index 08eb50f..47b2fad 100644 --- a/src/main/resources/mapper/LoginSessionMapper.xml +++ b/src/main/resources/mapper/LoginSessionMapper.xml @@ -11,6 +11,7 @@ + DELETE FROM login_session WHERE id = #{id} diff --git a/src/main/resources/mapper/SmsCodeMapper.xml b/src/main/resources/mapper/SmsCodeMapper.xml index 194317b..317cde1 100644 --- a/src/main/resources/mapper/SmsCodeMapper.xml +++ b/src/main/resources/mapper/SmsCodeMapper.xml @@ -21,6 +21,6 @@ UPDATE sms_code SET used = TRUE - WHERE id = #{id} + WHERE id = #{id} AND used = FALSE diff --git a/src/main/resources/mapper/UserAccountMapper.xml b/src/main/resources/mapper/UserAccountMapper.xml index b393b54..b705b7f 100644 --- a/src/main/resources/mapper/UserAccountMapper.xml +++ b/src/main/resources/mapper/UserAccountMapper.xml @@ -9,6 +9,28 @@ created_at, updated_at + + + + + + + + UPDATE user_account SET wechat_openid = #{wechatOpenid}, + wechat_unionid = COALESCE(#{wechatUnionid}, wechat_unionid), mobile = #{mobile} + WHERE id = #{id} + + + + UPDATE user_account SET nickname = #{nickname} WHERE id = #{id} + + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index fca47eb..817c03a 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -3,50 +3,28 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - - id, open_id, union_id, phone, avatar_url, nickname, gender, birthday, birth_hour, - province, city, district, points, pinned_daily_card_id, profile_edit_count, created_at, updated_at + + a.id, a.wechat_openid AS open_id, a.wechat_unionid AS union_id, a.mobile AS phone, + a.nickname, a.status, a.created_at, p.updated_at, p.avatar_url, + CASE p.gender WHEN 1 THEN '男' WHEN 2 THEN '女' ELSE '未知' END AS gender, + p.birthday, + ELT(p.birth_shichen + 1, '子时', '丑时', '寅时', '卯时', '辰时', '巳时', + '午时', '未时', '申时', '酉时', '戌时', '亥时') AS birth_hour, + p.residence_province AS province, p.residence_city AS city, p.residence_district AS district, + COALESCE(p.profile_edit_count, 0) AS profile_edit_count, + p.profile_edit_window_start - - INSERT INTO hera_user ( - open_id, union_id, phone, avatar_url, nickname, gender, birthday, birth_hour, - province, city, district, points, pinned_daily_card_id, profile_edit_count, created_at, updated_at - ) VALUES ( - #{openId}, #{unionId}, #{phone}, #{avatarUrl}, #{nickname}, #{gender}, #{birthday}, #{birthHour}, - #{province}, #{city}, #{district}, #{points}, #{pinnedDailyCardId}, #{profileEditCount}, - #{createdAt}, #{updatedAt} - ) - - - - UPDATE hera_user - SET union_id = #{unionId}, - phone = #{phone}, - avatar_url = #{avatarUrl}, - nickname = #{nickname}, - gender = #{gender}, - birthday = #{birthday}, - birth_hour = #{birthHour}, - province = #{province}, - city = #{city}, - district = #{district}, - points = #{points}, - pinned_daily_card_id = #{pinnedDailyCardId}, - profile_edit_count = #{profileEditCount}, - updated_at = #{updatedAt} - WHERE id = #{id} - diff --git a/src/main/resources/mapper/UserProfileMapper.xml b/src/main/resources/mapper/UserProfileMapper.xml index fcc58dd..075c9b4 100644 --- a/src/main/resources/mapper/UserProfileMapper.xml +++ b/src/main/resources/mapper/UserProfileMapper.xml @@ -7,9 +7,27 @@ id, user_id, real_name_encrypted, gender, birthday, birth_shichen, birth_country_code, birth_region_code, birth_city_code, birth_district_code, id_card_no_encrypted, id_card_hash, id_verified, id_verified_at, avatar_url, residence_province, residence_city, residence_district, - residence_address, signature, created_at, updated_at + residence_address, signature, profile_edit_count, + profile_edit_window_start, created_at, updated_at + + INSERT INTO user_profile (user_id) VALUES (#{userId}) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id) + + + + UPDATE user_profile SET gender = #{gender}, birthday = #{birthday}, birth_shichen = #{birthShichen}, + residence_province = #{residenceProvince}, residence_city = #{residenceCity}, + residence_district = #{residenceDistrict}, profile_edit_count = #{profileEditCount}, + profile_edit_window_start = #{profileEditWindowStart} + WHERE user_id = #{userId} + + + + UPDATE user_profile SET avatar_url = #{avatarUrl} WHERE user_id = #{userId} + + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 268755b..5e3a31c 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,25 +1,3 @@ -CREATE TABLE IF NOT EXISTS hera_user ( - id BIGINT NOT NULL AUTO_INCREMENT, - open_id VARCHAR(128) NOT NULL, - union_id VARCHAR(128) NULL, - phone VARCHAR(32) NULL, - avatar_url VARCHAR(512) NULL, - nickname VARCHAR(64) NOT NULL, - gender VARCHAR(16) NULL, - birthday DATE NULL, - birth_hour VARCHAR(32) NULL, - province VARCHAR(64) NULL, - city VARCHAR(64) NULL, - district VARCHAR(64) NULL, - points INT NOT NULL DEFAULT 0, - pinned_daily_card_id VARCHAR(64) NULL, - profile_edit_count INT NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uk_hera_user_open_id (open_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - CREATE TABLE IF NOT EXISTS login_session ( id BIGINT NOT NULL AUTO_INCREMENT, session_key VARCHAR(64) NOT NULL, @@ -66,7 +44,7 @@ CREATE TABLE IF NOT EXISTS user_account ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, nickname VARCHAR(50) NULL, - password_hash VARCHAR(100) NOT NULL, + password_hash VARCHAR(100) NULL COMMENT '密码哈希;纯微信账号未设置密码时为空', mobile VARCHAR(20) NULL, email VARCHAR(100) NULL, nfc_uid VARCHAR(64) NULL, @@ -109,6 +87,8 @@ CREATE TABLE IF NOT EXISTS user_profile ( residence_district VARCHAR(50) NULL, residence_address VARCHAR(255) NULL, signature VARCHAR(500) NULL, + profile_edit_count INT NOT NULL DEFAULT 0 COMMENT '当前半年周期内资料修改次数', + profile_edit_window_start DATETIME NULL COMMENT '资料修改额度周期开始时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), diff --git a/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java b/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java new file mode 100644 index 0000000..10dc6a6 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java @@ -0,0 +1,133 @@ +package com.hongzhen.hera.storage; + +import com.hongzhen.hera.config.HeraProperties; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.http.HttpProtocol; +import com.qcloud.cos.region.Region; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 显式启用的真实 COS 联调,验证后清理本次创建的对象。 + */ +@EnabledIfSystemProperty(named = "hera.cos.integration", matches = "true") +class CosIntegrationTest { + @Test + void uploadsPrivateFilesAndDownloadsThroughSignedGateway() throws Exception { + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + FileSystemResource local = new FileSystemResource(".hera-local.yml"); + if (local.exists()) { + yaml.setResources(new ClassPathResource("application.yml"), local); + } else { + yaml.setResources(new ClassPathResource("application.yml")); + } + Properties values = yaml.getObject(); + assertNotNull(values); + StandardEnvironment environment = new StandardEnvironment(); + CosProperties properties = new CosProperties(); + properties.setSecretId(resolve(environment, values, "hera.cos.secret-id")); + properties.setSecretKey(resolve(environment, values, "hera.cos.secret-key")); + properties.setBucket(resolve(environment, values, "hera.cos.bucket")); + properties.setRegion(resolve(environment, values, "hera.cos.region")); + properties.setPublicBaseUrl(resolve(environment, values, "hera.cos.public-base-url")); + assertFalse(properties.getSecretId().isEmpty(), "COS SecretId is required"); + assertFalse(properties.getSecretKey().isEmpty(), "COS SecretKey is required"); + HeraProperties hera = new HeraProperties(); + hera.setJwtSecret(resolve(environment, values, "hera.jwt-secret")); + ClientConfig config = new ClientConfig(new Region(properties.getRegion())); + config.setHttpProtocol(HttpProtocol.https); + config.setConnectionTimeout(10000); + config.setSocketTimeout(30000); + COSClient client = new COSClient( + new BasicCOSCredentials(properties.getSecretId(), properties.getSecretKey()), config); + CosStorageService storage = new CosStorageService(properties); + ReflectionTestUtils.setField(storage, "client", client); + FileAccessService access = new FileAccessService(storage, properties, hera); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new FileAccessController(access)).build(); + List createdKeys = new ArrayList(); + try { + byte[] png = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="); + verifyFile(storage, access, mvc, properties, createdKeys, png, true); + verifyFile(storage, access, mvc, properties, createdKeys, + "hera-cos-integration-test".getBytes(StandardCharsets.UTF_8), false); + } finally { + try { + for (String key : createdKeys) { + client.deleteObject(properties.getBucket(), key); + assertFalse(client.doesObjectExist(properties.getBucket(), key), "Test object was not deleted"); + } + } finally { + storage.close(); + } + } + } + + private void verifyFile(CosStorageService storage, FileAccessService access, MockMvc mvc, + CosProperties properties, List createdKeys, byte[] bytes, boolean avatar) throws Exception { + String reference = storage.upload(Long.MAX_VALUE, + new MockMultipartFile("file", "test", avatar ? "image/png" : "text/plain", bytes), + avatar, avatar ? ".png" : ""); + String objectKey = reference.substring("cos://".length()); + createdKeys.add(objectKey); + String unsigned = "https://" + properties.getBucket() + ".cos." + properties.getRegion() + + ".myqcloud.com/" + objectKey; + HttpURLConnection anonymous = (HttpURLConnection) new URL(unsigned).openConnection(); + anonymous.setConnectTimeout(10000); + anonymous.setReadTimeout(30000); + try { + assertEquals(403, anonymous.getResponseCode(), "Unsigned read must be denied by the private bucket"); + } finally { + anonymous.disconnect(); + } + String entry = access.resolveUrl(reference); + String token = entry.substring(entry.indexOf("token=") + 6); + String location = mvc.perform(get("/file/access").param("token", token)) + .andExpect(status().isFound()).andReturn().getResponse().getHeader("Location"); + assertNotNull(location); + HttpURLConnection signed = (HttpURLConnection) new URL(location).openConnection(); + signed.setConnectTimeout(10000); + signed.setReadTimeout(30000); + try { + assertEquals(200, signed.getResponseCode(), "Signed download failed"); + try (InputStream input = signed.getInputStream(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + assertArrayEquals(bytes, output.toByteArray()); + } + } finally { + signed.disconnect(); + } + } + + private String resolve(StandardEnvironment environment, Properties values, String name) { + return environment.resolveRequiredPlaceholders(values.getProperty(name, "")); + } +} diff --git a/src/test/java/com/hongzhen/hera/storage/CosStorageServiceTest.java b/src/test/java/com/hongzhen/hera/storage/CosStorageServiceTest.java new file mode 100644 index 0000000..3f6f5a1 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/storage/CosStorageServiceTest.java @@ -0,0 +1,77 @@ +package com.hongzhen.hera.storage; + +import com.hongzhen.hera.common.BusinessException; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.PutObjectRequest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CosStorageServiceTest { + private CosProperties properties() { + CosProperties properties = new CosProperties(); + properties.setBucket("hera-1319913268"); + properties.setRegion("ap-beijing"); + properties.setSecretId("test-secret-id"); + properties.setSecretKey("test-secret-key"); + return properties; + } + + @Test + void signsPrivateObjectWithHttpsWithoutNetworkAccess() { + CosStorageService service = new CosStorageService(properties()); + try { + String url = service.resolveOwnedUrl(12L, "cos://hera/avatar/12/example.png"); + assertTrue(url.startsWith("https://hera-1319913268.cos.ap-beijing.myqcloud.com/hera/avatar/12/example.png?")); + assertTrue(url.contains("q-signature=")); + } finally { + service.close(); + } + } + + @Test + void preservesLegacyAvatarPaths() { + CosStorageService service = new CosStorageService(new CosProperties()); + assertNull(service.resolveUrl(null)); + assertEquals("/uploads/avatars/old.png", service.resolveUrl("/uploads/avatars/old.png")); + } + + @Test + void rejectsOtherUsersFilesAndTraversal() { + CosStorageService service = new CosStorageService(properties()); + assertThrows(BusinessException.class, + () -> service.resolveOwnedUrl(1L, "cos://hera/files/2/file")); + assertThrows(BusinessException.class, + () -> service.resolveOwnedUrl(1L, "cos://hera/files/1/../2/file")); + } + + @Test + void uploadsAvatarWithOwnerPrefixAndMetadata() { + COSClient client = mock(COSClient.class); + CosStorageService service = new CosStorageService(properties()); + ReflectionTestUtils.setField(service, "client", client); + MockMultipartFile file = new MockMultipartFile("file", "avatar.png", "image/png", new byte[]{1, 2, 3}); + String reference = service.upload(7L, file, true, ".png"); + ArgumentCaptor request = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(client).putObject(request.capture()); + assertTrue(reference.matches("cos://hera/avatar/7/[a-f0-9]{32}\\.png")); + assertEquals("hera-1319913268", request.getValue().getBucketName()); + assertEquals(3L, request.getValue().getMetadata().getContentLength()); + assertEquals("image/png", request.getValue().getMetadata().getContentType()); + service.close(); + verify(client).shutdown(); + } + + @Test + void rejectsEmptyFilesAndMissingCredentials() { + CosStorageService service = new CosStorageService(new CosProperties()); + assertThrows(BusinessException.class, () -> service.upload(1L, + new MockMultipartFile("file", new byte[0]), false, "")); + assertThrows(BusinessException.class, + () -> service.resolveUrl("cos://hera/avatar/1/example.png")); + } +} diff --git a/src/test/java/com/hongzhen/hera/storage/FileAccessServiceTest.java b/src/test/java/com/hongzhen/hera/storage/FileAccessServiceTest.java new file mode 100644 index 0000000..8747dd4 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/storage/FileAccessServiceTest.java @@ -0,0 +1,52 @@ +package com.hongzhen.hera.storage; + +import com.hongzhen.hera.common.BusinessException; +import com.hongzhen.hera.config.HeraProperties; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FileAccessServiceTest { + private static final String SECRET = "file-access-test-secret-file-access-test-secret"; + + private FileAccessService service(CosStorageService storage) { + HeraProperties hera = new HeraProperties(); + hera.setJwtSecret(SECRET); + CosProperties cos = new CosProperties(); + cos.setPublicBaseUrl("http://101.43.139.56/file/"); + return new FileAccessService(storage, cos, hera); + } + + @Test + void gatewayTokenResolvesOnlyItsBoundObject() { + CosStorageService storage = mock(CosStorageService.class); + FileAccessService service = service(storage); + String reference = "cos://hera/avatar/1/test.png"; + when(storage.resolveUrl(reference)).thenReturn("https://cos.example/signed"); + String url = service.resolveUrl(reference); + assertTrue(url.startsWith("http://101.43.139.56/file/access?token=")); + String token = url.substring(url.indexOf("token=") + 6); + assertEquals("https://cos.example/signed", service.redirectUrl(token)); + verify(storage).resolveUrl(reference); + } + + @Test + void rejectsExpiredTokenAndLoginToken() { + FileAccessService service = service(mock(CosStorageService.class)); + byte[] key = SECRET.getBytes(StandardCharsets.UTF_8); + String expired = Jwts.builder().setSubject("cos://hera/files/1/test") + .setAudience("hera-file").setExpiration(new Date(0)) + .signWith(SignatureAlgorithm.HS256, key).compact(); + String login = Jwts.builder().setSubject("1") + .setExpiration(new Date(System.currentTimeMillis() + 60000)) + .signWith(SignatureAlgorithm.HS256, key).compact(); + assertThrows(BusinessException.class, () -> service.redirectUrl(expired)); + assertThrows(BusinessException.class, () -> service.redirectUrl(login)); + assertThrows(BusinessException.class, () -> service.redirectUrl("invalid")); + } +} diff --git a/src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java b/src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java new file mode 100644 index 0000000..5b3eae3 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java @@ -0,0 +1,87 @@ +package com.hongzhen.hera.user; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.util.StreamUtils; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 在隔离数据库验证旧用户复制、字段转换及身份冲突保护。 + */ +public class UserTableMigrationTest { + private JdbcTemplate jdbc; + private UserTableMigration migration; + + @BeforeEach + void schema() throws Exception { + DriverManagerDataSource source = new DriverManagerDataSource("jdbc:h2:mem:legacy-" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + migration = new UserTableMigration(source); + String sql; + try (InputStream input = new ClassPathResource("schema.sql").getInputStream()) { + sql = StreamUtils.copyToString(input, StandardCharsets.UTF_8); + } + sql = sql.replace("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ""); + for (String statement : sql.split(";")) { + if (statement.trim().matches("(?s)CREATE TABLE IF NOT EXISTS (user_account|user_profile) .*")) { + jdbc.execute(statement); + } + } + jdbc.execute("CREATE ALIAS FIELD FOR 'com.hongzhen.hera.user.UserTableMigrationTest.field'"); + jdbc.execute("CREATE TABLE hera_user (id BIGINT PRIMARY KEY, open_id VARCHAR(128), union_id VARCHAR(128), " + + "phone VARCHAR(32), nickname VARCHAR(64), gender VARCHAR(16), birthday DATE, birth_hour VARCHAR(32), " + + "avatar_url VARCHAR(512), province VARCHAR(64), city VARCHAR(64), district VARCHAR(64), points INT, " + + "pinned_daily_card_id VARCHAR(64), profile_edit_count INT, created_at DATETIME, updated_at DATETIME)"); + jdbc.update("INSERT INTO hera_user VALUES (42, 'legacy-openid', 'legacy-union', '13800000042', " + + "'旧昵称', '女', '1998-06-12', '寅时', '/uploads/avatars/old.png', '北京', '北京', '朝阳', " + + "27, 'travel', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + } + + public static int field(String value, String... values) { + for (int i = 0; i < values.length; i++) { + if (values[i].equals(value)) { + return i + 1; + } + } + return 0; + } + + @Test + void copiesLegacyIdentityAndProfileWithoutRemovingSourceAndIsRepeatable() { + migration.migrateLegacyUsers(jdbc); + migration.migrateLegacyUsers(jdbc); + assertEquals("legacy-openid", jdbc.queryForObject("SELECT wechat_openid FROM user_account WHERE id = 42", String.class)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_name IN ('user_account', 'user_profile') " + + "AND column_name IN ('points', 'pinned_daily_card_id')", Integer.class)); + assertEquals(2, jdbc.queryForObject("SELECT gender FROM user_profile WHERE user_id = 42", Integer.class)); + assertEquals(2, jdbc.queryForObject("SELECT birth_shichen FROM user_profile WHERE user_id = 42", Integer.class)); + assertEquals("/uploads/avatars/old.png", jdbc.queryForObject("SELECT avatar_url FROM user_profile WHERE user_id = 42", String.class)); + assertEquals(3, jdbc.queryForObject("SELECT profile_edit_count FROM user_profile WHERE user_id = 42", Integer.class)); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM user_profile", Integer.class)); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM hera_user", Integer.class)); + } + + @Test + void rejectsConflictingAccountIdBeforeCopyingAnyUser() { + jdbc.update("INSERT INTO user_account(id, username, wechat_openid) VALUES (42, 'another-user', 'another-openid')"); + assertThrows(IllegalStateException.class, () -> migration.migrateLegacyUsers(jdbc)); + assertEquals("another-openid", jdbc.queryForObject("SELECT wechat_openid FROM user_account WHERE id = 42", String.class)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM user_profile", Integer.class)); + } + + @Test + void rejectsSameMobileWithDifferentUserId() { + jdbc.update("INSERT INTO user_account(id, username, mobile) VALUES (7, 'existing', '13800000042')"); + assertThrows(IllegalStateException.class, () -> migration.migrateLegacyUsers(jdbc)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM user_account WHERE id = 42", Integer.class)); + } +} diff --git a/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java b/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java new file mode 100644 index 0000000..c75b517 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java @@ -0,0 +1,153 @@ +package com.hongzhen.hera.user; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hongzhen.hera.auth.WechatClient; +import com.hongzhen.hera.auth.WxSession; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StreamUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 使用独立 H2 MySQL 模式验证控制层、事务和真实 Mapper XML 的用户落库链路。 + */ +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:user-unification;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa", + "spring.datasource.password=", "spring.sql.init.mode=never", + "hera.jwt-secret=user-unification-test-secret-user-unification-test-secret"}) +@AutoConfigureMockMvc +@Transactional +public class UserUnificationTest { + @Autowired private MockMvc mvc; + @Autowired private JdbcTemplate jdbc; + @Autowired private ObjectMapper json; + @Autowired private UserService userService; + @MockBean private WechatClient wechatClient; + @MockBean private UserTableMigration migration; + private String openId; + + @BeforeEach + void schema() throws Exception { + // 复用生产建表脚本;仅去除 H2 不支持的 MySQL 表级字符集配置。 + String ddl; + try (java.io.InputStream input = new ClassPathResource("schema.sql").getInputStream()) { + ddl = StreamUtils.copyToString(input, StandardCharsets.UTF_8); + } + ddl = ddl.replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ""); + for (String statement : ddl.split(";")) { + if (statement.trim().matches("(?s)CREATE TABLE IF NOT EXISTS (login_session|sms_code|user_account|user_profile) .*")) { + jdbc.execute(statement); + } + } + jdbc.execute("CREATE ALIAS IF NOT EXISTS ELT FOR 'com.hongzhen.hera.user.UserUnificationTest.elt'"); + openId = "test-" + UUID.randomUUID(); + when(wechatClient.code2Session(anyString())).thenReturn(new WxSession(openId, "test-union")); + } + + public static String elt(Integer index, String... values) { + return index == null || index < 1 || index > values.length ? null : values[index - 1]; + } + + @Test + void registrationProfileAvatarAndRepeatLoginUseFormalTables() throws Exception { + JsonNode login = register("13800000001"); + long id = login.path("user").path("id").asLong(); + String token = login.path("token").asText(); + assertFalse(token.isEmpty()); + assertFalse(login.path("user").has("points")); + assertTrue(login.path("user").path("birthday").isNull()); + assertEquals(id, jdbc.queryForObject("SELECT id FROM user_account WHERE wechat_openid = ?", Long.class, openId)); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM user_profile WHERE user_id = ?", Integer.class, id)); + assertNull(jdbc.queryForObject("SELECT password_hash FROM user_account WHERE id = ?", String.class, id)); + jdbc.update("UPDATE user_profile SET id_verified = 1, real_name_encrypted = 'test-cipher' WHERE user_id = ?", id); + + mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(profileBody("测试昵称"))) + .andExpect(status().isOk()); + assertEquals("测试昵称", jdbc.queryForObject("SELECT nickname FROM user_account WHERE id = ?", String.class, id)); + assertEquals(2, jdbc.queryForObject("SELECT gender FROM user_profile WHERE user_id = ?", Integer.class, id)); + assertEquals(2, jdbc.queryForObject("SELECT birth_shichen FROM user_profile WHERE user_id = ?", Integer.class, id)); + assertEquals(1, jdbc.queryForObject("SELECT profile_edit_count FROM user_profile WHERE user_id = ?", Integer.class, id)); + assertEquals(1, jdbc.queryForObject("SELECT id_verified FROM user_profile WHERE user_id = ?", Integer.class, id)); + assertEquals("test-cipher", jdbc.queryForObject("SELECT real_name_encrypted FROM user_profile WHERE user_id = ?", String.class, id)); + String avatar = "cos://hera/avatar/" + id + "/test.png"; + userService.updateAvatar(id, avatar); + assertEquals(avatar, jdbc.queryForObject("SELECT avatar_url FROM user_profile WHERE user_id = ?", String.class, id)); + String daily = mvc.perform(get("/api/daily/home").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + assertFalse(json.readTree(daily).path("data").has("pinnedCardId")); + mvc.perform(get("/api/points/exchange-status").header("Authorization", "Bearer " + token)) + .andExpect(status().isNotFound()); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post("/api/daily/cards/travel/pin").header("Authorization", "Bearer " + token)) + .andExpect(status().isNotFound()); + JsonNode again = post("/api/auth/wx-login", "{\"code\":\"repeat\"}"); + assertTrue(again.path("phoneBound").asBoolean()); + assertFalse(again.path("token").asText().isEmpty()); + assertEquals(id, again.path("user").path("id").asLong()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'hera_user'", Integer.class)); + } + + @Test + void reusesUnboundMobileAccountWithoutCreatingDuplicate() throws Exception { + jdbc.update("INSERT INTO user_account(username, mobile) VALUES (?, ?)", "existing", "13800000002"); + long id = jdbc.queryForObject("SELECT id FROM user_account WHERE username = 'existing'", Long.class); + JsonNode login = register("13800000002"); + assertEquals(id, login.path("user").path("id").asLong()); + assertEquals(openId, jdbc.queryForObject("SELECT wechat_openid FROM user_account WHERE id = ?", String.class, id)); + } + + @Test + void editQuotaResetsAfterSixMonthsAndRejectsSixthEdit() throws Exception { + JsonNode login = register("13800000003"); + long id = login.path("user").path("id").asLong(); + String token = login.path("token").asText(); + jdbc.update("UPDATE user_profile SET profile_edit_count = 5, profile_edit_window_start = ? WHERE user_id = ?", + java.time.LocalDateTime.now().minusMonths(7), id); + mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(profileBody("重置额度"))).andExpect(status().isOk()); + assertEquals(1, jdbc.queryForObject("SELECT profile_edit_count FROM user_profile WHERE user_id = ?", Integer.class, id)); + jdbc.update("UPDATE user_profile SET profile_edit_count = 5 WHERE user_id = ?", id); + mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(profileBody("超出额度"))).andExpect(status().isBadRequest()); + assertEquals("重置额度", jdbc.queryForObject("SELECT nickname FROM user_account WHERE id = ?", String.class, id)); + } + + private JsonNode register(String phone) throws Exception { + JsonNode wx = post("/api/auth/wx-login", "{\"code\":\"test\"}"); + assertFalse(wx.path("phoneBound").asBoolean()); + JsonNode sms = post("/api/auth/sms", "{\"phone\":\"" + phone + "\"}"); + return post("/api/auth/bind-phone", "{\"sessionKey\":\"" + wx.path("sessionKey").asText() + + "\",\"phone\":\"" + phone + "\",\"captcha\":\"" + sms.path("mockCode").asText() + "\"}"); + } + + private JsonNode post(String path, String body) throws Exception { + String response = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + return json.readTree(response).path("data"); + } + + private String profileBody(String nickname) { + return "{\"nickname\":\"" + nickname + "\",\"gender\":\"女\",\"birthday\":\"1998-06-12\"," + + "\"birthHour\":\"寅时\",\"province\":\"北京市\",\"city\":\"北京市\",\"district\":\"朝阳区\"}"; + } +} diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..a9d38e4 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + %level %logger - %msg%n + + + + + + +