From 7fbdadeff492448ca6b80e29e5bf69b012daa4ef Mon Sep 17 00:00:00 2001 From: zhouzhibo Date: Tue, 3 Nov 2026 00:16:17 +0800 Subject: [PATCH] feat: split worship records and return latest prayers and dedications --- README.md | 52 +++- .../hera/content/ContentController.java | 8 +- .../hongzhen/hera/data/UserVirtualItem.java | 2 +- .../hera/data/UserVirtualItemLifecycle.java | 33 +++ .../data/UserVirtualItemLifecycleMapper.java | 18 ++ .../hera/data/UserVirtualItemOffering.java | 122 ++------ .../UserVirtualItemOfferingDedication.java | 22 ++ ...erVirtualItemOfferingDedicationMapper.java | 10 + .../data/UserVirtualItemOfferingIncense.java | 32 +++ .../UserVirtualItemOfferingIncenseMapper.java | 10 + .../data/UserVirtualItemOfferingMapper.java | 19 +- .../data/UserVirtualItemOfferingPrayer.java | 17 ++ .../UserVirtualItemOfferingPrayerMapper.java | 10 + .../data/UserVirtualItemOfferingService.java | 12 - .../hera/data/UserVirtualItemTransfer.java | 140 ---------- .../data/UserVirtualItemTransferMapper.java | 32 --- .../data/UserVirtualItemTransferService.java | 12 - .../hongzhen/hera/nfc/LifecycleAction.java | 18 ++ .../java/com/hongzhen/hera/nfc/NfcDtos.java | 135 +++++---- .../com/hongzhen/hera/nfc/OfferingType.java | 4 +- .../hongzhen/hera/nfc/TiangongItemView.java | 12 +- .../com/hongzhen/hera/nfc/TiangongMapper.java | 2 +- .../hera/nfc/VirtualAssetController.java | 39 +-- .../hera/nfc/VirtualAssetService.java | 260 ++++++++++-------- src/main/resources/application-local.yml | 3 + src/main/resources/application-prod.yml | 3 + .../db/virtual-item-lifecycle-update.sql | 14 + src/main/resources/mapper/TiangongMapper.xml | 54 +++- .../mapper/UserVirtualItemLifecycleMapper.xml | 19 ++ ...serVirtualItemOfferingDedicationMapper.xml | 8 + .../UserVirtualItemOfferingIncenseMapper.xml | 8 + .../mapper/UserVirtualItemOfferingMapper.xml | 115 +++++--- .../UserVirtualItemOfferingPrayerMapper.xml | 8 + .../mapper/UserVirtualItemTransferMapper.xml | 54 ---- src/main/resources/schema.sql | 89 +++--- .../hera/nfc/NfcMySqlIntegrationTest.java | 103 ++++++- .../hongzhen/hera/nfc/NfcWorkflowTest.java | 227 +++++++++++---- 37 files changed, 993 insertions(+), 733 deletions(-) create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycle.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycleMapper.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedication.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedicationMapper.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncense.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncenseMapper.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayer.java create mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayerMapper.java delete mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingService.java delete mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java delete mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java delete mode 100644 src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferService.java create mode 100644 src/main/java/com/hongzhen/hera/nfc/LifecycleAction.java create mode 100644 src/main/resources/db/virtual-item-lifecycle-update.sql create mode 100644 src/main/resources/mapper/UserVirtualItemLifecycleMapper.xml create mode 100644 src/main/resources/mapper/UserVirtualItemOfferingDedicationMapper.xml create mode 100644 src/main/resources/mapper/UserVirtualItemOfferingIncenseMapper.xml create mode 100644 src/main/resources/mapper/UserVirtualItemOfferingPrayerMapper.xml delete mode 100644 src/main/resources/mapper/UserVirtualItemTransferMapper.xml diff --git a/README.md b/README.md index ac30be6..32b99eb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Hera 后端 +# 祈运小程序后端 Spring Boot 后端,提供微信小程序登录、手机号验证码绑定、用户资料、协议和关注我们接口。 @@ -183,13 +183,22 @@ tail -f logs/hera-error.log - `GET /api/nfc/bindings`:本人绑定与解绑历史。 - `GET /api/virtual-items`:启用的虚拟物字典,按字典排序。 - `GET /api/tiangong/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":"祈愿"}`。 +- `POST /api/user/virtual-items/offerings`:`{"userVirtualItemId":1,"offeringType":2,"prayerContent":"愿家人平安"}`。 +- `POST /api/user/virtual-items/lifecycle`:`{"userVirtualItemId":1,"actionType":1}`,`1` 请神、`2` 送神。 +- `GET /api/user/virtual-items/lifecycle?holdingId=1`:本人请神/送神流水,`holdingId` 可省略。 - `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`:本人发出或收到的回向记录。 +- `POST /api/user/virtual-items/dedications`:`{"userVirtualItemId":1,"dedicationTarget":"家人","dedicationContent":"愿平安顺遂"}`。 +- `GET /api/user/virtual-items/dedications`:本人发起的回向记录。 +- `/api/user/virtual-items/transfers` 保留为上述回向接口的路径别名,使用相同请求和响应,不再接收接收账号 ID。 + +`GET /api/tiangong/items` 还会为每位神仙返回 `latestPrayer`(`prayerContent`)和 +`latestDedication`(`dedicationTarget`、可空的 `dedicationContent`),供祈愿、回向表单回显。 +仅匹配当前登录用户与该神仙字典 ID 的 `status=1` 且存在对应明细的记录; +两类分别按 `offered_at DESC, id DESC` 取最新一条,没有可用记录时对应字段为 `null`。 虚拟物字典与天宫列表均返回 `iconUrl`(列表图标)和 `iconDetailUrl`(神仙详情页大图), 两种图片独立配置,不互相覆盖。`virtual_item.icon_detail_url` 按现库使用 @@ -219,18 +228,43 @@ WebP 应在上传前用支持该格式的工具完整解码,核对尺寸、透 同类虚拟物在一个用户下只有一条资产记录,保留首次获得的 `nfc_uid` 和获得时间。 绑定新设备可恢复该类失效资产。解绑最后一台提供该类虚拟物的设备后,资产状态置为 `3`(失效), 不删除供奉和回向历史;仍有同类设备时保留资产。失效资产不能互动或直接请神,必须重新绑定。 -供奉类型 `1` 请神、`2` 上香、`3` 祈愿、`4` 功德、`5` 送神。绑定激活自动记录请神; +供奉类型与最新表设计一致:`1` 上香、`2` 祈愿、`3` 回向。原来的 `1` 请神、`5` 送神 +必须改为调用独立的 `/lifecycle` 接口,客户端需随本次后端适配同步升级,不能沿用旧供奉编码。 +请神/送神写 `user_virtual_item_lifecycle`,其中 `action_type` 为 `1` 请神、`2` 送神。 +绑定首次发放或恢复资产也写请神流水;重复绑定本人设备或添加同类设备不重复写请神流水。 送神置状态 `0`,保有对应设备时可通过请神恢复为 `1`。重复请神、重复送神会拒绝。 -只有功德请求可填写 `meritValue`,要求 1 至 1000000,仅作互动记录,不代表积分、支付或余额。 -回向只写记录,不创建接收方资产、不扣除发起方资产,也不把发起方状态改为已回向。 -昵称快照从账号表生成;接收方原本有同类资产时保存其关系 ID,没有则为 `null`。 +供奉主记录只保留用户、虚拟物、类型、状态和时间,明细分别写入 +`user_virtual_item_offering_incense`、`user_virtual_item_offering_prayer`、 +`user_virtual_item_offering_dedication`,主记录与明细在同一事务提交。 +上香可传 `incenseCount`(3、6、9,默认3)、`incenseType`(可省略,1至127)、`remark`(最多255字)。 +每次请求由服务端记录 `incenseTimes=1`;客户端不能通过该字段扩大次数。 +祈愿必须填写 `prayerContent`(非空,最多500字,兼容旧字段名 `content`)。 +通过供奉入口回向必须填写 `dedicationTarget`(非空,最多255字),`dedicationContent` 可为空、最多500字。 +同一请求只允许填写所选类型的明细,`meritValue` 仍为禁用参数,填写即拒绝。 +响应中按类型返回嵌套 `incense`、`prayer`、`dedication` 明细,其余两个为 `null`。 +`holdingId` 历史筛选先校验归属,再按当前用户和虚拟物查询,不依赖主表已删除的持有关系字段。 +天宫的 `incenseCount` 是本人该虚拟物有效上香主记录所关联明细的 `SUM(incense_times)`, +不按香支数或记录条数计算,无记录时返回0;祈愿、回向和无效主记录不参与累计。 + +回向只保存发起用户、虚拟物、对象文本和可选的回向文;不关联接收账号,不转移资产。 +专用回向接口兼容 `name`/`content` 字段别名,返回统一的供奉主记录和 `dedication` 明细。 +回向列表仅返回当前用户发起的记录,不通过对象文本推测“接收人”。 记录不开放通用修改和删除。UID 校验仅确认已入库设备,不具备防复制能力; 若产品需要防克隆,须增加硬件挑战响应或服务端签发的激活凭证,不能将普通 UID 当作秘密。 `NfcWorkflowTest` 在独立 H2 MySQL 模式测试库验证真实 Mapper XML、HTTP、事务与并发抢绑, 覆盖解绑上限、跨年、同类多设备、失效恢复、越权、供奉和只记账回向,不访问线上业务数据。 `mvn test -Dtest=NfcMySqlIntegrationTest -Dhera.nfc.mysql.integration=true` 显式检查现库列名, -通过只读、零行查询验证 XML 与现表兼容,再在七张连接级临时表中验证读写与回滚。 +通过只读、零行查询验证 XML 与现有供奉拆分表兼容,再在十张连接级临时表中验证读写与回滚。 +新增请神/送神流水表在临时表中验证,不要求联调前向正式库建表。 临时表创建成功并全部覆盖后才允许测试写入;结束时回滚并关闭连接自动清理, 不修改正式表结构和业务记录。测试账号需有 `CREATE TEMPORARY TABLES` 权限。 + +### 供奉拆分后的部署 + +本次适配以已经存在的供奉主表及三张明细表为前提;部署新代码前,执行 +`src/main/resources/db/virtual-item-lifecycle-update.sql` 创建独立的请神/送神流水表。 +脚本可重复执行,不更新已有业务记录、不推测回填历史。新环境由 `schema.sql` 创建全套表。 +旧的 `named-dedication-update.sql` 只适用于历史回向表,不能在当前已拆分的数据库上重放。 +从更早的旧供奉结构升级时,需另行制定历史数据迁移,不能只重命名类型编码或通过删表重建处理。 diff --git a/src/main/java/com/hongzhen/hera/content/ContentController.java b/src/main/java/com/hongzhen/hera/content/ContentController.java index 6e5faa4..b2c2f64 100644 --- a/src/main/java/com/hongzhen/hera/content/ContentController.java +++ b/src/main/java/com/hongzhen/hera/content/ContentController.java @@ -15,7 +15,7 @@ public class ContentController { @GetMapping("/agreement/{type}") public ApiResponse> agreement(@PathVariable String type) { String title = "privacy".equals(type) ? "隐私政策" : "用户协议"; - String body = "欢迎使用那吉。本页面内容为占位协议文本,请在正式上线前替换为经法务确认的完整条款。"; + String body = "欢迎使用祈运。本页面内容为占位协议文本,请在正式上线前替换为经法务确认的完整条款。"; Map data = new HashMap(); data.put("title", title); data.put("body", body); @@ -26,9 +26,9 @@ public class ContentController { public ApiResponse> follow() { Map data = new HashMap(); data.put("title", "关注我们 一触即吉"); - data.put("redBookKeyword", "那小吉"); - data.put("videoKeyword", "那吉 nagiflow"); - data.put("brand", "那吉 NAGI FLOW"); + data.put("redBookKeyword", "祈运"); + data.put("videoKeyword", "祈运"); + data.put("brand", "祈运"); return ApiResponse.ok(data); } } diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java index a8bf88f..ea61342 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItem.java @@ -19,7 +19,7 @@ public class UserVirtualItem extends BaseEntity { private Integer virtualItemId; /** - * 来源 NFC 芯片 UID。 + * 来源 NFC 芯片 UID,数据库不允许为空。 */ private String nfcUid; diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycle.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycle.java new file mode 100644 index 0000000..d891b9f --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycle.java @@ -0,0 +1,33 @@ +package com.hongzhen.hera.data; + +import com.hongzhen.hera.common.BaseEntity; +import java.time.LocalDateTime; + +/** 用户虚拟物请神、送神独立流水。 */ +public class UserVirtualItemLifecycle extends BaseEntity { + /** 用户持有关系 ID。 */ + private Long userVirtualItemId; + /** 发起操作的用户账号 ID。 */ + private Long userId; + /** 虚拟物字典 ID。 */ + private Integer virtualItemId; + /** 操作类型:1 请神,2 送神。 */ + private Integer actionType; + /** 操作发生时间。 */ + private LocalDateTime occurredAt; + + public Long getUserVirtualItemId() { return userVirtualItemId; } + public void setUserVirtualItemId(Long userVirtualItemId) { this.userVirtualItemId = userVirtualItemId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public Integer getVirtualItemId() { return virtualItemId; } + public void setVirtualItemId(Integer virtualItemId) { this.virtualItemId = virtualItemId; } + + public Integer getActionType() { return actionType; } + public void setActionType(Integer actionType) { this.actionType = actionType; } + + public LocalDateTime getOccurredAt() { return occurredAt; } + public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt = occurredAt; } +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycleMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycleMapper.java new file mode 100644 index 0000000..1ab57a7 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemLifecycleMapper.java @@ -0,0 +1,18 @@ +package com.hongzhen.hera.data; + +import java.util.List; +import java.util.Optional; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** 请神、送神流水的持久化访问。 */ +@Mapper +public interface UserVirtualItemLifecycleMapper { + /** 新增流水并回填主键。 */ + int insert(UserVirtualItemLifecycle entity); + /** 按主键读取刚写入的流水。 */ + Optional findById(@Param("id") Long id); + /** 分页读取本人流水,可按持有关系筛选。 */ + List findByUser(@Param("userId") Long userId, + @Param("holdingId") Long holdingId, @Param("offset") int offset, @Param("limit") int limit); +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOffering.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOffering.java index 80a1ad1..5283870 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOffering.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOffering.java @@ -3,112 +3,46 @@ package com.hongzhen.hera.data; import com.hongzhen.hera.common.BaseEntity; import java.time.LocalDateTime; -/** - * 用户虚拟物供奉记录实体。 - */ +/** 用户供奉主记录及按类型关联的明细。 */ public class UserVirtualItemOffering extends BaseEntity { - - /** - * 用户虚拟物关系 ID。 - */ - private Long userVirtualItemId; - - /** - * 用户账号 ID。 - */ + /** 发起供奉的用户账号 ID。 */ private Long userId; - - /** - * 虚拟物 ID。 - */ + /** 虚拟物字典 ID。 */ private Integer virtualItemId; - - /** - * 供奉类型:1 请神,2 上香,3 祈愿,4 功德,5 送神。 - */ + /** 供奉类型:1 上香,2 祈愿,3 回向。 */ private Integer offeringType; - - /** - * 祈愿文字或功德备注。 - */ - private String content; - - /** - * 功德值。 - */ - private Integer meritValue; - - /** - * 记录状态:1 生效,0 失效。 - */ + /** 记录状态:1 生效,0 失效。 */ private Integer status; - - /** - * 供奉时间。 - */ + /** 供奉发生时间。 */ private LocalDateTime offeredAt; + /** 上香明细,仅上香记录返回,不属于主表字段。 */ + private UserVirtualItemOfferingIncense incense; + /** 祈愿明细,仅祈愿记录返回,不属于主表字段。 */ + private UserVirtualItemOfferingPrayer prayer; + /** 回向明细,仅回向记录返回,不属于主表字段。 */ + private UserVirtualItemOfferingDedication dedication; - public Long getUserVirtualItemId() { - return userVirtualItemId; - } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } - public void setUserVirtualItemId(Long userVirtualItemId) { - this.userVirtualItemId = userVirtualItemId; - } + public Integer getVirtualItemId() { return virtualItemId; } + public void setVirtualItemId(Integer virtualItemId) { this.virtualItemId = virtualItemId; } - public Long getUserId() { - return userId; - } + public Integer getOfferingType() { return offeringType; } + public void setOfferingType(Integer offeringType) { this.offeringType = offeringType; } - public void setUserId(Long userId) { - this.userId = userId; - } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } - public Integer getVirtualItemId() { - return virtualItemId; - } + public LocalDateTime getOfferedAt() { return offeredAt; } + public void setOfferedAt(LocalDateTime offeredAt) { this.offeredAt = offeredAt; } - public void setVirtualItemId(Integer virtualItemId) { - this.virtualItemId = virtualItemId; - } + public UserVirtualItemOfferingIncense getIncense() { return incense; } + public void setIncense(UserVirtualItemOfferingIncense incense) { this.incense = incense; } - public Integer getOfferingType() { - return offeringType; - } + public UserVirtualItemOfferingPrayer getPrayer() { return prayer; } + public void setPrayer(UserVirtualItemOfferingPrayer prayer) { this.prayer = prayer; } - public void setOfferingType(Integer offeringType) { - this.offeringType = offeringType; - } - - 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 Integer getStatus() { - return status; - } - - public void setStatus(Integer status) { - this.status = status; - } - - public LocalDateTime getOfferedAt() { - return offeredAt; - } - - public void setOfferedAt(LocalDateTime offeredAt) { - this.offeredAt = offeredAt; - } + public UserVirtualItemOfferingDedication getDedication() { return dedication; } + public void setDedication(UserVirtualItemOfferingDedication dedication) { this.dedication = dedication; } } diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedication.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedication.java new file mode 100644 index 0000000..517d0b9 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedication.java @@ -0,0 +1,22 @@ +package com.hongzhen.hera.data; + +import com.hongzhen.hera.common.BaseEntity; + +/** 回向记录明细,仅按对象文本记录。 */ +public class UserVirtualItemOfferingDedication extends BaseEntity { + /** 关联的供奉主记录 ID,一条主记录对应一条明细。 */ + private Long offeringId; + /** 回向对象,不能为空,最多 255 字,不关联接收账号。 */ + private String dedicationTarget; + /** 回向内容,可为空,最多 500 字。 */ + private String dedicationContent; + + public Long getOfferingId() { return offeringId; } + public void setOfferingId(Long offeringId) { this.offeringId = offeringId; } + + public String getDedicationTarget() { return dedicationTarget; } + public void setDedicationTarget(String dedicationTarget) { this.dedicationTarget = dedicationTarget; } + + public String getDedicationContent() { return dedicationContent; } + public void setDedicationContent(String dedicationContent) { this.dedicationContent = dedicationContent; } +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedicationMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedicationMapper.java new file mode 100644 index 0000000..8230bd3 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingDedicationMapper.java @@ -0,0 +1,10 @@ +package com.hongzhen.hera.data; + +import org.apache.ibatis.annotations.Mapper; + +/** 持久化供奉的回向明细。 */ +@Mapper +public interface UserVirtualItemOfferingDedicationMapper { + /** 新增与供奉主记录一一对应的明细并回填主键。 */ + int insert(UserVirtualItemOfferingDedication entity); +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncense.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncense.java new file mode 100644 index 0000000..fb254ba --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncense.java @@ -0,0 +1,32 @@ +package com.hongzhen.hera.data; + +import com.hongzhen.hera.common.BaseEntity; + +/** 上香记录明细。 */ +public class UserVirtualItemOfferingIncense extends BaseEntity { + /** 关联的供奉主记录 ID,一条主记录对应一条明细。 */ + private Long offeringId; + /** 本条记录的上香动作次数,由服务端记录。 */ + private Integer incenseTimes; + /** 每次上香的支数,只允许 3、6、9。 */ + private Integer incenseCount; + /** 香类型,可为空。 */ + private Integer incenseType; + /** 上香备注,最多 255 字。 */ + private String remark; + + public Long getOfferingId() { return offeringId; } + public void setOfferingId(Long offeringId) { this.offeringId = offeringId; } + + public Integer getIncenseTimes() { return incenseTimes; } + public void setIncenseTimes(Integer incenseTimes) { this.incenseTimes = incenseTimes; } + + public Integer getIncenseCount() { return incenseCount; } + public void setIncenseCount(Integer incenseCount) { this.incenseCount = incenseCount; } + + public Integer getIncenseType() { return incenseType; } + public void setIncenseType(Integer incenseType) { this.incenseType = incenseType; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncenseMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncenseMapper.java new file mode 100644 index 0000000..f0014a3 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingIncenseMapper.java @@ -0,0 +1,10 @@ +package com.hongzhen.hera.data; + +import org.apache.ibatis.annotations.Mapper; + +/** 持久化供奉的上香明细。 */ +@Mapper +public interface UserVirtualItemOfferingIncenseMapper { + /** 新增与供奉主记录一一对应的明细并回填主键。 */ + int insert(UserVirtualItemOfferingIncense entity); +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java index f2a0e5d..8035f59 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingMapper.java @@ -1,32 +1,25 @@ package com.hongzhen.hera.data; -import com.hongzhen.hera.common.CrudMapper; import java.util.List; import java.util.Optional; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @Mapper -public interface UserVirtualItemOfferingMapper extends CrudMapper { +public interface UserVirtualItemOfferingMapper { /** - * 分页读取用户供奉历史,可筛选本人持有关系。 + * 分页读取用户供奉历史,可筛选虚拟物字典项。 */ List findByUser(@Param("userId") Long userId, - @Param("holdingId") Long holdingId, @Param("offset") int offset, @Param("limit") int limit); + @Param("virtualItemId") Integer virtualItemId, @Param("offeringType") Integer offeringType, @Param("offset") int offset, @Param("limit") int limit); - @Override + /** 按主键读取主记录与类型对应明细。 */ Optional findById(Long id); - @Override + /** 分页读取主记录及其明细。 */ List findAll(@Param("offset") int offset, @Param("limit") int limit); - @Override + /** 新增主记录并回填 ID,必须与明细写入处于同一事务。 */ int insert(UserVirtualItemOffering entity); - - @Override - int update(UserVirtualItemOffering entity); - - @Override - int deleteById(Long id); } diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayer.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayer.java new file mode 100644 index 0000000..7a14a20 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayer.java @@ -0,0 +1,17 @@ +package com.hongzhen.hera.data; + +import com.hongzhen.hera.common.BaseEntity; + +/** 祈愿记录明细。 */ +public class UserVirtualItemOfferingPrayer extends BaseEntity { + /** 关联的供奉主记录 ID,一条主记录对应一条明细。 */ + private Long offeringId; + /** 祈愿内容,不能为空,最多 500 字。 */ + private String prayerContent; + + public Long getOfferingId() { return offeringId; } + public void setOfferingId(Long offeringId) { this.offeringId = offeringId; } + + public String getPrayerContent() { return prayerContent; } + public void setPrayerContent(String prayerContent) { this.prayerContent = prayerContent; } +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayerMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayerMapper.java new file mode 100644 index 0000000..99a7123 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingPrayerMapper.java @@ -0,0 +1,10 @@ +package com.hongzhen.hera.data; + +import org.apache.ibatis.annotations.Mapper; + +/** 持久化供奉的祈愿明细。 */ +@Mapper +public interface UserVirtualItemOfferingPrayerMapper { + /** 新增与供奉主记录一一对应的明细并回填主键。 */ + int insert(UserVirtualItemOfferingPrayer entity); +} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingService.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingService.java deleted file mode 100644 index ce2a21b..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemOfferingService.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.hongzhen.hera.data; - -import com.hongzhen.hera.common.CrudService; -import org.springframework.stereotype.Service; - -@Service -public class UserVirtualItemOfferingService extends CrudService { - - public UserVirtualItemOfferingService(UserVirtualItemOfferingMapper mapper) { - super(mapper, "供奉记录"); - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java deleted file mode 100644 index 7c71c2d..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.hongzhen.hera.data; - -import com.hongzhen.hera.common.BaseEntity; -import java.time.LocalDateTime; - -/** - * 用户虚拟物回向记录实体。 - */ -public class UserVirtualItemTransfer extends BaseEntity { - - /** - * 转出用户 ID。 - */ - private Long fromUserId; - - /** - * 转出用户昵称快照。 - */ - private String fromUserName; - - /** - * 接收用户 ID,按任意姓名回向时为空,不伪造接收账号。 - */ - private Long toUserId; - - /** - * 接收用户昵称快照。 - */ - private String toUserName; - - /** - * 虚拟物 ID。 - */ - private Integer virtualItemId; - - /** - * 转出方原持有关系 ID。 - */ - private Long fromUserItemId; - - /** - * 接收方已有的持有关系 ID 快照,未持有时为空;当前不创建接收资产。 - */ - private Long toUserItemId; - - /** - * 回向寄语或祝福语。 - */ - private String content; - - /** - * 记录状态:1 生效,0 已撤销。 - */ - private Integer status; - - /** - * 回向时间。 - */ - private LocalDateTime transferredAt; - - public Long getFromUserId() { - return fromUserId; - } - - public void setFromUserId(Long fromUserId) { - this.fromUserId = fromUserId; - } - - public String getFromUserName() { - return fromUserName; - } - - public void setFromUserName(String fromUserName) { - this.fromUserName = fromUserName; - } - - public Long getToUserId() { - return toUserId; - } - - public void setToUserId(Long toUserId) { - this.toUserId = toUserId; - } - - public String getToUserName() { - return toUserName; - } - - public void setToUserName(String toUserName) { - this.toUserName = toUserName; - } - - public Integer getVirtualItemId() { - return virtualItemId; - } - - public void setVirtualItemId(Integer virtualItemId) { - this.virtualItemId = virtualItemId; - } - - public Long getFromUserItemId() { - return fromUserItemId; - } - - public void setFromUserItemId(Long fromUserItemId) { - this.fromUserItemId = fromUserItemId; - } - - public Long getToUserItemId() { - return toUserItemId; - } - - public void setToUserItemId(Long toUserItemId) { - this.toUserItemId = toUserItemId; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public Integer getStatus() { - return status; - } - - public void setStatus(Integer status) { - this.status = status; - } - - public LocalDateTime getTransferredAt() { - return transferredAt; - } - - public void setTransferredAt(LocalDateTime transferredAt) { - this.transferredAt = transferredAt; - } -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java deleted file mode 100644 index 8b4b0c1..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferMapper.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.hongzhen.hera.data; - -import com.hongzhen.hera.common.CrudMapper; -import java.util.List; -import java.util.Optional; -import org.apache.ibatis.annotations.Mapper; -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); - - @Override - List findAll(@Param("offset") int offset, @Param("limit") int limit); - - @Override - int insert(UserVirtualItemTransfer entity); - - @Override - int update(UserVirtualItemTransfer entity); - - @Override - int deleteById(Long id); -} diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferService.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferService.java deleted file mode 100644 index a87ac68..0000000 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransferService.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.hongzhen.hera.data; - -import com.hongzhen.hera.common.CrudService; -import org.springframework.stereotype.Service; - -@Service -public class UserVirtualItemTransferService extends CrudService { - - public UserVirtualItemTransferService(UserVirtualItemTransferMapper mapper) { - super(mapper, "回向记录"); - } -} diff --git a/src/main/java/com/hongzhen/hera/nfc/LifecycleAction.java b/src/main/java/com/hongzhen/hera/nfc/LifecycleAction.java new file mode 100644 index 0000000..7bc917a --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/LifecycleAction.java @@ -0,0 +1,18 @@ +package com.hongzhen.hera.nfc; + +import com.hongzhen.hera.common.BusinessException; + +/** 请神、送神操作,与互动供奉类型独立编码。 */ +public enum LifecycleAction { + INVITE(1), SEND_OFF(2); + private final int code; + LifecycleAction(int code) { this.code = code; } + public int getCode() { return code; } + /** 校验客户端传入的生命周期操作类型。 */ + public static LifecycleAction from(Integer code) { + for (LifecycleAction action : values()) { + if (Integer.valueOf(action.code).equals(code)) { return action; } + } + throw new BusinessException("不支持的请神或送神操作"); + } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java b/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java index fdbe762..7184721 100644 --- a/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java +++ b/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java @@ -74,84 +74,83 @@ public final class NfcDtos { public Integer getRemainingUnbinds() { return remainingUnbinds; } } - /** - * 当前用户虚拟物的互动请求。 - */ - public static class OfferingRequest { - /** - * 当前用户持有关系 ID,不是虚拟物字典 ID。 - */ + /** 当前用户持有关系请求,不能传入其他用户的持有关系。 */ + public static class HoldingRequest { + /** 当前用户持有关系 ID,不是虚拟物字典 ID。 */ @NotNull @Positive private Long userVirtualItemId; - /** - * 1 请神,2 上香,3 祈愿,5 送神,4 本期不开放。 - */ - @NotNull @Min(1) @Max(5) - private Integer offeringType; - /** - * 祈愿文字或备注。 - */ - @Size(max = 500) - private String content; - /** - * 保留字段,本期不支持填写。 - */ - @javax.validation.constraints.Null(message = "本期不支持功德功能") - private Integer meritValue; public Long getUserVirtualItemId() { return userVirtualItemId; } public void setUserVirtualItemId(Long id) { this.userVirtualItemId = id; } + } + + /** 请神、送神请求,与供奉互动独立。 */ + public static class LifecycleRequest extends HoldingRequest { + /** 1 请神,2 送神。 */ + @NotNull @Min(1) @Max(2) + private Integer actionType; + public Integer getActionType() { return actionType; } + public void setActionType(Integer actionType) { this.actionType = actionType; } + } + + /** 上香、祈愿、回向请求,只填写对应类型的明细。 */ + public static class OfferingRequest extends HoldingRequest { + /** 供奉类型:1 上香,2 祈愿,3 回向。 */ + @NotNull @Min(1) @Max(3) + private Integer offeringType; + /** 每次上香支数,只允许 3、6、9,省略时按 3 支。 */ + @Min(3) @Max(9) + private Integer incenseCount; + /** 香类型,可为空,限定为正的有符号 TINYINT。 */ + @Min(1) @Max(127) + private Integer incenseType; + /** 上香备注。 */ + @Size(max = 255) + private String remark; + /** 祈愿内容,兼容旧请求的 content 字段。 */ + @com.fasterxml.jackson.annotation.JsonAlias("content") + @Size(max = 500) + private String prayerContent; + /** 回向对象,仅保存文本,不关联接收账号。 */ + @Size(max = 255) + private String dedicationTarget; + /** 回向内容,可为空。 */ + @Size(max = 500) + private String dedicationContent; + /** 历史保留参数,拒绝功德请求,避免客户端误以为已记录。 */ + @javax.validation.constraints.Null(message = "本期不支持功德功能") + private Integer meritValue; 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 void setOfferingType(Integer offeringType) { this.offeringType = offeringType; } + public Integer getIncenseCount() { return incenseCount; } + public void setIncenseCount(Integer incenseCount) { this.incenseCount = incenseCount; } + public Integer getIncenseType() { return incenseType; } + public void setIncenseType(Integer incenseType) { this.incenseType = incenseType; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + public String getPrayerContent() { return prayerContent; } + public void setPrayerContent(String prayerContent) { this.prayerContent = prayerContent; } + public String getDedicationTarget() { return dedicationTarget; } + public void setDedicationTarget(String dedicationTarget) { this.dedicationTarget = dedicationTarget; } + public String getDedicationContent() { return dedicationContent; } + public void setDedicationContent(String dedicationContent) { this.dedicationContent = dedicationContent; } 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; - /** - * 回向寄语。 - */ + /** 按文本对象回向,兼容 name/content 请求字段。 */ + public static class NamedDedicationRequest extends HoldingRequest { + /** 回向对象,最多 255 字。 */ + @com.fasterxml.jackson.annotation.JsonAlias("name") + @NotBlank @Size(max = 255) + private String dedicationTarget; + /** 回向内容,可为空,最多 500 字。 */ + @com.fasterxml.jackson.annotation.JsonAlias("content") @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 NamedDedicationRequest { - /** 当前用户持有关系 ID。 */ - @NotNull @Positive - private Long userVirtualItemId; - /** 回向对象姓名,只作记录,不据此关联账号。 */ - @NotBlank @Size(max = 50) - private String name; - /** 回向祝愿。 */ - @NotBlank @Size(max = 500) - private String content; - public Long getUserVirtualItemId() { return userVirtualItemId; } - public void setUserVirtualItemId(Long id) { this.userVirtualItemId = id; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } - public String getContent() { return content; } - public void setContent(String content) { this.content = content; } + private String dedicationContent; + public String getDedicationTarget() { return dedicationTarget; } + public void setDedicationTarget(String dedicationTarget) { this.dedicationTarget = dedicationTarget; } + public String getDedicationContent() { return dedicationContent; } + public void setDedicationContent(String dedicationContent) { this.dedicationContent = dedicationContent; } } /** diff --git a/src/main/java/com/hongzhen/hera/nfc/OfferingType.java b/src/main/java/com/hongzhen/hera/nfc/OfferingType.java index 91e3a36..0718cb0 100644 --- a/src/main/java/com/hongzhen/hera/nfc/OfferingType.java +++ b/src/main/java/com/hongzhen/hera/nfc/OfferingType.java @@ -3,10 +3,10 @@ package com.hongzhen.hera.nfc; import com.hongzhen.hera.common.BusinessException; /** - * 本期启用的供奉行为类型,编码 4 保留但不开放。 + * 与供奉主表一致的互动类型,不包含请神、送神。 */ public enum OfferingType { - INVITE(1), INCENSE(2), WISH(3), SEND_OFF(5); + INCENSE(1), WISH(2), DEDICATION(3); private final int code; diff --git a/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java b/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java index bb906a2..fb739d7 100644 --- a/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java +++ b/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java @@ -1,5 +1,7 @@ package com.hongzhen.hera.nfc; +import com.hongzhen.hera.data.UserVirtualItemOfferingDedication; +import com.hongzhen.hera.data.UserVirtualItemOfferingPrayer; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; @@ -23,8 +25,12 @@ public class TiangongItemView { private boolean active; /** 本人最初取得该持有关系的时间。 */ private LocalDateTime obtainedAt; - /** 本人该持有关系的有效上香流水总数。 */ + /** 本人该虚拟物的有效上香次数之和,不按香支数统计。 */ private long incenseCount; + /** 本人该神仙最新生效且有明细的祈愿,无可用记录时为空。 */ + private UserVirtualItemOfferingPrayer latestPrayer; + /** 本人该神仙最新生效且有明细的回向,无可用记录时为空。 */ + private UserVirtualItemOfferingDedication latestDedication; public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } public String getName() { return name; } @@ -43,6 +49,10 @@ public class TiangongItemView { public void setObtainedAt(LocalDateTime obtainedAt) { this.obtainedAt = obtainedAt; } public long getIncenseCount() { return incenseCount; } public void setIncenseCount(long incenseCount) { this.incenseCount = incenseCount; } + public UserVirtualItemOfferingPrayer getLatestPrayer() { return latestPrayer; } + public void setLatestPrayer(UserVirtualItemOfferingPrayer latestPrayer) { this.latestPrayer = latestPrayer; } + public UserVirtualItemOfferingDedication getLatestDedication() { return latestDedication; } + public void setLatestDedication(UserVirtualItemOfferingDedication latestDedication) { this.latestDedication = latestDedication; } /** 按既有取得时间计算已满的供奉天数,不添加虚构的天数。 */ public long getDays() { return obtainedAt == null ? 0 : Math.max(0, ChronoUnit.DAYS.between(obtainedAt, VirtualAssetService.now())); } } diff --git a/src/main/java/com/hongzhen/hera/nfc/TiangongMapper.java b/src/main/java/com/hongzhen/hera/nfc/TiangongMapper.java index fe7b190..157340f 100644 --- a/src/main/java/com/hongzhen/hera/nfc/TiangongMapper.java +++ b/src/main/java/com/hongzhen/hera/nfc/TiangongMapper.java @@ -4,7 +4,7 @@ import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -/** 查询当前账号的神仙持有状态与上香统计。 */ +/** 查询当前账号的神仙持有状态、上香统计与最新生效祈愿、回向。 */ @Mapper public interface TiangongMapper { /** 字典 ID 为 TINYINT,读取全部已启用神仙,避免分页漏掉持有关系。 */ diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java index 9fbfe52..a540a1d 100644 --- a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java @@ -3,7 +3,7 @@ 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.UserVirtualItemLifecycle; import com.hongzhen.hera.data.VirtualItem; import com.hongzhen.hera.security.CurrentUser; import java.util.List; @@ -77,26 +77,29 @@ public class VirtualAssetController { 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)); - } - - /** 记录任意姓名的回向,不创建或转移接收方资产。 */ - @PostMapping("/api/user/virtual-items/dedications") - public ApiResponse dedicateToName(@Valid @RequestBody NfcDtos.NamedDedicationRequest request) { + /** 记录按对象文本发起的回向,transfers 路径兼容原入口。 */ + @PostMapping({"/api/user/virtual-items/dedications", "/api/user/virtual-items/transfers"}) + public ApiResponse dedicateToName(@Valid @RequestBody NfcDtos.NamedDedicationRequest request) { return ApiResponse.ok(service.dedicateToName(CurrentUser.id(), request)); } - /** - * 获取本人参与的回向历史。 - */ - @GetMapping("/api/user/virtual-items/transfers") - public ApiResponse> transfers(@RequestParam(defaultValue = "0") int offset, + /** 分页读取本人发起的回向,不按对象文本查询其他账号。 */ + @GetMapping({"/api/user/virtual-items/dedications", "/api/user/virtual-items/transfers"}) + public ApiResponse> dedications(@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "20") int limit) { - return ApiResponse.ok(service.transfers(CurrentUser.id(), offset, limit)); + return ApiResponse.ok(service.dedications(CurrentUser.id(), offset, limit)); + } + + /** 请神或送神,状态变更与独立流水同时提交。 */ + @PostMapping("/api/user/virtual-items/lifecycle") + public ApiResponse changeLifecycle(@Valid @RequestBody NfcDtos.LifecycleRequest request) { + return ApiResponse.ok(service.changeLifecycle(CurrentUser.id(), request)); + } + + /** 分页读取本人的请神、送神流水。 */ + @GetMapping("/api/user/virtual-items/lifecycle") + public ApiResponse> lifecycle(@RequestParam(required = false) Long holdingId, + @RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.ok(service.lifecycle(CurrentUser.id(), holdingId, offset, limit)); } } diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java index 32c4d6e..ed749d2 100644 --- a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java @@ -6,11 +6,17 @@ 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.UserVirtualItemLifecycle; +import com.hongzhen.hera.data.UserVirtualItemLifecycleMapper; import com.hongzhen.hera.data.UserVirtualItemMapper; import com.hongzhen.hera.data.UserVirtualItemOffering; +import com.hongzhen.hera.data.UserVirtualItemOfferingDedication; +import com.hongzhen.hera.data.UserVirtualItemOfferingDedicationMapper; +import com.hongzhen.hera.data.UserVirtualItemOfferingIncense; +import com.hongzhen.hera.data.UserVirtualItemOfferingIncenseMapper; import com.hongzhen.hera.data.UserVirtualItemOfferingMapper; -import com.hongzhen.hera.data.UserVirtualItemTransfer; -import com.hongzhen.hera.data.UserVirtualItemTransferMapper; +import com.hongzhen.hera.data.UserVirtualItemOfferingPrayer; +import com.hongzhen.hera.data.UserVirtualItemOfferingPrayerMapper; import com.hongzhen.hera.data.VirtualItem; import com.hongzhen.hera.data.VirtualItemMapper; import com.hongzhen.hera.storage.FileAccessService; @@ -27,24 +33,37 @@ import org.springframework.util.StringUtils; */ @Service public class VirtualAssetService { + private static final int SINGLE_INCENSE_TIME = 1; + private static final int DEFAULT_INCENSE_COUNT = 3; + private static final java.util.Set SUPPORTED_INCENSE_COUNTS = + new HashSet<>(java.util.Arrays.asList(3, 6, 9)); + private final UserAccountMapper accounts; private final VirtualItemMapper dictionary; private final UserVirtualItemMapper holdings; private final UserVirtualItemOfferingMapper offerings; - private final UserVirtualItemTransferMapper transfers; + private final UserVirtualItemLifecycleMapper lifecycle; + private final UserVirtualItemOfferingIncenseMapper incense; + private final UserVirtualItemOfferingPrayerMapper prayers; + private final UserVirtualItemOfferingDedicationMapper dedications; private final NfcChipMapper chips; private final TiangongMapper tiangong; private final FileAccessService fileAccess; public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary, UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings, - UserVirtualItemTransferMapper transfers, NfcChipMapper chips, TiangongMapper tiangong, + UserVirtualItemLifecycleMapper lifecycle, UserVirtualItemOfferingIncenseMapper incense, + UserVirtualItemOfferingPrayerMapper prayers, UserVirtualItemOfferingDedicationMapper dedications, + NfcChipMapper chips, TiangongMapper tiangong, FileAccessService fileAccess) { this.accounts = accounts; this.dictionary = dictionary; this.holdings = holdings; this.offerings = offerings; - this.transfers = transfers; + this.lifecycle = lifecycle; + this.incense = incense; + this.prayers = prayers; + this.dedications = dedications; this.chips = chips; this.tiangong = tiangong; this.fileAccess = fileAccess; @@ -140,7 +159,7 @@ public class VirtualAssetService { if (Integer.valueOf(NfcRules.ACTIVE).equals(item.getStatus())) { return; } holdings.updateStatus(item.getId(), userId, NfcRules.ACTIVE); } - recordOffering(item, OfferingType.INVITE, null, null, now); + recordLifecycle(item, LifecycleAction.INVITE, now); } /** @@ -152,33 +171,113 @@ public class VirtualAssetService { .ifPresent(item -> holdings.updateStatus(item.getId(), userId, NfcRules.EXPIRED)); } - /** - * 记录请神、上香、祈愿或送神,状态变化与流水在同一事务提交。 - */ + /** 请神、送神状态与独立流水在同一事务提交。 */ @Transactional - public UserVirtualItemOffering offer(Long userId, NfcDtos.OfferingRequest request) { + public UserVirtualItemLifecycle changeLifecycle(Long userId, NfcDtos.LifecycleRequest request) { account(userId, true); UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId()); - OfferingType type = OfferingType.from(request.getOfferingType()); - rejectMerit(request.getMeritValue()); - validateContent(request.getContent()); - if (type == OfferingType.WISH && !StringUtils.hasText(request.getContent())) { - throw new BusinessException("请先输入您的愿望"); - } + LifecycleAction action = LifecycleAction.from(request.getActionType()); enabled(item.getVirtualItemId()); requireBoundDevice(userId, item); - if (type == OfferingType.INVITE) { + if (action == LifecycleAction.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); - } + holdings.updateStatus(item.getId(), userId, NfcRules.SENT_OFF); } - return recordOffering(item, type, request.getContent(), request.getMeritValue(), now()); + return recordLifecycle(item, action, now()); + } + + private UserVirtualItemLifecycle recordLifecycle(UserVirtualItem item, LifecycleAction action, + LocalDateTime occurredAt) { + UserVirtualItemLifecycle record = new UserVirtualItemLifecycle(); + record.setUserVirtualItemId(item.getId()); + record.setUserId(item.getUserId()); + record.setVirtualItemId(item.getVirtualItemId()); + record.setActionType(action.getCode()); + record.setOccurredAt(occurredAt); + if (lifecycle.insert(record) != 1) { throw new BusinessException("请神送神流水保存失败"); } + return lifecycle.findById(record.getId()).orElseThrow(() -> new BusinessException("请神送神流水保存失败")); + } + + /** 上香、祈愿、回向主记录与对应明细在同一事务提交。 */ + @Transactional + public UserVirtualItemOffering offer(Long userId, NfcDtos.OfferingRequest request) { + account(userId, true); + UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId()); + requireActive(item); + enabled(item.getVirtualItemId()); + requireBoundDevice(userId, item); + OfferingType type = OfferingType.from(request.getOfferingType()); + validateOffering(type, request); + UserVirtualItemOffering record = new UserVirtualItemOffering(); + record.setUserId(userId); + record.setVirtualItemId(item.getVirtualItemId()); + record.setOfferingType(type.getCode()); + record.setOfferedAt(now()); + record.setStatus(NfcRules.ACTIVE); + if (offerings.insert(record) != 1) { throw new BusinessException("供奉记录保存失败"); } + insertOfferingDetail(record.getId(), type, request); + return offerings.findById(record.getId()).orElseThrow(() -> new BusinessException("供奉记录保存失败")); + } + + private void validateOffering(OfferingType type, NfcDtos.OfferingRequest request) { + if (request.getMeritValue() != null) { throw new BusinessException("本期不支持功德功能"); } + validateLength(request.getRemark(), 255); + validateLength(request.getPrayerContent(), 500); + validateLength(request.getDedicationTarget(), 255); + validateLength(request.getDedicationContent(), 500); + boolean hasIncense = request.getIncenseCount() != null || request.getIncenseType() != null + || request.getRemark() != null; + boolean hasPrayer = request.getPrayerContent() != null; + boolean hasDedication = request.getDedicationTarget() != null || request.getDedicationContent() != null; + if (type == OfferingType.INCENSE) { + if (hasPrayer || hasDedication) { throw new BusinessException("上香只能填写上香明细"); } + Integer count = request.getIncenseCount(); + if (count != null && !SUPPORTED_INCENSE_COUNTS.contains(count)) { throw new BusinessException("上香支数只能为三支、六支或九支"); } + Integer incenseType = request.getIncenseType(); + if (incenseType != null && (incenseType < 1 || incenseType > 127)) { + throw new BusinessException("香类型不正确"); + } + } else if (type == OfferingType.WISH) { + if (hasIncense || hasDedication) { throw new BusinessException("祈愿只能填写祈愿明细"); } + if (!StringUtils.hasText(request.getPrayerContent())) { throw new BusinessException("请先输入您的愿望"); } + } else { + if (hasIncense || hasPrayer) { throw new BusinessException("回向只能填写回向明细"); } + if (!StringUtils.hasText(request.getDedicationTarget())) { throw new BusinessException("请填写回向对象"); } + } + } + + private void validateLength(String text, int maximum) { + if (text != null && text.length() > maximum) { throw new BusinessException("内容长度超过限制"); } + } + + private void insertOfferingDetail(Long offeringId, OfferingType type, NfcDtos.OfferingRequest request) { + int inserted; + if (type == OfferingType.INCENSE) { + UserVirtualItemOfferingIncense detail = new UserVirtualItemOfferingIncense(); + detail.setOfferingId(offeringId); + detail.setIncenseTimes(SINGLE_INCENSE_TIME); + detail.setIncenseCount(request.getIncenseCount() == null ? DEFAULT_INCENSE_COUNT : request.getIncenseCount()); + detail.setIncenseType(request.getIncenseType()); + detail.setRemark(request.getRemark()); + inserted = incense.insert(detail); + } else if (type == OfferingType.WISH) { + UserVirtualItemOfferingPrayer detail = new UserVirtualItemOfferingPrayer(); + detail.setOfferingId(offeringId); + detail.setPrayerContent(request.getPrayerContent().trim()); + inserted = prayers.insert(detail); + } else { + UserVirtualItemOfferingDedication detail = new UserVirtualItemOfferingDedication(); + detail.setOfferingId(offeringId); + detail.setDedicationTarget(request.getDedicationTarget().trim()); + detail.setDedicationContent(request.getDedicationContent() == null ? null : request.getDedicationContent().trim()); + inserted = dedications.insert(detail); + } + if (inserted != 1) { throw new BusinessException("供奉明细保存失败"); } } private void requireActive(UserVirtualItem item) { @@ -193,94 +292,15 @@ public class VirtualAssetService { } } - private void rejectMerit(Integer value) { - 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(); - } - - /** 记录任意姓名的回向祝愿,不查询接收账号、不转移资产。 */ - @Transactional - public UserVirtualItemTransfer dedicateToName(Long userId, NfcDtos.NamedDedicationRequest request) { - UserAccount from = account(userId, true); - UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId()); - requireActive(item); - enabled(item.getVirtualItemId()); - requireBoundDevice(userId, item); - if (!StringUtils.hasText(request.getName()) || request.getName().trim().length() > 50 - || !StringUtils.hasText(request.getContent())) { - throw new BusinessException("请填写有效的回向对象和祝愿"); - } - validateContent(request.getContent()); - UserVirtualItemTransfer record = new UserVirtualItemTransfer(); - record.setFromUserId(userId); - record.setFromUserName(displayName(from)); - record.setToUserName(request.getName().trim()); - record.setVirtualItemId(item.getVirtualItemId()); - record.setFromUserItemId(item.getId()); - record.setContent(request.getContent().trim()); - record.setStatus(NfcRules.ACTIVE); - record.setTransferredAt(now()); - transfers.insert(record); - return transfers.findById(record.getId()).orElseThrow(() -> new BusinessException("回向记录保存失败")); + public UserVirtualItemOffering dedicateToName(Long userId, NfcDtos.NamedDedicationRequest request) { + NfcDtos.OfferingRequest offering = new NfcDtos.OfferingRequest(); + offering.setUserVirtualItemId(request.getUserVirtualItemId()); + offering.setOfferingType(OfferingType.DEDICATION.getCode()); + offering.setDedicationTarget(request.getDedicationTarget()); + offering.setDedicationContent(request.getDedicationContent()); + return offer(userId, offering); } /** @@ -302,21 +322,25 @@ public class VirtualAssetService { } } - /** - * 分页读取本人供奉历史,可按本人持有关系筛选。 - */ + /** 分页读取本人供奉历史,可按本人持有关系筛选。 */ 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)); + Integer virtualItemId = holdingId == null ? null : ownHolding(userId, holdingId).getVirtualItemId(); + return offerings.findByUser(userId, virtualItemId, null, Math.max(0, offset), NfcRules.limit(limit)); } - /** - * 分页读取本人发出或收到的回向历史。 - */ - public List transfers(Long userId, int offset, int limit) { + /** 仅分页读取本人发起的回向历史。 */ + public List dedications(Long userId, int offset, int limit) { account(userId, false); - return transfers.findByUser(userId, Math.max(0, offset), NfcRules.limit(limit)); + return offerings.findByUser(userId, null, OfferingType.DEDICATION.getCode(), + Math.max(0, offset), NfcRules.limit(limit)); + } + + /** 分页读取本人请神、送神流水,可按本人持有关系筛选。 */ + public List lifecycle(Long userId, Long holdingId, int offset, int limit) { + account(userId, false); + if (holdingId != null) { ownHolding(userId, holdingId); } + return lifecycle.findByUser(userId, holdingId, Math.max(0, offset), NfcRules.limit(limit)); } static LocalDateTime now() { return LocalDateTime.now(NfcRules.BUSINESS_ZONE); } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c81f8b1..2ef9288 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -11,5 +11,8 @@ spring: logging: level: root: INFO +mybatis: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl hera: jwt-secret: "${HERA_JWT_SECRET:replace-this-development-secret-replace-this-development-secret}" diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bfa05df..8fcec10 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,6 +8,9 @@ spring: sql: init: mode: never +mybatis: + configuration: + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: root: INFO diff --git a/src/main/resources/db/virtual-item-lifecycle-update.sql b/src/main/resources/db/virtual-item-lifecycle-update.sql new file mode 100644 index 0000000..99797d3 --- /dev/null +++ b/src/main/resources/db/virtual-item-lifecycle-update.sql @@ -0,0 +1,14 @@ +-- 用于已完成供奉主表及三类明细表拆分的数据库。 +-- 部署新代码前执行;可重复执行,不重建已有表,不回填推测的历史记录。 +CREATE TABLE IF NOT EXISTS user_virtual_item_lifecycle ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '请神送神流水主键', + user_virtual_item_id BIGINT UNSIGNED NOT NULL COMMENT '用户持有关系 ID', + user_id BIGINT UNSIGNED NOT NULL COMMENT '操作用户 ID', + virtual_item_id TINYINT UNSIGNED NOT NULL COMMENT '虚拟物字典 ID', + action_type TINYINT NOT NULL COMMENT '1 请神,2 送神', + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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_lifecycle_user_holding (user_id, user_virtual_item_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户虚拟物请神送神流水'; diff --git a/src/main/resources/mapper/TiangongMapper.xml b/src/main/resources/mapper/TiangongMapper.xml index f433c92..57ffd50 100644 --- a/src/main/resources/mapper/TiangongMapper.xml +++ b/src/main/resources/mapper/TiangongMapper.xml @@ -1,13 +1,61 @@ - SELECT v.id, v.item_name AS name, v.icon_url, v.icon_detail_url, v.description, h.id AS holding_id, h.obtained_at, CASE WHEN h.status = 1 AND EXISTS (SELECT c.id FROM nfc_chip c WHERE c.current_bind_user_id = #{userId} AND c.virtual_item_id = v.id) THEN 1 ELSE 0 END AS active, - (SELECT COUNT(o.id) FROM user_virtual_item_offering o WHERE o.user_id = #{userId} - AND o.user_virtual_item_id = h.id AND o.offering_type = 2 AND o.status = 1) AS incense_count + COALESCE(stats.incense_count, 0) AS incense_count, + stats.prayer_id, stats.prayer_offering_id, stats.prayer_content, stats.prayer_created_at, stats.prayer_updated_at, + stats.dedication_id, stats.dedication_offering_id, stats.dedication_target, + stats.dedication_content, stats.dedication_created_at, stats.dedication_updated_at FROM virtual_item v LEFT JOIN user_virtual_item h ON h.virtual_item_id = v.id AND h.user_id = #{userId} + LEFT JOIN ( + SELECT ranked.virtual_item_id, SUM(COALESCE(ranked.incense_times, 0)) AS incense_count, + MAX(CASE WHEN ranked.offering_type = 2 AND ranked.recent_rank = 1 THEN ranked.prayer_id END) AS prayer_id, + MAX(CASE WHEN ranked.offering_type = 2 AND ranked.recent_rank = 1 THEN ranked.offering_id END) AS prayer_offering_id, + MAX(CASE WHEN ranked.offering_type = 2 AND ranked.recent_rank = 1 THEN ranked.prayer_content END) AS prayer_content, + MAX(CASE WHEN ranked.offering_type = 2 AND ranked.recent_rank = 1 THEN ranked.detail_created_at END) AS prayer_created_at, + MAX(CASE WHEN ranked.offering_type = 2 AND ranked.recent_rank = 1 THEN ranked.detail_updated_at END) AS prayer_updated_at, + MAX(CASE WHEN ranked.offering_type = 3 AND ranked.recent_rank = 1 THEN ranked.dedication_id END) AS dedication_id, + MAX(CASE WHEN ranked.offering_type = 3 AND ranked.recent_rank = 1 THEN ranked.offering_id END) AS dedication_offering_id, + MAX(CASE WHEN ranked.offering_type = 3 AND ranked.recent_rank = 1 THEN ranked.dedication_target END) AS dedication_target, + MAX(CASE WHEN ranked.offering_type = 3 AND ranked.recent_rank = 1 THEN ranked.dedication_content END) AS dedication_content, + MAX(CASE WHEN ranked.offering_type = 3 AND ranked.recent_rank = 1 THEN ranked.detail_created_at END) AS dedication_created_at, + MAX(CASE WHEN ranked.offering_type = 3 AND ranked.recent_rank = 1 THEN ranked.detail_updated_at END) AS dedication_updated_at + FROM ( + SELECT o.id AS offering_id, o.virtual_item_id, o.offering_type, i.incense_times, + p.id AS prayer_id, p.prayer_content, + d.id AS dedication_id, d.dedication_target, d.dedication_content, + COALESCE(p.created_at, d.created_at) AS detail_created_at, + COALESCE(p.updated_at, d.updated_at) AS detail_updated_at, + ROW_NUMBER() OVER (PARTITION BY o.virtual_item_id, o.offering_type ORDER BY o.offered_at DESC, o.id DESC) AS recent_rank + FROM user_virtual_item_offering o + LEFT JOIN user_virtual_item_offering_incense i ON i.offering_id = o.id AND o.offering_type = 1 + LEFT JOIN user_virtual_item_offering_prayer p ON p.offering_id = o.id AND o.offering_type = 2 + LEFT JOIN user_virtual_item_offering_dedication d ON d.offering_id = o.id AND o.offering_type = 3 + WHERE o.user_id = #{userId} AND o.status = 1 + AND (o.offering_type = 1 OR (o.offering_type = 2 AND p.id IS NOT NULL) OR (o.offering_type = 3 AND d.id IS NOT NULL)) + ) ranked GROUP BY ranked.virtual_item_id + ) stats ON stats.virtual_item_id = v.id WHERE v.enabled = 1 ORDER BY v.sort_order, v.id diff --git a/src/main/resources/mapper/UserVirtualItemLifecycleMapper.xml b/src/main/resources/mapper/UserVirtualItemLifecycleMapper.xml new file mode 100644 index 0000000..046b19a --- /dev/null +++ b/src/main/resources/mapper/UserVirtualItemLifecycleMapper.xml @@ -0,0 +1,19 @@ + + + + + id, user_virtual_item_id, user_id, virtual_item_id, action_type, occurred_at, created_at, updated_at + + + INSERT INTO user_virtual_item_lifecycle (user_virtual_item_id, user_id, virtual_item_id, action_type, occurred_at) + VALUES (#{userVirtualItemId}, #{userId}, #{virtualItemId}, #{actionType}, #{occurredAt}) + + + + diff --git a/src/main/resources/mapper/UserVirtualItemOfferingDedicationMapper.xml b/src/main/resources/mapper/UserVirtualItemOfferingDedicationMapper.xml new file mode 100644 index 0000000..99f4a55 --- /dev/null +++ b/src/main/resources/mapper/UserVirtualItemOfferingDedicationMapper.xml @@ -0,0 +1,8 @@ + + + + + INSERT INTO user_virtual_item_offering_dedication (offering_id, dedication_target, dedication_content) + VALUES (#{offeringId}, #{dedicationTarget}, #{dedicationContent}) + + diff --git a/src/main/resources/mapper/UserVirtualItemOfferingIncenseMapper.xml b/src/main/resources/mapper/UserVirtualItemOfferingIncenseMapper.xml new file mode 100644 index 0000000..24448d3 --- /dev/null +++ b/src/main/resources/mapper/UserVirtualItemOfferingIncenseMapper.xml @@ -0,0 +1,8 @@ + + + + + INSERT INTO user_virtual_item_offering_incense (offering_id, incense_times, incense_count, incense_type, remark) + VALUES (#{offeringId}, #{incenseTimes}, #{incenseCount}, #{incenseType}, #{remark}) + + diff --git a/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml b/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml index 0274fe8..55a237a 100644 --- a/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml +++ b/src/main/resources/mapper/UserVirtualItemOfferingMapper.xml @@ -1,52 +1,79 @@ - + - - - id, user_virtual_item_id, user_id, virtual_item_id, offering_type, content, merit_value, - status, offered_at, created_at, updated_at + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + o.id, o.user_id, o.virtual_item_id, o.offering_type, o.status, o.offered_at, o.created_at, o.updated_at, + incense.id AS incense_id, + incense.offering_id AS incense_offering_id, + incense.incense_times AS incense_incense_times, + incense.incense_count AS incense_incense_count, + incense.incense_type AS incense_incense_type, + incense.remark AS incense_remark, + incense.created_at AS incense_created_at, + incense.updated_at AS incense_updated_at, + prayer.id AS prayer_id, + prayer.offering_id AS prayer_offering_id, + prayer.prayer_content AS prayer_prayer_content, + prayer.created_at AS prayer_created_at, + prayer.updated_at AS prayer_updated_at, + dedication.id AS dedication_id, + dedication.offering_id AS dedication_offering_id, + dedication.dedication_target AS dedication_dedication_target, + dedication.dedication_content AS dedication_dedication_content, + dedication.created_at AS dedication_created_at, + dedication.updated_at AS dedication_updated_at - - + SELECT FROM + WHERE o.id = #{id} - - + SELECT FROM + ORDER BY o.id DESC LIMIT #{offset}, #{limit} + + - - INSERT INTO user_virtual_item_offering ( - user_virtual_item_id, user_id, virtual_item_id, offering_type, content, merit_value, - status, offered_at - ) VALUES ( - #{userVirtualItemId}, #{userId}, #{virtualItemId}, #{offeringType}, #{content}, #{meritValue}, - COALESCE(#{status}, 1), COALESCE(#{offeredAt}, CURRENT_TIMESTAMP) - ) + INSERT INTO user_virtual_item_offering (user_id, virtual_item_id, offering_type, status, offered_at) + VALUES (#{userId}, #{virtualItemId}, #{offeringType}, COALESCE(#{status}, 1), COALESCE(#{offeredAt}, CURRENT_TIMESTAMP)) - - - UPDATE user_virtual_item_offering - SET user_virtual_item_id = #{userVirtualItemId}, - user_id = #{userId}, - virtual_item_id = #{virtualItemId}, - offering_type = #{offeringType}, - content = #{content}, - merit_value = #{meritValue}, - status = COALESCE(#{status}, status), - offered_at = COALESCE(#{offeredAt}, offered_at) - WHERE id = #{id} - - - DELETE FROM user_virtual_item_offering WHERE id = #{id} - diff --git a/src/main/resources/mapper/UserVirtualItemOfferingPrayerMapper.xml b/src/main/resources/mapper/UserVirtualItemOfferingPrayerMapper.xml new file mode 100644 index 0000000..05973d9 --- /dev/null +++ b/src/main/resources/mapper/UserVirtualItemOfferingPrayerMapper.xml @@ -0,0 +1,8 @@ + + + + + INSERT INTO user_virtual_item_offering_prayer (offering_id, prayer_content) + VALUES (#{offeringId}, #{prayerContent}) + + diff --git a/src/main/resources/mapper/UserVirtualItemTransferMapper.xml b/src/main/resources/mapper/UserVirtualItemTransferMapper.xml deleted file mode 100644 index 6300a25..0000000 --- a/src/main/resources/mapper/UserVirtualItemTransferMapper.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - id, from_user_id, from_user_name, to_user_id, to_user_name, virtual_item_id, - from_user_item_id, to_user_item_id, content, status, transferred_at, created_at, updated_at - - - - - - - - INSERT INTO user_virtual_item_transfer ( - from_user_id, from_user_name, to_user_id, to_user_name, virtual_item_id, - from_user_item_id, to_user_item_id, content, status, transferred_at - ) VALUES ( - #{fromUserId}, #{fromUserName}, #{toUserId}, #{toUserName}, #{virtualItemId}, - #{fromUserItemId}, #{toUserItemId}, #{content}, COALESCE(#{status}, 1), - COALESCE(#{transferredAt}, CURRENT_TIMESTAMP) - ) - - - - UPDATE user_virtual_item_transfer - SET from_user_id = #{fromUserId}, - from_user_name = #{fromUserName}, - to_user_id = #{toUserId}, - to_user_name = #{toUserName}, - virtual_item_id = #{virtualItemId}, - from_user_item_id = #{fromUserItemId}, - to_user_item_id = #{toUserItemId}, - content = #{content}, - status = COALESCE(#{status}, status), - transferred_at = COALESCE(#{transferredAt}, transferred_at) - WHERE id = #{id} - - - DELETE FROM user_virtual_item_transfer WHERE id = #{id} - - diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 191c9a9..b83a059 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS nfc_chip ( activated_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 '最近一次解绑所属年份,按北京时间计算', + last_unbind_year SMALLINT UNSIGNED 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, @@ -133,7 +133,7 @@ CREATE TABLE IF NOT EXISTS user_virtual_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, virtual_item_id TINYINT UNSIGNED NOT NULL, - nfc_uid VARCHAR(64) NULL, + nfc_uid VARCHAR(64) NOT NULL, sort_order INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, obtained_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -147,42 +147,65 @@ CREATE TABLE IF NOT EXISTS user_virtual_item ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS user_virtual_item_offering ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_virtual_item_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - virtual_item_id TINYINT UNSIGNED NOT NULL, - offering_type TINYINT NOT NULL, - content VARCHAR(500) NULL, - merit_value INT NULL, - status TINYINT NOT NULL DEFAULT 1, - offered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + user_id bigint unsigned NOT NULL COMMENT '用户ID(冗余,便于统计)', + virtual_item_id tinyint unsigned NOT NULL COMMENT '虚拟物ID(冗余,关联 virtual_item.id)', + offering_type tinyint NOT NULL COMMENT '供奉类型:1-上香,2-祈愿,3-回向', + status tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-生效,0-失效', + offered_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '供奉时间', + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), KEY idx_user_id (user_id), - KEY idx_user_virtual_item_id (user_virtual_item_id), KEY idx_virtual_item_id (virtual_item_id), KEY idx_offering_type (offering_type), - KEY idx_offered_at (offered_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + KEY idx_offered_at (offered_at), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户虚拟物供奉记录表(公共字段)'; -CREATE TABLE IF NOT EXISTS user_virtual_item_transfer ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - from_user_id BIGINT UNSIGNED NOT NULL, - from_user_name VARCHAR(50) NULL, - to_user_id BIGINT UNSIGNED NULL COMMENT '注册接收方账号,可为空表示仅按姓名回向', - to_user_name VARCHAR(50) NULL, - virtual_item_id TINYINT UNSIGNED NOT NULL, - from_user_item_id BIGINT UNSIGNED NULL, - to_user_item_id BIGINT UNSIGNED NULL, - content VARCHAR(500) NULL, - status TINYINT NOT NULL DEFAULT 1, - transferred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, +CREATE TABLE IF NOT EXISTS user_virtual_item_offering_incense ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + offering_id bigint unsigned NOT NULL COMMENT '供奉记录ID(关联 user_virtual_item_offering.id)', + incense_times int unsigned NOT NULL DEFAULT '1' COMMENT '上香次数(该供奉记录下上香动作发生次数)', + incense_count tinyint unsigned NOT NULL DEFAULT '3' COMMENT '上香数量(枚举固定值):3-三支,6-六支,9-九支', + incense_type tinyint DEFAULT NULL COMMENT '香类型:可扩展,如1-普通香,2-高级香', + remark varchar(255) DEFAULT NULL COMMENT '上香备注(可空)', + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_offering_id (offering_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上香记录详情表'; + +CREATE TABLE IF NOT EXISTS user_virtual_item_offering_prayer ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + offering_id bigint unsigned NOT NULL COMMENT '供奉记录ID(关联 user_virtual_item_offering.id)', + prayer_content varchar(500) NOT NULL COMMENT '祈愿内容', + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_offering_id (offering_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='祈愿记录详情表'; + +CREATE TABLE IF NOT EXISTS user_virtual_item_offering_dedication ( + id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', + offering_id bigint unsigned NOT NULL COMMENT '供奉记录ID(关联 user_virtual_item_offering.id)', + dedication_target varchar(255) NOT NULL COMMENT '回向对象(如:某某、某某的家人、一切众生)', + dedication_content varchar(500) DEFAULT NULL COMMENT '回向内容/回向文(可空)', + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_offering_id (offering_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='回向记录详情表'; + +CREATE TABLE IF NOT EXISTS user_virtual_item_lifecycle ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '请神送神流水主键', + user_virtual_item_id BIGINT UNSIGNED NOT NULL COMMENT '用户持有关系 ID', + user_id BIGINT UNSIGNED NOT NULL COMMENT '操作用户 ID', + virtual_item_id TINYINT UNSIGNED NOT NULL COMMENT '虚拟物字典 ID', + action_type TINYINT NOT NULL COMMENT '1 请神,2 送神', + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 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_from_user_id (from_user_id), - KEY idx_to_user_id (to_user_id), - KEY idx_virtual_item_id (virtual_item_id), - KEY idx_transferred_at (transferred_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + KEY idx_lifecycle_user_holding (user_id, user_virtual_item_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户虚拟物请神送神流水'; diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java index 11aa1e4..3794c06 100644 --- a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java +++ b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java @@ -6,7 +6,10 @@ 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.UserVirtualItemLifecycleMapper; +import com.hongzhen.hera.data.UserVirtualItemOfferingIncenseMapper; +import com.hongzhen.hera.data.UserVirtualItemOfferingPrayerMapper; +import com.hongzhen.hera.data.UserVirtualItemOfferingDedicationMapper; import com.hongzhen.hera.data.VirtualItemMapper; import com.hongzhen.hera.data.VirtualItem; import com.hongzhen.hera.config.HeraProperties; @@ -44,6 +47,46 @@ import static org.junit.jupiter.api.Assertions.*; */ @EnabledIfSystemProperty(named = "hera.nfc.mysql.integration", matches = "true") class NfcMySqlIntegrationTest { + @Test + void mysqlLatestOfferingsAreScopedAndIgnoreInactiveRecords() throws Exception { + try (Connection connection = openConnection()) { + createTemporaryTables(connection); + connection.setAutoCommit(false); + SqlSessionFactoryBean factory = mapperFactory(connection); + try (SqlSession session = factory.getObject().openSession()) { + try (Statement sql = connection.createStatement()) { + sql.executeUpdate("INSERT INTO virtual_item(id, item_name, icon_detail_url) VALUES " + + "(1, 'temporary-wealth', 'https://example.invalid/wealth.png'), " + + "(2, 'temporary-study', 'https://example.invalid/study.png')"); + sql.executeUpdate("INSERT INTO user_virtual_item_offering(id, user_id, virtual_item_id, offering_type, status, offered_at) VALUES " + + "(1, 1, 1, 2, 1, '2026-01-01'), (2, 1, 1, 2, 1, '2026-02-01'), " + + "(3, 1, 1, 2, 0, '2026-03-01'), (4, 2, 1, 2, 1, '2026-04-01'), " + + "(5, 1, 2, 2, 1, '2026-04-01'), (6, 1, 1, 2, 1, '2026-05-01'), " + + "(7, 1, 1, 3, 1, '2026-01-01'), (8, 1, 1, 3, 1, '2026-02-01'), " + + "(9, 1, 1, 3, 0, '2026-03-01')"); + sql.executeUpdate("INSERT INTO user_virtual_item_offering_prayer(offering_id, prayer_content) VALUES " + + "(1, 'old'), (2, 'latest'), (3, 'inactive'), (4, 'other-user'), (5, 'other-deity')"); + sql.executeUpdate("INSERT INTO user_virtual_item_offering_dedication(offering_id, dedication_target, dedication_content) VALUES " + + "(7, 'old-target', 'old-content'), (8, 'latest-target', NULL), (9, 'inactive-target', 'inactive-content')"); + } + TiangongMapper mapper = session.getMapper(TiangongMapper.class); + List items = mapper.findByUser(1L); + assertEquals(2, items.size()); + assertEquals("latest", items.get(0).getLatestPrayer().getPrayerContent()); + assertEquals("latest-target", items.get(0).getLatestDedication().getDedicationTarget()); + assertNull(items.get(0).getLatestDedication().getDedicationContent()); + assertEquals("other-deity", items.get(1).getLatestPrayer().getPrayerContent()); + assertEquals("other-user", mapper.findByUser(2L).get(0).getLatestPrayer().getPrayerContent()); + for (TiangongItemView empty : mapper.findByUser(3L)) { + assertNull(empty.getLatestPrayer()); + assertNull(empty.getLatestDedication()); + } + session.rollback(true); + assertTrue(mapper.findByUser(1L).isEmpty()); + } + } + } + @Test void mysqlVirtualItemDefinitionIsReadOnly() throws Exception { try (Connection connection = openConnection()) { @@ -146,8 +189,7 @@ class NfcMySqlIntegrationTest { 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(UserVirtualItemOfferingMapper.class).findByUser(-1L, null, null, 0, 0).isEmpty()); assertTrue(session.getMapper(VirtualItemMapper.class).findEnabled(0, 0).isEmpty()); assertTrue(session.getMapper(UserAccountMapper.class).findAll(0, 0).isEmpty()); } @@ -158,13 +200,16 @@ class NfcMySqlIntegrationTest { NfcChipMapper chips = session.getMapper(NfcChipMapper.class); UserVirtualItemMapper holdings = session.getMapper(UserVirtualItemMapper.class); UserVirtualItemOfferingMapper offerings = session.getMapper(UserVirtualItemOfferingMapper.class); - UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class); + UserVirtualItemLifecycleMapper lifecycle = session.getMapper(UserVirtualItemLifecycleMapper.class); UserNfcBindingMapper bindings = session.getMapper(UserNfcBindingMapper.class); HeraProperties hera = new HeraProperties(); hera.setJwtSecret("mysql-integration-test-key-mysql-integration-test-key"); CosProperties cos = new CosProperties(); VirtualAssetService assets = new VirtualAssetService(session.getMapper(UserAccountMapper.class), - session.getMapper(VirtualItemMapper.class), holdings, offerings, transfers, chips, session.getMapper(TiangongMapper.class), + session.getMapper(VirtualItemMapper.class), holdings, offerings, lifecycle, + session.getMapper(UserVirtualItemOfferingIncenseMapper.class), + session.getMapper(UserVirtualItemOfferingPrayerMapper.class), + session.getMapper(UserVirtualItemOfferingDedicationMapper.class), chips, session.getMapper(TiangongMapper.class), new FileAccessService(new CosStorageService(cos), cos, hera)); NfcService nfc = new NfcService(chips, bindings, assets); try (Statement statement = connection.createStatement()) { @@ -190,13 +235,33 @@ class NfcMySqlIntegrationTest { NfcDtos.OfferingRequest offer = new NfcDtos.OfferingRequest(); offer.setUserVirtualItemId(holdingId); offer.setOfferingType(OfferingType.INCENSE.getCode()); - assets.offer(1L, offer); - assertEquals(2, offerings.findByUser(1L, holdingId, 0, 100).size()); - NfcDtos.TransferRequest dedication = new NfcDtos.TransferRequest(); + assertEquals(3, assets.offer(1L, offer).getIncense().getIncenseCount()); + assertEquals(1, offerings.findByUser(1L, 1, null, 0, 100).size()); + assertEquals(1, lifecycle.findByUser(1L, holdingId, 0, 100).size()); + offer.setOfferingType(OfferingType.WISH.getCode()); + offer.setPrayerContent("temporary-prayer"); + assertEquals("temporary-prayer", assets.offer(1L, offer).getPrayer().getPrayerContent()); + NfcDtos.NamedDedicationRequest dedication = new NfcDtos.NamedDedicationRequest(); dedication.setUserVirtualItemId(holdingId); - dedication.setToUserId(2L); - assets.dedicate(1L, dedication); - assertEquals(1, transfers.findByUser(2L, 0, 100).size()); + dedication.setDedicationTarget("temporary-target"); + assertNull(assets.dedicateToName(1L, dedication).getDedication().getDedicationContent()); + assertEquals(1, assets.dedications(1L, 0, 100).size()); + assertTrue(assets.dedications(2L, 0, 100).isEmpty()); + TiangongItemView current = assets.tiangong(1L).get(0); + assertEquals(1, current.getIncenseCount()); + assertEquals("temporary-prayer", current.getLatestPrayer().getPrayerContent()); + assertEquals("temporary-target", current.getLatestDedication().getDedicationTarget()); + assertNull(current.getLatestDedication().getDedicationContent()); + assertNull(assets.tiangong(2L).get(0).getLatestPrayer()); + assertNull(assets.tiangong(2L).get(0).getLatestDedication()); + NfcDtos.LifecycleRequest action = new NfcDtos.LifecycleRequest(); + action.setUserVirtualItemId(holdingId); + action.setActionType(LifecycleAction.SEND_OFF.getCode()); + assets.changeLifecycle(1L, action); + assertEquals(0, holdings.findById(holdingId).get().getStatus()); + action.setActionType(LifecycleAction.INVITE.getCode()); + assets.changeLifecycle(1L, action); + assertEquals(3, lifecycle.findByUser(1L, holdingId, 0, 100).size()); assertFalse(holdings.findByUserAndItem(2L, 1).isPresent()); assets.sort(1L, Arrays.asList(holdingId)); nfc.unbind(1L, "temporary-test-chip"); @@ -208,6 +273,15 @@ class NfcMySqlIntegrationTest { assertFalse(chips.findByUid("temporary-test-chip").isPresent()); assertTrue(bindings.findByUser(1L, 0, 100).isEmpty()); assertTrue(holdings.findByUser(1L, 0, 100).isEmpty()); + assertTrue(offerings.findByUser(1L, null, null, 0, 100).isEmpty()); + assertTrue(lifecycle.findByUser(1L, null, 0, 100).isEmpty()); + for (String detail : Arrays.asList("incense", "prayer", "dedication")) { + try (Statement sql = connection.createStatement(); ResultSet result = sql.executeQuery( + "SELECT COUNT(id) FROM user_virtual_item_offering_" + detail)) { + assertTrue(result.next()); + assertEquals(0, result.getInt(1)); + } + } } } } @@ -231,13 +305,13 @@ class NfcMySqlIntegrationTest { 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) \\(.*")) { + 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_offering_incense|user_virtual_item_offering_prayer|user_virtual_item_offering_dedication|user_virtual_item_lifecycle) \\(.*")) { sql.execute(trimmed.replaceFirst("CREATE TABLE IF NOT EXISTS", "CREATE TEMPORARY TABLE")); created++; } } } - assertEquals(7, created, "必须先成功创建全部临时表,禁止退回正式表执行测试写入"); + assertEquals(10, created, "必须先成功创建全部临时表,禁止退回正式表执行测试写入"); } private void reportSchema(Connection connection) throws Exception { @@ -259,7 +333,8 @@ class NfcMySqlIntegrationTest { } 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")) { + "user_virtual_item_offering", "user_virtual_item_offering_incense", + "user_virtual_item_offering_prayer", "user_virtual_item_offering_dedication", "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")) { diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java index 225b964..f685700 100644 --- a/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java +++ b/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java @@ -3,6 +3,11 @@ 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.UserVirtualItemOfferingPrayerMapper; +import com.hongzhen.hera.data.UserVirtualItemLifecycleMapper; +import org.springframework.boot.test.mock.mockito.SpyBean; +import static org.mockito.Mockito.doThrow; +import static org.mockito.ArgumentMatchers.any; import com.hongzhen.hera.data.NfcChip; import com.hongzhen.hera.data.NfcChipMapper; import com.hongzhen.hera.data.VirtualItem; @@ -55,6 +60,8 @@ class NfcWorkflowTest { @Autowired private NfcService nfc; @Autowired private NfcChipMapper chips; @Autowired private VirtualItemMapper dictionary; + @SpyBean private UserVirtualItemOfferingPrayerMapper prayers; + @SpyBean private UserVirtualItemLifecycleMapper lifecycle; @BeforeEach void schema() throws Exception { @@ -73,8 +80,9 @@ class NfcWorkflowTest { } } // 仅清理此独立内存测试库,生产数据库不会被加载。 - 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")) { + for (String table : Arrays.asList("user_nfc_binding", "user_virtual_item_offering_incense", + "user_virtual_item_offering_prayer", "user_virtual_item_offering_dedication", + "user_virtual_item_lifecycle", "user_virtual_item_offering", "user_virtual_item", "nfc_chip", "virtual_item", "user_profile", "user_account")) { jdbc.update("DELETE FROM " + table); } for (long id = 1; id <= 3; id++) { @@ -201,7 +209,8 @@ class NfcWorkflowTest { 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(2, integer("SELECT COUNT(id) FROM user_virtual_item_lifecycle WHERE action_type = 1")); + assertEquals(0, integer("SELECT COUNT(id) FROM user_virtual_item_offering")); 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()); @@ -265,97 +274,192 @@ class NfcWorkflowTest { } @Test - void offeringLifecycleAndMeritAreValidatedAndScopedToOwner() throws Exception { + void lifecycleHasSeparateHistoryAndCannotOperateOnAnotherUsersHolding() 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, 5)) { - postJson(1, "/api/user/virtual-items/offerings", offering(id, type, type == 3 ? ",\"content\":\"愿家人平安\"" : ""), 200); - } + String endpoint = "/api/user/virtual-items/lifecycle"; + postJson(2, endpoint, lifecycleRequest(id, 2), 400); + postJson(1, endpoint, lifecycleRequest(id, 1), 400); + postJson(1, endpoint, lifecycleRequest(id, 3), 400); + postJson(1, endpoint, lifecycleRequest(id, 2), 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, endpoint, lifecycleRequest(id, 2), 400); postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 400); + postJson(1, endpoint, lifecycleRequest(id, 1), 200); assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE id = " + id)); - assertEquals(0, integer("SELECT COUNT(id) FROM user_virtual_item_offering WHERE offering_type = 4")); - assertEquals(5, getJson(1, "/api/user/virtual-items/offerings?holdingId=" + id).size()); - assertEquals(0, getJson(2, "/api/user/virtual-items/offerings").size()); + assertEquals(3, getJson(1, endpoint + "?holdingId=" + id).size()); + assertEquals(0, getJson(2, endpoint).size()); + assertEquals(0, integer("SELECT COUNT(id) FROM user_virtual_item_offering")); + assertEquals(401, mvc.perform(post(endpoint).contentType(MediaType.APPLICATION_JSON) + .content(lifecycleRequest(id, 2))).andReturn().getResponse().getStatus()); + nfc.unbind(1L, "chip-a"); + postJson(1, endpoint, lifecycleRequest(id, 1), 400); + nfc.bind(1L, "chip-a"); + assertEquals(4, getJson(1, endpoint).size()); } @Test - void tiangongAggregatesConfirmedIncenseAndNeverExposesMeritOrForeignOwnership() throws Exception { + void offeringsUseNewTypesAndPersistOnlyTheirOwnDetails() throws Exception { nfc.bind(1L, "chip-a"); long id = holding(1, 1); + String endpoint = "/api/user/virtual-items/offerings"; + postJson(2, endpoint, offering(id, 1, ""), 400); + postJson(1, endpoint, offering(id, 4, ""), 400); + postJson(1, endpoint, offering(id, 5, ""), 400); + postJson(1, endpoint, offering(id, 1, ",\"meritValue\":10"), 400); + postJson(1, endpoint, offering(id, 2, ""), 400); + postJson(1, endpoint, offering(id, 3, ""), 400); + postJson(1, endpoint, offering(id, 1, ",\"incenseCount\":4"), 400); + postJson(1, endpoint, offering(id, 1, ",\"incenseType\":128"), 400); + postJson(1, endpoint, offering(id, 1, ",\"prayerContent\":\"错误明细\""), 400); + postJson(1, endpoint, offering(id, 2, ",\"prayerContent\":\"愿平安\",\"incenseCount\":3"), 400); + JsonNode incense = postJson(1, endpoint, offering(id, 1, + ",\"incenseCount\":6,\"incenseTimes\":999,\"remark\":\"祈福\""), 200); + assertEquals(1, incense.path("offeringType").asInt()); + assertEquals(1, incense.path("incense").path("incenseTimes").asInt()); + assertEquals(6, incense.path("incense").path("incenseCount").asInt()); + assertTrue(incense.path("prayer").isNull()); + assertTrue(incense.path("dedication").isNull()); + JsonNode wish = postJson(1, endpoint, offering(id, 2, ",\"content\":\" 愿家人平安 \""), 200); + assertEquals("愿家人平安", wish.path("prayer").path("prayerContent").asText()); + assertTrue(wish.path("incense").isNull()); + JsonNode dedication = postJson(1, endpoint, offering(id, 3, ",\"dedicationTarget\":\"众生\""), 200); + assertEquals("众生", dedication.path("dedication").path("dedicationTarget").asText()); + assertTrue(dedication.path("dedication").path("dedicationContent").isNull()); + assertEquals(3, getJson(1, endpoint + "?holdingId=" + id).size()); + assertEquals(0, getJson(2, endpoint).size()); + assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_offering_incense")); + assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_offering_prayer")); + assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_offering_dedication")); + assertEquals(400, mvc.perform(get(endpoint + "?holdingId=" + id).header("Authorization", token(2))) + .andReturn().getResponse().getStatus()); + } + + @Test + void tiangongSumsIncenseTimesAndExcludesInactivePrayerAndOtherUsers() throws Exception { + nfc.bind(1L, "chip-a"); + nfc.bind(1L, "chip-c"); + nfc.bind(2L, "chip-b"); + long id = holding(1, 1); + String endpoint = "/api/user/virtual-items/offerings"; + long first = 0; for (int count = 0; count < 3; count++) { - postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ""), 200); + JsonNode record = postJson(1, endpoint, offering(id, 1, ",\"incenseCount\":9"), 200); + if (count == 0) { first = record.path("id").asLong(); } } - postJson(1, "/api/user/virtual-items/offerings", offering(id, 3, ""), 400); - postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ",\"meritValue\":8"), 400); - JsonNode list = getJson(1, "/api/tiangong/items"); - assertEquals(2, list.size()); - JsonNode wealth = list.get(1); - assertEquals("财神", wealth.path("name").asText()); - assertEquals(id, wealth.path("holdingId").asLong()); + jdbc.update("UPDATE user_virtual_item_offering_incense SET incense_times = 5 WHERE offering_id = ?", first); + postJson(1, endpoint, offering(id, 2, ",\"prayerContent\":\"愿平安\""), 200); + postJson(1, endpoint, offering(id, 3, ",\"dedicationTarget\":\"家人\""), 200); + postJson(1, endpoint, offering(holding(1, 2), 1, ""), 200); + postJson(2, endpoint, offering(holding(2, 1), 1, ""), 200); + JsonNode wealth = getJson(1, "/api/tiangong/items").get(1); + assertEquals(7, wealth.path("incenseCount").asLong()); assertTrue(wealth.path("active").asBoolean()); - assertEquals(3, wealth.path("incenseCount").asInt()); - assertFalse(wealth.has("merit")); - assertFalse(wealth.has("balance")); - JsonNode foreign = getJson(2, "/api/tiangong/items").get(1); - assertFalse(foreign.path("active").asBoolean()); - assertTrue(foreign.path("holdingId").isNull()); - assertEquals(0, foreign.path("incenseCount").asInt()); + assertEquals(1, getJson(2, "/api/tiangong/items").get(1).path("incenseCount").asLong()); + assertEquals(0, getJson(3, "/api/tiangong/items").get(1).path("incenseCount").asLong()); + jdbc.update("UPDATE user_virtual_item_offering SET status = 0 WHERE id = ?", first); + assertEquals(2, getJson(1, "/api/tiangong/items").get(1).path("incenseCount").asLong()); nfc.unbind(1L, "chip-a"); assertFalse(getJson(1, "/api/tiangong/items").get(1).path("active").asBoolean()); - assertEquals(3, integer("SELECT COUNT(id) FROM user_virtual_item_offering WHERE offering_type = 2")); + assertEquals(2, getJson(1, "/api/tiangong/items").get(1).path("incenseCount").asLong()); } @Test - void dedicationToArbitraryNameIsPersistedWithoutRecipientAccountOrAssetMutation() throws Exception { + void tiangongReturnsLatestUsablePrayerAndDedicationForEachUserAndDeity() throws Exception { + jdbc.update("INSERT INTO user_virtual_item_offering(id, user_id, virtual_item_id, offering_type, status, offered_at) VALUES " + + "(50001, 1, 1, 2, 1, '2026-01-01 00:00:00'), " + + "(50002, 1, 1, 2, 1, '2026-02-01 00:00:00'), " + + "(50003, 1, 1, 2, 1, '2026-02-01 00:00:00'), " + + "(50004, 1, 1, 2, 0, '2026-03-01 00:00:00'), " + + "(50005, 1, 1, 2, 1, '2026-03-01 00:00:00'), " + + "(50006, 2, 1, 2, 1, '2026-04-01 00:00:00'), " + + "(50007, 1, 2, 2, 1, '2026-04-01 00:00:00'), " + + "(50008, 1, 1, 2, 1, '2025-01-01 00:00:00'), " + + "(50011, 1, 1, 3, 1, '2026-01-01 00:00:00'), " + + "(50012, 1, 1, 3, 1, '2026-02-01 00:00:00'), " + + "(50013, 1, 1, 3, 1, '2026-02-01 00:00:00'), " + + "(50014, 1, 1, 3, 0, '2026-03-01 00:00:00'), " + + "(50015, 1, 1, 3, 1, '2026-03-01 00:00:00'), " + + "(50016, 2, 1, 3, 1, '2026-04-01 00:00:00'), " + + "(50017, 1, 2, 3, 1, '2026-04-01 00:00:00'), " + + "(50018, 1, 1, 3, 1, '2025-01-01 00:00:00')"); + jdbc.update("INSERT INTO user_virtual_item_offering_prayer(offering_id, prayer_content) VALUES " + + "(50001, '旧愿望'), (50002, '同时间较早愿望'), (50003, '最新可用愿望'), " + + "(50004, '失效愿望'), (50006, '其他用户愿望'), (50007, '其他神仙愿望'), (50008, '较大ID旧愿望')"); + jdbc.update("INSERT INTO user_virtual_item_offering_dedication(offering_id, dedication_target, dedication_content) VALUES " + + "(50011, '旧对象', '旧祝愿'), (50012, '同时间较早对象', '旧内容'), (50013, '一切众生', NULL), " + + "(50014, '失效对象', '失效内容'), (50016, '其他用户对象', '其他内容'), " + + "(50017, '其他神仙对象', '其他内容'), (50018, '较大ID旧对象', '旧内容')"); + + JsonNode items = getJson(1, "/api/tiangong/items"); + assertEquals(2, items.size()); + JsonNode wealth = items.get(1); + assertEquals(1, wealth.path("id").asInt()); + assertEquals(50003, wealth.path("latestPrayer").path("offeringId").asLong()); + assertEquals("最新可用愿望", wealth.path("latestPrayer").path("prayerContent").asText()); + assertEquals(50013, wealth.path("latestDedication").path("offeringId").asLong()); + assertEquals("一切众生", wealth.path("latestDedication").path("dedicationTarget").asText()); + assertTrue(wealth.path("latestDedication").path("dedicationContent").isNull()); + assertEquals("其他神仙愿望", items.get(0).path("latestPrayer").path("prayerContent").asText()); + JsonNode otherUser = getJson(2, "/api/tiangong/items").get(1); + assertEquals("其他用户愿望", otherUser.path("latestPrayer").path("prayerContent").asText()); + assertEquals("其他用户对象", otherUser.path("latestDedication").path("dedicationTarget").asText()); + + jdbc.update("UPDATE user_virtual_item_offering SET status = 0 WHERE user_id = 1 AND virtual_item_id = 1"); + JsonNode invalidOnly = getJson(1, "/api/tiangong/items").get(1); + assertTrue(invalidOnly.path("latestPrayer").isNull()); + assertTrue(invalidOnly.path("latestDedication").isNull()); + for (JsonNode noHistory : getJson(3, "/api/tiangong/items")) { + assertTrue(noHistory.path("latestPrayer").isNull()); + assertTrue(noHistory.path("latestDedication").isNull()); + } + } + + @Test + void dedicationStoresTextOnlyAndAcceptsOptionalContent() throws Exception { nfc.bind(1L, "chip-a"); long id = holding(1, 1); String body = "{\"userVirtualItemId\":" + id + ",\"name\":\" 家人 \",\"content\":\" 愿平安顺遂 \"}"; JsonNode record = postJson(1, "/api/user/virtual-items/dedications", body, 200); - assertEquals("家人", record.path("toUserName").asText()); - assertEquals("愿平安顺遂", record.path("content").asText()); - assertTrue(record.path("toUserId").isNull()); - assertTrue(record.path("toUserItemId").isNull()); - assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_transfer WHERE to_user_id IS NULL")); + assertEquals("家人", record.path("dedication").path("dedicationTarget").asText()); + assertEquals("愿平安顺遂", record.path("dedication").path("dedicationContent").asText()); + assertFalse(record.has("toUserId")); assertEquals(3, integer("SELECT COUNT(id) FROM user_account")); assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item WHERE status = 1")); assertEquals(1, getJson(1, "/api/user/virtual-items/transfers").size()); - assertEquals(0, getJson(2, "/api/user/virtual-items/transfers").size()); + assertEquals(0, getJson(2, "/api/user/virtual-items/dedications").size()); postJson(2, "/api/user/virtual-items/dedications", body, 400); postJson(1, "/api/user/virtual-items/dedications", body.replace(" 家人 ", " "), 400); - postJson(1, "/api/user/virtual-items/dedications", body.replace(" 愿平安顺遂 ", " "), 400); + postJson(1, "/api/user/virtual-items/dedications", body.replace(" 愿平安顺遂 ", " "), 200); + String target = String.join("", Collections.nCopies(255, "人")); + postJson(1, "/api/user/virtual-items/transfers", "{\"userVirtualItemId\":" + id + + ",\"dedicationTarget\":\"" + target + "\"}", 200); + postJson(1, "/api/user/virtual-items/transfers", "{\"userVirtualItemId\":" + id + + ",\"dedicationTarget\":\"" + target + "人\"}", 400); + postJson(1, "/api/user/virtual-items/transfers", "{\"userVirtualItemId\":" + id + + ",\"toUserId\":2}", 400); nfc.unbind(1L, "chip-a"); postJson(1, "/api/user/virtual-items/dedications", body, 400); - assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_transfer")); + assertEquals(3, integer("SELECT COUNT(id) FROM user_virtual_item_offering_dedication")); } @Test - void dedicationRecordsSnapshotsWithoutTransferringOrCreatingAssets() throws Exception { + void detailFailureRollsBackOfferingAndLifecycleFailureRollsBackState() 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")); + doThrow(new BusinessException("模拟明细写入失败")).when(prayers).insert(any()); + postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ",\"prayerContent\":\"愿平安\""), 400); + assertEquals(0, integer("SELECT COUNT(id) FROM user_virtual_item_offering")); + assertEquals(0, integer("SELECT COUNT(id) FROM user_virtual_item_offering_prayer")); + doThrow(new BusinessException("模拟流水写入失败")).when(lifecycle).insert(any()); + postJson(1, "/api/user/virtual-items/lifecycle", lifecycleRequest(id, 2), 400); 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")); + assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_lifecycle")); + postJson(2, "/api/nfc/bind", "{\"uid\":\"chip-b\"}", 400); + assertNull(chips.findByUid("chip-b").get().getCurrentBindUserId()); + assertEquals(0, chips.findByUid("chip-b").get().getBindCount()); + assertEquals(0, integer("SELECT COUNT(id) FROM user_nfc_binding WHERE user_id = 2")); + assertEquals(0, integer("SELECT COUNT(id) FROM user_virtual_item WHERE user_id = 2")); } @Test @@ -424,7 +528,7 @@ class NfcWorkflowTest { 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")); + assertEquals(1, integer("SELECT COUNT(id) FROM user_virtual_item_lifecycle")); } finally { executor.shutdownNow(); assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); @@ -447,6 +551,9 @@ class NfcWorkflowTest { return jdbc.queryForObject("SELECT id FROM user_virtual_item WHERE user_id = ? AND virtual_item_id = ?", Long.class, userId, itemId); } + private String lifecycleRequest(long id, int action) { + return "{\"userVirtualItemId\":" + id + ",\"actionType\":" + action + "}"; + } private String offering(long id, int type, String extra) { return "{\"userVirtualItemId\":" + id + ",\"offeringType\":" + type + extra + "}"; }