feat: support virtual item detail images and COS access

This commit is contained in:
zhouzhibo committed 2026-10-21 20:21:28 +08:00
1 parent 0f927c1884
commit eefd54ad96
17 files changed
+528 -26

No files matched your search

+28 -2
View File
@@ -108,13 +108,17 @@ tail -f logs/hera-error.log
资料查询返回的头像 URL 和文件上传返回的 URL 有效期为 900 秒;过期后重新获取。
历史 `/uploads/avatars/` 路径继续按原有本地静态资源配置访问,旧文件没有自动迁移。
访问入口默认使用 `http://101.43.139.56/file/access?token=...`,Nginx 保留路径和查询参数
转发至后端;参考 `deploy/nginx-file.conf.example`。后端校验文件专用短期凭证后,
访问入口默认使用 `http://101.43.139.56/hera/file/access?token=...`,Nginx 将外部
`/hera/file/access` 转发至后端 `/file/access`,保留查询参数;参考
`deploy/nginx-file.conf.example`。不要将携带应用文件凭证的入口直接代理至 COS。后端校验文件专用短期凭证后,
302 跳转至 COS 官方 HTTPS 签名 URL。入口不要求登录头,必须携带有效文件凭证。
文件链接属于临时访问凭据,不应写入日志或分享给无权访问的人。
部署时通过 `HERA_JWT_SECRET` 配置随机签名密钥,并通过 `TENCENT_COS_PUBLIC_BASE_URL`
配置生产 HTTPS 域名下的 `/file/` 地址。小程序需配置入口和跳转目标的 HTTPS 合法域名。
当前 HTTP IP 地址供联调使用,不满足正式小程序的 HTTPS 域名要求。
如果部署环境或私有配置仍将 `TENCENT_COS_PUBLIC_BASE_URL`/`hera.cos.public-base-url`
设为旧的 `/file/` 地址,需要一并改为 `http://101.43.139.56/hera/file/`,否则会覆盖默认值。
更新配置并重启服务后,重新获取资料或天宫列表生成新链接;已有缓存链接不会自动改变。
### COS 验证
@@ -178,6 +182,7 @@ tail -f logs/hera-error.log
- `GET /api/nfc/devices`:本人当前设备。
- `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,...]}`,按提交顺序设置排序值。
@@ -186,6 +191,27 @@ tail -f logs/hera-error.log
- `POST /api/user/virtual-items/transfers`:`{"userVirtualItemId":1,"toUserId":2,"content":"祝福"}`。
- `GET /api/user/virtual-items/transfers`:本人发出或收到的回向记录。
虚拟物字典与天宫列表均返回 `iconUrl`(列表图标)和 `iconDetailUrl`(神仙详情页大图),
两种图片独立配置,不互相覆盖。`virtual_item.icon_detail_url` 按现库使用
`VARCHAR(255) NOT NULL`、无默认值,后台入库和更新字典时必须提供,已有表不由启动流程自动改列。
私有桶图片与头像一致:数据库保存 `cos://hera/virtual-items/{itemId}/...` 持久标识,
上述两个业务接口实时返回有效期 900 秒的文件访问入口,不向数据库写入临时签名 URL。
已有普通 HTTP/HTTPS 图片地址保持原样。文件网关允许签名访问该专用目录,但不将它视为用户个人文件。
COS 身份需具有该目录下的 `PutObject`、`GetObject` 权限;不改变存储桶私有读权限。
`VirtualItemImageImportTest` 是显式授权才会执行的真实数据导入,默认跳过。
启用参数为 `-Dhera.virtual-item.image-import=true`,并且必须同时指定
`hera.import.item-id`、`hera.import.item-name`、`hera.import.icon-path`、`hera.import.detail-path`。
它校验目标名称、PNG/WebP 文件头与 5MB 大小限制,按 SHA-256 命名对象并验证签名下载内容,
先在 `target/` 备份旧图片值,再通过旧值条件同时更新两个图片字段;不修改其他字典项。
`-Dhera.import.inspect-only=true` 只列出 ID 与名称,不上传或写库;新增缺失字典项必须显式设置
`-Dhera.import.create-if-missing=true`,不允许将已有同名神仙用其他 ID 重复创建。
批量导入可通过 `-Dhera.import.manifest=/absolute/path/manifest.json` 指定 JSON 数组,
每项包含 `id`、`name`、`iconPath`、`detailPath`;批次内 ID 和名称不可重复。
WebP 应在上传前用支持该格式的工具完整解码,核对尺寸、透明通道与压缩质量,
导入器不会将不支持 WebP 的 JDK ImageIO 当作解码器;原始文件不应被压缩结果覆盖。
不要在普通构建、CI 或无人审核的自动化中启用此参数。
一个芯片仅允许一个当前用户,一个微信用户可以绑定多个芯片;要求有效账号已绑定微信和手机号。
重复绑定本人设备只刷新更新时间,不增加绑定次数,也不重复发放资产。
每台芯片按北京时间自然年最多解绑两次,次数跟随芯片,不因换用户而重置,跨年重新计算。
+1
View File
@@ -12,3 +12,4 @@ hera:
cos:
secret-id: "${TENCENT_COS_SECRET_ID}"
secret-key: "${TENCENT_COS_SECRET_KEY}"
public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/hera/file/}"
+4 -3
View File
@@ -1,7 +1,8 @@
# Include this location in the server block for 101.43.139.56.
# Preserve /file/access and the token query when forwarding.
location /file/ {
proxy_pass http://127.0.0.1:8080;
# The public /hera/file/access path maps to Spring's /file/access.
# Keep the query string intact; do not forward this route directly to COS.
location /hera/file/ {
proxy_pass http://127.0.0.1:8080/file/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
@@ -17,6 +17,11 @@ public class VirtualItem extends BaseEntity {
*/
private String iconUrl;
/**
* 神仙详情页展示的大图 URL,最长 255 个字符,数据库不允许空值。
*/
private String iconDetailUrl;
/**
* 虚拟物描述。
*/
@@ -48,6 +53,14 @@ public class VirtualItem extends BaseEntity {
this.iconUrl = iconUrl;
}
public String getIconDetailUrl() {
return iconDetailUrl;
}
public void setIconDetailUrl(String iconDetailUrl) {
this.iconDetailUrl = iconDetailUrl;
}
public String getDescription() {
return description;
}
@@ -9,6 +9,13 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface VirtualItemMapper extends CrudMapper<VirtualItem> {
/**
* 按旧图片值与名称校验后仅更新图片,避免上传期间覆盖其他管理员的图片变更。
*/
int updateImages(@Param("id") Long id, @Param("expectedName") String expectedName,
@Param("expectedIcon") String expectedIcon, @Param("expectedDetail") String expectedDetail,
@Param("icon") String icon, @Param("detail") String detail);
/**
* 按字典顺序分页查询启用中的虚拟物。
*/
@@ -11,6 +11,10 @@ public class TiangongItemView {
private String name;
/** 后端配置的图标。 */
private String iconUrl;
/**
* 后端配置的神仙详情页大图,与列表图标分别维护。
*/
private String iconDetailUrl;
/** 后端配置的介绍。 */
private String description;
/** 本人持有关系 ID,未持有时为空。 */
@@ -27,6 +31,8 @@ public class TiangongItemView {
public void setName(String name) { this.name = name; }
public String getIconUrl() { return iconUrl; }
public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; }
public String getIconDetailUrl() { return iconDetailUrl; }
public void setIconDetailUrl(String iconDetailUrl) { this.iconDetailUrl = iconDetailUrl; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Long getHoldingId() { return holdingId; }
@@ -13,6 +13,7 @@ import com.hongzhen.hera.data.UserVirtualItemTransfer;
import com.hongzhen.hera.data.UserVirtualItemTransferMapper;
import com.hongzhen.hera.data.VirtualItem;
import com.hongzhen.hera.data.VirtualItemMapper;
import com.hongzhen.hera.storage.FileAccessService;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
@@ -33,10 +34,12 @@ public class VirtualAssetService {
private final UserVirtualItemTransferMapper transfers;
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) {
UserVirtualItemTransferMapper transfers, NfcChipMapper chips, TiangongMapper tiangong,
FileAccessService fileAccess) {
this.accounts = accounts;
this.dictionary = dictionary;
this.holdings = holdings;
@@ -44,6 +47,7 @@ public class VirtualAssetService {
this.transfers = transfers;
this.chips = chips;
this.tiangong = tiangong;
this.fileAccess = fileAccess;
}
/**
@@ -63,13 +67,23 @@ public class VirtualAssetService {
*/
public List<VirtualItem> catalog(Long userId, int offset, int limit) {
account(userId, false);
return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit));
List<VirtualItem> items = dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit));
for (VirtualItem item : items) {
item.setIconUrl(fileAccess.resolveUrl(item.getIconUrl()));
item.setIconDetailUrl(fileAccess.resolveUrl(item.getIconDetailUrl()));
}
return items;
}
/** 获取本人真实神仙状态及上香计数。 */
public List<TiangongItemView> tiangong(Long userId) {
account(userId, false);
return tiangong.findByUser(userId);
List<TiangongItemView> items = tiangong.findByUser(userId);
for (TiangongItemView item : items) {
item.setIconUrl(fileAccess.resolveUrl(item.getIconUrl()));
item.setIconDetailUrl(fileAccess.resolveUrl(item.getIconDetailUrl()));
}
return items;
}
/**
@@ -58,7 +58,8 @@ public class FileAccessService {
.parseClaimsJws(token).getBody();
if (claims.getExpiration() == null || claims.getSubject() == null
|| !(claims.getSubject().startsWith("cos://hera/avatar/")
|| claims.getSubject().startsWith("cos://hera/files/"))) {
|| claims.getSubject().startsWith("cos://hera/files/")
|| claims.getSubject().startsWith("cos://hera/virtual-items/"))) {
throw new BusinessException("文件访问凭证不合法");
}
return storageService.resolveUrl(claims.getSubject());
+1 -1
View File
@@ -50,7 +50,7 @@ hera:
bucket: "${TENCENT_COS_BUCKET:hera-1319913268}"
region: "${TENCENT_COS_REGION:ap-beijing}"
signed-url-seconds: 900
public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/file/}"
public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/hera/file/}"
jwt-secret: "${HERA_JWT_SECRET:}"
upload-dir: "${HERA_UPLOAD_DIR:./uploads}"
wx:
+1 -1
View File
@@ -2,7 +2,7 @@
<!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,
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}
@@ -4,7 +4,7 @@
<mapper namespace="com.hongzhen.hera.data.VirtualItemMapper">
<sql id="BaseColumnList">
id, item_name, icon_url, description, sort_order, enabled, created_at, updated_at
id, item_name, icon_url, icon_detail_url, description, sort_order, enabled, created_at, updated_at
</sql>
<select id="findById" resultType="com.hongzhen.hera.data.VirtualItem">
@@ -19,9 +19,9 @@
</select>
<insert id="insert">
INSERT INTO virtual_item (id, item_name, icon_url, description, sort_order, enabled)
INSERT INTO virtual_item (id, item_name, icon_url, icon_detail_url, description, sort_order, enabled)
VALUES (
#{id}, #{itemName}, #{iconUrl}, #{description}, COALESCE(#{sortOrder}, 0), COALESCE(#{enabled}, 1)
#{id}, #{itemName}, #{iconUrl}, #{iconDetailUrl}, #{description}, COALESCE(#{sortOrder}, 0), COALESCE(#{enabled}, 1)
)
</insert>
@@ -29,6 +29,7 @@
UPDATE virtual_item
SET item_name = #{itemName},
icon_url = #{iconUrl},
icon_detail_url = #{iconDetailUrl},
description = #{description},
sort_order = COALESCE(#{sortOrder}, sort_order),
enabled = COALESCE(#{enabled}, enabled)
@@ -36,6 +37,12 @@
</update>
<delete id="deleteById">DELETE FROM virtual_item WHERE id = #{id}</delete>
<update id="updateImages">
UPDATE virtual_item SET icon_url = #{icon}, icon_detail_url = #{detail}, updated_at = CURRENT_TIMESTAMP
WHERE id = #{id} AND item_name = #{expectedName}
AND (icon_url = #{expectedIcon} OR (icon_url IS NULL AND #{expectedIcon} IS NULL))
AND icon_detail_url = #{expectedDetail}
</update>
<select id="findEnabled" resultType="com.hongzhen.hera.data.VirtualItem">
SELECT <include refid="BaseColumnList"/> FROM virtual_item
WHERE enabled = 1 ORDER BY sort_order, id LIMIT #{offset}, #{limit}
+1
View File
@@ -119,6 +119,7 @@ CREATE TABLE IF NOT EXISTS virtual_item (
id INT UNSIGNED NOT NULL,
item_name VARCHAR(50) NOT NULL,
icon_url VARCHAR(255) NULL,
icon_detail_url VARCHAR(255) NOT NULL COMMENT '神仙详情页大图 URL',
description VARCHAR(255) NULL,
sort_order SMALLINT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
@@ -38,6 +38,20 @@ class EnvironmentProfilesTest {
}
}
@Test
void fileGatewayDefaultsToHeraProxyAndCanUseDeploymentOverride() {
for (String profile : new String[]{"local", "prod"}) {
try (GenericApplicationContext context = environment(profile)) {
assertEquals("http://101.43.139.56/hera/file/",
context.getEnvironment().getProperty("hera.cos.public-base-url"));
}
}
try (GenericApplicationContext context = environment("prod", "https://files.example/hera/file/")) {
assertEquals("https://files.example/hera/file/",
context.getEnvironment().getProperty("hera.cos.public-base-url"));
}
}
@Test
void productionRejectsWeakSecretsWithoutPrintingTheirValues() {
MockEnvironment env = new MockEnvironment();
@@ -62,10 +76,17 @@ class EnvironmentProfilesTest {
}
private GenericApplicationContext environment(String profile) {
return environment(profile, null);
}
private GenericApplicationContext environment(String profile, String fileBaseUrl) {
GenericApplicationContext context = new GenericApplicationContext();
Map<String, Object> values = new HashMap<>();
values.put("HERA_LOCAL_CONFIG", "optional:file:./target/nonexistent-profile-test.yml");
values.put("HERA_PROD_CONFIG", "optional:file:./target/nonexistent-profile-test.yml");
if (fileBaseUrl != null) {
values.put("TENCENT_COS_PUBLIC_BASE_URL", fileBaseUrl);
}
if (profile != null) {
values.put("spring.profiles.active", profile);
values.put("HERA_JWT_SECRET", "");
@@ -8,6 +8,11 @@ import com.hongzhen.hera.data.UserVirtualItemMapper;
import com.hongzhen.hera.data.UserVirtualItemOfferingMapper;
import com.hongzhen.hera.data.UserVirtualItemTransferMapper;
import com.hongzhen.hera.data.VirtualItemMapper;
import com.hongzhen.hera.data.VirtualItem;
import com.hongzhen.hera.config.HeraProperties;
import com.hongzhen.hera.storage.CosProperties;
import com.hongzhen.hera.storage.CosStorageService;
import com.hongzhen.hera.storage.FileAccessService;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
@@ -39,6 +44,61 @@ import static org.junit.jupiter.api.Assertions.*;
*/
@EnabledIfSystemProperty(named = "hera.nfc.mysql.integration", matches = "true")
class NfcMySqlIntegrationTest {
@Test
void mysqlVirtualItemDefinitionIsReadOnly() throws Exception {
try (Connection connection = openConnection()) {
connection.setReadOnly(true);
try (Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SHOW CREATE TABLE virtual_item")) {
assertTrue(result.next());
System.out.println(result.getString(2));
}
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(
"SELECT data_type, character_maximum_length, is_nullable, column_default FROM information_schema.columns "
+ "WHERE table_schema = DATABASE() AND table_name = 'virtual_item' AND column_name = 'icon_detail_url'")) {
assertTrue(result.next());
assertEquals("varchar", result.getString(1));
assertEquals(255, result.getInt(2));
assertEquals("NO", result.getString(3));
assertNull(result.getString(4));
}
}
}
@Test
void mysqlVirtualItemDetailImagesRoundTripInTemporaryTables() throws Exception {
try (Connection connection = openConnection()) {
connection.setReadOnly(true);
SqlSessionFactoryBean factory = mapperFactory(connection);
try (SqlSession session = factory.getObject().openSession()) {
assertTrue(session.getMapper(VirtualItemMapper.class).findAll(0, 0).isEmpty());
}
connection.setReadOnly(false);
createTemporaryTables(connection);
connection.setAutoCommit(false);
try (SqlSession session = factory.getObject().openSession()) {
VirtualItemMapper dictionary = session.getMapper(VirtualItemMapper.class);
VirtualItem item = new VirtualItem();
item.setId(10L);
item.setItemName("temporary-image-test");
item.setIconUrl("https://example.invalid/thumbnail.png");
item.setIconDetailUrl("https://example.invalid/detail.png");
assertEquals(1, dictionary.insert(item));
assertEquals(item.getIconDetailUrl(), dictionary.findById(10L).get().getIconDetailUrl());
item.setIconDetailUrl("https://example.invalid/detail-updated.png");
assertEquals(1, dictionary.update(item));
assertEquals(item.getIconDetailUrl(), dictionary.findAll(0, 100).get(0).getIconDetailUrl());
assertEquals(item.getIconDetailUrl(), dictionary.findEnabled(0, 100).get(0).getIconDetailUrl());
TiangongItemView view = session.getMapper(TiangongMapper.class).findByUser(-1L).get(0);
assertEquals(item.getIconUrl(), view.getIconUrl());
assertEquals(item.getIconDetailUrl(), view.getIconDetailUrl());
assertFalse(view.isActive());
session.rollback(true);
assertTrue(dictionary.findAll(0, 100).isEmpty());
}
}
}
@Test
void mysqlChipDefinitionIsReadOnly() throws Exception {
try (Connection connection = openConnection()) {
@@ -79,12 +139,7 @@ class NfcMySqlIntegrationTest {
void mysqlXmlUsesIsolatedTemporaryTablesAndReportsDeploymentSchemaGaps() throws Exception {
try (Connection connection = openConnection()) {
reportSchema(connection);
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(new SingleConnectionDataSource(connection, true));
Configuration config = new Configuration();
config.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(config);
factory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
SqlSessionFactoryBean factory = mapperFactory(connection);
connection.setReadOnly(true);
try (SqlSession session = factory.getObject().openSession()) {
// limit=0 让现库解析全部 XML 查询字段,但不读取用户业务记录。
@@ -105,14 +160,19 @@ class NfcMySqlIntegrationTest {
UserVirtualItemOfferingMapper offerings = session.getMapper(UserVirtualItemOfferingMapper.class);
UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.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, transfers, chips, session.getMapper(TiangongMapper.class),
new FileAccessService(new CosStorageService(cos), cos, hera));
NfcService nfc = new NfcService(chips, bindings, assets);
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) "
+ "VALUES (1, 'nfc-test-1', 'test-one', '13800000001', 'wx-one'), "
+ "(2, 'nfc-test-2', 'test-two', '13800000002', 'wx-two')");
statement.executeUpdate("INSERT INTO virtual_item(id, item_name) VALUES (1, 'test-item')");
statement.executeUpdate("INSERT INTO virtual_item(id, item_name, icon_detail_url) "
+ "VALUES (1, 'test-item', 'https://example.invalid/detail.png')");
}
NfcChip chip = new NfcChip();
chip.setUid("temporary-test-chip");
@@ -152,6 +212,16 @@ class NfcMySqlIntegrationTest {
}
}
private SqlSessionFactoryBean mapperFactory(Connection connection) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(new SingleConnectionDataSource(connection, true));
Configuration config = new Configuration();
config.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(config);
factory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
return factory;
}
private void createTemporaryTables(Connection connection) throws Exception {
String ddl;
try (InputStream input = new ClassPathResource("schema.sql").getInputStream()) {
@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.hongzhen.hera.common.BusinessException;
import com.hongzhen.hera.data.NfcChip;
import com.hongzhen.hera.data.NfcChipMapper;
import com.hongzhen.hera.data.VirtualItem;
import com.hongzhen.hera.data.VirtualItemMapper;
import com.hongzhen.hera.security.JwtService;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -52,6 +54,7 @@ class NfcWorkflowTest {
@Autowired private JwtService jwt;
@Autowired private NfcService nfc;
@Autowired private NfcChipMapper chips;
@Autowired private VirtualItemMapper dictionary;
@BeforeEach
void schema() throws Exception {
@@ -78,12 +81,81 @@ class NfcWorkflowTest {
jdbc.update("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) VALUES (?, ?, ?, ?, ?)",
id, "account" + id, "昵称" + id, "1380000000" + id, "openid" + id);
}
jdbc.update("INSERT INTO virtual_item(id, item_name, sort_order) VALUES (1, '财神', 2), (2, '文昌星', 1)");
jdbc.update("INSERT INTO virtual_item(id, item_name, enabled) VALUES (3, '停用项', 0)");
jdbc.update("INSERT INTO virtual_item(id, item_name, icon_detail_url, sort_order) VALUES "
+ "(1, '财神', 'https://example.invalid/wealth-detail.png', 2), "
+ "(2, '文昌星', 'https://example.invalid/study-detail.png', 1)");
jdbc.update("INSERT INTO virtual_item(id, item_name, icon_detail_url, enabled) "
+ "VALUES (3, '停用项', 'https://example.invalid/disabled-detail.png', 0)");
jdbc.update("INSERT INTO nfc_chip(uid, sn, virtual_item_id) VALUES ('chip-a', 'SN-A', 1), "
+ "('chip-b', 'SN-B', 1), ('chip-c', 'SN-C', 2), ('disabled-chip', 'SN-DISABLED', 3)");
}
@Test
void virtualItemDetailImagePersistsAndIsExposedByCatalogAndTiangong() throws Exception {
VirtualItem item = new VirtualItem();
item.setId(10L);
item.setItemName("详情大图测试");
item.setIconUrl("https://example.invalid/thumbnail.png");
item.setIconDetailUrl("https://example.invalid/detail.png");
assertEquals(1, dictionary.insert(item));
assertEquals(item.getIconDetailUrl(), dictionary.findById(10L).get().getIconDetailUrl());
assertTrue(dictionary.findAll(0, 100).stream().anyMatch(row -> item.getIconDetailUrl().equals(row.getIconDetailUrl())));
assertTrue(dictionary.findEnabled(0, 100).stream().anyMatch(row -> item.getIconDetailUrl().equals(row.getIconDetailUrl())));
item.setIconDetailUrl("https://example.invalid/detail-updated.png");
assertEquals(1, dictionary.update(item));
for (String endpoint : Arrays.asList("/api/virtual-items", "/api/tiangong/items")) {
JsonNode response = getJson(1, endpoint).get(0);
assertEquals(10L, response.path("id").asLong());
assertEquals(item.getIconUrl(), response.path("iconUrl").asText());
assertEquals(item.getIconDetailUrl(), response.path("iconDetailUrl").asText());
}
item.setEnabled(0);
dictionary.update(item);
assertEquals(2, getJson(1, "/api/virtual-items").size());
assertEquals(2, getJson(1, "/api/tiangong/items").size());
assertEquals(1, dictionary.deleteById(10L));
}
@Test
void virtualItemDetailImageMatchesNonNullAndLengthConstraints() {
VirtualItem item = new VirtualItem();
item.setId(11L);
item.setItemName("大图字段边界测试");
assertThrows(DataIntegrityViolationException.class, () -> dictionary.insert(item));
String prefix = "https://example.invalid/";
String maximum = prefix + String.join("", Collections.nCopies(255 - prefix.length(), "a"));
item.setIconDetailUrl(maximum);
assertEquals(1, dictionary.insert(item));
assertEquals(maximum, dictionary.findById(11L).get().getIconDetailUrl());
item.setIconDetailUrl(maximum + "a");
assertThrows(DataIntegrityViolationException.class, () -> dictionary.update(item));
assertEquals(maximum, dictionary.findById(11L).get().getIconDetailUrl());
}
@Test
void cosImagesAreSignedForResponsesAndOnlyReferencesAreStored() throws Exception {
VirtualItem previous = dictionary.findById(1L).get();
String icon = "cos://hera/virtual-items/1/icon-test.png";
String detail = "cos://hera/virtual-items/1/detail-test.png";
assertEquals(0, dictionary.updateImages(1L, previous.getItemName(), "stale-image",
previous.getIconDetailUrl(), icon, detail));
assertEquals(1, dictionary.updateImages(1L, previous.getItemName(), previous.getIconUrl(),
previous.getIconDetailUrl(), icon, detail));
for (String endpoint : Arrays.asList("/api/virtual-items", "/api/tiangong/items")) {
JsonNode item = getJson(1, endpoint).get(1);
assertTrue(item.path("iconUrl").asText().contains("/access?token="));
assertTrue(item.path("iconDetailUrl").asText().contains("/access?token="));
assertFalse(item.path("iconUrl").asText().startsWith("cos://"));
}
VirtualItem stored = dictionary.findById(1L).get();
assertEquals(icon, stored.getIconUrl());
assertEquals(detail, stored.getIconDetailUrl());
assertEquals(previous.getItemName(), stored.getItemName());
assertEquals(previous.getSortOrder(), stored.getSortOrder());
assertEquals(previous.getEnabled(), stored.getEnabled());
}
@Test
void serialNumberIsPersistedByCrudAndReturnedWithoutChangingUidBinding() throws Exception {
NfcChip chip = new NfcChip();
@@ -18,7 +18,7 @@ class FileAccessServiceTest {
HeraProperties hera = new HeraProperties();
hera.setJwtSecret(SECRET);
CosProperties cos = new CosProperties();
cos.setPublicBaseUrl("http://101.43.139.56/file/");
cos.setPublicBaseUrl("http://101.43.139.56/hera/file/");
return new FileAccessService(storage, cos, hera);
}
@@ -29,7 +29,7 @@ class FileAccessServiceTest {
String reference = "cos://hera/avatar/1/test.png";
when(storage.resolveUrl(reference)).thenReturn("https://cos.example/signed");
String url = service.resolveUrl(reference);
assertTrue(url.startsWith("http://101.43.139.56/file/access?token="));
assertTrue(url.startsWith("http://101.43.139.56/hera/file/access?token="));
String token = url.substring(url.indexOf("token=") + 6);
assertEquals("https://cos.example/signed", service.redirectUrl(token));
verify(storage).resolveUrl(reference);
@@ -49,4 +49,16 @@ class FileAccessServiceTest {
assertThrows(BusinessException.class, () -> service.redirectUrl(login));
assertThrows(BusinessException.class, () -> service.redirectUrl("invalid"));
}
@Test
void virtualItemImageTokenWorksWithoutGrantingUserFileOwnership() {
CosStorageService storage = mock(CosStorageService.class);
FileAccessService service = service(storage);
String reference = "cos://hera/virtual-items/1/icon-test.png";
when(storage.resolveUrl(reference)).thenReturn("https://cos.example/signed-image");
String url = service.resolveUrl(reference);
assertEquals("https://cos.example/signed-image", service.redirectUrl(url.substring(url.indexOf("token=") + 6)));
CosStorageService ownership = new CosStorageService(new CosProperties());
assertThrows(BusinessException.class, () -> ownership.validateOwnership(1L, reference));
}
}
@@ -0,0 +1,250 @@
package com.hongzhen.hera.storage;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.hongzhen.hera.data.VirtualItem;
import com.hongzhen.hera.data.VirtualItemMapper;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.exception.CosServiceException;
import com.qcloud.cos.http.HttpMethodName;
import com.qcloud.cos.http.HttpProtocol;
import com.qcloud.cos.model.ObjectMetadata;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.region.Region;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Instant;
import java.util.Date;
import java.util.Properties;
import java.util.Optional;
import java.util.HashSet;
import java.util.Set;
import javax.imageio.ImageIO;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;
import org.springframework.util.StreamUtils;
import static org.junit.jupiter.api.Assertions.*;
/**
* 必须显式授权运行的持久图片导入,不启动应用、不改表,仅写入指定字典项的图片。
* 上传采用内容哈希命名,重试不覆盖其他图片;数据库提交结果不明时保留对象,不冒险删除。
*/
@EnabledIfSystemProperty(named = "hera.virtual-item.image-import", matches = "true")
class VirtualItemImageImportTest {
private static final long MAX_IMAGE_BYTES = 5L * 1024L * 1024L;
@Test
void uploadImagesAndPersistReferences() throws Exception {
String manifest = System.getProperty("hera.import.manifest");
if (manifest == null) {
importImages(Long.parseLong(required("hera.import.item-id")), required("hera.import.item-name"),
required("hera.import.icon-path"), required("hera.import.detail-path"));
return;
}
JsonNode targets = new ObjectMapper().readTree(Paths.get(manifest).toFile());
assertTrue(targets.isArray() && targets.size() > 0 && targets.size() <= 100, "导入清单必须为 1 至 100 项");
Set<Long> ids = new HashSet<>();
Set<String> names = new HashSet<>();
for (JsonNode target : targets) {
assertTrue(target.path("id").asLong() > 0 && target.path("id").asLong() <= 255, "字典 ID 必须在 1 至 255");
assertTrue(ids.add(target.path("id").asLong()), "清单中不允许重复 ID");
String name = target.path("name").asText();
assertTrue(!name.trim().isEmpty() && name.length() <= 50 && names.add(name), "清单名称无效或重复");
image(target.path("iconPath").asText());
image(target.path("detailPath").asText());
}
for (JsonNode target : targets) {
importImages(target.path("id").asLong(), target.path("name").asText(),
target.path("iconPath").asText(), target.path("detailPath").asText());
}
}
private void importImages(long id, String expectedName, String iconPath, String detailPath) throws Exception {
String stage = "configuration";
COSClient client = null;
try {
assertTrue(id > 0 && id <= 255, "字典 ID 必须在 1 至 255");
byte[] icon = image(iconPath);
byte[] detail = image(detailPath);
Properties config = configuration();
String bucket = value(config, "hera.cos.bucket");
assertEquals("hera-1319913268", bucket, "仅向用户指定的存储桶导入");
ClientConfig settings = new ClientConfig(new Region(value(config, "hera.cos.region")));
settings.setHttpProtocol(HttpProtocol.https);
settings.setConnectionTimeout(10000);
settings.setSocketTimeout(20000);
client = new COSClient(new BasicCOSCredentials(value(config, "hera.cos.secret-id"),
value(config, "hera.cos.secret-key")), settings);
stage = "read target row";
try (Connection connection = DriverManager.getConnection(value(config, "spring.datasource.url"),
value(config, "spring.datasource.username"), value(config, "spring.datasource.password"))) {
assertEquals("hera", connection.getCatalog(), "仅操作用户指定的数据库");
connection.setAutoCommit(false);
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(new SingleConnectionDataSource(connection, true));
Configuration mybatis = new Configuration();
mybatis.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(mybatis);
factory.setMapperLocations(new ClassPathResource("mapper/VirtualItemMapper.xml"));
try (SqlSession session = factory.getObject().openSession()) {
VirtualItemMapper mapper = session.getMapper(VirtualItemMapper.class);
if (Boolean.getBoolean("hera.import.inspect-only")) {
for (VirtualItem item : mapper.findAll(0, 100)) {
System.out.println("Virtual item target: id=" + item.getId() + ", name=" + item.getItemName());
}
System.out.println("Read-only target inspection complete");
return;
}
Optional<VirtualItem> current = mapper.findById(id);
boolean creating = !current.isPresent();
VirtualItem old;
if (creating) {
assertTrue(Boolean.getBoolean("hera.import.create-if-missing"), "目标不存在,必须显式允许新增");
assertTrue(mapper.findAll(0, 100).stream().noneMatch(item -> expectedName.equals(item.getItemName())),
"已有同名神仙使用其他 ID,禁止重复创建");
old = new VirtualItem();
old.setId(id);
old.setItemName(expectedName);
old.setSortOrder(Math.toIntExact(id));
old.setEnabled(1);
} else {
old = current.get();
}
assertTrue(expectedName.equals(old.getItemName()), "目标名称不符,禁止上传或覆盖其他神仙");
String iconKey = "hera/virtual-items/" + id + "/icon-" + digest(icon) + extension(icon);
String detailKey = "hera/virtual-items/" + id + "/detail-" + digest(detail) + extension(detail);
String iconReference = "cos://" + iconKey;
String detailReference = "cos://" + detailKey;
Path backup = Paths.get("target", "virtual-item-" + id + "-image-backup-" + System.currentTimeMillis() + ".json");
Files.createDirectories(backup.getParent());
ObjectNode report = new ObjectMapper().createObjectNode();
report.put("id", id).put("created", creating).put("name", old.getItemName()).put("previousIconUrl", old.getIconUrl())
.put("previousIconDetailUrl", old.getIconDetailUrl()).put("iconReference", iconReference)
.put("detailReference", detailReference);
Files.write(backup, new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(report));
stage = "upload and verify icon";
uploadAndVerify(client, bucket, iconKey, icon);
stage = "upload and verify detail";
uploadAndVerify(client, bucket, detailKey, detail);
stage = "update image fields";
if (creating) {
old.setIconUrl(iconReference);
old.setIconDetailUrl(detailReference);
assertEquals(1, mapper.insert(old));
} else if (!iconReference.equals(old.getIconUrl()) || !detailReference.equals(old.getIconDetailUrl())) {
assertEquals(1, mapper.updateImages(id, expectedName, old.getIconUrl(), old.getIconDetailUrl(),
iconReference, detailReference), "图片在上传期间发生变更,已停止覆盖");
}
stage = "commit image fields";
session.commit(true);
session.clearCache();
VirtualItem saved = mapper.findById(id).get();
assertTrue(iconReference.equals(saved.getIconUrl()) && detailReference.equals(saved.getIconDetailUrl()),
"图片引用落库验证失败");
assertEquals(old.getItemName(), saved.getItemName());
assertEquals(old.getDescription(), saved.getDescription());
assertEquals(old.getSortOrder(), saved.getSortOrder());
assertEquals(old.getEnabled(), saved.getEnabled());
System.out.println("Image import complete: itemId=" + id + ", created=" + creating
+ ", images=2, uploaded SHA-256 verified, backup=" + backup);
}
}
} catch (CosServiceException exception) {
throw new AssertionError("COS import failed at " + stage + ", HTTP status=" + exception.getStatusCode());
} catch (Exception exception) {
throw new AssertionError("Image import failed at " + stage + ", exceptionType=" + exception.getClass().getSimpleName());
} finally {
if (client != null) { client.shutdown(); }
}
}
private void uploadAndVerify(COSClient client, String bucket, String key, byte[] content) throws Exception {
if (!client.doesObjectExist(bucket, key)) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(key.endsWith(".webp") ? "image/webp" : "image/png");
metadata.setContentLength(content.length);
try (InputStream input = new ByteArrayInputStream(content)) {
client.putObject(new PutObjectRequest(bucket, key, input, metadata));
}
}
URL signed = client.generatePresignedUrl(bucket, key, Date.from(Instant.now().plusSeconds(300)), HttpMethodName.GET);
HttpURLConnection download = (HttpURLConnection) signed.openConnection();
download.setConnectTimeout(10000);
download.setReadTimeout(20000);
try {
assertEquals(200, download.getResponseCode(), "签名下载验证失败");
try (InputStream input = download.getInputStream()) {
assertEquals(digest(content), digest(StreamUtils.copyToByteArray(input)), "上传后内容与待上传文件不一致");
}
} finally { download.disconnect(); }
}
private byte[] image(String path) throws Exception {
Path file = Paths.get(path);
assertTrue(Files.isRegularFile(file), "原图不存在");
assertTrue(Files.size(file) > 0 && Files.size(file) <= MAX_IMAGE_BYTES, "图片必须在 5MB 以内");
byte[] content = Files.readAllBytes(file);
if (".png".equals(extension(content))) {
assertNotNull(ImageIO.read(new ByteArrayInputStream(content)), "无法解码 PNG");
}
return content;
}
private String extension(byte[] content) {
if (content.length > 8 && content[0] == (byte) 0x89 && content[1] == 'P'
&& content[2] == 'N' && content[3] == 'G') { return ".png"; }
if (content.length > 20 && content[0] == 'R' && content[1] == 'I' && content[2] == 'F'
&& content[3] == 'F' && content[8] == 'W' && content[9] == 'E' && content[10] == 'B'
&& content[11] == 'P') { return ".webp"; }
throw new IllegalArgumentException("仅支持 PNG 或经过完整解码校验的 WebP 文件");
}
private String digest(byte[] value) throws Exception {
StringBuilder result = new StringBuilder();
for (byte b : MessageDigest.getInstance("SHA-256").digest(value)) { result.append(String.format("%02x", b & 0xff)); }
return result.toString();
}
private Properties configuration() {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
FileSystemResource local = new FileSystemResource(".hera-local.yml");
yaml.setResources(local.exists() ? new Resource[]{new ClassPathResource("application.yml"),
new ClassPathResource("application-local.yml"), local} : new Resource[]{new ClassPathResource("application.yml"),
new ClassPathResource("application-local.yml")});
return yaml.getObject();
}
private String value(Properties values, String name) {
String result = new StandardEnvironment().resolveRequiredPlaceholders(values.getProperty(name, ""));
assertFalse(result.isEmpty(), "缺少配置项 " + name);
return result;
}
private String required(String name) {
String result = System.getProperty(name);
assertNotNull(result, "必须显式提供 " + name);
assertFalse(result.trim().isEmpty(), "不能为空 " + name);
return result;
}
}