feat: integrate COS and unify user persistence
This commit is contained in:
1 parent
8a97a0b054
commit
ad5e305ad4
39 files changed
+1355
-269
No files matched your search
@@ -1,4 +1,5 @@
|
||||
.idea/
|
||||
target/
|
||||
*.iml
|
||||
.hera-local.yml
|
||||
|
||||
@@ -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}`:按主键查询
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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\":\"朝阳区\"}";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user