feat: integrate COS and unify user persistence

This commit is contained in:
zhouzhibo committed 2026-10-15 21:28:05 +08:00
1 parent 8a97a0b054
commit ad5e305ad4
39 files changed
+1355 -269

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
.idea/
target/
*.iml
.hera-local.yml
+55 -3
View File
@@ -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 <token>`,并支持统一操作:
- `GET /api/data/{resource}/{id}`:按主键查询
+11
View File
@@ -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;
}
+10
View File
@@ -18,6 +18,11 @@
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.227</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -51,6 +56,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
@@ -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
@@ -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));
}
}
@@ -9,4 +9,7 @@ public interface LoginSessionMapper {
int insert(LoginSession loginSession);
Optional<LoginSession> findBySessionKey(String sessionKey);
/** 核销本次登录会话。 */
int deleteById(Long id);
}
@@ -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("验证码已使用");
}
}
}
@@ -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<PinCardResponse> pin(@PathVariable String cardId) {
return ApiResponse.ok(new PinCardResponse(dailyService.pin(CurrentUser.id(), cardId)));
}
}
@@ -16,11 +16,10 @@ public class DailyDtos {
private List<LuckyPanel> luckyPanels;
private List<FortuneCard> cards;
private List<LifeTip> lifeTips;
private String pinnedCardId;
public DailyHomeResponse(LocalDate date, String lunarDate, String weekName, String fortuneLevel, String greeting,
String dailySummary, List<CalendarDay> calendarDays, List<LuckyPanel> luckyPanels, List<FortuneCard> cards,
List<LifeTip> lifeTips, String pinnedCardId) {
List<LifeTip> 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;
}
}
}
@@ -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) {
@@ -9,6 +9,21 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserAccountMapper extends CrudMapper<UserAccount> {
/** 按微信身份查询正式账号。 */
Optional<UserAccount> findByOpenId(String openId);
/** 按手机号查询正式账号。 */
Optional<UserAccount> findByMobile(String mobile);
/** 锁定账号行,串行化当前用户的写操作。 */
Optional<UserAccount> findByIdForUpdate(Long id);
/** 绑定微信和手机号,保留其他账号字段。 */
int bindWechat(UserAccount entity);
/** 修改显示昵称。 */
int updateNickname(@Param("id") Long id, @Param("nickname") String nickname);
@Override
Optional<UserAccount> findById(Long id);
@@ -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;
}
@@ -9,6 +9,15 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserProfileMapper extends CrudMapper<UserProfile> {
/** 补齐账号的一对一资料行,不覆盖已有资料。 */
int ensureProfile(Long userId);
/** 更新用户可编辑资料,不修改实名认证等后台字段。 */
int updatePersonal(UserProfile entity);
/** 按账号 ID 更新头像对象标识。 */
int updateAvatar(@Param("userId") Long userId, @Param("avatarUrl") String avatarUrl);
@Override
Optional<UserProfile> findById(Long id);
@@ -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<Map<String, Object>> exchangeStatus() {
int points = userService.get(CurrentUser.id()).getPoints();
Map<String, Object> data = new HashMap<String, Object>();
data.put("points", points);
data.put("available", false);
data.put("message", "你当前拥有 " + points + " 吉分,兑换功能即将开放,敬请期待!");
return ApiResponse.ok(data);
}
}
@@ -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; }
}
@@ -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;
}
}
}
@@ -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<Void> 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();
}
}
@@ -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("文件访问链接无效或已过期");
}
}
}
@@ -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<Map<String, String>> upload(@RequestParam("file") MultipartFile file) {
String reference = storageService.upload(CurrentUser.id(), file, false, "");
Map<String, String> result = new HashMap<String, String>();
result.put("reference", reference);
result.put("url", accessService.resolveUrl(reference));
return ApiResponse.ok(result);
}
/**
* 为当前用户拥有的文件重新生成临时访问链接。
*/
@GetMapping("/url")
public ApiResponse<String> url(@RequestParam("reference") String reference) {
storageService.validateOwnership(CurrentUser.id(), reference);
return ApiResponse.ok(accessService.resolveUrl(reference));
}
}
@@ -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);
}
}
@@ -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() {
+34 -34
View File
@@ -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;
}
@@ -10,7 +10,4 @@ public interface UserMapper {
Optional<User> findByOpenId(String openId);
int insert(User user);
int update(User user);
}
@@ -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<String> 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
);
}
@@ -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)");
}
}
+11 -4
View File
@@ -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}"
@@ -11,6 +11,7 @@
<select id="findBySessionKey" resultType="com.hongzhen.hera.auth.LoginSession">
SELECT id, session_key, open_id, union_id, expires_at
FROM login_session
WHERE session_key = #{sessionKey}
WHERE session_key = #{sessionKey} FOR UPDATE
</select>
<delete id="deleteById">DELETE FROM login_session WHERE id = #{id}</delete>
</mapper>
+1 -1
View File
@@ -21,6 +21,6 @@
<update id="markUsed">
UPDATE sms_code
SET used = TRUE
WHERE id = #{id}
WHERE id = #{id} AND used = FALSE
</update>
</mapper>
@@ -9,6 +9,28 @@
created_at, updated_at
</sql>
<select id="findByOpenId" resultType="com.hongzhen.hera.data.UserAccount">
SELECT <include refid="BaseColumnList"/> FROM user_account WHERE wechat_openid = #{openId}
</select>
<select id="findByMobile" resultType="com.hongzhen.hera.data.UserAccount">
SELECT <include refid="BaseColumnList"/> FROM user_account WHERE mobile = #{mobile}
</select>
<select id="findByIdForUpdate" resultType="com.hongzhen.hera.data.UserAccount">
SELECT <include refid="BaseColumnList"/> FROM user_account WHERE id = #{id} FOR UPDATE
</select>
<update id="bindWechat">
UPDATE user_account SET wechat_openid = #{wechatOpenid},
wechat_unionid = COALESCE(#{wechatUnionid}, wechat_unionid), mobile = #{mobile}
WHERE id = #{id}
</update>
<update id="updateNickname">
UPDATE user_account SET nickname = #{nickname} WHERE id = #{id}
</update>
<select id="findById" resultType="com.hongzhen.hera.data.UserAccount">
SELECT <include refid="BaseColumnList"/> FROM user_account WHERE id = #{id}
</select>
+16 -38
View File
@@ -3,50 +3,28 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hongzhen.hera.user.UserMapper">
<sql id="BaseColumnList">
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
<sql id="JoinedColumns">
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
</sql>
<select id="findById" resultType="com.hongzhen.hera.user.User">
SELECT <include refid="BaseColumnList"/>
FROM hera_user
WHERE id = #{id}
SELECT <include refid="JoinedColumns"/>
FROM user_account a LEFT JOIN user_profile p ON p.user_id = a.id
WHERE a.id = #{id}
</select>
<select id="findByOpenId" resultType="com.hongzhen.hera.user.User">
SELECT <include refid="BaseColumnList"/>
FROM hera_user
WHERE open_id = #{openId}
SELECT <include refid="JoinedColumns"/>
FROM user_account a LEFT JOIN user_profile p ON p.user_id = a.id
WHERE a.wechat_openid = #{openId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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}
)
</insert>
<update id="update">
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}
</update>
</mapper>
@@ -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
</sql>
<insert id="ensureProfile">
INSERT INTO user_profile (user_id) VALUES (#{userId})
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id)
</insert>
<update id="updatePersonal">
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>
<update id="updateAvatar">
UPDATE user_profile SET avatar_url = #{avatarUrl} WHERE user_id = #{userId}
</update>
<select id="findById" resultType="com.hongzhen.hera.data.UserProfile">
SELECT <include refid="BaseColumnList"/> FROM user_profile WHERE id = #{id}
</select>
+3 -23
View File
@@ -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),
@@ -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<String> createdKeys = new ArrayList<String>();
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<String> 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, ""));
}
}
@@ -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<PutObjectRequest> 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"));
}
}
@@ -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"));
}
}
@@ -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));
}
}
@@ -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\":\"朝阳区\"}";
}
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%level %logger - %msg%n</pattern>
</encoder>
</appender>
<!-- Do not log request URLs or signed file credentials during integration tests. -->
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>