feat: support birthday calendar and named worship dedications

This commit is contained in:
zhouzhibo committed 2026-10-18 23:35:33 +08:00
1 parent 30c5f9f114
commit 0f927c1884
21 files changed
+329 -31

No files matched your search

@@ -30,6 +30,9 @@ public class UserProfile extends BaseEntity {
*/ */
private LocalDate birthday; private LocalDate birthday;
/** 生日显示历法:solar 阳历,lunar 农历,birthday 始终存阳历日期。 */
private String birthdayCalendar;
/** /**
* 出生时辰,取值范围为 0 至 11。 * 出生时辰,取值范围为 0 至 11。
*/ */
@@ -164,6 +167,14 @@ public class UserProfile extends BaseEntity {
this.birthday = birthday; this.birthday = birthday;
} }
public String getBirthdayCalendar() {
return birthdayCalendar;
}
public void setBirthdayCalendar(String birthdayCalendar) {
this.birthdayCalendar = birthdayCalendar;
}
public Integer getBirthShichen() { public Integer getBirthShichen() {
return birthShichen; return birthShichen;
} }
@@ -19,7 +19,7 @@ public class UserVirtualItemTransfer extends BaseEntity {
private String fromUserName; private String fromUserName;
/** /**
* 接收用户 ID。 * 接收用户 ID,按任意姓名回向时为空,不伪造接收账号。
*/ */
private Long toUserId; private Long toUserId;
@@ -84,7 +84,7 @@ public final class NfcDtos {
@NotNull @Positive @NotNull @Positive
private Long userVirtualItemId; private Long userVirtualItemId;
/** /**
* 1 请神,2 上香,3 祈愿,4 功德,5 送神。 * 1 请神,2 上香,3 祈愿,5 送神,4 本期不开放。
*/ */
@NotNull @Min(1) @Max(5) @NotNull @Min(1) @Max(5)
private Integer offeringType; private Integer offeringType;
@@ -94,9 +94,9 @@ public final class NfcDtos {
@Size(max = 500) @Size(max = 500)
private String content; private String content;
/** /**
* 仅功德行为填写,范围为 1 至 1000000。 * 保留字段,本期不支持填写。
*/ */
@Min(1) @Max(1000000) @javax.validation.constraints.Null(message = "本期不支持功德功能")
private Integer meritValue; private Integer meritValue;
public Long getUserVirtualItemId() { return userVirtualItemId; } public Long getUserVirtualItemId() { return userVirtualItemId; }
public void setUserVirtualItemId(Long id) { this.userVirtualItemId = id; } 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 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; }
}
/** /**
* 批量调整本人资产顺序,提交顺序对应从零开始的排序值。 * 批量调整本人资产顺序,提交顺序对应从零开始的排序值。
*/ */
@@ -3,10 +3,10 @@ package com.hongzhen.hera.nfc;
import com.hongzhen.hera.common.BusinessException; import com.hongzhen.hera.common.BusinessException;
/** /**
* 供奉行为类型,功德仅记录数值,不表示支付或可消费余额。 * 本期启用的供奉行为类型,编码 4 保留但不开放。
*/ */
public enum OfferingType { 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; private final int code;
@@ -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<TiangongItemView>> list() { return ApiResponse.ok(service.tiangong(CurrentUser.id())); }
}
@@ -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())); }
}
@@ -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<TiangongItemView> findByUser(@Param("userId") Long userId);
}
@@ -85,6 +85,12 @@ public class VirtualAssetController {
return ApiResponse.ok(service.dedicate(CurrentUser.id(), request)); return ApiResponse.ok(service.dedicate(CurrentUser.id(), request));
} }
/** 记录任意姓名的回向,不创建或转移接收方资产。 */
@PostMapping("/api/user/virtual-items/dedications")
public ApiResponse<UserVirtualItemTransfer> dedicateToName(@Valid @RequestBody NfcDtos.NamedDedicationRequest request) {
return ApiResponse.ok(service.dedicateToName(CurrentUser.id(), request));
}
/** /**
* 获取本人参与的回向历史。 * 获取本人参与的回向历史。
*/ */
@@ -32,16 +32,18 @@ public class VirtualAssetService {
private final UserVirtualItemOfferingMapper offerings; private final UserVirtualItemOfferingMapper offerings;
private final UserVirtualItemTransferMapper transfers; private final UserVirtualItemTransferMapper transfers;
private final NfcChipMapper chips; private final NfcChipMapper chips;
private final TiangongMapper tiangong;
public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary, public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary,
UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings, UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings,
UserVirtualItemTransferMapper transfers, NfcChipMapper chips) { UserVirtualItemTransferMapper transfers, NfcChipMapper chips, TiangongMapper tiangong) {
this.accounts = accounts; this.accounts = accounts;
this.dictionary = dictionary; this.dictionary = dictionary;
this.holdings = holdings; this.holdings = holdings;
this.offerings = offerings; this.offerings = offerings;
this.transfers = transfers; this.transfers = transfers;
this.chips = chips; this.chips = chips;
this.tiangong = tiangong;
} }
/** /**
@@ -64,6 +66,12 @@ public class VirtualAssetService {
return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit)); return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit));
} }
/** 获取本人真实神仙状态及上香计数。 */
public List<TiangongItemView> tiangong(Long userId) {
account(userId, false);
return tiangong.findByUser(userId);
}
/** /**
* 查询本人资产,包含送神或解绑失效记录,便于展示历史。 * 查询本人资产,包含送神或解绑失效记录,便于展示历史。
*/ */
@@ -131,15 +139,18 @@ public class VirtualAssetService {
} }
/** /**
* 记录请神、上香、祈愿、功德或送神,状态变化与流水在同一事务提交。 * 记录请神、上香、祈愿或送神,状态变化与流水在同一事务提交。
*/ */
@Transactional @Transactional
public UserVirtualItemOffering offer(Long userId, NfcDtos.OfferingRequest request) { public UserVirtualItemOffering offer(Long userId, NfcDtos.OfferingRequest request) {
account(userId, true); account(userId, true);
UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId()); UserVirtualItem item = ownHolding(userId, request.getUserVirtualItemId());
OfferingType type = OfferingType.from(request.getOfferingType()); OfferingType type = OfferingType.from(request.getOfferingType());
validateMerit(type, request.getMeritValue()); rejectMerit(request.getMeritValue());
validateContent(request.getContent()); validateContent(request.getContent());
if (type == OfferingType.WISH && !StringUtils.hasText(request.getContent())) {
throw new BusinessException("请先输入您的愿望");
}
enabled(item.getVirtualItemId()); enabled(item.getVirtualItemId());
requireBoundDevice(userId, item); requireBoundDevice(userId, item);
if (type == OfferingType.INVITE) { if (type == OfferingType.INVITE) {
@@ -168,14 +179,8 @@ public class VirtualAssetService {
} }
} }
private void validateMerit(OfferingType type, Integer value) { private void rejectMerit(Integer value) {
if (type == OfferingType.MERIT) { if (value != null) { throw new BusinessException("本期不支持功德功能"); }
if (value == null || value < 1 || value > NfcRules.MAX_MERIT_VALUE) {
throw new BusinessException("功德值必须为 1 至 1000000");
}
} else if (value != null) {
throw new BusinessException("只有功德行为可以填写功德值");
}
} }
private void validateContent(String content) { private void validateContent(String content) {
@@ -238,6 +243,32 @@ public class VirtualAssetService {
return StringUtils.hasText(user.getNickname()) ? user.getNickname() : "用户" + user.getId(); 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 不合法则整批回滚。 * 按客户端顺序设置本人资产的排序值,任一 ID 不合法则整批回滚。
*/ */
@@ -5,6 +5,7 @@ import java.util.List;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import javax.validation.constraints.PastOrPresent; import javax.validation.constraints.PastOrPresent;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size; import javax.validation.constraints.Size;
public class ProfileDtos { public class ProfileDtos {
@@ -16,6 +17,8 @@ public class ProfileDtos {
private String avatarUrl; private String avatarUrl;
private String gender; private String gender;
private LocalDate birthday; private LocalDate birthday;
/** 用户选择的生日显示历法,birthday 字段始终为阳历。 */
private String birthdayCalendar;
private String birthHour; private String birthHour;
private String province; private String province;
private String city; private String city;
@@ -23,7 +26,7 @@ public class ProfileDtos {
private int remainingEdits; private int remainingEdits;
private ElementReport elementReport; 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) { String province, String city, String district, int remainingEdits, ElementReport elementReport) {
this.id = id; this.id = id;
this.nickname = nickname; this.nickname = nickname;
@@ -31,6 +34,7 @@ public class ProfileDtos {
this.avatarUrl = avatarUrl; this.avatarUrl = avatarUrl;
this.gender = gender; this.gender = gender;
this.birthday = birthday; this.birthday = birthday;
this.birthdayCalendar = birthdayCalendar;
this.birthHour = birthHour; this.birthHour = birthHour;
this.province = province; this.province = province;
this.city = city; this.city = city;
@@ -63,6 +67,10 @@ public class ProfileDtos {
return birthday; return birthday;
} }
public String getBirthdayCalendar() {
return birthdayCalendar;
}
public String getBirthHour() { public String getBirthHour() {
return birthHour; return birthHour;
} }
@@ -182,6 +190,10 @@ public class ProfileDtos {
@NotNull @NotNull
@PastOrPresent @PastOrPresent
private LocalDate birthday; private LocalDate birthday;
/** 生日显示历法,可选 solar、lunar,未指定时使用阳历。 */
@NotBlank
@Pattern(regexp = "solar|lunar", message = "生日历法只能为阳历或农历")
private String birthdayCalendar = "solar";
@NotBlank @NotBlank
private String birthHour; private String birthHour;
@NotBlank @NotBlank
@@ -206,6 +218,10 @@ public class ProfileDtos {
return birthday; return birthday;
} }
public String getBirthdayCalendar() {
return birthdayCalendar;
}
public String getBirthHour() { public String getBirthHour() {
return birthHour; return birthHour;
} }
@@ -48,6 +48,9 @@ public class User {
*/ */
private LocalDate birthday; private LocalDate birthday;
/** 生日显示历法:solar 阳历,lunar 农历,生日日期本身始终为阳历。 */
private String birthdayCalendar;
/** /**
* 出生时辰。 * 出生时辰。
*/ */
@@ -173,6 +176,14 @@ public class User {
this.birthday = birthday; this.birthday = birthday;
} }
public String getBirthdayCalendar() {
return birthdayCalendar;
}
public void setBirthdayCalendar(String birthdayCalendar) {
this.birthdayCalendar = birthdayCalendar;
}
public String getBirthHour() { public String getBirthHour() {
return birthHour; return birthHour;
} }
@@ -79,6 +79,7 @@ public class UserService {
profile.setUserId(userId); profile.setUserId(userId);
profile.setGender(gender); profile.setGender(gender);
profile.setBirthday(request.getBirthday()); profile.setBirthday(request.getBirthday());
profile.setBirthdayCalendar(request.getBirthdayCalendar());
profile.setBirthShichen(shichen); profile.setBirthShichen(shichen);
profile.setResidenceProvince(request.getProvince()); profile.setResidenceProvince(request.getProvince());
profile.setResidenceCity(request.getCity()); profile.setResidenceCity(request.getCity());
@@ -124,6 +125,7 @@ public class UserService {
fileAccessService.resolveUrl(user.getAvatarUrl()), fileAccessService.resolveUrl(user.getAvatarUrl()),
user.getGender(), user.getGender(),
user.getBirthday(), user.getBirthday(),
"lunar".equals(user.getBirthdayCalendar()) ? "lunar" : "solar",
user.getBirthHour(), user.getBirthHour(),
user.getProvince(), user.getProvince(),
user.getCity(), user.getCity(),
@@ -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;
@@ -0,0 +1,4 @@
-- 部署前在目标数据库手工执行,不修改历史记录。
-- 不删除功德历史列或流水,本期在业务接口禁止功德行为。
ALTER TABLE user_virtual_item_transfer
MODIFY COLUMN to_user_id BIGINT UNSIGNED NULL COMMENT '注册接收方账号,可为空表示仅按姓名回向';
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hongzhen.hera.nfc.TiangongMapper">
<select id="findByUser" resultType="com.hongzhen.hera.nfc.TiangongItemView">
SELECT v.id, v.item_name AS name, v.icon_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
FROM virtual_item v LEFT JOIN user_virtual_item h ON h.virtual_item_id = v.id AND h.user_id = #{userId}
WHERE v.enabled = 1 ORDER BY v.sort_order, v.id
</select>
</mapper>
+1 -1
View File
@@ -7,7 +7,7 @@
a.id, a.wechat_openid AS open_id, a.wechat_unionid AS union_id, a.mobile AS phone, 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, 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, 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, '子时', '丑时', '寅时', '卯时', '辰时', '巳时', ELT(p.birth_shichen + 1, '子时', '丑时', '寅时', '卯时', '辰时', '巳时',
'午时', '未时', '申时', '酉时', '戌时', '亥时') AS birth_hour, '午时', '未时', '申时', '酉时', '戌时', '亥时') AS birth_hour,
p.residence_province AS province, p.residence_city AS city, p.residence_district AS district, p.residence_province AS province, p.residence_city AS city, p.residence_district AS district,
@@ -4,7 +4,7 @@
<mapper namespace="com.hongzhen.hera.data.UserProfileMapper"> <mapper namespace="com.hongzhen.hera.data.UserProfileMapper">
<sql id="BaseColumnList"> <sql id="BaseColumnList">
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, 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, id_verified, id_verified_at, avatar_url, residence_province, residence_city, residence_district,
residence_address, signature, profile_edit_count, residence_address, signature, profile_edit_count,
@@ -17,7 +17,7 @@
</insert> </insert>
<update id="updatePersonal"> <update id="updatePersonal">
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_province = #{residenceProvince}, residence_city = #{residenceCity},
residence_district = #{residenceDistrict}, profile_edit_count = #{profileEditCount}, residence_district = #{residenceDistrict}, profile_edit_count = #{profileEditCount},
profile_edit_window_start = #{profileEditWindowStart} profile_edit_window_start = #{profileEditWindowStart}
@@ -41,12 +41,12 @@
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> <insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO user_profile ( 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, 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, id_verified, id_verified_at, avatar_url, residence_province, residence_city, residence_district,
residence_address, signature residence_address, signature
) VALUES ( ) VALUES (
#{userId}, #{realNameEncrypted}, COALESCE(#{gender}, 0), #{birthday}, #{birthShichen}, #{userId}, #{realNameEncrypted}, COALESCE(#{gender}, 0), #{birthday}, COALESCE(#{birthdayCalendar}, 'solar'), #{birthShichen},
#{birthCountryCode}, #{birthRegionCode}, #{birthCityCode}, #{birthDistrictCode}, #{birthCountryCode}, #{birthRegionCode}, #{birthCityCode}, #{birthDistrictCode},
#{idCardNoEncrypted}, #{idCardHash}, COALESCE(#{idVerified}, 0), #{idVerifiedAt}, #{avatarUrl}, #{idCardNoEncrypted}, #{idCardHash}, COALESCE(#{idVerified}, 0), #{idVerifiedAt}, #{avatarUrl},
#{residenceProvince}, #{residenceCity}, #{residenceDistrict}, #{residenceAddress}, #{signature} #{residenceProvince}, #{residenceCity}, #{residenceDistrict}, #{residenceAddress}, #{signature}
@@ -59,6 +59,7 @@
real_name_encrypted = #{realNameEncrypted}, real_name_encrypted = #{realNameEncrypted},
gender = COALESCE(#{gender}, gender), gender = COALESCE(#{gender}, gender),
birthday = #{birthday}, birthday = #{birthday},
birthday_calendar = COALESCE(#{birthdayCalendar}, birthday_calendar),
birth_shichen = #{birthShichen}, birth_shichen = #{birthShichen},
birth_country_code = #{birthCountryCode}, birth_country_code = #{birthCountryCode},
birth_region_code = #{birthRegionCode}, birth_region_code = #{birthRegionCode},
+2 -1
View File
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS user_profile (
real_name_encrypted VARCHAR(128) NULL, real_name_encrypted VARCHAR(128) NULL,
gender TINYINT NOT NULL DEFAULT 0, gender TINYINT NOT NULL DEFAULT 0,
birthday DATE NULL, birthday DATE NULL,
birthday_calendar VARCHAR(8) NOT NULL DEFAULT 'solar' COMMENT '生日显示历法 solar阳历 lunar农历,birthday始终为阳历日期',
birth_shichen TINYINT UNSIGNED NULL, birth_shichen TINYINT UNSIGNED NULL,
birth_country_code CHAR(2) NULL, birth_country_code CHAR(2) NULL,
birth_region_code VARCHAR(20) 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, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
from_user_id BIGINT UNSIGNED NOT NULL, from_user_id BIGINT UNSIGNED NOT NULL,
from_user_name VARCHAR(50) 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, to_user_name VARCHAR(50) NULL,
virtual_item_id TINYINT UNSIGNED NOT NULL, virtual_item_id TINYINT UNSIGNED NOT NULL,
from_user_item_id BIGINT UNSIGNED NULL, from_user_item_id BIGINT UNSIGNED NULL,
@@ -106,7 +106,7 @@ class NfcMySqlIntegrationTest {
UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class); UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class);
UserNfcBindingMapper bindings = session.getMapper(UserNfcBindingMapper.class); UserNfcBindingMapper bindings = session.getMapper(UserNfcBindingMapper.class);
VirtualAssetService assets = new VirtualAssetService(session.getMapper(UserAccountMapper.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); NfcService nfc = new NfcService(chips, bindings, assets);
try (Statement statement = connection.createStatement()) { try (Statement statement = connection.createStatement()) {
statement.executeUpdate("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) " 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(); long holdingId = holdings.findByUserAndItem(1L, 1).get().getId();
NfcDtos.OfferingRequest offer = new NfcDtos.OfferingRequest(); NfcDtos.OfferingRequest offer = new NfcDtos.OfferingRequest();
offer.setUserVirtualItemId(holdingId); offer.setUserVirtualItemId(holdingId);
offer.setOfferingType(OfferingType.MERIT.getCode()); offer.setOfferingType(OfferingType.INCENSE.getCode());
offer.setMeritValue(8);
assets.offer(1L, offer); assets.offer(1L, offer);
assertEquals(2, offerings.findByUser(1L, holdingId, 0, 100).size()); assertEquals(2, offerings.findByUser(1L, holdingId, 0, 100).size());
NfcDtos.TransferRequest dedication = new NfcDtos.TransferRequest(); NfcDtos.TransferRequest dedication = new NfcDtos.TransferRequest();
@@ -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, 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, ""), 400);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ",\"meritValue\":-1"), 400); postJson(1, "/api/user/virtual-items/offerings", offering(id, 4, ",\"meritValue\":-1"), 400);
for (int type : Arrays.asList(2, 3, 4, 5)) { for (int type : Arrays.asList(2, 3, 5)) {
postJson(1, "/api/user/virtual-items/offerings", offering(id, type, type == 4 ? ",\"meritValue\":12" : ""), 200); 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)); 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, 2, ""), 400);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 200); postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 200);
postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 400); postJson(1, "/api/user/virtual-items/offerings", offering(id, 1, ""), 400);
assertEquals(1, integer("SELECT status FROM user_virtual_item WHERE id = " + id)); 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(0, integer("SELECT COUNT(id) FROM user_virtual_item_offering WHERE offering_type = 4"));
assertEquals(6, getJson(1, "/api/user/virtual-items/offerings?holdingId=" + id).size()); assertEquals(5, getJson(1, "/api/user/virtual-items/offerings?holdingId=" + id).size());
assertEquals(0, getJson(2, "/api/user/virtual-items/offerings").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 @Test
void dedicationRecordsSnapshotsWithoutTransferringOrCreatingAssets() throws Exception { void dedicationRecordsSnapshotsWithoutTransferringOrCreatingAssets() throws Exception {
nfc.bind(1L, "chip-a"); nfc.bind(1L, "chip-a");
@@ -6,6 +6,8 @@ import com.hongzhen.hera.auth.WechatClient;
import com.hongzhen.hera.auth.WxSession; import com.hongzhen.hera.auth.WxSession;
import com.hongzhen.hera.data.UserAccount; import com.hongzhen.hera.data.UserAccount;
import com.hongzhen.hera.data.UserAccountMapper; 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.nio.charset.StandardCharsets;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -44,6 +46,7 @@ public class UserUnificationTest {
@Autowired private ObjectMapper json; @Autowired private ObjectMapper json;
@Autowired private UserService userService; @Autowired private UserService userService;
@Autowired private UserAccountMapper accountMapper; @Autowired private UserAccountMapper accountMapper;
@Autowired private UserProfileMapper profileMapper;
@Autowired private ApplicationContext context; @Autowired private ApplicationContext context;
@MockBean private WechatClient wechatClient; @MockBean private WechatClient wechatClient;
private String openId; private String openId;
@@ -179,6 +182,57 @@ public class UserUnificationTest {
+ "\",\"phone\":\"" + phone + "\",\"captcha\":\"" + sms.path("mockCode").asText() + "\"}"); + "\",\"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 { private JsonNode post(String path, String body) throws Exception {
String response = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path) String response = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path)
.contentType(MediaType.APPLICATION_JSON).content(body)) .contentType(MediaType.APPLICATION_JSON).content(body))