feat: implement NFC lifecycle with SN support and Redis integration
This commit is contained in:
1 parent
ad5e305ad4
commit
e22e5c4024
53 files changed
+2252
-361
No files matched your search
@@ -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` 权限。
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user