From 0f927c1884f2a48e1069d356bd896a4907d15ab4 Mon Sep 17 00:00:00 2001 From: zhouzhibo Date: Sun, 18 Oct 2026 23:35:33 +0800 Subject: [PATCH] feat: support birthday calendar and named worship dedications --- .../com/hongzhen/hera/data/UserProfile.java | 11 ++++ .../hera/data/UserVirtualItemTransfer.java | 2 +- .../java/com/hongzhen/hera/nfc/NfcDtos.java | 25 +++++++- .../com/hongzhen/hera/nfc/OfferingType.java | 4 +- .../hongzhen/hera/nfc/TiangongController.java | 17 ++++++ .../hongzhen/hera/nfc/TiangongItemView.java | 42 ++++++++++++++ .../com/hongzhen/hera/nfc/TiangongMapper.java | 12 ++++ .../hera/nfc/VirtualAssetController.java | 6 ++ .../hera/nfc/VirtualAssetService.java | 53 +++++++++++++---- .../com/hongzhen/hera/user/ProfileDtos.java | 18 +++++- .../java/com/hongzhen/hera/user/User.java | 11 ++++ .../com/hongzhen/hera/user/UserService.java | 2 + .../resources/db/birthday-calendar-update.sql | 9 +++ .../resources/db/named-dedication-update.sql | 4 ++ src/main/resources/mapper/TiangongMapper.xml | 13 +++++ src/main/resources/mapper/UserMapper.xml | 2 +- .../resources/mapper/UserProfileMapper.xml | 9 +-- src/main/resources/schema.sql | 3 +- .../hera/nfc/NfcMySqlIntegrationTest.java | 5 +- .../hongzhen/hera/nfc/NfcWorkflowTest.java | 58 +++++++++++++++++-- .../hera/user/UserUnificationTest.java | 54 +++++++++++++++++ 21 files changed, 329 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/hongzhen/hera/nfc/TiangongController.java create mode 100644 src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java create mode 100644 src/main/java/com/hongzhen/hera/nfc/TiangongMapper.java create mode 100644 src/main/resources/db/birthday-calendar-update.sql create mode 100644 src/main/resources/db/named-dedication-update.sql create mode 100644 src/main/resources/mapper/TiangongMapper.xml diff --git a/src/main/java/com/hongzhen/hera/data/UserProfile.java b/src/main/java/com/hongzhen/hera/data/UserProfile.java index 02c4ab9..a37acc6 100644 --- a/src/main/java/com/hongzhen/hera/data/UserProfile.java +++ b/src/main/java/com/hongzhen/hera/data/UserProfile.java @@ -30,6 +30,9 @@ public class UserProfile extends BaseEntity { */ private LocalDate birthday; + /** 生日显示历法:solar 阳历,lunar 农历,birthday 始终存阳历日期。 */ + private String birthdayCalendar; + /** * 出生时辰,取值范围为 0 至 11。 */ @@ -164,6 +167,14 @@ public class UserProfile extends BaseEntity { this.birthday = birthday; } + public String getBirthdayCalendar() { + return birthdayCalendar; + } + + public void setBirthdayCalendar(String birthdayCalendar) { + this.birthdayCalendar = birthdayCalendar; + } + public Integer getBirthShichen() { return birthShichen; } diff --git a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java index a882c76..7c71c2d 100644 --- a/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java +++ b/src/main/java/com/hongzhen/hera/data/UserVirtualItemTransfer.java @@ -19,7 +19,7 @@ public class UserVirtualItemTransfer extends BaseEntity { private String fromUserName; /** - * 接收用户 ID。 + * 接收用户 ID,按任意姓名回向时为空,不伪造接收账号。 */ private Long toUserId; diff --git a/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java b/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java index 427f086..fdbe762 100644 --- a/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java +++ b/src/main/java/com/hongzhen/hera/nfc/NfcDtos.java @@ -84,7 +84,7 @@ public final class NfcDtos { @NotNull @Positive private Long userVirtualItemId; /** - * 1 请神,2 上香,3 祈愿,4 功德,5 送神。 + * 1 请神,2 上香,3 祈愿,5 送神,4 本期不开放。 */ @NotNull @Min(1) @Max(5) private Integer offeringType; @@ -94,9 +94,9 @@ public final class NfcDtos { @Size(max = 500) private String content; /** - * 仅功德行为填写,范围为 1 至 1000000。 + * 保留字段,本期不支持填写。 */ - @Min(1) @Max(1000000) + @javax.validation.constraints.Null(message = "本期不支持功德功能") private Integer meritValue; public Long getUserVirtualItemId() { return userVirtualItemId; } public void setUserVirtualItemId(Long id) { this.userVirtualItemId = id; } @@ -135,6 +135,25 @@ public final class NfcDtos { 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; } + } + /** * 批量调整本人资产顺序,提交顺序对应从零开始的排序值。 */ diff --git a/src/main/java/com/hongzhen/hera/nfc/OfferingType.java b/src/main/java/com/hongzhen/hera/nfc/OfferingType.java index c22a04a..91e3a36 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), MERIT(4), SEND_OFF(5); + INVITE(1), INCENSE(2), WISH(3), SEND_OFF(5); private final int code; diff --git a/src/main/java/com/hongzhen/hera/nfc/TiangongController.java b/src/main/java/com/hongzhen/hera/nfc/TiangongController.java new file mode 100644 index 0000000..57369a1 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/TiangongController.java @@ -0,0 +1,17 @@ +package com.hongzhen.hera.nfc; + +import com.hongzhen.hera.common.ApiResponse; +import com.hongzhen.hera.security.CurrentUser; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 天宫本人持有状态和计数查询。 */ +@RestController +public class TiangongController { + private final VirtualAssetService service; + public TiangongController(VirtualAssetService service) { this.service = service; } + /** 只返回当前 JWT 账号的持有关系及统计,不接收客户端用户 ID。 */ + @GetMapping("/api/tiangong/items") + public ApiResponse> list() { return ApiResponse.ok(service.tiangong(CurrentUser.id())); } +} diff --git a/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java b/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java new file mode 100644 index 0000000..0e9dcad --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java @@ -0,0 +1,42 @@ +package com.hongzhen.hera.nfc; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +/** 天宫列表与详情共用的本人供奉视图,不包含功德或钱包信息。 */ +public class TiangongItemView { + /** 神仙字典 ID,不是本人持有关系 ID。 */ + private Integer id; + /** 后端配置的神仙名称。 */ + private String name; + /** 后端配置的图标。 */ + private String iconUrl; + /** 后端配置的介绍。 */ + private String description; + /** 本人持有关系 ID,未持有时为空。 */ + private Long holdingId; + /** 是否供奉中且仍有对应绑定设备。 */ + private boolean active; + /** 本人最初取得该持有关系的时间。 */ + private LocalDateTime obtainedAt; + /** 本人该持有关系的有效上香流水总数。 */ + private long incenseCount; + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getIconUrl() { return iconUrl; } + public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Long getHoldingId() { return holdingId; } + public void setHoldingId(Long holdingId) { this.holdingId = holdingId; } + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + public LocalDateTime getObtainedAt() { return obtainedAt; } + public void setObtainedAt(LocalDateTime obtainedAt) { this.obtainedAt = obtainedAt; } + public long getIncenseCount() { return incenseCount; } + public void setIncenseCount(long incenseCount) { this.incenseCount = incenseCount; } + /** 按既有取得时间计算已满的供奉天数,不添加虚构的天数。 */ + 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 new file mode 100644 index 0000000..fe7b190 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/nfc/TiangongMapper.java @@ -0,0 +1,12 @@ +package com.hongzhen.hera.nfc; + +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** 查询当前账号的神仙持有状态与上香统计。 */ +@Mapper +public interface TiangongMapper { + /** 字典 ID 为 TINYINT,读取全部已启用神仙,避免分页漏掉持有关系。 */ + List findByUser(@Param("userId") Long userId); +} diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java index 45992a1..9fbfe52 100644 --- a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetController.java @@ -85,6 +85,12 @@ public class VirtualAssetController { return ApiResponse.ok(service.dedicate(CurrentUser.id(), request)); } + /** 记录任意姓名的回向,不创建或转移接收方资产。 */ + @PostMapping("/api/user/virtual-items/dedications") + public ApiResponse dedicateToName(@Valid @RequestBody NfcDtos.NamedDedicationRequest request) { + return ApiResponse.ok(service.dedicateToName(CurrentUser.id(), request)); + } + /** * 获取本人参与的回向历史。 */ diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java index 95a4e90..627f9e8 100644 --- a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java @@ -32,16 +32,18 @@ public class VirtualAssetService { private final UserVirtualItemOfferingMapper offerings; private final UserVirtualItemTransferMapper transfers; private final NfcChipMapper chips; + private final TiangongMapper tiangong; public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary, UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings, - UserVirtualItemTransferMapper transfers, NfcChipMapper chips) { + UserVirtualItemTransferMapper transfers, NfcChipMapper chips, TiangongMapper tiangong) { this.accounts = accounts; this.dictionary = dictionary; this.holdings = holdings; this.offerings = offerings; this.transfers = transfers; this.chips = chips; + this.tiangong = tiangong; } /** @@ -64,6 +66,12 @@ public class VirtualAssetService { return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit)); } + /** 获取本人真实神仙状态及上香计数。 */ + public List tiangong(Long userId) { + account(userId, false); + return tiangong.findByUser(userId); + } + /** * 查询本人资产,包含送神或解绑失效记录,便于展示历史。 */ @@ -131,15 +139,18 @@ public class VirtualAssetService { } /** - * 记录请神、上香、祈愿、功德或送神,状态变化与流水在同一事务提交。 + * 记录请神、上香、祈愿或送神,状态变化与流水在同一事务提交。 */ @Transactional public UserVirtualItemOffering offer(Long userId, NfcDtos.OfferingRequest request) { account(userId, true); UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId()); OfferingType type = OfferingType.from(request.getOfferingType()); - validateMerit(type, request.getMeritValue()); + rejectMerit(request.getMeritValue()); validateContent(request.getContent()); + if (type == OfferingType.WISH && !StringUtils.hasText(request.getContent())) { + throw new BusinessException("请先输入您的愿望"); + } enabled(item.getVirtualItemId()); requireBoundDevice(userId, item); if (type == OfferingType.INVITE) { @@ -168,14 +179,8 @@ public class VirtualAssetService { } } - private void validateMerit(OfferingType type, Integer value) { - if (type == OfferingType.MERIT) { - if (value == null || value < 1 || value > NfcRules.MAX_MERIT_VALUE) { - throw new BusinessException("功德值必须为 1 至 1000000"); - } - } else if (value != null) { - throw new BusinessException("只有功德行为可以填写功德值"); - } + private void rejectMerit(Integer value) { + if (value != null) { throw new BusinessException("本期不支持功德功能"); } } private void validateContent(String content) { @@ -238,6 +243,32 @@ public class VirtualAssetService { 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("回向记录保存失败")); + } + /** * 按客户端顺序设置本人资产的排序值,任一 ID 不合法则整批回滚。 */ diff --git a/src/main/java/com/hongzhen/hera/user/ProfileDtos.java b/src/main/java/com/hongzhen/hera/user/ProfileDtos.java index 7bee4e1..84c9794 100644 --- a/src/main/java/com/hongzhen/hera/user/ProfileDtos.java +++ b/src/main/java/com/hongzhen/hera/user/ProfileDtos.java @@ -5,6 +5,7 @@ import java.util.List; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.PastOrPresent; +import javax.validation.constraints.Pattern; import javax.validation.constraints.Size; public class ProfileDtos { @@ -16,6 +17,8 @@ public class ProfileDtos { private String avatarUrl; private String gender; private LocalDate birthday; + /** 用户选择的生日显示历法,birthday 字段始终为阳历。 */ + private String birthdayCalendar; private String birthHour; private String province; private String city; @@ -23,7 +26,7 @@ public class ProfileDtos { private int remainingEdits; private ElementReport elementReport; - public ProfileResponse(Long id, String nickname, String phone, String avatarUrl, String gender, LocalDate birthday, String birthHour, + public ProfileResponse(Long id, String nickname, String phone, String avatarUrl, String gender, LocalDate birthday, String birthdayCalendar, String birthHour, String province, String city, String district, int remainingEdits, ElementReport elementReport) { this.id = id; this.nickname = nickname; @@ -31,6 +34,7 @@ public class ProfileDtos { this.avatarUrl = avatarUrl; this.gender = gender; this.birthday = birthday; + this.birthdayCalendar = birthdayCalendar; this.birthHour = birthHour; this.province = province; this.city = city; @@ -63,6 +67,10 @@ public class ProfileDtos { return birthday; } + public String getBirthdayCalendar() { + return birthdayCalendar; + } + public String getBirthHour() { return birthHour; } @@ -182,6 +190,10 @@ public class ProfileDtos { @NotNull @PastOrPresent private LocalDate birthday; + /** 生日显示历法,可选 solar、lunar,未指定时使用阳历。 */ + @NotBlank + @Pattern(regexp = "solar|lunar", message = "生日历法只能为阳历或农历") + private String birthdayCalendar = "solar"; @NotBlank private String birthHour; @NotBlank @@ -206,6 +218,10 @@ public class ProfileDtos { return birthday; } + public String getBirthdayCalendar() { + return birthdayCalendar; + } + public String getBirthHour() { return birthHour; } diff --git a/src/main/java/com/hongzhen/hera/user/User.java b/src/main/java/com/hongzhen/hera/user/User.java index cd1a631..ce2b354 100644 --- a/src/main/java/com/hongzhen/hera/user/User.java +++ b/src/main/java/com/hongzhen/hera/user/User.java @@ -48,6 +48,9 @@ public class User { */ private LocalDate birthday; + /** 生日显示历法:solar 阳历,lunar 农历,生日日期本身始终为阳历。 */ + private String birthdayCalendar; + /** * 出生时辰。 */ @@ -173,6 +176,14 @@ public class User { this.birthday = birthday; } + public String getBirthdayCalendar() { + return birthdayCalendar; + } + + public void setBirthdayCalendar(String birthdayCalendar) { + this.birthdayCalendar = birthdayCalendar; + } + public String getBirthHour() { return birthHour; } diff --git a/src/main/java/com/hongzhen/hera/user/UserService.java b/src/main/java/com/hongzhen/hera/user/UserService.java index 14acb78..b45fb4e 100644 --- a/src/main/java/com/hongzhen/hera/user/UserService.java +++ b/src/main/java/com/hongzhen/hera/user/UserService.java @@ -79,6 +79,7 @@ public class UserService { profile.setUserId(userId); profile.setGender(gender); profile.setBirthday(request.getBirthday()); + profile.setBirthdayCalendar(request.getBirthdayCalendar()); profile.setBirthShichen(shichen); profile.setResidenceProvince(request.getProvince()); profile.setResidenceCity(request.getCity()); @@ -124,6 +125,7 @@ public class UserService { fileAccessService.resolveUrl(user.getAvatarUrl()), user.getGender(), user.getBirthday(), + "lunar".equals(user.getBirthdayCalendar()) ? "lunar" : "solar", user.getBirthHour(), user.getProvince(), user.getCity(), diff --git a/src/main/resources/db/birthday-calendar-update.sql b/src/main/resources/db/birthday-calendar-update.sql new file mode 100644 index 0000000..ed3261b --- /dev/null +++ b/src/main/resources/db/birthday-calendar-update.sql @@ -0,0 +1,9 @@ +-- 在部署新版后端前,由部署人员在目标数据库执行。不在应用启动时修改远程库。 +-- 仅增加显示偏好列,不重写生日,不删表或删除数据。MySQL DDL 会隐式提交。 +SET @birthday_calendar_ddl = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'user_profile' AND column_name = 'birthday_calendar') = 0, + 'ALTER TABLE user_profile ADD COLUMN birthday_calendar VARCHAR(8) NOT NULL DEFAULT ''solar'' COMMENT ''生日显示历法 solar阳历 lunar农历,birthday始终为阳历日期''', + 'DO 0'); +PREPARE birthday_calendar_stmt FROM @birthday_calendar_ddl; +EXECUTE birthday_calendar_stmt; +DEALLOCATE PREPARE birthday_calendar_stmt; diff --git a/src/main/resources/db/named-dedication-update.sql b/src/main/resources/db/named-dedication-update.sql new file mode 100644 index 0000000..38e26ff --- /dev/null +++ b/src/main/resources/db/named-dedication-update.sql @@ -0,0 +1,4 @@ +-- 部署前在目标数据库手工执行,不修改历史记录。 +-- 不删除功德历史列或流水,本期在业务接口禁止功德行为。 +ALTER TABLE user_virtual_item_transfer + MODIFY COLUMN to_user_id BIGINT UNSIGNED NULL COMMENT '注册接收方账号,可为空表示仅按姓名回向'; diff --git a/src/main/resources/mapper/TiangongMapper.xml b/src/main/resources/mapper/TiangongMapper.xml new file mode 100644 index 0000000..baf5ed0 --- /dev/null +++ b/src/main/resources/mapper/TiangongMapper.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index 817c03a..cccb359 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -7,7 +7,7 @@ a.id, a.wechat_openid AS open_id, a.wechat_unionid AS union_id, a.mobile AS phone, a.nickname, a.status, a.created_at, p.updated_at, p.avatar_url, CASE p.gender WHEN 1 THEN '男' WHEN 2 THEN '女' ELSE '未知' END AS gender, - p.birthday, + p.birthday, COALESCE(p.birthday_calendar, 'solar') AS birthday_calendar, ELT(p.birth_shichen + 1, '子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时') AS birth_hour, p.residence_province AS province, p.residence_city AS city, p.residence_district AS district, diff --git a/src/main/resources/mapper/UserProfileMapper.xml b/src/main/resources/mapper/UserProfileMapper.xml index 075c9b4..6bd844c 100644 --- a/src/main/resources/mapper/UserProfileMapper.xml +++ b/src/main/resources/mapper/UserProfileMapper.xml @@ -4,7 +4,7 @@ - id, user_id, real_name_encrypted, gender, birthday, birth_shichen, birth_country_code, + id, user_id, real_name_encrypted, gender, birthday, birthday_calendar, birth_shichen, birth_country_code, birth_region_code, birth_city_code, birth_district_code, id_card_no_encrypted, id_card_hash, id_verified, id_verified_at, avatar_url, residence_province, residence_city, residence_district, residence_address, signature, profile_edit_count, @@ -17,7 +17,7 @@ - UPDATE user_profile SET gender = #{gender}, birthday = #{birthday}, birth_shichen = #{birthShichen}, + UPDATE user_profile SET gender = #{gender}, birthday = #{birthday}, birthday_calendar = #{birthdayCalendar}, birth_shichen = #{birthShichen}, residence_province = #{residenceProvince}, residence_city = #{residenceCity}, residence_district = #{residenceDistrict}, profile_edit_count = #{profileEditCount}, profile_edit_window_start = #{profileEditWindowStart} @@ -41,12 +41,12 @@ INSERT INTO user_profile ( - user_id, real_name_encrypted, gender, birthday, birth_shichen, birth_country_code, + user_id, real_name_encrypted, gender, birthday, birthday_calendar, birth_shichen, birth_country_code, birth_region_code, birth_city_code, birth_district_code, id_card_no_encrypted, id_card_hash, id_verified, id_verified_at, avatar_url, residence_province, residence_city, residence_district, residence_address, signature ) VALUES ( - #{userId}, #{realNameEncrypted}, COALESCE(#{gender}, 0), #{birthday}, #{birthShichen}, + #{userId}, #{realNameEncrypted}, COALESCE(#{gender}, 0), #{birthday}, COALESCE(#{birthdayCalendar}, 'solar'), #{birthShichen}, #{birthCountryCode}, #{birthRegionCode}, #{birthCityCode}, #{birthDistrictCode}, #{idCardNoEncrypted}, #{idCardHash}, COALESCE(#{idVerified}, 0), #{idVerifiedAt}, #{avatarUrl}, #{residenceProvince}, #{residenceCity}, #{residenceDistrict}, #{residenceAddress}, #{signature} @@ -59,6 +59,7 @@ real_name_encrypted = #{realNameEncrypted}, gender = COALESCE(#{gender}, gender), birthday = #{birthday}, + birthday_calendar = COALESCE(#{birthdayCalendar}, birthday_calendar), birth_shichen = #{birthShichen}, birth_country_code = #{birthCountryCode}, birth_region_code = #{birthRegionCode}, diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index a3c8695..0565024 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS user_profile ( real_name_encrypted VARCHAR(128) NULL, gender TINYINT NOT NULL DEFAULT 0, birthday DATE NULL, + birthday_calendar VARCHAR(8) NOT NULL DEFAULT 'solar' COMMENT '生日显示历法 solar阳历 lunar农历,birthday始终为阳历日期', birth_shichen TINYINT UNSIGNED NULL, birth_country_code CHAR(2) NULL, birth_region_code VARCHAR(20) NULL, @@ -168,7 +169,7 @@ 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 NOT 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, diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java index 77cbc8a..dc34b1c 100644 --- a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java +++ b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java @@ -106,7 +106,7 @@ class NfcMySqlIntegrationTest { UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class); UserNfcBindingMapper bindings = session.getMapper(UserNfcBindingMapper.class); VirtualAssetService assets = new VirtualAssetService(session.getMapper(UserAccountMapper.class), - session.getMapper(VirtualItemMapper.class), holdings, offerings, transfers, chips); + session.getMapper(VirtualItemMapper.class), holdings, offerings, transfers, chips, session.getMapper(TiangongMapper.class)); NfcService nfc = new NfcService(chips, bindings, assets); try (Statement statement = connection.createStatement()) { statement.executeUpdate("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) " @@ -129,8 +129,7 @@ class NfcMySqlIntegrationTest { long holdingId = holdings.findByUserAndItem(1L, 1).get().getId(); NfcDtos.OfferingRequest offer = new NfcDtos.OfferingRequest(); offer.setUserVirtualItemId(holdingId); - offer.setOfferingType(OfferingType.MERIT.getCode()); - offer.setMeritValue(8); + offer.setOfferingType(OfferingType.INCENSE.getCode()); assets.offer(1L, offer); assertEquals(2, offerings.findByUser(1L, holdingId, 0, 100).size()); NfcDtos.TransferRequest dedication = new NfcDtos.TransferRequest(); diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java index 5ad426e..3ccdd92 100644 --- a/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java +++ b/src/test/java/com/hongzhen/hera/nfc/NfcWorkflowTest.java @@ -200,19 +200,69 @@ class NfcWorkflowTest { postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ",\"meritValue\":10"), 400); postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ""), 400); postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ",\"meritValue\":-1"), 400); - for (int type : Arrays.asList(2, 3, 4, 5)) { - postJson(1, "/api/user/virtual-items/offerings", offering(id, type, type == 4 ? ",\"meritValue\":12" : ""), 200); + for (int type : Arrays.asList(2, 3, 5)) { + postJson(1, "/api/user/virtual-items/offerings", offering(id, type, type == 3 ? ",\"content\":\"愿家人平安\"" : ""), 200); } assertEquals(0, integer("SELECT status FROM user_virtual_item WHERE id = " + id)); postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ""), 400); postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 200); postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 400); assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE id = " + id)); - assertEquals(12, integer("SELECT merit_value FROM user_virtual_item_offering WHERE offering_type = 4")); - assertEquals(6, getJson(1, "/api/user/virtual-items/offerings?holdingId=" + id).size()); + assertEquals(0, 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()); } + @Test + void tiangongAggregatesConfirmedIncenseAndNeverExposesMeritOrForeignOwnership() throws Exception { + nfc.bind(1L, "chip-a"); + long id = holding(1, 1); + for (int count = 0; count < 3; count++) { + postJson(1, "/api/user/virtual-items/offerings", offering(id, 2, ""), 200); + } + 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()); + 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()); + 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")); + } + + @Test + void dedicationToArbitraryNameIsPersistedWithoutRecipientAccountOrAssetMutation() 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(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()); + 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); + 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")); + } + @Test void dedicationRecordsSnapshotsWithoutTransferringOrCreatingAssets() throws Exception { nfc.bind(1L, "chip-a"); diff --git a/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java b/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java index 428a3dd..af0b2ad 100644 --- a/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java +++ b/src/test/java/com/hongzhen/hera/user/UserUnificationTest.java @@ -6,6 +6,8 @@ import com.hongzhen.hera.auth.WechatClient; import com.hongzhen.hera.auth.WxSession; import com.hongzhen.hera.data.UserAccount; import com.hongzhen.hera.data.UserAccountMapper; +import com.hongzhen.hera.data.UserProfile; +import com.hongzhen.hera.data.UserProfileMapper; import java.nio.charset.StandardCharsets; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -44,6 +46,7 @@ public class UserUnificationTest { @Autowired private ObjectMapper json; @Autowired private UserService userService; @Autowired private UserAccountMapper accountMapper; + @Autowired private UserProfileMapper profileMapper; @Autowired private ApplicationContext context; @MockBean private WechatClient wechatClient; private String openId; @@ -179,6 +182,57 @@ public class UserUnificationTest { + "\",\"phone\":\"" + phone + "\",\"captcha\":\"" + sms.path("mockCode").asText() + "\"}"); } + @Test + void birthdayCalendarPersistsAcrossProfileAndRepeatLoginWithoutChangingSolarBirthday() throws Exception { + JsonNode login = register("13800000007"); + long id = login.path("user").path("id").asLong(); + String token = login.path("token").asText(); + assertEquals("solar", login.path("user").path("birthdayCalendar").asText()); + String lunarBody = profileBody("农历生日").replace("\"birthday\":", "\"birthdayCalendar\":\"lunar\",\"birthday\":"); + mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(lunarBody)).andExpect(status().isOk()); + assertEquals("lunar", jdbc.queryForObject("SELECT birthday_calendar FROM user_profile WHERE user_id = ?", String.class, id)); + String body = mvc.perform(get("/api/user/profile").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode profile = json.readTree(body).path("data"); + assertEquals("lunar", profile.path("birthdayCalendar").asText()); + assertEquals("1998-06-12", profile.path("birthday").asText()); + JsonNode again = post("/api/auth/wx-login", "{\"code\":\"repeat-calendar\"}"); + assertEquals("lunar", again.path("user").path("birthdayCalendar").asText()); + mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(lunarBody.replace("\"lunar\"", "\"solar\""))) + .andExpect(status().isOk()); + assertEquals("solar", jdbc.queryForObject("SELECT birthday_calendar FROM user_profile WHERE user_id = ?", String.class, id)); + } + + @Test + void invalidBirthdayCalendarDoesNotConsumeProfileQuota() throws Exception { + JsonNode login = register("13800000008"); + String token = login.path("token").asText(); + String invalid = profileBody("非法历法").replace("\"birthday\":", "\"birthdayCalendar\":\"invalid\",\"birthday\":"); + mvc.perform(put("/api/user/profile").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest()); + assertEquals(0, jdbc.queryForObject("SELECT profile_edit_count FROM user_profile WHERE user_id = ?", Integer.class, login.path("user").path("id").asLong())); + } + + @Test + void profileCrudMapperReadsAndWritesBirthdayCalendar() { + UserAccount account = new UserAccount(); + account.setUsername("calendar_" + UUID.randomUUID().toString().replace("-", "")); + accountMapper.insert(account); + UserProfile profile = new UserProfile(); + profile.setUserId(account.getId()); + profile.setBirthday(java.time.LocalDate.of(2023, 3, 22)); + profile.setBirthdayCalendar("lunar"); + assertEquals(1, profileMapper.insert(profile)); + UserProfile stored = profileMapper.findById(profile.getId()).get(); + assertEquals("lunar", stored.getBirthdayCalendar()); + stored.setBirthdayCalendar("solar"); + assertEquals(1, profileMapper.update(stored)); + assertEquals("solar", profileMapper.findById(profile.getId()).get().getBirthdayCalendar()); + assertEquals(profile.getBirthday(), profileMapper.findById(profile.getId()).get().getBirthday()); + } + private JsonNode post(String path, String body) throws Exception { String response = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) .contentType(MediaType.APPLICATION_JSON).content(body))