feat: implement NFC lifecycle with SN support and Redis integration

This commit is contained in:
zhouzhibo committed 2026-10-17 22:43:12 +08:00
1 parent ad5e305ad4
commit e22e5c4024
53 files changed
+2252 -361

No files matched your search

+77 -14
View File
@@ -22,6 +22,23 @@ mvn spring-boot:run
本机敏感配置可放在仓库根目录的 `.hera-local.yml`,使用与 `application.yml` 相同的 YAML
层级。后端从项目根目录启动时会自动加载,此文件已加入 Git 忽略清单;服务器使用环境变量配置。
## Redis
使用 Spring Data Redis 和 Lettuce,依赖版本由 Spring Boot 2.7.18 管理。
默认地址为 `101.43.139.56:6379`,逻辑库为 `0`,连接与命令超时均为 3 秒。
支持 `REDIS_HOST`、`REDIS_PORT`、`REDIS_USERNAME`、`REDIS_PASSWORD`、`REDIS_DATABASE`、
`REDIS_SSL` 环境变量;本机密码配置在被 Git 忽略的 `.hera-local.yml` 中。
业务代码可注入 `StringRedisTemplate`。Key、Value 和 Hash 均采用字符串序列化;
结构化数据使用现有 `ObjectMapper` 显式转换为 JSON。建议 Key 使用 `hera:{业务}:` 前缀,
缓存和临时数据写入时必须指定合理的 TTL。当前接入连接能力,登录会话、mock 短信验证码
仍由现有 MySQL 链路保存,未启用用户资料自动缓存。
`mvn test` 包含不访问真实 Redis 的配置测试。
`mvn test -Dtest=RedisIntegrationTest -Dhera.redis.integration=true` 显式验证真实连接、
认证、PING、字符串读写与 TTL,使用 `hera:test:redis:{随机值}` 测试键并在结束后删除,
不清空 Redis。该测试独立于 MySQL,不执行用户表迁移。
## 腾讯云 COS 文件存储
存储桶为 `hera-1319913268`,地域为 `ap-beijing`,桶权限保持私有读。
@@ -70,25 +87,71 @@ mvn spring-boot:run
保存在账号表;性别、生日、出生时辰、头像、现居住地区与资料修改额度保存在资料表。
`User` 仅作为联合查询模型;业务接口不再读写 `hera_user`。
首次启动会由 `UserTableMigration` 为既有正式表补齐缺失字段,并将 `password_hash` 调整为
可空(纯微信用户未设置密码),因此数据库账号需要相应的建表及 ALTER 权限。
如发现旧表 `hera_user`,会保留用户 ID,将旧用户和资料迁入正式表,同时保留原表。
迁移使用数据库锁、事务及版本记录;身份或手机号冲突时停止迁移,不覆盖冲突账号。
旧用户的资料修改周期以原 `updated_at` 为起点;新周期为首次修改起六个月,最多五次。
重启前应备份数据库;本地 H2 测试不代表已执行远端 MySQL 迁移。
当前系统尚未上线,以当前数据库与 `schema.sql` 为准,不执行历史用户迁移、
启动时补列或修改旧表结构,也不读取或维护历史迁移版本表。
`schema.sql` 仅通过 `CREATE TABLE IF NOT EXISTS` 初始化缺失的当前业务表;
已有表结构若发生变化,应先更新代码与建表定义,不自动兼容历史结构。
纯微信账号的 `password_hash` 可空;资料修改周期为首次修改起六个月,最多五次。
历史迁移源码与对应测试已移除,但本次清理不会删除数据库中已有的表或记录。
`POST /api/auth/wx-login` 对已绑定手机号的用户额外返回 `token` 和 `user`;未绑定时,
继续通过 `sessionKey` 和 mock 短信验证码调用 `/api/auth/bind-phone` 完成注册或绑定。
绑定成功会核销会话与验证码。禁用账号不能登录或使用个人资料接口。
个人资料接口的字段名保持不变;资料尚未填写时,生日、时辰及五行报告可能为空,不填充虚构资料。
以下接口均需要携带 `Authorization: Bearer <token>`,并支持统一操作:
原 `/api/data/**` 通用 CRUD 控制器已撤下,避免普通登录用户改写设备归属、资产、
账号状态或他人资料。Mapper 与内部 CRUD Service 保留,不作为小程序 HTTP 接口。
账号注册和资料修改继续使用 `/api/auth/**` 与 `/api/user/profile`,尚未提供管理员接口。
- `GET /api/data/{resource}/{id}`:按主键查询
- `GET /api/data/{resource}?offset=0&limit=20`:分页列表,`limit` 最大为 100
- `POST /api/data/{resource}`:新增
- `PUT /api/data/{resource}/{id}`:更新
- `DELETE /api/data/{resource}/{id}`:删除
## NFC 与虚拟物
`resource` 可取 `nfc-chips`、`user-accounts`、`user-profiles`、`virtual-items`、
`user-virtual-items`、`offerings`、`transfers`。
全部接口需要 `Authorization: Bearer <token>`,用户身份取 JWT,不接收操作者用户 ID。
列表接受 `offset=0&limit=20`,单页最大 100。芯片和虚拟物字典由可信后台或数据库预先入库,
不允许小程序自行注册硬件、指定设备奖励或修改流水。
代码以现库结构为准:绑定流水通过 `nfc_uid` 关联设备,芯片不再使用 `last_login_at`。
`nfc_chip.sn` 为设备序列号,按现库定义使用 `VARCHAR(64) NOT NULL`,无默认值、无唯一索引。
硬件入库和更新时必须提供 SN;扫描、绑定及本人设备列表返回 `sn`,客户端不能通过绑定请求修改它。
设备归属和绑定历史仍以唯一 UID 关联,不将 SN 当作唯一标识或绑定凭证。
新库由 `schema.sql` 创建该字段;旧库若尚未新增,应使用真实序列号补录已有设备后再设置非空约束,
不自动生成序列号或用 UID 代填,本服务不在启动时自动修改已有表。
旧库如缺少芯片归属、解绑额度或绑定流水表,可先备份,再手工执行
`src/main/resources/db/nfc-schema-update.sql`;该脚本不由应用自动执行。新库由 `schema.sql` 建表。
若已有同名表但字段结构不同,部署前应核对,不会通过删表自动适配。
- `POST /api/nfc/scan`:`{"uid":"设备UID"}`,查看绑定状态,不返回他人身份或 NDEF。
- `POST /api/nfc/bind`:同上,绑定设备、写绑定流水、激活预置虚拟物,整体事务提交。
- `POST /api/nfc/unbind`:同上,解绑本人设备并写解绑时间。
- `GET /api/nfc/devices`:本人当前设备。
- `GET /api/nfc/bindings`:本人绑定与解绑历史。
- `GET /api/virtual-items`:启用的虚拟物字典,按字典排序。
- `GET /api/user/virtual-items`:本人资产,含失效与送神状态。
- `GET /api/user/virtual-items/{id}`:本人持有关系详情。
- `PUT /api/user/virtual-items/sort`:`{"ids":[持有关系ID,...]}`,按提交顺序设置排序值。
- `POST /api/user/virtual-items/offerings`:`{"userVirtualItemId":1,"offeringType":3,"content":"祈愿"}`。
- `GET /api/user/virtual-items/offerings?holdingId=1`:本人供奉记录,`holdingId` 可省略。
- `POST /api/user/virtual-items/transfers`:`{"userVirtualItemId":1,"toUserId":2,"content":"祝福"}`。
- `GET /api/user/virtual-items/transfers`:本人发出或收到的回向记录。
一个芯片仅允许一个当前用户,一个微信用户可以绑定多个芯片;要求有效账号已绑定微信和手机号。
重复绑定本人设备只刷新更新时间,不增加绑定次数,也不重复发放资产。
每台芯片按北京时间自然年最多解绑两次,次数跟随芯片,不因换用户而重置,跨年重新计算。
同类虚拟物在一个用户下只有一条资产记录,保留首次获得的 `nfc_uid` 和获得时间。
绑定新设备可恢复该类失效资产。解绑最后一台提供该类虚拟物的设备后,资产状态置为 `3`(失效),
不删除供奉和回向历史;仍有同类设备时保留资产。失效资产不能互动或直接请神,必须重新绑定。
供奉类型 `1` 请神、`2` 上香、`3` 祈愿、`4` 功德、`5` 送神。绑定激活自动记录请神;
送神置状态 `0`,保有对应设备时可通过请神恢复为 `1`。重复请神、重复送神会拒绝。
只有功德请求可填写 `meritValue`,要求 1 至 1000000,仅作互动记录,不代表积分、支付或余额。
回向只写记录,不创建接收方资产、不扣除发起方资产,也不把发起方状态改为已回向。
昵称快照从账号表生成;接收方原本有同类资产时保存其关系 ID,没有则为 `null`。
记录不开放通用修改和删除。UID 校验仅确认已入库设备,不具备防复制能力;
若产品需要防克隆,须增加硬件挑战响应或服务端签发的激活凭证,不能将普通 UID 当作秘密。
`NfcWorkflowTest` 在独立 H2 MySQL 模式测试库验证真实 Mapper XML、HTTP、事务与并发抢绑,
覆盖解绑上限、跨年、同类多设备、失效恢复、越权、供奉和只记账回向,不访问线上业务数据。
`mvn test -Dtest=NfcMySqlIntegrationTest -Dhera.nfc.mysql.integration=true` 显式检查现库列名,
通过只读、零行查询验证 XML 与现表兼容,再在七张连接级临时表中验证读写与回滚。
临时表创建成功并全部覆盖后才允许测试写入;结束时回滚并关闭连接自动清理,
不修改正式表结构和业务记录。测试账号需有 `CREATE TEMPORARY TABLES` 权限。
+4
View File
@@ -31,6 +31,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
@@ -1,5 +1,6 @@
package com.hongzhen.hera.common;
import com.hongzhen.hera.security.AuthenticationException;
import org.springframework.http.HttpStatus;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.validation.BindException;
@@ -11,6 +12,13 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 登录过期与普通业务校验区分,供客户端清理会话并重新登录。 */
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResponse<Void> handleAuthentication(AuthenticationException exception) {
return new ApiResponse<Void>(401, exception.getMessage(), null);
}
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleBusiness(BusinessException exception) {
@@ -0,0 +1,21 @@
package com.hongzhen.hera.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* Redis 客户端配置,统一使用字符串序列化,结构化数据由业务显式转换为 JSON。
*/
@Configuration(proxyBeanMethods = false)
public class RedisConfig {
/**
* 提供共享的字符串客户端,避免默认 RedisTemplate 使用 Java 原生对象序列化。
*/
@Bean(name = {"redisTemplate", "stringRedisTemplate"})
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
@@ -14,6 +14,11 @@ public class NfcChip extends BaseEntity {
*/
private String uid;
/**
* 设备序列号,最长 64 个字符,不能为空值,当前不保证唯一。
*/
private String sn;
/**
* 芯片型号。
*/
@@ -50,9 +55,56 @@ public class NfcChip extends BaseEntity {
private LocalDateTime activatedAt;
/**
* 最后登录时间。
* 当前绑定用户 ID,未绑定时为空。
*/
private LocalDateTime lastLoginAt;
private Long currentBindUserId;
/**
* 最近解绑年份内的解绑次数,每芯片每年最多两次。
*/
private Integer unbindCountThisYear;
/**
* 最近解绑所属的北京时间自然年。
*/
private Integer lastUnbindYear;
/**
* 累计成功绑定次数,同一用户重复请求不计次。
*/
private Integer bindCount;
public Long getCurrentBindUserId() {
return currentBindUserId;
}
public void setCurrentBindUserId(Long currentBindUserId) {
this.currentBindUserId = currentBindUserId;
}
public Integer getUnbindCountThisYear() {
return unbindCountThisYear;
}
public void setUnbindCountThisYear(Integer unbindCountThisYear) {
this.unbindCountThisYear = unbindCountThisYear;
}
public Integer getLastUnbindYear() {
return lastUnbindYear;
}
public void setLastUnbindYear(Integer lastUnbindYear) {
this.lastUnbindYear = lastUnbindYear;
}
public Integer getBindCount() {
return bindCount;
}
public void setBindCount(Integer bindCount) {
this.bindCount = bindCount;
}
public String getUid() {
return uid;
@@ -62,6 +114,14 @@ public class NfcChip extends BaseEntity {
this.uid = uid;
}
public String getSn() {
return sn;
}
public void setSn(String sn) {
this.sn = sn;
}
public String getChipType() {
return chipType;
}
@@ -117,12 +177,4 @@ public class NfcChip extends BaseEntity {
public void setActivatedAt(LocalDateTime activatedAt) {
this.activatedAt = activatedAt;
}
public LocalDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
}
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/nfc-chips")
public class NfcChipController extends CrudController<NfcChip> {
public NfcChipController(NfcChipService service) {
super(service);
}
}
@@ -9,6 +9,32 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface NfcChipMapper extends CrudMapper<NfcChip> {
/**
* 按入库 UID 查询设备。
*/
Optional<NfcChip> findByUid(String uid);
/**
* 锁定设备行,串行化不同用户的绑定请求。
*/
Optional<NfcChip> lockByUid(String uid);
/**
* 查询用户当前持有的设备。
*/
List<NfcChip> findByUser(@Param("userId") Long userId, @Param("offset") int offset,
@Param("limit") int limit);
/**
* 统计仍为用户提供某种虚拟物的设备数量。
*/
int countBoundItem(@Param("userId") Long userId, @Param("itemId") Integer itemId);
/**
* 更新归属、绑定次数和年度解绑额度,不修改硬件或预置虚拟物。
*/
int saveBinding(NfcChip chip);
@Override
Optional<NfcChip> findById(Long id);
@@ -33,11 +33,6 @@ public class UserAccount extends BaseEntity {
*/
private String email;
/**
* 已绑定的 NFC 标签 UID。
*/
private String nfcUid;
/**
* 微信 OpenID。
*/
@@ -114,14 +109,6 @@ public class UserAccount extends BaseEntity {
this.email = email;
}
public String getNfcUid() {
return nfcUid;
}
public void setNfcUid(String nfcUid) {
this.nfcUid = nfcUid;
}
public String getWechatOpenid() {
return wechatOpenid;
}
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/user-accounts")
public class UserAccountController extends CrudController<UserAccount> {
public UserAccountController(UserAccountService service) {
super(service);
}
}
@@ -0,0 +1,41 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.BaseEntity;
import java.time.LocalDateTime;
/**
* 用户与 NFC 设备的绑定、解绑历史,不随资产失效而删除。
*/
public class UserNfcBinding extends BaseEntity {
/**
* 绑定时的芯片 UID 快照。
*/
private String nfcUid;
/**
* 绑定用户账号 ID。
*/
private Long userId;
/**
* 绑定状态:1 绑定中,0 已解绑。
*/
private Integer status;
/**
* 本次绑定时间。
*/
private LocalDateTime boundAt;
/**
* 本次解绑时间,绑定中为空。
*/
private LocalDateTime unboundAt;
public String getNfcUid() { return nfcUid; }
public void setNfcUid(String nfcUid) { this.nfcUid = nfcUid; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public LocalDateTime getBoundAt() { return boundAt; }
public void setBoundAt(LocalDateTime boundAt) { this.boundAt = boundAt; }
public LocalDateTime getUnboundAt() { return unboundAt; }
public void setUnboundAt(LocalDateTime unboundAt) { this.unboundAt = unboundAt; }
}
@@ -0,0 +1,29 @@
package com.hongzhen.hera.data;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* NFC 绑定历史持久层,流水仅允许新增与完成解绑。
*/
@Mapper
public interface UserNfcBindingMapper {
/**
* 写入当前绑定流水并回填主键。
*/
int insert(UserNfcBinding binding);
/**
* 完成指定芯片与当前持有人的有效绑定。
*/
int finishBinding(@Param("uid") String uid, @Param("userId") Long userId,
@Param("now") LocalDateTime now);
/**
* 分页查询指定用户的绑定和解绑历史。
*/
List<UserNfcBinding> findByUser(@Param("userId") Long userId,
@Param("offset") int offset, @Param("limit") int limit);
}
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/user-profiles")
public class UserProfileController extends CrudController<UserProfile> {
public UserProfileController(UserProfileService service) {
super(service);
}
}
@@ -29,7 +29,7 @@ public class UserVirtualItem extends BaseEntity {
private Integer sortOrder;
/**
* 持有状态:1 供奉中,0 已送神,2 已回向,3 已过期。
* 持有状态:1 供奉中,0 已送神,2 历史已回向,3 已失效(含解绑)。
*/
private Integer status;
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/user-virtual-items")
public class UserVirtualItemController extends CrudController<UserVirtualItem> {
public UserVirtualItemController(UserVirtualItemService service) {
super(service);
}
}
@@ -9,6 +9,28 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserVirtualItemMapper extends CrudMapper<UserVirtualItem> {
/**
* 按用户与字典项查询唯一持有关系。
*/
Optional<UserVirtualItem> findByUserAndItem(@Param("userId") Long userId,
@Param("itemId") Integer itemId);
/**
* 按用户自定义顺序分页读取本人资产。
*/
List<UserVirtualItem> findByUser(@Param("userId") Long userId, @Param("offset") int offset,
@Param("limit") int limit);
/**
* 在用户归属条件下变更资产生命周期状态。
*/
int updateStatus(@Param("id") Long id, @Param("userId") Long userId, @Param("status") int status);
/**
* 在用户归属条件下调整资产顺序。
*/
int updateSort(@Param("id") Long id, @Param("userId") Long userId, @Param("sortOrder") int sortOrder);
@Override
Optional<UserVirtualItem> findById(Long id);
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/offerings")
public class UserVirtualItemOfferingController extends CrudController<UserVirtualItemOffering> {
public UserVirtualItemOfferingController(UserVirtualItemOfferingService service) {
super(service);
}
}
@@ -9,6 +9,12 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserVirtualItemOfferingMapper extends CrudMapper<UserVirtualItemOffering> {
/**
* 分页读取用户供奉历史,可筛选本人持有关系。
*/
List<UserVirtualItemOffering> findByUser(@Param("userId") Long userId,
@Param("holdingId") Long holdingId, @Param("offset") int offset, @Param("limit") int limit);
@Override
Optional<UserVirtualItemOffering> findById(Long id);
@@ -39,7 +39,7 @@ public class UserVirtualItemTransfer extends BaseEntity {
private Long fromUserItemId;
/**
* 接收方新持有关系 ID。
* 接收方已有的持有关系 ID 快照,未持有时为空;当前不创建接收资产。
*/
private Long toUserItemId;
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/transfers")
public class UserVirtualItemTransferController extends CrudController<UserVirtualItemTransfer> {
public UserVirtualItemTransferController(UserVirtualItemTransferService service) {
super(service);
}
}
@@ -9,6 +9,12 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserVirtualItemTransferMapper extends CrudMapper<UserVirtualItemTransfer> {
/**
* 分页查询用户发出或接收的回向记录。
*/
List<UserVirtualItemTransfer> findByUser(@Param("userId") Long userId,
@Param("offset") int offset, @Param("limit") int limit);
@Override
Optional<UserVirtualItemTransfer> findById(Long id);
@@ -1,14 +0,0 @@
package com.hongzhen.hera.data;
import com.hongzhen.hera.common.CrudController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/data/virtual-items")
public class VirtualItemController extends CrudController<VirtualItem> {
public VirtualItemController(VirtualItemService service) {
super(service);
}
}
@@ -9,6 +9,11 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface VirtualItemMapper extends CrudMapper<VirtualItem> {
/**
* 按字典顺序分页查询启用中的虚拟物。
*/
List<VirtualItem> findEnabled(@Param("offset") int offset, @Param("limit") int limit);
@Override
Optional<VirtualItem> findById(Long id);
@@ -0,0 +1,67 @@
package com.hongzhen.hera.nfc;
import com.hongzhen.hera.common.ApiResponse;
import com.hongzhen.hera.data.UserNfcBinding;
import com.hongzhen.hera.security.CurrentUser;
import java.util.List;
import javax.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 登录用户的 NFC 设备操作接口,用户身份一律取 JWT 上下文。
*/
@RestController
@RequestMapping("/api/nfc")
public class NfcController {
private final NfcService service;
public NfcController(NfcService service) { this.service = service; }
/**
* 扫描设备状态。
*/
@PostMapping("/scan")
public ApiResponse<NfcDtos.ChipView> scan(@Valid @RequestBody NfcDtos.ChipRequest request) {
return ApiResponse.ok(service.scan(CurrentUser.id(), request.getUid()));
}
/**
* 绑定设备及其虚拟物。
*/
@PostMapping("/bind")
public ApiResponse<NfcDtos.ChipView> bind(@Valid @RequestBody NfcDtos.ChipRequest request) {
return ApiResponse.ok(service.bind(CurrentUser.id(), request.getUid()));
}
/**
* 解绑设备,同步撤销不再有设备支持的虚拟物。
*/
@PostMapping("/unbind")
public ApiResponse<Void> unbind(@Valid @RequestBody NfcDtos.ChipRequest request) {
service.unbind(CurrentUser.id(), request.getUid());
return ApiResponse.ok(null);
}
/**
* 分页读取本人当前设备。
*/
@GetMapping("/devices")
public ApiResponse<List<NfcDtos.ChipView>> list(@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.list(CurrentUser.id(), offset, limit));
}
/**
* 分页读取本人绑定历史。
*/
@GetMapping("/bindings")
public ApiResponse<List<UserNfcBinding>> history(@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.history(CurrentUser.id(), offset, limit));
}
}
@@ -0,0 +1,150 @@
package com.hongzhen.hera.nfc;
import java.util.List;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
import javax.validation.constraints.Size;
/**
* NFC 业务请求与扫描响应,不接收客户端指定的资产归属和状态。
*/
public final class NfcDtos {
private NfcDtos() { }
/**
* 读取到的芯片 UID,必须对应已经入库的硬件。
*/
public static class ChipRequest {
/**
* 芯片 UID,按入库值传入,不改变大小写。
*/
@NotBlank @Size(max = 64)
private String uid;
public String getUid() { return uid; }
public void setUid(String uid) { this.uid = uid; }
}
/**
* 公开设备状态,不暴露其他用户 ID 或芯片 NDEF 内容。
*/
public static class ChipView {
/**
* 芯片唯一标识。
*/
private final String uid;
/**
* 设备序列号,仅展示,不作为唯一的绑定凭证。
*/
private final String sn;
/**
* 芯片预置的虚拟物 ID。
*/
private final Integer virtualItemId;
/**
* 是否已经绑定用户。
*/
private final boolean bound;
/**
* 是否绑定当前登录用户。
*/
private final boolean boundToMe;
/**
* 本自然年剩余解绑次数,非持有人不可见。
*/
private final Integer remainingUnbinds;
public ChipView(String uid, String sn, Integer virtualItemId, boolean bound, boolean boundToMe,
Integer remainingUnbinds) {
this.uid = uid;
this.sn = sn;
this.virtualItemId = virtualItemId;
this.bound = bound;
this.boundToMe = boundToMe;
this.remainingUnbinds = remainingUnbinds;
}
public String getUid() { return uid; }
public String getSn() { return sn; }
public Integer getVirtualItemId() { return virtualItemId; }
public boolean isBound() { return bound; }
public boolean isBoundToMe() { return boundToMe; }
public Integer getRemainingUnbinds() { return remainingUnbinds; }
}
/**
* 当前用户虚拟物的互动请求。
*/
public static class OfferingRequest {
/**
* 当前用户持有关系 ID,不是虚拟物字典 ID。
*/
@NotNull @Positive
private Long userVirtualItemId;
/**
* 1 请神,2 上香,3 祈愿,4 功德,5 送神。
*/
@NotNull @Min(1) @Max(5)
private Integer offeringType;
/**
* 祈愿文字或备注。
*/
@Size(max = 500)
private String content;
/**
* 仅功德行为填写,范围为 1 至 1000000。
*/
@Min(1) @Max(1000000)
private Integer meritValue;
public Long getUserVirtualItemId() { return userVirtualItemId; }
public void setUserVirtualItemId(Long id) { this.userVirtualItemId = id; }
public Integer getOfferingType() { return offeringType; }
public void setOfferingType(Integer type) { this.offeringType = type; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public Integer getMeritValue() { return meritValue; }
public void setMeritValue(Integer meritValue) { this.meritValue = meritValue; }
}
/**
* 回向记录请求,不触发真实资产转赠。
*/
public static class TransferRequest {
/**
* 发起方持有关系 ID。
*/
@NotNull @Positive
private Long userVirtualItemId;
/**
* 接收方账号 ID,昵称从数据库生成快照。
*/
@NotNull @Positive
private Long toUserId;
/**
* 回向寄语。
*/
@Size(max = 500)
private String content;
public Long getUserVirtualItemId() { return userVirtualItemId; }
public void setUserVirtualItemId(Long id) { this.userVirtualItemId = id; }
public Long getToUserId() { return toUserId; }
public void setToUserId(Long toUserId) { this.toUserId = toUserId; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
}
/**
* 批量调整本人资产顺序,提交顺序对应从零开始的排序值。
*/
public static class SortRequest {
/**
* 待排序的持有关系 ID,最多一百项,不允许重复。
*/
@NotEmpty @Size(max = 100)
private List<@NotNull @Positive Long> ids;
public List<Long> getIds() { return ids; }
public void setIds(List<Long> ids) { this.ids = ids; }
}
}
@@ -0,0 +1,20 @@
package com.hongzhen.hera.nfc;
import java.time.ZoneId;
/**
* NFC 与虚拟物生命周期的业务常量。
*/
final class NfcRules {
static final int ACTIVE = 1;
static final int SENT_OFF = 0;
static final int EXPIRED = 3;
static final int MAX_YEARLY_UNBINDS = 2;
static final int MAX_PAGE_SIZE = 100;
static final int MAX_MERIT_VALUE = 1000000;
static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private NfcRules() { }
static int limit(int requested) { return Math.max(1, Math.min(requested, MAX_PAGE_SIZE)); }
}
@@ -0,0 +1,135 @@
package com.hongzhen.hera.nfc;
import com.hongzhen.hera.common.BusinessException;
import com.hongzhen.hera.data.NfcChip;
import com.hongzhen.hera.data.NfcChipMapper;
import com.hongzhen.hera.data.UserAccount;
import com.hongzhen.hera.data.UserNfcBinding;
import com.hongzhen.hera.data.UserNfcBindingMapper;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* 设备唯一归属与解绑额度管理,设备、流水、资产在同一事务中写入。
*/
@Service
public class NfcService {
private final NfcChipMapper chips;
private final UserNfcBindingMapper bindings;
private final VirtualAssetService assets;
public NfcService(NfcChipMapper chips, UserNfcBindingMapper bindings, VirtualAssetService assets) {
this.chips = chips;
this.bindings = bindings;
this.assets = assets;
}
/**
* 扫描已入库芯片,只返回设备状态,不泄露当前持有人的身份。
*/
public NfcDtos.ChipView scan(Long userId, String uid) {
assets.account(userId, false);
return view(userId, find(uid, false));
}
/**
* 绑定设备并激活其预置资产;重复绑定本人设备不增加次数或重复发放。
*/
@Transactional
public NfcDtos.ChipView bind(Long userId, String uid) {
UserAccount user = assets.account(userId, true);
requireWechat(user);
NfcChip chip = find(uid, true);
LocalDateTime now = VirtualAssetService.now();
if (chip.getCurrentBindUserId() != null) {
if (!userId.equals(chip.getCurrentBindUserId())) {
throw new BusinessException("该设备已被其他用户绑定");
}
chips.saveBinding(chip);
return view(userId, chip);
}
chip.setCurrentBindUserId(userId);
chip.setBindCount(chip.getBindCount() + 1);
if (chip.getActivatedAt() == null) { chip.setActivatedAt(now); }
chips.saveBinding(chip);
UserNfcBinding binding = new UserNfcBinding();
binding.setNfcUid(chip.getUid());
binding.setUserId(userId);
binding.setBoundAt(now);
bindings.insert(binding);
assets.grant(userId, chip, now);
return view(userId, chip);
}
/**
* 仅当前持有人可解绑;每台设备每北京时间自然年最多解绑两次。
*/
@Transactional
public void unbind(Long userId, String uid) {
assets.account(userId, true);
NfcChip chip = find(uid, true);
if (!userId.equals(chip.getCurrentBindUserId())) {
throw new BusinessException("只能解绑本人当前绑定的设备");
}
LocalDateTime now = VirtualAssetService.now();
int used = annualUnbinds(chip, now.getYear());
if (used >= NfcRules.MAX_YEARLY_UNBINDS) {
throw new BusinessException("该设备本年度解绑次数已用完,最多两次");
}
if (bindings.finishBinding(chip.getUid(), userId, now) != 1) {
throw new BusinessException("设备绑定历史不一致,请联系管理员核对");
}
chip.setCurrentBindUserId(null);
chip.setLastUnbindYear(now.getYear());
chip.setUnbindCountThisYear(used + 1);
chips.saveBinding(chip);
assets.revokeIfUnsupported(userId, chip.getVirtualItemId());
}
/**
* 查询当前用户绑定的设备。
*/
public List<NfcDtos.ChipView> list(Long userId, int offset, int limit) {
assets.account(userId, false);
return chips.findByUser(userId, Math.max(0, offset), NfcRules.limit(limit)).stream()
.map(chip -> view(userId, chip)).collect(Collectors.toList());
}
/**
* 查询本人完整绑定、解绑历史。
*/
public List<UserNfcBinding> history(Long userId, int offset, int limit) {
assets.account(userId, false);
return bindings.findByUser(userId, Math.max(0, offset), NfcRules.limit(limit));
}
private void requireWechat(UserAccount user) {
if (!StringUtils.hasText(user.getWechatOpenid()) || !StringUtils.hasText(user.getMobile())) {
throw new BusinessException("请先完成微信登录和手机号绑定");
}
}
private NfcChip find(String uid, boolean lock) {
if (!StringUtils.hasText(uid) || uid.length() > 64) {
throw new BusinessException("芯片 UID 不正确");
}
return (lock ? chips.lockByUid(uid.trim()) : chips.findByUid(uid.trim()))
.orElseThrow(() -> new BusinessException("设备未入库或不存在"));
}
private int annualUnbinds(NfcChip chip, int year) {
return Integer.valueOf(year).equals(chip.getLastUnbindYear()) ? chip.getUnbindCountThisYear() : 0;
}
private NfcDtos.ChipView view(Long userId, NfcChip chip) {
boolean mine = userId.equals(chip.getCurrentBindUserId());
Integer remaining = mine ? Math.max(0, NfcRules.MAX_YEARLY_UNBINDS
- annualUnbinds(chip, VirtualAssetService.now().getYear())) : null;
return new NfcDtos.ChipView(chip.getUid(), chip.getSn(), chip.getVirtualItemId(),
chip.getCurrentBindUserId() != null, mine, remaining);
}
}
@@ -0,0 +1,26 @@
package com.hongzhen.hera.nfc;
import com.hongzhen.hera.common.BusinessException;
/**
* 供奉行为类型,功德仅记录数值,不表示支付或可消费余额。
*/
public enum OfferingType {
INVITE(1), INCENSE(2), WISH(3), MERIT(4), SEND_OFF(5);
private final int code;
OfferingType(int code) { this.code = code; }
public int getCode() { return code; }
/**
* 将客户端类型转换为受支持的供奉行为。
*/
public static OfferingType from(Integer code) {
for (OfferingType type : values()) {
if (Integer.valueOf(type.code).equals(code)) { return type; }
}
throw new BusinessException("不支持的供奉类型");
}
}
@@ -0,0 +1,96 @@
package com.hongzhen.hera.nfc;
import com.hongzhen.hera.common.ApiResponse;
import com.hongzhen.hera.data.UserVirtualItem;
import com.hongzhen.hera.data.UserVirtualItemOffering;
import com.hongzhen.hera.data.UserVirtualItemTransfer;
import com.hongzhen.hera.data.VirtualItem;
import com.hongzhen.hera.security.CurrentUser;
import java.util.List;
import javax.validation.Valid;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 虚拟物字典、本人资产、供奉与只记账回向接口。
*/
@RestController
public class VirtualAssetController {
private final VirtualAssetService service;
public VirtualAssetController(VirtualAssetService service) { this.service = service; }
/**
* 获取已启用的虚拟物字典。
*/
@GetMapping("/api/virtual-items")
public ApiResponse<List<VirtualItem>> catalog(@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.catalog(CurrentUser.id(), offset, limit));
}
/**
* 获取本人持有资产列表。
*/
@GetMapping("/api/user/virtual-items")
public ApiResponse<List<UserVirtualItem>> list(@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.list(CurrentUser.id(), offset, limit));
}
/**
* 获取本人持有资产详情。
*/
@GetMapping("/api/user/virtual-items/{id}")
public ApiResponse<UserVirtualItem> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(CurrentUser.id(), id));
}
/**
* 更新本人资产排序。
*/
@PutMapping("/api/user/virtual-items/sort")
public ApiResponse<Void> sort(@Valid @RequestBody NfcDtos.SortRequest request) {
service.sort(CurrentUser.id(), request.getIds());
return ApiResponse.ok(null);
}
/**
* 发起一次供奉行为。
*/
@PostMapping("/api/user/virtual-items/offerings")
public ApiResponse<UserVirtualItemOffering> offer(@Valid @RequestBody NfcDtos.OfferingRequest request) {
return ApiResponse.ok(service.offer(CurrentUser.id(), request));
}
/**
* 获取本人供奉历史。
*/
@GetMapping("/api/user/virtual-items/offerings")
public ApiResponse<List<UserVirtualItemOffering>> offerings(@RequestParam(required = false) Long holdingId,
@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.offerings(CurrentUser.id(), holdingId, offset, limit));
}
/**
* 记录一次回向,不发生资产转移。
*/
@PostMapping("/api/user/virtual-items/transfers")
public ApiResponse<UserVirtualItemTransfer> dedicate(@Valid @RequestBody NfcDtos.TransferRequest request) {
return ApiResponse.ok(service.dedicate(CurrentUser.id(), request));
}
/**
* 获取本人参与的回向历史。
*/
@GetMapping("/api/user/virtual-items/transfers")
public ApiResponse<List<UserVirtualItemTransfer>> transfers(@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit) {
return ApiResponse.ok(service.transfers(CurrentUser.id(), offset, limit));
}
}
@@ -0,0 +1,278 @@
package com.hongzhen.hera.nfc;
import com.hongzhen.hera.common.BusinessException;
import com.hongzhen.hera.data.NfcChip;
import com.hongzhen.hera.data.NfcChipMapper;
import com.hongzhen.hera.data.UserAccount;
import com.hongzhen.hera.data.UserAccountMapper;
import com.hongzhen.hera.data.UserVirtualItem;
import com.hongzhen.hera.data.UserVirtualItemMapper;
import com.hongzhen.hera.data.UserVirtualItemOffering;
import com.hongzhen.hera.data.UserVirtualItemOfferingMapper;
import com.hongzhen.hera.data.UserVirtualItemTransfer;
import com.hongzhen.hera.data.UserVirtualItemTransferMapper;
import com.hongzhen.hera.data.VirtualItem;
import com.hongzhen.hera.data.VirtualItemMapper;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* 虚拟物资产、供奉与回向业务,写入前统一锁定用户账号以串行化资产状态变化。
*/
@Service
public class VirtualAssetService {
private final UserAccountMapper accounts;
private final VirtualItemMapper dictionary;
private final UserVirtualItemMapper holdings;
private final UserVirtualItemOfferingMapper offerings;
private final UserVirtualItemTransferMapper transfers;
private final NfcChipMapper chips;
public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary,
UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings,
UserVirtualItemTransferMapper transfers, NfcChipMapper chips) {
this.accounts = accounts;
this.dictionary = dictionary;
this.holdings = holdings;
this.offerings = offerings;
this.transfers = transfers;
this.chips = chips;
}
/**
* 校验账号可用;写链路使用行锁,避免同用户多设备同时创建重复资产。
*/
UserAccount account(Long id, boolean lock) {
UserAccount user = (lock ? accounts.findByIdForUpdate(id) : accounts.findById(id))
.orElseThrow(() -> new BusinessException("用户不存在"));
if (!Integer.valueOf(NfcRules.ACTIVE).equals(user.getStatus())) {
throw new BusinessException("账号已停用");
}
return user;
}
/**
* 查询当前启用的虚拟物字典。
*/
public List<VirtualItem> catalog(Long userId, int offset, int limit) {
account(userId, false);
return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit));
}
/**
* 查询本人资产,包含送神或解绑失效记录,便于展示历史。
*/
public List<UserVirtualItem> list(Long userId, int offset, int limit) {
account(userId, false);
return holdings.findByUser(userId, Math.max(0, offset), NfcRules.limit(limit));
}
/**
* 查询本人持有关系,不允许通过主键读取他人资产。
*/
public UserVirtualItem get(Long userId, Long id) {
account(userId, false);
return ownHolding(userId, id);
}
private UserVirtualItem ownHolding(Long userId, Long id) {
UserVirtualItem item = holdings.findById(id)
.orElseThrow(() -> new BusinessException("虚拟物不存在或无权访问"));
if (!userId.equals(item.getUserId())) {
throw new BusinessException("虚拟物不存在或无权访问");
}
return item;
}
private void enabled(Integer id) {
VirtualItem item = dictionary.findById(id.longValue())
.orElseThrow(() -> new BusinessException("芯片预置虚拟物不存在"));
if (!Integer.valueOf(NfcRules.ACTIVE).equals(item.getEnabled())) {
throw new BusinessException("该虚拟物已停用");
}
}
/**
* 绑定成功后授予或恢复资产,必须由持有账号锁的绑定事务调用。
*/
void grant(Long userId, NfcChip chip, LocalDateTime now) {
if (chip.getVirtualItemId() == null) { return; }
enabled(chip.getVirtualItemId());
Optional<UserVirtualItem> existing = holdings.findByUserAndItem(userId, chip.getVirtualItemId());
UserVirtualItem item;
if (!existing.isPresent()) {
item = new UserVirtualItem();
item.setUserId(userId);
item.setVirtualItemId(chip.getVirtualItemId());
item.setNfcUid(chip.getUid());
item.setStatus(NfcRules.ACTIVE);
item.setObtainedAt(now);
holdings.insert(item);
} else {
item = existing.get();
if (Integer.valueOf(NfcRules.ACTIVE).equals(item.getStatus())) { return; }
holdings.updateStatus(item.getId(), userId, NfcRules.ACTIVE);
}
recordOffering(item, OfferingType.INVITE, null, null, now);
}
/**
* 最后一台同种设备解绑后使资产失效,保留首次来源与历史流水。
*/
void revokeIfUnsupported(Long userId, Integer itemId) {
if (itemId == null || chips.countBoundItem(userId, itemId) > 0) { return; }
holdings.findByUserAndItem(userId, itemId)
.ifPresent(item -> holdings.updateStatus(item.getId(), userId, NfcRules.EXPIRED));
}
/**
* 记录请神、上香、祈愿、功德或送神,状态变化与流水在同一事务提交。
*/
@Transactional
public UserVirtualItemOffering offer(Long userId, NfcDtos.OfferingRequest request) {
account(userId, true);
UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId());
OfferingType type = OfferingType.from(request.getOfferingType());
validateMerit(type, request.getMeritValue());
validateContent(request.getContent());
enabled(item.getVirtualItemId());
requireBoundDevice(userId, item);
if (type == OfferingType.INVITE) {
if (!Integer.valueOf(NfcRules.SENT_OFF).equals(item.getStatus())) {
throw new BusinessException("只有已送神的虚拟物可以重新请神");
}
holdings.updateStatus(item.getId(), userId, NfcRules.ACTIVE);
} else {
requireActive(item);
if (type == OfferingType.SEND_OFF) {
holdings.updateStatus(item.getId(), userId, NfcRules.SENT_OFF);
}
}
return recordOffering(item, type, request.getContent(), request.getMeritValue(), now());
}
private void requireActive(UserVirtualItem item) {
if (!Integer.valueOf(NfcRules.ACTIVE).equals(item.getStatus())) {
throw new BusinessException("虚拟物未在供奉中");
}
}
private void requireBoundDevice(Long userId, UserVirtualItem item) {
if (chips.countBoundItem(userId, item.getVirtualItemId()) == 0) {
throw new BusinessException("请先绑定对应的 NFC 设备");
}
}
private void validateMerit(OfferingType type, Integer value) {
if (type == OfferingType.MERIT) {
if (value == null || value < 1 || value > NfcRules.MAX_MERIT_VALUE) {
throw new BusinessException("功德值必须为 1 至 1000000");
}
} else if (value != null) {
throw new BusinessException("只有功德行为可以填写功德值");
}
}
private void validateContent(String content) {
if (content != null && content.length() > 500) {
throw new BusinessException("内容不能超过 500 字");
}
}
private UserVirtualItemOffering recordOffering(UserVirtualItem item, OfferingType type,
String content, Integer merit, LocalDateTime now) {
UserVirtualItemOffering record = new UserVirtualItemOffering();
record.setUserVirtualItemId(item.getId());
record.setUserId(item.getUserId());
record.setVirtualItemId(item.getVirtualItemId());
record.setOfferingType(type.getCode());
record.setContent(content);
record.setMeritValue(merit);
record.setOfferedAt(now);
record.setStatus(NfcRules.ACTIVE);
offerings.insert(record);
return offerings.findById(record.getId()).orElseThrow(() -> new BusinessException("供奉记录保存失败"));
}
/**
* 仅记录回向,双方资产、来源和状态均保持不变。
*/
@Transactional
public UserVirtualItemTransfer dedicate(Long userId, NfcDtos.TransferRequest request) {
Long targetId = request.getToUserId();
if (targetId == null || targetId <= 0 || userId.equals(targetId)) {
throw new BusinessException("请选择其他有效用户进行回向");
}
// 始终按账号 ID 升序加锁,避免双方相互回向时死锁。
UserAccount first = account(Math.min(userId, targetId), true);
UserAccount second = account(Math.max(userId, targetId), true);
UserAccount from = first.getId().equals(userId) ? first : second;
UserAccount to = first.getId().equals(targetId) ? first : second;
UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId());
requireActive(item);
enabled(item.getVirtualItemId());
requireBoundDevice(userId, item);
validateContent(request.getContent());
UserVirtualItemTransfer record = new UserVirtualItemTransfer();
record.setFromUserId(userId);
record.setFromUserName(displayName(from));
record.setToUserId(targetId);
record.setToUserName(displayName(to));
record.setVirtualItemId(item.getVirtualItemId());
record.setFromUserItemId(item.getId());
holdings.findByUserAndItem(targetId, item.getVirtualItemId())
.ifPresent(target -> record.setToUserItemId(target.getId()));
record.setContent(request.getContent());
record.setStatus(NfcRules.ACTIVE);
record.setTransferredAt(now());
transfers.insert(record);
return transfers.findById(record.getId()).orElseThrow(() -> new BusinessException("回向记录保存失败"));
}
private String displayName(UserAccount user) {
return StringUtils.hasText(user.getNickname()) ? user.getNickname() : "用户" + user.getId();
}
/**
* 按客户端顺序设置本人资产的排序值,任一 ID 不合法则整批回滚。
*/
@Transactional
public void sort(Long userId, List<Long> ids) {
account(userId, true);
if (ids == null || ids.isEmpty() || ids.size() > NfcRules.MAX_PAGE_SIZE
|| new HashSet<>(ids).size() != ids.size()) {
throw new BusinessException("排序列表不能为空、重复或超过 100 项");
}
for (Long id : ids) {
if (id == null || id <= 0) { throw new BusinessException("无效的资产 ID"); }
ownHolding(userId, id);
}
for (int index = 0; index < ids.size(); index++) {
holdings.updateSort(ids.get(index), userId, index);
}
}
/**
* 分页读取本人供奉历史,可按本人持有关系筛选。
*/
public List<UserVirtualItemOffering> offerings(Long userId, Long holdingId, int offset, int limit) {
account(userId, false);
if (holdingId != null) { ownHolding(userId, holdingId); }
return offerings.findByUser(userId, holdingId, Math.max(0, offset), NfcRules.limit(limit));
}
/**
* 分页读取本人发出或收到的回向历史。
*/
public List<UserVirtualItemTransfer> transfers(Long userId, int offset, int limit) {
account(userId, false);
return transfers.findByUser(userId, Math.max(0, offset), NfcRules.limit(limit));
}
static LocalDateTime now() { return LocalDateTime.now(NfcRules.BUSINESS_ZONE); }
}
@@ -1,6 +1,5 @@
package com.hongzhen.hera.security;
import com.hongzhen.hera.common.BusinessException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
@@ -22,7 +21,7 @@ public class AuthInterceptor implements HandlerInterceptor {
}
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
throw new BusinessException("请先登录");
throw new AuthenticationException("请先登录");
}
CurrentUser.set(jwtService.parseUserId(auth.substring(7)));
return true;
@@ -0,0 +1,10 @@
package com.hongzhen.hera.security;
/**
* 登录凭证缺失或失效;统一返回401,避免将JWT解析细节暴露给前端。
*/
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}
@@ -2,6 +2,7 @@ package com.hongzhen.hera.security;
import com.hongzhen.hera.config.HeraProperties;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.JwtException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
@@ -27,7 +28,16 @@ public class JwtService {
.compact();
}
/** 校验登录JWT并读取正数用户ID,不对客户端暴露解析细节。 */
public Long parseUserId(String token) {
return Long.valueOf(Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody().getSubject());
try {
Long userId = Long.valueOf(Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody().getSubject());
if (userId <= 0) {
throw new AuthenticationException("登录凭证无效,请重新登录");
}
return userId;
} catch (JwtException | IllegalArgumentException exception) {
throw new AuthenticationException("登录状态已过期,请重新登录");
}
}
}
@@ -13,12 +13,10 @@ 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;
@@ -1,114 +0,0 @@
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)");
}
}
+15
View File
@@ -4,6 +4,21 @@ server:
spring:
application:
name: hera
redis:
host: "${REDIS_HOST:101.43.139.56}"
port: "${REDIS_PORT:6379}"
username: "${REDIS_USERNAME:}"
password: "${REDIS_PASSWORD:}"
database: "${REDIS_DATABASE:0}"
connect-timeout: 3s
timeout: 3s
ssl: "${REDIS_SSL:false}"
lettuce:
shutdown-timeout: 100ms
data:
redis:
repositories:
enabled: false
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: "${DB_URL:jdbc:mysql://101.43.139.56:3306/hera?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}"
@@ -0,0 +1,59 @@
-- 仅由部署人员在目标数据库手工执行,不在应用启动时执行。
-- 执行前备份并暂停 NFC 写请求;MySQL DDL 会隐式提交。
-- 已存在同名列时不重复添加;本脚本不删除表、历史记录或资产。
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'nfc_chip' AND column_name = 'current_bind_user_id') = 0,
'ALTER TABLE nfc_chip ADD COLUMN current_bind_user_id BIGINT UNSIGNED NULL COMMENT ''当前绑定用户''', 'DO 0');
PREPARE nfc_stmt FROM @ddl;
EXECUTE nfc_stmt;
DEALLOCATE PREPARE nfc_stmt;
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'nfc_chip' AND column_name = 'unbind_count_this_year') = 0,
'ALTER TABLE nfc_chip ADD COLUMN unbind_count_this_year INT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''本自然年解绑次数''', 'DO 0');
PREPARE nfc_stmt FROM @ddl;
EXECUTE nfc_stmt;
DEALLOCATE PREPARE nfc_stmt;
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'nfc_chip' AND column_name = 'last_unbind_year') = 0,
'ALTER TABLE nfc_chip ADD COLUMN last_unbind_year INT NULL COMMENT ''最近解绑所属北京时间年份''', 'DO 0');
PREPARE nfc_stmt FROM @ddl;
EXECUTE nfc_stmt;
DEALLOCATE PREPARE nfc_stmt;
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'nfc_chip' AND column_name = 'bind_count') = 0,
'ALTER TABLE nfc_chip ADD COLUMN bind_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''累计绑定次数''', 'DO 0');
PREPARE nfc_stmt FROM @ddl;
EXECUTE nfc_stmt;
DEALLOCATE PREPARE nfc_stmt;
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'nfc_chip' AND index_name = 'idx_chip_owner_item') = 0,
'ALTER TABLE nfc_chip ADD KEY idx_chip_owner_item (current_bind_user_id, virtual_item_id)', 'DO 0');
PREPARE nfc_stmt FROM @ddl;
EXECUTE nfc_stmt;
DEALLOCATE PREPARE nfc_stmt;
CREATE TABLE IF NOT EXISTS user_nfc_binding (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '绑定流水 ID',
nfc_uid VARCHAR(64) NOT NULL COMMENT '芯片 UID 快照',
user_id BIGINT UNSIGNED NOT NULL COMMENT '绑定用户 ID',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1 绑定中,0 已解绑',
bound_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
unbound_at 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),
KEY idx_binding_user (user_id, status, id),
KEY idx_binding_chip (nfc_uid, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 NFC 绑定历史';
-- 已有设备归属但缺少流水时补当前绑定快照,不虚构此前绑定、解绑历史。
INSERT INTO user_nfc_binding (nfc_uid, user_id, status, bound_at)
SELECT c.uid, c.current_bind_user_id, 1, COALESCE(c.activated_at, CURRENT_TIMESTAMP)
FROM nfc_chip c
WHERE c.current_bind_user_id IS NOT NULL
AND NOT EXISTS (SELECT b.id FROM user_nfc_binding b WHERE b.nfc_uid = c.uid AND b.status = 1);
+29 -8
View File
@@ -4,8 +4,9 @@
<mapper namespace="com.hongzhen.hera.data.NfcChipMapper">
<sql id="BaseColumnList">
id, uid, chip_type, manufacturer, capacity, ndef_content, holder_type, virtual_item_id,
activated_at, last_login_at, created_at, updated_at
id, uid, sn, chip_type, manufacturer, capacity, ndef_content, holder_type, virtual_item_id,
activated_at, current_bind_user_id, unbind_count_this_year,
last_unbind_year, bind_count, created_at, updated_at
</sql>
<select id="findById" resultType="com.hongzhen.hera.data.NfcChip">
@@ -21,27 +22,47 @@
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO nfc_chip (
uid, chip_type, manufacturer, capacity, ndef_content, holder_type, virtual_item_id,
activated_at, last_login_at
uid, sn, chip_type, manufacturer, capacity, ndef_content, holder_type, virtual_item_id,
activated_at
) VALUES (
#{uid}, #{chipType}, #{manufacturer}, #{capacity}, #{ndefContent}, #{holderType},
#{virtualItemId}, #{activatedAt}, #{lastLoginAt}
#{uid}, #{sn}, #{chipType}, #{manufacturer}, #{capacity}, #{ndefContent}, #{holderType},
#{virtualItemId}, #{activatedAt}
)
</insert>
<update id="update">
UPDATE nfc_chip
SET uid = #{uid},
sn = #{sn},
chip_type = #{chipType},
manufacturer = #{manufacturer},
capacity = #{capacity},
ndef_content = #{ndefContent},
holder_type = #{holderType},
virtual_item_id = #{virtualItemId},
activated_at = #{activatedAt},
last_login_at = #{lastLoginAt}
activated_at = #{activatedAt}
WHERE id = #{id}
</update>
<delete id="deleteById">DELETE FROM nfc_chip WHERE id = #{id}</delete>
<select id="findByUid" resultType="com.hongzhen.hera.data.NfcChip">
SELECT <include refid="BaseColumnList"/> FROM nfc_chip WHERE uid = #{uid}
</select>
<select id="lockByUid" resultType="com.hongzhen.hera.data.NfcChip">
SELECT <include refid="BaseColumnList"/> FROM nfc_chip WHERE uid = #{uid} FOR UPDATE
</select>
<select id="findByUser" resultType="com.hongzhen.hera.data.NfcChip">
SELECT <include refid="BaseColumnList"/> FROM nfc_chip
WHERE current_bind_user_id = #{userId} ORDER BY id DESC LIMIT #{offset}, #{limit}
</select>
<select id="countBoundItem" resultType="int">
SELECT COUNT(id) FROM nfc_chip WHERE current_bind_user_id = #{userId} AND virtual_item_id = #{itemId}
</select>
<update id="saveBinding">
UPDATE nfc_chip SET current_bind_user_id = #{currentBindUserId},
unbind_count_this_year = #{unbindCountThisYear}, last_unbind_year = #{lastUnbindYear},
bind_count = #{bindCount}, activated_at = #{activatedAt},
updated_at = CURRENT_TIMESTAMP WHERE id = #{id}
</update>
</mapper>
@@ -4,7 +4,7 @@
<mapper namespace="com.hongzhen.hera.data.UserAccountMapper">
<sql id="BaseColumnList">
id, username, nickname, password_hash, mobile, email, nfc_uid, wechat_openid, wechat_unionid,
id, username, nickname, password_hash, mobile, email, wechat_openid, wechat_unionid,
status, register_country_code, register_region_code, register_city, register_timezone,
created_at, updated_at
</sql>
@@ -44,10 +44,10 @@
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user_account (
username, nickname, password_hash, mobile, email, nfc_uid, wechat_openid, wechat_unionid,
username, nickname, password_hash, mobile, email, wechat_openid, wechat_unionid,
status, register_country_code, register_region_code, register_city, register_timezone
) VALUES (
#{username}, #{nickname}, #{passwordHash}, #{mobile}, #{email}, #{nfcUid}, #{wechatOpenid},
#{username}, #{nickname}, #{passwordHash}, #{mobile}, #{email}, #{wechatOpenid},
#{wechatUnionid}, COALESCE(#{status}, 1), #{registerCountryCode}, #{registerRegionCode},
#{registerCity}, #{registerTimezone}
)
@@ -60,7 +60,6 @@
password_hash = COALESCE(#{passwordHash}, password_hash),
mobile = #{mobile},
email = #{email},
nfc_uid = #{nfcUid},
wechat_openid = #{wechatOpenid},
wechat_unionid = #{wechatUnionid},
status = COALESCE(#{status}, status),
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hongzhen.hera.data.UserNfcBindingMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user_nfc_binding (nfc_uid, user_id, status, bound_at)
VALUES (#{nfcUid}, #{userId}, 1, #{boundAt})
</insert>
<update id="finishBinding">
UPDATE user_nfc_binding SET status = 0, unbound_at = #{now}, updated_at = #{now}
WHERE nfc_uid = #{uid} AND user_id = #{userId} AND status = 1
</update>
<select id="findByUser" resultType="com.hongzhen.hera.data.UserNfcBinding">
SELECT id, nfc_uid, user_id, status, bound_at, unbound_at, created_at, updated_at
FROM user_nfc_binding WHERE user_id = #{userId} ORDER BY id DESC LIMIT #{offset}, #{limit}
</select>
</mapper>
@@ -38,4 +38,20 @@
</update>
<delete id="deleteById">DELETE FROM user_virtual_item WHERE id = #{id}</delete>
<select id="findByUserAndItem" resultType="com.hongzhen.hera.data.UserVirtualItem">
SELECT <include refid="BaseColumnList"/> FROM user_virtual_item
WHERE user_id = #{userId} AND virtual_item_id = #{itemId}
</select>
<select id="findByUser" resultType="com.hongzhen.hera.data.UserVirtualItem">
SELECT <include refid="BaseColumnList"/> FROM user_virtual_item
WHERE user_id = #{userId} ORDER BY sort_order, id LIMIT #{offset}, #{limit}
</select>
<update id="updateStatus">
UPDATE user_virtual_item SET status = #{status}, updated_at = CURRENT_TIMESTAMP
WHERE id = #{id} AND user_id = #{userId}
</update>
<update id="updateSort">
UPDATE user_virtual_item SET sort_order = #{sortOrder}, updated_at = CURRENT_TIMESTAMP
WHERE id = #{id} AND user_id = #{userId}
</update>
</mapper>
@@ -43,4 +43,10 @@
</update>
<delete id="deleteById">DELETE FROM user_virtual_item_offering WHERE id = #{id}</delete>
<select id="findByUser" resultType="com.hongzhen.hera.data.UserVirtualItemOffering">
SELECT <include refid="BaseColumnList"/> FROM user_virtual_item_offering
WHERE user_id = #{userId}
<if test="holdingId != null">AND user_virtual_item_id = #{holdingId}</if>
ORDER BY id DESC LIMIT #{offset}, #{limit}
</select>
</mapper>
@@ -46,4 +46,9 @@
</update>
<delete id="deleteById">DELETE FROM user_virtual_item_transfer WHERE id = #{id}</delete>
<select id="findByUser" resultType="com.hongzhen.hera.data.UserVirtualItemTransfer">
SELECT <include refid="BaseColumnList"/> FROM user_virtual_item_transfer
WHERE from_user_id = #{userId} OR to_user_id = #{userId}
ORDER BY id DESC LIMIT #{offset}, #{limit}
</select>
</mapper>
@@ -36,4 +36,8 @@
</update>
<delete id="deleteById">DELETE FROM virtual_item WHERE id = #{id}</delete>
<select id="findEnabled" resultType="com.hongzhen.hera.data.VirtualItem">
SELECT <include refid="BaseColumnList"/> FROM virtual_item
WHERE enabled = 1 ORDER BY sort_order, id LIMIT #{offset}, #{limit}
</select>
</mapper>
+21 -5
View File
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS sms_code (
CREATE TABLE IF NOT EXISTS nfc_chip (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uid VARCHAR(64) NOT NULL,
sn VARCHAR(64) NOT NULL COMMENT '设备序列号',
chip_type VARCHAR(50) NULL,
manufacturer VARCHAR(50) NULL,
capacity INT UNSIGNED NULL,
@@ -29,15 +30,18 @@ CREATE TABLE IF NOT EXISTS nfc_chip (
holder_type TINYINT UNSIGNED NULL,
virtual_item_id TINYINT UNSIGNED NULL,
activated_at DATETIME NULL,
last_login_at DATETIME NULL,
current_bind_user_id BIGINT UNSIGNED NULL COMMENT '当前绑定用户,未绑定时为空',
unbind_count_this_year INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '本自然年解绑次数,每芯片最多两次',
last_unbind_year INT NULL COMMENT '最近一次解绑所属年份,按北京时间计算',
bind_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计成功绑定次数',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_uid (uid),
KEY idx_chip_owner_item (current_bind_user_id, virtual_item_id),
KEY idx_holder_type (holder_type),
KEY idx_virtual_item_id (virtual_item_id),
KEY idx_activated_at (activated_at),
KEY idx_last_login_at (last_login_at)
KEY idx_activated_at (activated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_account (
@@ -47,7 +51,6 @@ CREATE TABLE IF NOT EXISTS user_account (
password_hash VARCHAR(100) NULL COMMENT '密码哈希;纯微信账号未设置密码时为空',
mobile VARCHAR(20) NULL,
email VARCHAR(100) NULL,
nfc_uid VARCHAR(64) NULL,
wechat_openid VARCHAR(64) NULL,
wechat_unionid VARCHAR(64) NULL,
status TINYINT NOT NULL DEFAULT 1,
@@ -61,7 +64,6 @@ CREATE TABLE IF NOT EXISTS user_account (
UNIQUE KEY uk_username (username),
UNIQUE KEY uk_mobile (mobile),
UNIQUE KEY uk_email (email),
UNIQUE KEY uk_nfc_uid (nfc_uid),
UNIQUE KEY uk_wechat_openid (wechat_openid),
KEY idx_wechat_unionid (wechat_unionid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -98,6 +100,20 @@ CREATE TABLE IF NOT EXISTS user_profile (
CONSTRAINT fk_profile_user FOREIGN KEY (user_id) REFERENCES user_account (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_nfc_binding (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '绑定流水 ID',
nfc_uid VARCHAR(64) NOT NULL COMMENT '芯片 UID 快照',
user_id BIGINT UNSIGNED NOT NULL COMMENT '绑定用户 ID',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1 绑定中,0 已解绑',
bound_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
unbound_at 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),
KEY idx_binding_user (user_id, status, id),
KEY idx_binding_chip (nfc_uid, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 NFC 绑定历史';
CREATE TABLE IF NOT EXISTS virtual_item (
id INT UNSIGNED NOT NULL,
item_name VARCHAR(50) NOT NULL,
@@ -0,0 +1,42 @@
package com.hongzhen.hera.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import static org.junit.jupiter.api.Assertions.*;
/**
* 验证 Redis 自动配置与自定义序列化,无需连接真实 Redis。
*/
class RedisConfigTest {
@Test
void exposesSingleStringTemplateWithConnectionProperties() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RedisAutoConfiguration.class))
.withUserConfiguration(RedisConfig.class)
.withPropertyValues("spring.redis.host=127.0.0.1", "spring.redis.port=6380",
"spring.redis.database=2", "spring.redis.password=test-only-password",
"spring.redis.connect-timeout=3s", "spring.redis.timeout=3s")
.run(context -> {
assertNull(context.getStartupFailure());
StringRedisTemplate template = context.getBean(StringRedisTemplate.class);
assertSame(template, context.getBean("redisTemplate"));
assertSame(template, context.getBean("stringRedisTemplate"));
assertInstanceOf(StringRedisSerializer.class, template.getKeySerializer());
assertInstanceOf(StringRedisSerializer.class, template.getValueSerializer());
assertInstanceOf(StringRedisSerializer.class, template.getHashKeySerializer());
assertInstanceOf(StringRedisSerializer.class, template.getHashValueSerializer());
LettuceConnectionFactory factory = context.getBean(LettuceConnectionFactory.class);
assertEquals("127.0.0.1", factory.getHostName());
assertEquals(6380, factory.getPort());
assertEquals(2, factory.getDatabase());
assertTrue(factory.getStandaloneConfiguration().getPassword().isPresent());
});
}
}
@@ -0,0 +1,48 @@
package com.hongzhen.hera.config;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.junit.jupiter.api.Assertions.*;
/**
* 显式启用的 Redis 真实联调;仅读写并清理本次创建的随机测试键。
*/
@EnabledIfSystemProperty(named = "hera.redis.integration", matches = "true")
@SpringBootTest(classes = {RedisConfig.class, RedisIntegrationTest.TestConfiguration.class})
class RedisIntegrationTest {
@org.springframework.boot.test.context.TestConfiguration(proxyBeanMethods = false)
@ImportAutoConfiguration(RedisAutoConfiguration.class)
static class TestConfiguration {
}
@Autowired
private StringRedisTemplate redis;
@Test
void authenticatesAndRoundTripsExpiringValue() {
try (RedisConnection connection = redis.getConnectionFactory().getConnection()) {
assertEquals("PONG", connection.ping());
}
String key = "hera:test:redis:" + UUID.randomUUID();
try {
assertTrue(Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key, "redis-连接验证", Duration.ofSeconds(60))));
assertEquals("redis-连接验证", redis.opsForValue().get(key));
Long ttl = redis.getExpire(key, TimeUnit.SECONDS);
assertNotNull(ttl);
assertTrue(ttl > 0 && ttl <= 60);
} finally {
redis.delete(key);
}
assertFalse(Boolean.TRUE.equals(redis.hasKey(key)));
}
}
@@ -0,0 +1,205 @@
package com.hongzhen.hera.nfc;
import com.hongzhen.hera.data.NfcChip;
import com.hongzhen.hera.data.NfcChipMapper;
import com.hongzhen.hera.data.UserAccountMapper;
import com.hongzhen.hera.data.UserNfcBindingMapper;
import com.hongzhen.hera.data.UserVirtualItemMapper;
import com.hongzhen.hera.data.UserVirtualItemOfferingMapper;
import com.hongzhen.hera.data.UserVirtualItemTransferMapper;
import com.hongzhen.hera.data.VirtualItemMapper;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.mybatis.spring.SqlSessionFactoryBean;
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.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;
import org.springframework.util.StreamUtils;
import static org.junit.jupiter.api.Assertions.*;
/**
* 显式启用的 MySQL XML 验证,仅向连接级临时表写入,关闭连接后自动清理,不改业务表。
*/
@EnabledIfSystemProperty(named = "hera.nfc.mysql.integration", matches = "true")
class NfcMySqlIntegrationTest {
@Test
void mysqlChipDefinitionIsReadOnly() throws Exception {
try (Connection connection = openConnection()) {
connection.setReadOnly(true);
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SHOW CREATE TABLE nfc_chip")) {
assertTrue(result.next());
System.out.println(result.getString(2));
}
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(
"SELECT data_type, character_maximum_length, is_nullable, column_default FROM information_schema.columns "
+ "WHERE table_schema = DATABASE() AND table_name = 'nfc_chip' AND column_name = 'sn'")) {
assertTrue(result.next());
assertEquals("varchar", result.getString(1));
assertEquals(64, result.getInt(2));
assertEquals("NO", result.getString(3));
assertNull(result.getString(4));
}
}
}
private Connection openConnection() throws Exception {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
FileSystemResource local = new FileSystemResource(".hera-local.yml");
yaml.setResources(local.exists()
? new org.springframework.core.io.Resource[]{new ClassPathResource("application.yml"), local}
: new org.springframework.core.io.Resource[]{new ClassPathResource("application.yml")});
Properties values = yaml.getObject();
assertNotNull(values);
StandardEnvironment env = new StandardEnvironment();
String url = env.resolveRequiredPlaceholders(values.getProperty("spring.datasource.url"));
String username = env.resolveRequiredPlaceholders(values.getProperty("spring.datasource.username"));
String password = env.resolveRequiredPlaceholders(values.getProperty("spring.datasource.password", ""));
return DriverManager.getConnection(url, username, password);
}
@Test
void mysqlXmlUsesIsolatedTemporaryTablesAndReportsDeploymentSchemaGaps() throws Exception {
try (Connection connection = openConnection()) {
reportSchema(connection);
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(new SingleConnectionDataSource(connection, true));
Configuration config = new Configuration();
config.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(config);
factory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
connection.setReadOnly(true);
try (SqlSession session = factory.getObject().openSession()) {
// limit=0 让现库解析全部 XML 查询字段,但不读取用户业务记录。
assertTrue(session.getMapper(NfcChipMapper.class).findAll(0, 0).isEmpty());
assertTrue(session.getMapper(UserNfcBindingMapper.class).findByUser(-1L, 0, 0).isEmpty());
assertTrue(session.getMapper(UserVirtualItemMapper.class).findByUser(-1L, 0, 0).isEmpty());
assertTrue(session.getMapper(UserVirtualItemOfferingMapper.class).findByUser(-1L, null, 0, 0).isEmpty());
assertTrue(session.getMapper(UserVirtualItemTransferMapper.class).findByUser(-1L, 0, 0).isEmpty());
assertTrue(session.getMapper(VirtualItemMapper.class).findEnabled(0, 0).isEmpty());
assertTrue(session.getMapper(UserAccountMapper.class).findAll(0, 0).isEmpty());
}
connection.setReadOnly(false);
createTemporaryTables(connection);
connection.setAutoCommit(false);
try (SqlSession session = factory.getObject().openSession()) {
NfcChipMapper chips = session.getMapper(NfcChipMapper.class);
UserVirtualItemMapper holdings = session.getMapper(UserVirtualItemMapper.class);
UserVirtualItemOfferingMapper offerings = session.getMapper(UserVirtualItemOfferingMapper.class);
UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class);
UserNfcBindingMapper bindings = session.getMapper(UserNfcBindingMapper.class);
VirtualAssetService assets = new VirtualAssetService(session.getMapper(UserAccountMapper.class),
session.getMapper(VirtualItemMapper.class), holdings, offerings, transfers, chips);
NfcService nfc = new NfcService(chips, bindings, assets);
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) "
+ "VALUES (1, 'nfc-test-1', 'test-one', '13800000001', 'wx-one'), "
+ "(2, 'nfc-test-2', 'test-two', '13800000002', 'wx-two')");
statement.executeUpdate("INSERT INTO virtual_item(id, item_name) VALUES (1, 'test-item')");
}
NfcChip chip = new NfcChip();
chip.setUid("temporary-test-chip");
chip.setSn("MYSQL-SN-INITIAL");
chip.setVirtualItemId(1);
assertEquals(1, chips.insert(chip));
assertEquals(chip.getSn(), chips.findById(chip.getId()).get().getSn());
chip.setSn("MYSQL-SN-UPDATED");
assertEquals(1, chips.update(chip));
assertEquals(chip.getSn(), chips.findByUid(chip.getUid()).get().getSn());
assertEquals(chip.getSn(), nfc.bind(1L, "temporary-test-chip").getSn());
nfc.bind(1L, "temporary-test-chip");
assertEquals(1, bindings.findByUser(1L, 0, 100).size());
long holdingId = holdings.findByUserAndItem(1L, 1).get().getId();
NfcDtos.OfferingRequest offer = new NfcDtos.OfferingRequest();
offer.setUserVirtualItemId(holdingId);
offer.setOfferingType(OfferingType.MERIT.getCode());
offer.setMeritValue(8);
assets.offer(1L, offer);
assertEquals(2, offerings.findByUser(1L, holdingId, 0, 100).size());
NfcDtos.TransferRequest dedication = new NfcDtos.TransferRequest();
dedication.setUserVirtualItemId(holdingId);
dedication.setToUserId(2L);
assets.dedicate(1L, dedication);
assertEquals(1, transfers.findByUser(2L, 0, 100).size());
assertFalse(holdings.findByUserAndItem(2L, 1).isPresent());
assets.sort(1L, Arrays.asList(holdingId));
nfc.unbind(1L, "temporary-test-chip");
assertEquals(chip.getSn(), chips.findByUid(chip.getUid()).get().getSn());
assertEquals(3, holdings.findById(holdingId).get().getStatus());
assertNull(chips.findByUid("temporary-test-chip").get().getCurrentBindUserId());
session.rollback(true);
assertTrue(chips.findByUser(1L, 0, 100).isEmpty());
assertFalse(chips.findByUid("temporary-test-chip").isPresent());
assertTrue(bindings.findByUser(1L, 0, 100).isEmpty());
assertTrue(holdings.findByUser(1L, 0, 100).isEmpty());
}
}
}
private void createTemporaryTables(Connection connection) throws Exception {
String ddl;
try (InputStream input = new ClassPathResource("schema.sql").getInputStream()) {
ddl = StreamUtils.copyToString(input, StandardCharsets.UTF_8);
}
int created = 0;
try (Statement sql = connection.createStatement()) {
for (String statement : ddl.split(";")) {
String trimmed = statement.trim();
if (trimmed.matches("(?s)CREATE TABLE IF NOT EXISTS (nfc_chip|user_account|virtual_item|user_nfc_binding|user_virtual_item|user_virtual_item_offering|user_virtual_item_transfer) \\(.*")) {
sql.execute(trimmed.replaceFirst("CREATE TABLE IF NOT EXISTS", "CREATE TEMPORARY TABLE"));
created++;
}
}
}
assertEquals(7, created, "必须先成功创建全部临时表,禁止退回正式表执行测试写入");
}
private void reportSchema(Connection connection) throws Exception {
List<String> missing = new ArrayList<>();
for (String column : Arrays.asList("sn", "current_bind_user_id", "unbind_count_this_year", "last_unbind_year", "bind_count")) {
try (PreparedStatement sql = connection.prepareStatement("SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_schema = DATABASE() AND table_name = 'nfc_chip' AND column_name = ?")) {
sql.setString(1, column);
try (ResultSet result = sql.executeQuery()) {
result.next();
if (result.getInt(1) == 0) { missing.add("nfc_chip." + column); }
}
}
}
try (Statement sql = connection.createStatement(); ResultSet result = sql.executeQuery(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'user_nfc_binding'")) {
result.next();
if (result.getInt(1) == 0) { missing.add("user_nfc_binding"); }
}
System.out.println("NFC deployment schema gaps (read-only check): " + missing);
for (String table : Arrays.asList("nfc_chip", "user_nfc_binding", "user_virtual_item",
"user_virtual_item_offering", "user_virtual_item_transfer", "virtual_item")) {
List<String> columns = new ArrayList<>();
try (PreparedStatement sql = connection.prepareStatement("SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position")) {
sql.setString(1, table);
try (ResultSet result = sql.executeQuery()) {
while (result.next()) { columns.add(result.getString(1)); }
}
}
System.out.println("NFC current columns: " + table + " " + columns);
}
}
}
@@ -0,0 +1,348 @@
package com.hongzhen.hera.nfc;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hongzhen.hera.common.BusinessException;
import com.hongzhen.hera.data.NfcChip;
import com.hongzhen.hera.data.NfcChipMapper;
import com.hongzhen.hera.security.JwtService;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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.core.io.ClassPathResource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.StreamUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
/**
* 在独立 H2 MySQL 模式测试库验证 Controller、事务、真实 XML 与并发绑定链路。
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:h2:mem:nfc-workflow;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000",
"spring.datasource.driver-class-name=org.h2.Driver", "spring.datasource.username=sa",
"spring.datasource.password=", "spring.sql.init.mode=never",
"hera.jwt-secret=nfc-workflow-test-secret-nfc-workflow-test-secret"})
@AutoConfigureMockMvc
class NfcWorkflowTest {
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@Autowired private ObjectMapper json;
@Autowired private JwtService jwt;
@Autowired private NfcService nfc;
@Autowired private NfcChipMapper chips;
@BeforeEach
void schema() throws Exception {
String ddl;
try (InputStream input = new ClassPathResource("schema.sql").getInputStream()) {
ddl = StreamUtils.copyToString(input, StandardCharsets.UTF_8);
}
ddl = ddl.replace("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "")
.replace(" STORED", "");
for (String statement : ddl.split(";")) {
Matcher table = Pattern.compile("CREATE TABLE IF NOT EXISTS (\\w+)").matcher(statement);
if (table.find()) {
// H2 的索引名在 schema 内唯一,MySQL 则在每张表内唯一。
statement = statement.replaceAll("(KEY\\s+)(\\w+)(\\s*\\()", "$1" + table.group(1) + "_$2$3");
jdbc.execute(statement);
}
}
// 仅清理此独立内存测试库,生产数据库不会被加载。
for (String table : Arrays.asList("user_nfc_binding", "user_virtual_item_offering",
"user_virtual_item_transfer", "user_virtual_item", "nfc_chip", "virtual_item", "user_profile", "user_account")) {
jdbc.update("DELETE FROM " + table);
}
for (long id = 1; id <= 3; id++) {
jdbc.update("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) VALUES (?, ?, ?, ?, ?)",
id, "account" + id, "昵称" + id, "1380000000" + id, "openid" + id);
}
jdbc.update("INSERT INTO virtual_item(id, item_name, sort_order) VALUES (1, '财神', 2), (2, '文昌星', 1)");
jdbc.update("INSERT INTO virtual_item(id, item_name, enabled) VALUES (3, '停用项', 0)");
jdbc.update("INSERT INTO nfc_chip(uid, sn, virtual_item_id) VALUES ('chip-a', 'SN-A', 1), "
+ "('chip-b', 'SN-B', 1), ('chip-c', 'SN-C', 2), ('disabled-chip', 'SN-DISABLED', 3)");
}
@Test
void serialNumberIsPersistedByCrudAndReturnedWithoutChangingUidBinding() throws Exception {
NfcChip chip = new NfcChip();
chip.setUid("crud-chip");
chip.setSn(String.join("", Collections.nCopies(64, "S")));
assertEquals(1, chips.insert(chip));
assertEquals(chip.getSn(), chips.findById(chip.getId()).get().getSn());
assertEquals(chip.getSn(), chips.findByUid(chip.getUid()).get().getSn());
chip.setSn("SN-UPDATED");
assertEquals(1, chips.update(chip));
assertEquals("SN-UPDATED", chips.findById(chip.getId()).get().getSn());
assertTrue(chips.findAll(0, 100).stream().anyMatch(row -> "SN-UPDATED".equals(row.getSn())));
assertEquals(1, chips.deleteById(chip.getId()));
JsonNode bound = postJson(1, "/api/nfc/bind", "{\"uid\":\"chip-a\",\"sn\":\"client-value\"}", 200);
assertEquals("SN-A", bound.path("sn").asText());
assertEquals("SN-A", postJson(1, "/api/nfc/scan", "{\"uid\":\"chip-a\"}", 200).path("sn").asText());
assertEquals("SN-A", getJson(1, "/api/nfc/devices").get(0).path("sn").asText());
assertEquals("SN-A", chips.findByUser(1L, 0, 100).get(0).getSn());
nfc.unbind(1L, "chip-a");
assertEquals("SN-A", chips.findByUid("chip-a").get().getSn());
}
@Test
void serialNumberMatchesNotNullLengthAndNonUniqueDatabaseDefinition() {
NfcChip chip = new NfcChip();
chip.setUid("sn-validation-chip");
assertThrows(DataIntegrityViolationException.class, () -> chips.insert(chip));
chip.setSn(String.join("", Collections.nCopies(65, "S")));
assertThrows(DataIntegrityViolationException.class, () -> chips.insert(chip));
chip.setSn("SN-A");
assertEquals(1, chips.insert(chip));
assertEquals("SN-A", chips.findById(chip.getId()).get().getSn());
}
@Test
void bindingIsIdempotentAndSupportsManyDevicesButOneOwner() throws Exception {
postJson(1, "/api/nfc/bind", "{\"uid\":\"chip-a\"}", 200);
postJson(1, "/api/nfc/bind", "{\"uid\":\"chip-a\"}", 200);
postJson(2, "/api/nfc/bind", "{\"uid\":\"chip-a\"}", 400);
postJson(1, "/api/nfc/bind", "{\"uid\":\"chip-b\"}", 200);
postJson(1, "/api/nfc/bind", "{\"uid\":\"chip-c\"}", 200);
assertEquals(1, integer("SELECT bind_count FROM nfc_chip WHERE uid = 'chip-a'"));
assertEquals(3, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE status = 1"));
assertEquals(2, integer("SELECT COUNT(id) FROM user_virtual_item WHERE user_id = 1"));
assertEquals(2, integer("SELECT COUNT(id) FROM user_virtual_item_offering WHERE offering_type = 1"));
assertEquals("chip-a", jdbc.queryForObject("SELECT nfc_uid FROM user_virtual_item WHERE user_id = 1 AND virtual_item_id = 1", String.class));
JsonNode scan = postJson(2, "/api/nfc/scan", "{\"uid\":\"chip-a\"}", 200);
assertTrue(scan.path("bound").asBoolean());
assertFalse(scan.path("boundToMe").asBoolean());
assertTrue(scan.path("remainingUnbinds").isNull());
assertFalse(scan.has("currentBindUserId"));
assertEquals(3, getJson(1, "/api/nfc/devices?limit=1000").size());
assertEquals(0, getJson(2, "/api/nfc/bindings").size());
assertEquals(2, getJson(1, "/api/virtual-items").get(0).path("id").asInt());
}
@Test
void unbindQuotaBelongsToChipAndResetsOnNewCalendarYear() throws Exception {
nfc.bind(1L, "chip-a");
postJson(2, "/api/nfc/unbind", "{\"uid\":\"chip-a\"}", 400);
nfc.unbind(1L, "chip-a");
assertEquals(3, integer("SELECT status FROM user_virtual_item WHERE user_id = 1"));
postJson(1, "/api/nfc/unbind", "{\"uid\":\"chip-a\"}", 400);
nfc.bind(2L, "chip-a");
nfc.unbind(2L, "chip-a");
nfc.bind(1L, "chip-a");
postJson(1, "/api/nfc/unbind", "{\"uid\":\"chip-a\"}", 400);
assertEquals(2, integer("SELECT unbind_count_this_year FROM nfc_chip WHERE uid = 'chip-a'"));
assertEquals(1L, jdbc.queryForObject("SELECT current_bind_user_id FROM nfc_chip WHERE uid = 'chip-a'", Long.class));
assertEquals(1, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE status = 1"));
int lastYear = LocalDate.now(ZoneId.of("Asia/Shanghai")).getYear() - 1;
jdbc.update("UPDATE nfc_chip SET last_unbind_year = ? WHERE uid = 'chip-a'", lastYear);
nfc.unbind(1L, "chip-a");
assertEquals(1, integer("SELECT unbind_count_this_year FROM nfc_chip WHERE uid = 'chip-a'"));
assertEquals(0, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE status = 1"));
assertEquals(0, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE unbound_at IS NULL"));
}
@Test
void unbindRevokesOnlyLastSameKindDeviceAndRebindPreservesFirstSource() throws Exception {
nfc.bind(1L, "chip-a");
nfc.bind(1L, "chip-b");
nfc.unbind(1L, "chip-a");
assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE user_id = 1"));
nfc.unbind(1L, "chip-b");
assertEquals(3, integer("SELECT status FROM user_virtual_item WHERE user_id = 1"));
long id = holding(1, 1);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 400);
nfc.bind(1L, "chip-b");
assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE user_id = 1"));
assertEquals(id, holding(1, 1));
assertEquals("chip-a", jdbc.queryForObject("SELECT nfc_uid FROM user_virtual_item WHERE id = ?", String.class, id));
}
@Test
void failedGrantRollsBackChipBindingHistoryAndCounters() throws Exception {
postJson(1, "/api/nfc/bind", "{\"uid\":\"disabled-chip\"}", 400);
assertEquals(0, integer("SELECT bind_count FROM nfc_chip WHERE uid = 'disabled-chip'"));
assertNull(jdbc.queryForObject("SELECT current_bind_user_id FROM nfc_chip WHERE uid = 'disabled-chip'", Long.class));
assertEquals(0, integer("SELECT COUNT(id) FROM user_nfc_binding"));
postJson(1, "/api/nfc/bind", "{\"uid\":\"not-registered\"}", 400);
jdbc.update("UPDATE user_account SET wechat_openid = NULL WHERE id = 1");
postJson(1, "/api/nfc/bind", "{\"uid\":\"chip-a\"}", 400);
jdbc.update("UPDATE user_account SET status = 0 WHERE id = 2");
postJson(2, "/api/nfc/bind", "{\"uid\":\"chip-a\"}", 400);
}
@Test
void offeringLifecycleAndMeritAreValidatedAndScopedToOwner() throws Exception {
nfc.bind(1L, "chip-a");
long id = holding(1, 1);
postJson(2, "/api/user/virtual-items/offerings", offering(id, 2, ""), 400);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ",\"meritValue\":10"), 400);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ""), 400);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ",\"meritValue\":-1"), 400);
for (int type : Arrays.asList(2, 3, 4, 5)) {
postJson(1, "/api/user/virtual-items/offerings", offering(id, type, type == 4 ? ",\"meritValue\":12" : ""), 200);
}
assertEquals(0, integer("SELECT status FROM user_virtual_item WHERE id = " + id));
postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ""), 400);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 200);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 400);
assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE id = " + id));
assertEquals(12, integer("SELECT merit_value FROM user_virtual_item_offering WHERE offering_type = 4"));
assertEquals(6, getJson(1, "/api/user/virtual-items/offerings?holdingId=" + id).size());
assertEquals(0, getJson(2, "/api/user/virtual-items/offerings").size());
}
@Test
void dedicationRecordsSnapshotsWithoutTransferringOrCreatingAssets() throws Exception {
nfc.bind(1L, "chip-a");
long id = holding(1, 1);
String request = "{\"userVirtualItemId\":" + id + ",\"toUserId\":2,\"content\":\"祝福\"}";
JsonNode record = postJson(1, "/api/user/virtual-items/transfers", request, 200);
assertEquals("昵称1", record.path("fromUserName").asText());
assertEquals("昵称2", record.path("toUserName").asText());
assertTrue(record.path("toUserItemId").isNull());
assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item"));
assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE id = " + id));
nfc.bind(2L, "chip-b");
JsonNode withTarget = postJson(1, "/api/user/virtual-items/transfers", request, 200);
assertEquals(holding(2, 1), withTarget.path("toUserItemId").asLong());
jdbc.update("UPDATE user_account SET nickname = '新昵称' WHERE id = 2");
assertEquals("昵称2", getJson(2, "/api/user/virtual-items/transfers").get(0).path("toUserName").asText());
assertEquals(0, getJson(3, "/api/user/virtual-items/transfers").size());
postJson(3, "/api/user/virtual-items/transfers", request, 400);
postJson(1, "/api/user/virtual-items/transfers", request.replace("\"toUserId\":2", "\"toUserId\":1"), 400);
postJson(1, "/api/user/virtual-items/transfers", request.replace("\"toUserId\":2", "\"toUserId\":9999"), 400);
assertEquals(2, integer("SELECT COUNT(id) FROM user_virtual_item WHERE status = 1"));
}
@Test
void sortingAndReadsCannotAccessOthersAndGenericCrudCannotBypassRules() throws Exception {
nfc.bind(1L, "chip-a");
nfc.bind(1L, "chip-c");
nfc.bind(2L, "chip-b");
long first = holding(1, 1);
long second = holding(1, 2);
long other = holding(2, 1);
putSort(1, Arrays.asList(second, first), 200);
assertEquals(second, getJson(1, "/api/user/virtual-items").get(0).path("id").asLong());
putSort(1, Arrays.asList(first, other), 400);
putSort(1, Arrays.asList(first, first), 400);
assertEquals(1, integer("SELECT sort_order FROM user_virtual_item WHERE id = " + first));
assertEquals(400, mvc.perform(get("/api/user/virtual-items/" + other)
.header("Authorization", token(1))).andReturn().getResponse().getStatus());
for (String path : Arrays.asList("nfc-chips", "user-virtual-items", "offerings", "transfers",
"virtual-items", "user-accounts", "user-profiles")) {
mvc.perform(post("/api/data/" + path).header("Authorization", token(1))
.contentType(MediaType.APPLICATION_JSON).content("{}")).andExpect(result ->
assertTrue(result.getResponse().getStatus() >= 400));
}
assertEquals(401, mvc.perform(get("/api/nfc/devices")).andReturn().getResponse().getStatus());
}
@Test
void concurrentUsersCannotBothBindOneChip() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<Boolean> first = executor.submit(() -> attemptBind(1L, start));
Future<Boolean> second = executor.submit(() -> attemptBind(2L, start));
start.countDown();
assertNotEquals(first.get(15, TimeUnit.SECONDS), second.get(15, TimeUnit.SECONDS));
assertEquals(1, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE status = 1"));
assertEquals(1, integer("SELECT bind_count FROM nfc_chip WHERE uid = 'chip-a'"));
assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item"));
} finally {
executor.shutdownNow();
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
}
}
private boolean attemptBind(Long userId, CountDownLatch start) throws InterruptedException {
start.await();
try { nfc.bind(userId, "chip-a"); return true; }
catch (BusinessException expected) { return false; }
}
@Test
void concurrentSameUserDevicesGrantOnlyOneAsset() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<?> first = executor.submit(() -> {
start.await();
return nfc.bind(1L, "chip-a");
});
Future<?> second = executor.submit(() -> {
start.await();
return nfc.bind(1L, "chip-b");
});
start.countDown();
first.get(15, TimeUnit.SECONDS);
second.get(15, TimeUnit.SECONDS);
assertEquals(2, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE status = 1"));
assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item"));
assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_offering"));
} finally {
executor.shutdownNow();
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test
void inconsistentBindingHistoryDoesNotConsumeQuotaOrRevokeAsset() throws Exception {
nfc.bind(1L, "chip-a");
jdbc.update("UPDATE user_nfc_binding SET status = 0 WHERE nfc_uid = 'chip-a'");
postJson(1, "/api/nfc/unbind", "{\"uid\":\"chip-a\"}", 400);
assertEquals(0, integer("SELECT unbind_count_this_year FROM nfc_chip WHERE uid = 'chip-a'"));
assertEquals(1L, jdbc.queryForObject("SELECT current_bind_user_id FROM nfc_chip WHERE uid = 'chip-a'", Long.class));
assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE user_id = 1"));
}
private String token(long userId) { return "Bearer " + jwt.issue(userId); }
private int integer(String sql) { return jdbc.queryForObject(sql, Integer.class); }
private long holding(long userId, int itemId) {
return jdbc.queryForObject("SELECT id FROM user_virtual_item WHERE user_id = ? AND virtual_item_id = ?",
Long.class, userId, itemId);
}
private String offering(long id, int type, String extra) {
return "{\"userVirtualItemId\":" + id + ",\"offeringType\":" + type + extra + "}";
}
private JsonNode postJson(long user, String path, String body, int expected) throws Exception {
MvcResult result = mvc.perform(post(path).header("Authorization", token(user))
.contentType(MediaType.APPLICATION_JSON).content(body)).andReturn();
assertEquals(expected, result.getResponse().getStatus(), result.getResponse().getContentAsString());
return json.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data");
}
private JsonNode getJson(long user, String path) throws Exception {
MvcResult result = mvc.perform(get(path).header("Authorization", token(user))).andReturn();
assertEquals(200, result.getResponse().getStatus());
return json.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data");
}
private void putSort(long user, List<Long> ids, int expected) throws Exception {
MvcResult result = mvc.perform(put("/api/user/virtual-items/sort").header("Authorization", token(user))
.contentType(MediaType.APPLICATION_JSON).content("{\"ids\":" + json.writeValueAsString(ids) + "}"))
.andReturn();
assertEquals(expected, result.getResponse().getStatus());
}
}
@@ -0,0 +1,82 @@
package com.hongzhen.hera.storage;
import com.hongzhen.hera.config.HeraProperties;
import java.net.HttpURLConnection;
import java.net.URL;
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 static org.junit.jupiter.api.Assertions.*;
/**
* 显式启用的本地文件网关只读检查,不上传、删除或改写文件,不输出访问凭证。
*/
@EnabledIfSystemProperty(named = "hera.file.read.integration", matches = "true")
class FileGatewayReadIntegrationTest {
@Test
void existingAvatarLoadsThroughConfiguredGatewayAndPrivateCos() throws Exception {
String reference = System.getenv("HERA_FILE_READ_REFERENCE");
assertNotNull(reference, "请通过环境变量指定本次已授权检查的头像对象");
assertTrue(reference.startsWith("cos://hera/avatar/"));
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
FileSystemResource local = new FileSystemResource(".hera-local.yml");
yaml.setResources(new ClassPathResource("application.yml"), local);
Properties values = yaml.getObject();
assertNotNull(values);
StandardEnvironment environment = new StandardEnvironment();
CosProperties cos = new CosProperties();
cos.setSecretId(resolve(environment, values, "hera.cos.secret-id"));
cos.setSecretKey(resolve(environment, values, "hera.cos.secret-key"));
cos.setBucket(resolve(environment, values, "hera.cos.bucket"));
cos.setRegion(resolve(environment, values, "hera.cos.region"));
cos.setPublicBaseUrl(resolve(environment, values, "hera.cos.public-base-url"));
cos.setSignedUrlSeconds(Integer.parseInt(resolve(environment, values, "hera.cos.signed-url-seconds")));
HeraProperties hera = new HeraProperties();
hera.setJwtSecret(resolve(environment, values, "hera.jwt-secret"));
CosStorageService storage = new CosStorageService(cos);
try {
FileAccessService access = new FileAccessService(storage, cos, hera);
String entry = access.resolveUrl(reference);
assertTrue(entry.startsWith("http://127.0.0.1:8080/file/access?token="), "仅允许检查本地签名入口");
HttpURLConnection gateway = (HttpURLConnection) new URL(entry).openConnection();
gateway.setInstanceFollowRedirects(false);
gateway.setConnectTimeout(10000);
gateway.setReadTimeout(10000);
String location;
try {
assertEquals(302, gateway.getResponseCode(), "本地文件入口应返回签名跳转");
location = gateway.getHeaderField("Location");
assertEquals("no-store", gateway.getHeaderField("Cache-Control"));
} finally {
gateway.disconnect();
}
assertNotNull(location);
assertTrue(location.startsWith("https://"), "COS 文件必须经 HTTPS 读取");
HttpURLConnection download = (HttpURLConnection) new URL(location).openConnection();
download.setConnectTimeout(10000);
download.setReadTimeout(10000);
try {
assertEquals(200, download.getResponseCode(), "COS 签名读取应成功");
assertNotNull(download.getContentType());
assertTrue(download.getContentType().startsWith("image/"), "返回内容应为头像图片");
try (java.io.InputStream input = download.getInputStream()) {
assertTrue(input.read() >= 0, "头像图片不得为空");
}
} finally {
download.disconnect();
}
} finally {
storage.close();
}
}
private String resolve(StandardEnvironment environment, Properties values, String name) {
return environment.resolveRequiredPlaceholders(values.getProperty(name, ""));
}
}
@@ -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 com.hongzhen.hera.storage.CosStorageService;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
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.mock.web.MockMultipartFile;
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.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 隔离H2验证注册、资料、头像Controller到Mapper链路;微信与COS网络替身不访问真实服务。
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:h2:mem:avatar-endpoint;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=avatar-endpoint-test-secret-avatar-endpoint-test-secret",
"hera.cos.public-base-url=http://localhost:8080/file/"})
@AutoConfigureMockMvc
@Transactional
class AvatarEndpointTest {
private static final int MAX_AVATAR_BYTES = 5 * 1024 * 1024;
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@Autowired private ObjectMapper json;
@MockBean private WechatClient wechat;
@MockBean private CosStorageService storage;
@BeforeEach
void initializeIsolatedSchema() throws Exception {
String ddl;
try (InputStream input = new ClassPathResource("schema.sql").getInputStream()) {
ddl = StreamUtils.copyToString(input, StandardCharsets.UTF_8);
}
ddl = ddl.replace("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'");
when(wechat.code2Session(anyString())).thenAnswer(call -> new WxSession("endpoint-" + call.getArgument(0), null));
when(storage.upload(anyLong(), any(), eq(true), eq(".png")))
.thenAnswer(call -> "cos://hera/avatar/" + call.getArgument(0) + "/endpoint.png");
when(storage.resolveUrl(anyString())).thenReturn("https://example.invalid/avatar.png");
}
@Test
void registrationProfileAndAvatarUseBackendStorageAndPersistedReference() throws Exception {
String code = UUID.randomUUID().toString();
JsonNode login = register("13800001001", code);
String token = login.path("token").asText();
long id = login.path("user").path("id").asLong();
assertTrue(login.path("user").path("birthday").isNull());
mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON).content("{\"nickname\":\"头像联调测试\",\"gender\":\"女\","
+ "\"birthday\":\"1998-06-18\",\"birthHour\":\"辰时\",\"province\":\"北京市\",\"city\":\"北京市\",\"district\":\"朝阳区\"}"))
.andExpect(status().isOk());
JsonNode uploaded = body(mvc.perform(multipart("/api/user/avatar").file(png())
.header("Authorization", "Bearer " + token)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8));
verify(storage).upload(eq(id), any(), eq(true), eq(".png"));
assertEquals("cos://hera/avatar/" + id + "/endpoint.png",
jdbc.queryForObject("SELECT avatar_url FROM user_profile WHERE user_id = ?", String.class, id));
assertEquals(4, uploaded.path("remainingEdits").asInt());
assertEquals("头像联调测试", uploaded.path("nickname").asText());
String url = uploaded.path("avatarUrl").asText();
assertTrue(url.startsWith("http://localhost:8080/file/access?token="));
mvc.perform(get("/file/access").param("token", url.substring(url.indexOf("token=") + 6)))
.andExpect(status().isFound()).andExpect(header().string("Location", "https://example.invalid/avatar.png"));
JsonNode profile = body(mvc.perform(get("/api/user/profile").header("Authorization", "Bearer " + token))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8));
assertTrue(profile.path("avatarUrl").asText().contains("/file/access?token="));
JsonNode repeated = post("/api/auth/wx-login", "{\"code\":\"" + code + "\"}");
assertTrue(repeated.path("phoneBound").asBoolean());
assertFalse(repeated.path("token").asText().isEmpty());
assertEquals(id, repeated.path("user").path("id").asLong());
}
@Test
void avatarDoesNotConsumeOrRequirePersonalEditQuota() throws Exception {
JsonNode login = register("13800001002", UUID.randomUUID().toString());
long id = login.path("user").path("id").asLong();
jdbc.update("UPDATE user_profile SET profile_edit_count = 5, profile_edit_window_start = CURRENT_TIMESTAMP WHERE user_id = ?", id);
mvc.perform(multipart("/api/user/avatar").file(png()).header("Authorization", "Bearer " + login.path("token").asText()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.remainingEdits").value(0));
assertEquals(5, jdbc.queryForObject("SELECT profile_edit_count FROM user_profile WHERE user_id = ?", Integer.class, id));
}
@Test
void rejectsUnsupportedOrOversizeAvatarBeforeStorage() throws Exception {
String token = register("13800001003", UUID.randomUUID().toString()).path("token").asText();
for (MockMultipartFile file : new MockMultipartFile[]{
new MockMultipartFile("file", "bad.gif", "image/gif", new byte[]{1}),
new MockMultipartFile("file", "large.png", "image/png", new byte[MAX_AVATAR_BYTES + 1])}) {
mvc.perform(multipart("/api/user/avatar").file(file).header("Authorization", "Bearer " + token))
.andExpect(status().isBadRequest());
}
verify(storage, never()).upload(anyLong(), any(), anyBoolean(), anyString());
}
@Test
void missingOrInvalidLoginReturns401WithoutJwtInternals() throws Exception {
mvc.perform(get("/api/user/profile")).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
mvc.perform(get("/api/user/profile").header("Authorization", "Bearer invalid-token"))
.andExpect(status().isUnauthorized()).andExpect(jsonPath("$.message").value("登录状态已过期,请重新登录"));
}
private MockMultipartFile png() {
return new MockMultipartFile("file", "avatar.png", "image/png", Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aBykAAAAASUVORK5CYII="));
}
private JsonNode register(String phone, String code) throws Exception {
JsonNode wx = post("/api/auth/wx-login", "{\"code\":\"" + code + "\"}");
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 payload) throws Exception {
return body(mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path)
.contentType(MediaType.APPLICATION_JSON).content(payload)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8));
}
private JsonNode body(String response) throws Exception {
return json.readTree(response).path("data");
}
}
@@ -0,0 +1,56 @@
package com.hongzhen.hera.user;
import com.hongzhen.hera.data.UserAccountMapper;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.Properties;
import java.util.UUID;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.mybatis.spring.SqlSessionFactoryBean;
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.jdbc.datasource.SingleConnectionDataSource;
import static org.junit.jupiter.api.Assertions.*;
/**
* 显式启用的现有数据库只读检查;不启动应用、不执行迁移,不修改账号或重放微信凭证。
*/
@EnabledIfSystemProperty(named = "hera.user.schema.integration", matches = "true")
class UserAccountSchemaIntegrationTest {
@Test
void currentDatabaseSupportsAccountQueriesWithoutNfcUid() 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();
String url = environment.resolveRequiredPlaceholders(values.getProperty("spring.datasource.url"));
String username = environment.resolveRequiredPlaceholders(values.getProperty("spring.datasource.username"));
String password = environment.resolveRequiredPlaceholders(values.getProperty("spring.datasource.password", ""));
try (Connection connection = DriverManager.getConnection(url, username, password)) {
connection.setReadOnly(true);
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(new SingleConnectionDataSource(connection, true));
factory.setMapperLocations(new ClassPathResource("mapper/UserAccountMapper.xml"));
try (SqlSession session = factory.getObject().openSession()) {
UserAccountMapper mapper = session.getMapper(UserAccountMapper.class);
String absent = "schema-check-" + UUID.randomUUID();
assertFalse(mapper.findByOpenId(absent).isPresent());
assertFalse(mapper.findByMobile(absent).isPresent());
assertFalse(mapper.findById(-1L).isPresent());
assertTrue(mapper.findAll(0, 0).isEmpty());
}
}
}
}
@@ -1,87 +0,0 @@
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));
}
}
@@ -4,6 +4,8 @@ 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 com.hongzhen.hera.data.UserAccount;
import com.hongzhen.hera.data.UserAccountMapper;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
@@ -12,6 +14,7 @@ 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.context.ApplicationContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -40,8 +43,9 @@ public class UserUnificationTest {
@Autowired private JdbcTemplate jdbc;
@Autowired private ObjectMapper json;
@Autowired private UserService userService;
@Autowired private UserAccountMapper accountMapper;
@Autowired private ApplicationContext context;
@MockBean private WechatClient wechatClient;
@MockBean private UserTableMigration migration;
private String openId;
@BeforeEach
@@ -66,6 +70,42 @@ public class UserUnificationTest {
return index == null || index < 1 || index > values.length ? null : values[index - 1];
}
@Test
void currentSchemaStartsWithoutLegacyMigrationOrTables() {
assertFalse(context.containsBean("userTableMigration"));
assertThrows(ClassNotFoundException.class,
() -> Class.forName("com.hongzhen.hera.user.UserTableMigration"));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables "
+ "WHERE table_name IN ('hera_user', 'hera_schema_migration')", Integer.class));
}
@Test
void accountCrudWorksWithoutRemovedNfcColumn() throws Exception {
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_name = 'user_account' AND column_name = 'nfc_uid'", Integer.class));
UserAccount account = new UserAccount();
account.setUsername("no_nfc_" + UUID.randomUUID().toString().replace("-", ""));
account.setNickname("无账号NFC字段");
account.setMobile("13800000009");
account.setWechatOpenid(openId);
assertEquals(1, accountMapper.insert(account));
Long id = account.getId();
assertNotNull(id);
assertEquals(id, accountMapper.findByOpenId(openId).get().getId());
assertEquals(id, accountMapper.findByMobile(account.getMobile()).get().getId());
assertEquals(id, accountMapper.findByIdForUpdate(id).get().getId());
assertTrue(accountMapper.findAll(0, 100).stream().anyMatch(item -> id.equals(item.getId())));
account.setNickname("更新后");
account.setEmail("schema-check@example.test");
assertEquals(1, accountMapper.update(account));
UserAccount updated = accountMapper.findById(id).get();
assertEquals("更新后", updated.getNickname());
assertEquals(account.getEmail(), updated.getEmail());
assertFalse(json.readTree(json.writeValueAsString(updated)).has("nfcUid"));
assertEquals(1, accountMapper.deleteById(id));
assertFalse(accountMapper.findById(id).isPresent());
}
@Test
void registrationProfileAvatarAndRepeatLoginUseFormalTables() throws Exception {
JsonNode login = register("13800000001");