diff --git a/README.md b/README.md index cf5428b..fb41872 100644 --- a/README.md +++ b/README.md @@ -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 `,并支持统一操作: +原 `/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 `,用户身份取 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` 权限。 diff --git a/pom.xml b/pom.xml index c4b4335..724e1d4 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-data-redis + org.mybatis.spring.boot mybatis-spring-boot-starter diff --git a/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java b/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java index 69cc0f6..f6f7695 100644 --- a/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java +++ b/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java @@ -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 handleAuthentication(AuthenticationException exception) { + return new ApiResponse(401, exception.getMessage(), null); + } + @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleBusiness(BusinessException exception) { diff --git a/src/main/java/com/hongzhen/hera/config/RedisConfig.java b/src/main/java/com/hongzhen/hera/config/RedisConfig.java new file mode 100644 index 0000000..b07a0b5 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/config/RedisConfig.java @@ -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); + } +} diff --git a/src/main/java/com/hongzhen/hera/data/NfcChip.java b/src/main/java/com/hongzhen/hera/data/NfcChip.java index db95ddd..f75c538 100644 --- a/src/main/java/com/hongzhen/hera/data/NfcChip.java +++ b/src/main/java/com/hongzhen/hera/data/NfcChip.java @@ -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; - } } diff --git a/src/main/java/com/hongzhen/hera/data/NfcChipController.java b/src/main/java/com/hongzhen/hera/data/NfcChipController.java deleted file mode 100644 index 380d900..0000000 --- a/src/main/java/com/hongzhen/hera/data/NfcChipController.java +++ /dev/null @@ -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 { - - public NfcChipController(NfcChipService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/NfcChipMapper.java b/src/main/java/com/hongzhen/hera/data/NfcChipMapper.java index b06677d..0ea92a8 100644 --- a/src/main/java/com/hongzhen/hera/data/NfcChipMapper.java +++ b/src/main/java/com/hongzhen/hera/data/NfcChipMapper.java @@ -9,6 +9,32 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface NfcChipMapper extends CrudMapper { + /** + * 按入库 UID 查询设备。 + */ + Optional findByUid(String uid); + + /** + * 锁定设备行,串行化不同用户的绑定请求。 + */ + Optional lockByUid(String uid); + + /** + * 查询用户当前持有的设备。 + */ + List 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 findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/data/UserAccount.java b/src/main/java/com/hongzhen/hera/data/UserAccount.java index c4ca911..69a6cc1 100644 --- a/src/main/java/com/hongzhen/hera/data/UserAccount.java +++ b/src/main/java/com/hongzhen/hera/data/UserAccount.java @@ -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; } diff --git a/src/main/java/com/hongzhen/hera/data/UserAccountController.java b/src/main/java/com/hongzhen/hera/data/UserAccountController.java deleted file mode 100644 index 734efdc..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserAccountController.java +++ /dev/null @@ -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 { - - public UserAccountController(UserAccountService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserNfcBinding.java b/src/main/java/com/hongzhen/hera/data/UserNfcBinding.java new file mode 100644 index 0000000..01cb5ea --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserNfcBinding.java @@ -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; } +} diff --git a/src/main/java/com/hongzhen/hera/data/UserNfcBindingMapper.java b/src/main/java/com/hongzhen/hera/data/UserNfcBindingMapper.java new file mode 100644 index 0000000..2ea2968 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserNfcBindingMapper.java @@ -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 findByUser(@Param("userId") Long userId, + @Param("offset") int offset, @Param("limit") int limit); +} diff --git a/src/main/java/com/hongzhen/hera/data/UserProfileController.java b/src/main/java/com/hongzhen/hera/data/UserProfileController.java deleted file mode 100644 index c89fff9..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserProfileController.java +++ /dev/null @@ -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 { - - public UserProfileController(UserProfileService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java index 4a3e8dc..a8bf88f 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java @@ -29,7 +29,7 @@ public class UserVirtualItem extends BaseEntity { private Integer sortOrder; /** - * 持有状态:1 供奉中,0 已送神,2 已回向,3 已过期。 + * 持有状态:1 供奉中,0 已送神,2 历史已回向,3 已失效(含解绑)。 */ private Integer status; diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemController.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemController.java deleted file mode 100644 index 8c540b6..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemController.java +++ /dev/null @@ -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 { - - public UserVirtualItemController(UserVirtualItemService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemMapper.java index 1e04e65..f8996b4 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemMapper.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemMapper.java @@ -9,6 +9,28 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface UserVirtualItemMapper extends CrudMapper { + /** + * 按用户与字典项查询唯一持有关系。 + */ + Optional findByUserAndItem(@Param("userId") Long userId, + @Param("itemId") Integer itemId); + + /** + * 按用户自定义顺序分页读取本人资产。 + */ + List 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 findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingController.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingController.java deleted file mode 100644 index d1a0134..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingController.java +++ /dev/null @@ -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 { - - public UserVirtualItemOfferingController(UserVirtualItemOfferingService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java index 5435fab..f2a0e5d 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java @@ -9,6 +9,12 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface UserVirtualItemOfferingMapper extends CrudMapper { + /** + * 分页读取用户供奉历史,可筛选本人持有关系。 + */ + List findByUser(@Param("userId") Long userId, + @Param("holdingId") Long holdingId, @Param("offset") int offset, @Param("limit") int limit); + @Override Optional findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java index 502db76..a882c76 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java @@ -39,7 +39,7 @@ public class UserVirtualItemTransfer extends BaseEntity { private Long fromUserItemId; /** - * 接收方新持有关系 ID。 + * 接收方已有的持有关系 ID 快照,未持有时为空;当前不创建接收资产。 */ private Long toUserItemId; diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferController.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferController.java deleted file mode 100644 index aeb7903..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferController.java +++ /dev/null @@ -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 { - - public UserVirtualItemTransferController(UserVirtualItemTransferService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java index b75f623..8b4b0c1 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java @@ -9,6 +9,12 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface UserVirtualItemTransferMapper extends CrudMapper { + /** + * 分页查询用户发出或接收的回向记录。 + */ + List findByUser(@Param("userId") Long userId, + @Param("offset") int offset, @Param("limit") int limit); + @Override Optional findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/data/VirtualItemController.java b/src/main/java/com/hongzhen/hera/data/VirtualItemController.java deleted file mode 100644 index 58710e9..0000000 --- a/src/main/java/com/hongzhen/hera/data/VirtualItemController.java +++ /dev/null @@ -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 { - - public VirtualItemController(VirtualItemService service) { - super(service); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java b/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java index ab95392..0848bf0 100644 --- a/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java +++ b/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java @@ -9,6 +9,11 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface VirtualItemMapper extends CrudMapper { + /** + * 按字典顺序分页查询启用中的虚拟物。 + */ + List findEnabled(@Param("offset") int offset, @Param("limit") int limit); + @Override Optional findById(Long id); diff --git a/src/main/java/com/hongzhen/hera/nfc/NfcController.java b/src/main/java/com/hongzhen/hera/nfc/NfcController.java new file mode 100644 index 0000000..0924e0a --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/NfcController.java @@ -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 scan(@Valid @RequestBody NfcDtos.ChipRequest request) { + return ApiResponse.ok(service.scan(CurrentUser.id(), request.getUid())); + } + + /** + * 绑定设备及其虚拟物。 + */ + @PostMapping("/bind") + public ApiResponse bind(@Valid @RequestBody NfcDtos.ChipRequest request) { + return ApiResponse.ok(service.bind(CurrentUser.id(), request.getUid())); + } + + /** + * 解绑设备,同步撤销不再有设备支持的虚拟物。 + */ + @PostMapping("/unbind") + public ApiResponse unbind(@Valid @RequestBody NfcDtos.ChipRequest request) { + service.unbind(CurrentUser.id(), request.getUid()); + return ApiResponse.ok(null); + } + + /** + * 分页读取本人当前设备。 + */ + @GetMapping("/devices") + public ApiResponse> list(@RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.ok(service.list(CurrentUser.id(), offset, limit)); + } + + /** + * 分页读取本人绑定历史。 + */ + @GetMapping("/bindings") + public ApiResponse> history(@RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.ok(service.history(CurrentUser.id(), offset, limit)); + } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java b/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java new file mode 100644 index 0000000..427f086 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java @@ -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 getIds() { return ids; } + public void setIds(List ids) { this.ids = ids; } + } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/NfcRules.java b/src/main/java/com/hongzhen/hera/nfc/NfcRules.java new file mode 100644 index 0000000..f631bb0 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/NfcRules.java @@ -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)); } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/NfcService.java b/src/main/java/com/hongzhen/hera/nfc/NfcService.java new file mode 100644 index 0000000..86d2f23 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/NfcService.java @@ -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 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 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); + } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/OfferingType.java b/src/main/java/com/hongzhen/hera/nfc/OfferingType.java new file mode 100644 index 0000000..c22a04a --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/OfferingType.java @@ -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("不支持的供奉类型"); + } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java new file mode 100644 index 0000000..45992a1 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java @@ -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> 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(@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 get(@PathVariable Long id) { + return ApiResponse.ok(service.get(CurrentUser.id(), id)); + } + + /** + * 更新本人资产排序。 + */ + @PutMapping("/api/user/virtual-items/sort") + public ApiResponse sort(@Valid @RequestBody NfcDtos.SortRequest request) { + service.sort(CurrentUser.id(), request.getIds()); + return ApiResponse.ok(null); + } + + /** + * 发起一次供奉行为。 + */ + @PostMapping("/api/user/virtual-items/offerings") + public ApiResponse offer(@Valid @RequestBody NfcDtos.OfferingRequest request) { + return ApiResponse.ok(service.offer(CurrentUser.id(), request)); + } + + /** + * 获取本人供奉历史。 + */ + @GetMapping("/api/user/virtual-items/offerings") + public ApiResponse> 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 dedicate(@Valid @RequestBody NfcDtos.TransferRequest request) { + return ApiResponse.ok(service.dedicate(CurrentUser.id(), request)); + } + + /** + * 获取本人参与的回向历史。 + */ + @GetMapping("/api/user/virtual-items/transfers") + public ApiResponse> transfers(@RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.ok(service.transfers(CurrentUser.id(), offset, limit)); + } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java new file mode 100644 index 0000000..95a4e90 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java @@ -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 catalog(Long userId, int offset, int limit) { + account(userId, false); + return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit)); + } + + /** + * 查询本人资产,包含送神或解绑失效记录,便于展示历史。 + */ + public List 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 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 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 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 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); } +} diff --git a/src/main/java/com/hongzhen/hera/security/AuthInterceptor.java b/src/main/java/com/hongzhen/hera/security/AuthInterceptor.java index cf2db9a..9ee1849 100644 --- a/src/main/java/com/hongzhen/hera/security/AuthInterceptor.java +++ b/src/main/java/com/hongzhen/hera/security/AuthInterceptor.java @@ -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; diff --git a/src/main/java/com/hongzhen/hera/security/AuthenticationException.java b/src/main/java/com/hongzhen/hera/security/AuthenticationException.java new file mode 100644 index 0000000..64e29f1 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/security/AuthenticationException.java @@ -0,0 +1,10 @@ +package com.hongzhen.hera.security; + +/** + * 登录凭证缺失或失效;统一返回401,避免将JWT解析细节暴露给前端。 + */ +public class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/hongzhen/hera/security/JwtService.java b/src/main/java/com/hongzhen/hera/security/JwtService.java index dc5379a..078f763 100644 --- a/src/main/java/com/hongzhen/hera/security/JwtService.java +++ b/src/main/java/com/hongzhen/hera/security/JwtService.java @@ -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("登录状态已过期,请重新登录"); + } } } diff --git a/src/main/java/com/hongzhen/hera/user/UserService.java b/src/main/java/com/hongzhen/hera/user/UserService.java index 4c5e52f..14acb78 100644 --- a/src/main/java/com/hongzhen/hera/user/UserService.java +++ b/src/main/java/com/hongzhen/hera/user/UserService.java @@ -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; diff --git a/src/main/java/com/hongzhen/hera/user/UserTableMigration.java b/src/main/java/com/hongzhen/hera/user/UserTableMigration.java deleted file mode 100644 index e3bf499..0000000 --- a/src/main/java/com/hongzhen/hera/user/UserTableMigration.java +++ /dev/null @@ -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)"); - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f5809eb..986d78a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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}" diff --git a/src/main/resources/db/nfc-schema-update.sql b/src/main/resources/db/nfc-schema-update.sql new file mode 100644 index 0000000..9e53fa1 --- /dev/null +++ b/src/main/resources/db/nfc-schema-update.sql @@ -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); diff --git a/src/main/resources/mapper/NfcChipMapper.xml b/src/main/resources/mapper/NfcChipMapper.xml index 42fe8a5..e335d51 100644 --- a/src/main/resources/mapper/NfcChipMapper.xml +++ b/src/main/resources/mapper/NfcChipMapper.xml @@ -4,8 +4,9 @@ - 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 + SELECT FROM nfc_chip WHERE uid = #{uid} + + + + + + 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} + diff --git a/src/main/resources/mapper/UserAccountMapper.xml b/src/main/resources/mapper/UserAccountMapper.xml index b705b7f..b886aa8 100644 --- a/src/main/resources/mapper/UserAccountMapper.xml +++ b/src/main/resources/mapper/UserAccountMapper.xml @@ -4,7 +4,7 @@ - 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 @@ -44,10 +44,10 @@ 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), diff --git a/src/main/resources/mapper/UserNfcBindingMapper.xml b/src/main/resources/mapper/UserNfcBindingMapper.xml new file mode 100644 index 0000000..588eb38 --- /dev/null +++ b/src/main/resources/mapper/UserNfcBindingMapper.xml @@ -0,0 +1,16 @@ + + + + + INSERT INTO user_nfc_binding (nfc_uid, user_id, status, bound_at) + VALUES (#{nfcUid}, #{userId}, 1, #{boundAt}) + + + UPDATE user_nfc_binding SET status = 0, unbound_at = #{now}, updated_at = #{now} + WHERE nfc_uid = #{uid} AND user_id = #{userId} AND status = 1 + + + diff --git a/src/main/resources/mapper/UserVirtualItemMapper.xml b/src/main/resources/mapper/UserVirtualItemMapper.xml index e211869..77b3ebf 100644 --- a/src/main/resources/mapper/UserVirtualItemMapper.xml +++ b/src/main/resources/mapper/UserVirtualItemMapper.xml @@ -38,4 +38,20 @@ DELETE FROM user_virtual_item WHERE id = #{id} + + + + UPDATE user_virtual_item SET status = #{status}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} AND user_id = #{userId} + + + UPDATE user_virtual_item SET sort_order = #{sortOrder}, updated_at = CURRENT_TIMESTAMP + WHERE id = #{id} AND user_id = #{userId} + diff --git a/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml b/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml index cf1dc90..0274fe8 100644 --- a/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml +++ b/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml @@ -43,4 +43,10 @@ DELETE FROM user_virtual_item_offering WHERE id = #{id} + diff --git a/src/main/resources/mapper/UserVirtualItemTransferMapper.xml b/src/main/resources/mapper/UserVirtualItemTransferMapper.xml index e831379..6300a25 100644 --- a/src/main/resources/mapper/UserVirtualItemTransferMapper.xml +++ b/src/main/resources/mapper/UserVirtualItemTransferMapper.xml @@ -46,4 +46,9 @@ DELETE FROM user_virtual_item_transfer WHERE id = #{id} + diff --git a/src/main/resources/mapper/VirtualItemMapper.xml b/src/main/resources/mapper/VirtualItemMapper.xml index 28e662f..3b3e661 100644 --- a/src/main/resources/mapper/VirtualItemMapper.xml +++ b/src/main/resources/mapper/VirtualItemMapper.xml @@ -36,4 +36,8 @@ DELETE FROM virtual_item WHERE id = #{id} + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 5e3a31c..a3c8695 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -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, diff --git a/src/test/java/com/hongzhen/hera/config/RedisConfigTest.java b/src/test/java/com/hongzhen/hera/config/RedisConfigTest.java new file mode 100644 index 0000000..0ab90b9 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/config/RedisConfigTest.java @@ -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()); + }); + } +} diff --git a/src/test/java/com/hongzhen/hera/config/RedisIntegrationTest.java b/src/test/java/com/hongzhen/hera/config/RedisIntegrationTest.java new file mode 100644 index 0000000..30db298 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/config/RedisIntegrationTest.java @@ -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))); + } +} diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java new file mode 100644 index 0000000..899e836 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java @@ -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 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 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); + } + } +} diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java new file mode 100644 index 0000000..5ad426e --- /dev/null +++ b/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java @@ -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 first = executor.submit(() -> attemptBind(1L, start)); + Future 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 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()); + } +} diff --git a/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java b/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java new file mode 100644 index 0000000..0b6b785 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java @@ -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, "")); + } +} diff --git a/src/test/java/com/hongzhen/hera/user/AvatarEndpointTest.java b/src/test/java/com/hongzhen/hera/user/AvatarEndpointTest.java new file mode 100644 index 0000000..c88fe27 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/user/AvatarEndpointTest.java @@ -0,0 +1,153 @@ +package com.hongzhen.hera.user; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hongzhen.hera.auth.WechatClient; +import com.hongzhen.hera.auth.WxSession; +import 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"); + } +} diff --git a/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java b/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java new file mode 100644 index 0000000..acd7e34 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java @@ -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()); + } + } + } +} diff --git a/src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java b/src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java deleted file mode 100644 index 5b3eae3..0000000 --- a/src/test/java/com/hongzhen/hera/user/UserTableMigrationTest.java +++ /dev/null @@ -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)); - } -} diff --git a/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java b/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java index c75b517..428a3dd 100644 --- a/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java +++ b/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java @@ -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");