From eefd54ad96bf7a3ab266a60a8ab1af93421dc856 Mon Sep 17 00:00:00 2001 From: zhouzhibo Date: Wed, 21 Oct 2026 20:21:28 +0800 Subject: [PATCH] feat: support virtual item detail images and COS access --- README.md | 30 ++- deploy/hera-prod.yml.example | 1 + deploy/nginx-file.conf.example | 7 +- .../com/hongzhen/hera/data/VirtualItem.java | 13 + .../hongzhen/hera/data/VirtualItemMapper.java | 7 + .../hongzhen/hera/nfc/TiangongItemView.java | 6 + .../hera/nfc/VirtualAssetService.java | 20 +- .../hera/storage/FileAccessService.java | 3 +- src/main/resources/application.yml | 2 +- src/main/resources/mapper/TiangongMapper.xml | 2 +- .../resources/mapper/VirtualItemMapper.xml | 13 +- src/main/resources/schema.sql | 1 + .../hera/config/EnvironmentProfilesTest.java | 21 ++ .../hera/nfc/NfcMySqlIntegrationTest.java | 86 +++++- .../hongzhen/hera/nfc/NfcWorkflowTest.java | 76 +++++- .../hera/storage/FileAccessServiceTest.java | 16 +- .../storage/VirtualItemImageImportTest.java | 250 ++++++++++++++++++ 17 files changed, 528 insertions(+), 26 deletions(-) create mode 100644 src/test/java/com/hongzhen/hera/storage/VirtualItemImageImportTest.java diff --git a/README.md b/README.md index 125cba8..ac30be6 100644 --- a/README.md +++ b/README.md @@ -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 或无人审核的自动化中启用此参数。 + 一个芯片仅允许一个当前用户,一个微信用户可以绑定多个芯片;要求有效账号已绑定微信和手机号。 重复绑定本人设备只刷新更新时间,不增加绑定次数,也不重复发放资产。 每台芯片按北京时间自然年最多解绑两次,次数跟随芯片,不因换用户而重置,跨年重新计算。 diff --git a/deploy/hera-prod.yml.example b/deploy/hera-prod.yml.example index bc9c7a2..8ee2154 100644 --- a/deploy/hera-prod.yml.example +++ b/deploy/hera-prod.yml.example @@ -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/}" diff --git a/deploy/nginx-file.conf.example b/deploy/nginx-file.conf.example index c7b7d4f..cec41f7 100644 --- a/deploy/nginx-file.conf.example +++ b/deploy/nginx-file.conf.example @@ -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; diff --git a/src/main/java/com/hongzhen/hera/data/VirtualItem.java b/src/main/java/com/hongzhen/hera/data/VirtualItem.java index 9042949..cdd4d60 100644 --- a/src/main/java/com/hongzhen/hera/data/VirtualItem.java +++ b/src/main/java/com/hongzhen/hera/data/VirtualItem.java @@ -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; } diff --git a/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java b/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java index 0848bf0..a4baf48 100644 --- a/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java +++ b/src/main/java/com/hongzhen/hera/data/VirtualItemMapper.java @@ -9,6 +9,13 @@ import org.apache.ibatis.annotations.Param; @Mapper public interface VirtualItemMapper extends CrudMapper { + /** + * 按旧图片值与名称校验后仅更新图片,避免上传期间覆盖其他管理员的图片变更。 + */ + 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); + /** * 按字典顺序分页查询启用中的虚拟物。 */ diff --git a/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java b/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java index 0e9dcad..bb906a2 100644 --- a/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java +++ b/src/main/java/com/hongzhen/hera/nfc/TiangongItemView.java @@ -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; } diff --git a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java index 627f9e8..32c4d6e 100644 --- a/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java +++ b/src/main/java/com/hongzhen/hera/nfc/VirtualAssetService.java @@ -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 catalog(Long userId, int offset, int limit) { account(userId, false); - return dictionary.findEnabled(Math.max(0, offset), NfcRules.limit(limit)); + List 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 tiangong(Long userId) { account(userId, false); - return tiangong.findByUser(userId); + List items = tiangong.findByUser(userId); + for (TiangongItemView item : items) { + item.setIconUrl(fileAccess.resolveUrl(item.getIconUrl())); + item.setIconDetailUrl(fileAccess.resolveUrl(item.getIconDetailUrl())); + } + return items; } /** diff --git a/src/main/java/com/hongzhen/hera/storage/FileAccessService.java b/src/main/java/com/hongzhen/hera/storage/FileAccessService.java index 3e586e8..2477745 100644 --- a/src/main/java/com/hongzhen/hera/storage/FileAccessService.java +++ b/src/main/java/com/hongzhen/hera/storage/FileAccessService.java @@ -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()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 66bbf45..ae801df 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/mapper/TiangongMapper.xml b/src/main/resources/mapper/TiangongMapper.xml index baf5ed0..f433c92 100644 --- a/src/main/resources/mapper/TiangongMapper.xml +++ b/src/main/resources/mapper/TiangongMapper.xml @@ -2,7 +2,7 @@ @@ -19,9 +19,9 @@ - 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) ) @@ -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 @@ DELETE FROM virtual_item WHERE id = #{id} + + 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} +