feat: support virtual item detail images and COS access
This commit is contained in:
1 parent
0f927c1884
commit
eefd54ad96
17 files changed
+528
-26
No files matched your search
@@ -108,13 +108,17 @@ tail -f logs/hera-error.log
|
|||||||
资料查询返回的头像 URL 和文件上传返回的 URL 有效期为 900 秒;过期后重新获取。
|
资料查询返回的头像 URL 和文件上传返回的 URL 有效期为 900 秒;过期后重新获取。
|
||||||
历史 `/uploads/avatars/` 路径继续按原有本地静态资源配置访问,旧文件没有自动迁移。
|
历史 `/uploads/avatars/` 路径继续按原有本地静态资源配置访问,旧文件没有自动迁移。
|
||||||
|
|
||||||
访问入口默认使用 `http://101.43.139.56/file/access?token=...`,Nginx 保留路径和查询参数
|
访问入口默认使用 `http://101.43.139.56/hera/file/access?token=...`,Nginx 将外部
|
||||||
转发至后端;参考 `deploy/nginx-file.conf.example`。后端校验文件专用短期凭证后,
|
`/hera/file/access` 转发至后端 `/file/access`,保留查询参数;参考
|
||||||
|
`deploy/nginx-file.conf.example`。不要将携带应用文件凭证的入口直接代理至 COS。后端校验文件专用短期凭证后,
|
||||||
302 跳转至 COS 官方 HTTPS 签名 URL。入口不要求登录头,必须携带有效文件凭证。
|
302 跳转至 COS 官方 HTTPS 签名 URL。入口不要求登录头,必须携带有效文件凭证。
|
||||||
文件链接属于临时访问凭据,不应写入日志或分享给无权访问的人。
|
文件链接属于临时访问凭据,不应写入日志或分享给无权访问的人。
|
||||||
部署时通过 `HERA_JWT_SECRET` 配置随机签名密钥,并通过 `TENCENT_COS_PUBLIC_BASE_URL`
|
部署时通过 `HERA_JWT_SECRET` 配置随机签名密钥,并通过 `TENCENT_COS_PUBLIC_BASE_URL`
|
||||||
配置生产 HTTPS 域名下的 `/file/` 地址。小程序需配置入口和跳转目标的 HTTPS 合法域名。
|
配置生产 HTTPS 域名下的 `/file/` 地址。小程序需配置入口和跳转目标的 HTTPS 合法域名。
|
||||||
当前 HTTP IP 地址供联调使用,不满足正式小程序的 HTTPS 域名要求。
|
当前 HTTP IP 地址供联调使用,不满足正式小程序的 HTTPS 域名要求。
|
||||||
|
如果部署环境或私有配置仍将 `TENCENT_COS_PUBLIC_BASE_URL`/`hera.cos.public-base-url`
|
||||||
|
设为旧的 `/file/` 地址,需要一并改为 `http://101.43.139.56/hera/file/`,否则会覆盖默认值。
|
||||||
|
更新配置并重启服务后,重新获取资料或天宫列表生成新链接;已有缓存链接不会自动改变。
|
||||||
|
|
||||||
### COS 验证
|
### COS 验证
|
||||||
|
|
||||||
@@ -178,6 +182,7 @@ tail -f logs/hera-error.log
|
|||||||
- `GET /api/nfc/devices`:本人当前设备。
|
- `GET /api/nfc/devices`:本人当前设备。
|
||||||
- `GET /api/nfc/bindings`:本人绑定与解绑历史。
|
- `GET /api/nfc/bindings`:本人绑定与解绑历史。
|
||||||
- `GET /api/virtual-items`:启用的虚拟物字典,按字典排序。
|
- `GET /api/virtual-items`:启用的虚拟物字典,按字典排序。
|
||||||
|
- `GET /api/tiangong/items`:本人神仙持有状态、上香计数及展示图片。
|
||||||
- `GET /api/user/virtual-items`:本人资产,含失效与送神状态。
|
- `GET /api/user/virtual-items`:本人资产,含失效与送神状态。
|
||||||
- `GET /api/user/virtual-items/{id}`:本人持有关系详情。
|
- `GET /api/user/virtual-items/{id}`:本人持有关系详情。
|
||||||
- `PUT /api/user/virtual-items/sort`:`{"ids":[持有关系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":"祝福"}`。
|
- `POST /api/user/virtual-items/transfers`:`{"userVirtualItemId":1,"toUserId":2,"content":"祝福"}`。
|
||||||
- `GET /api/user/virtual-items/transfers`:本人发出或收到的回向记录。
|
- `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 或无人审核的自动化中启用此参数。
|
||||||
|
|
||||||
一个芯片仅允许一个当前用户,一个微信用户可以绑定多个芯片;要求有效账号已绑定微信和手机号。
|
一个芯片仅允许一个当前用户,一个微信用户可以绑定多个芯片;要求有效账号已绑定微信和手机号。
|
||||||
重复绑定本人设备只刷新更新时间,不增加绑定次数,也不重复发放资产。
|
重复绑定本人设备只刷新更新时间,不增加绑定次数,也不重复发放资产。
|
||||||
每台芯片按北京时间自然年最多解绑两次,次数跟随芯片,不因换用户而重置,跨年重新计算。
|
每台芯片按北京时间自然年最多解绑两次,次数跟随芯片,不因换用户而重置,跨年重新计算。
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ hera:
|
|||||||
cos:
|
cos:
|
||||||
secret-id: "${TENCENT_COS_SECRET_ID}"
|
secret-id: "${TENCENT_COS_SECRET_ID}"
|
||||||
secret-key: "${TENCENT_COS_SECRET_KEY}"
|
secret-key: "${TENCENT_COS_SECRET_KEY}"
|
||||||
|
public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/hera/file/}"
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Include this location in the server block for 101.43.139.56.
|
# Include this location in the server block for 101.43.139.56.
|
||||||
# Preserve /file/access and the token query when forwarding.
|
# The public /hera/file/access path maps to Spring's /file/access.
|
||||||
location /file/ {
|
# Keep the query string intact; do not forward this route directly to COS.
|
||||||
proxy_pass http://127.0.0.1:8080;
|
location /hera/file/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080/file/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ public class VirtualItem extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
private String iconUrl;
|
private String iconUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 神仙详情页展示的大图 URL,最长 255 个字符,数据库不允许空值。
|
||||||
|
*/
|
||||||
|
private String iconDetailUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 虚拟物描述。
|
* 虚拟物描述。
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +53,14 @@ public class VirtualItem extends BaseEntity {
|
|||||||
this.iconUrl = iconUrl;
|
this.iconUrl = iconUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getIconDetailUrl() {
|
||||||
|
return iconDetailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIconDetailUrl(String iconDetailUrl) {
|
||||||
|
this.iconDetailUrl = iconDetailUrl;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import org.apache.ibatis.annotations.Param;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface VirtualItemMapper extends CrudMapper<VirtualItem> {
|
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 name;
|
||||||
/** 后端配置的图标。 */
|
/** 后端配置的图标。 */
|
||||||
private String iconUrl;
|
private String iconUrl;
|
||||||
|
/**
|
||||||
|
* 后端配置的神仙详情页大图,与列表图标分别维护。
|
||||||
|
*/
|
||||||
|
private String iconDetailUrl;
|
||||||
/** 后端配置的介绍。 */
|
/** 后端配置的介绍。 */
|
||||||
private String description;
|
private String description;
|
||||||
/** 本人持有关系 ID,未持有时为空。 */
|
/** 本人持有关系 ID,未持有时为空。 */
|
||||||
@@ -27,6 +31,8 @@ public class TiangongItemView {
|
|||||||
public void setName(String name) { this.name = name; }
|
public void setName(String name) { this.name = name; }
|
||||||
public String getIconUrl() { return iconUrl; }
|
public String getIconUrl() { return iconUrl; }
|
||||||
public void setIconUrl(String iconUrl) { this.iconUrl = 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 String getDescription() { return description; }
|
||||||
public void setDescription(String description) { this.description = description; }
|
public void setDescription(String description) { this.description = description; }
|
||||||
public Long getHoldingId() { return holdingId; }
|
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.UserVirtualItemTransferMapper;
|
||||||
import com.hongzhen.hera.data.VirtualItem;
|
import com.hongzhen.hera.data.VirtualItem;
|
||||||
import com.hongzhen.hera.data.VirtualItemMapper;
|
import com.hongzhen.hera.data.VirtualItemMapper;
|
||||||
|
import com.hongzhen.hera.storage.FileAccessService;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -33,10 +34,12 @@ public class VirtualAssetService {
|
|||||||
private final UserVirtualItemTransferMapper transfers;
|
private final UserVirtualItemTransferMapper transfers;
|
||||||
private final NfcChipMapper chips;
|
private final NfcChipMapper chips;
|
||||||
private final TiangongMapper tiangong;
|
private final TiangongMapper tiangong;
|
||||||
|
private final FileAccessService fileAccess;
|
||||||
|
|
||||||
public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary,
|
public VirtualAssetService(UserAccountMapper accounts, VirtualItemMapper dictionary,
|
||||||
UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings,
|
UserVirtualItemMapper holdings, UserVirtualItemOfferingMapper offerings,
|
||||||
UserVirtualItemTransferMapper transfers, NfcChipMapper chips, TiangongMapper tiangong) {
|
UserVirtualItemTransferMapper transfers, NfcChipMapper chips, TiangongMapper tiangong,
|
||||||
|
FileAccessService fileAccess) {
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
this.dictionary = dictionary;
|
this.dictionary = dictionary;
|
||||||
this.holdings = holdings;
|
this.holdings = holdings;
|
||||||
@@ -44,6 +47,7 @@ public class VirtualAssetService {
|
|||||||
this.transfers = transfers;
|
this.transfers = transfers;
|
||||||
this.chips = chips;
|
this.chips = chips;
|
||||||
this.tiangong = tiangong;
|
this.tiangong = tiangong;
|
||||||
|
this.fileAccess = fileAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,13 +67,23 @@ public class VirtualAssetService {
|
|||||||
*/
|
*/
|
||||||
public List<VirtualItem> catalog(Long userId, int offset, int limit) {
|
public List<VirtualItem> catalog(Long userId, int offset, int limit) {
|
||||||
account(userId, false);
|
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) {
|
public List<TiangongItemView> tiangong(Long userId) {
|
||||||
account(userId, false);
|
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();
|
.parseClaimsJws(token).getBody();
|
||||||
if (claims.getExpiration() == null || claims.getSubject() == null
|
if (claims.getExpiration() == null || claims.getSubject() == null
|
||||||
|| !(claims.getSubject().startsWith("cos://hera/avatar/")
|
|| !(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("文件访问凭证不合法");
|
throw new BusinessException("文件访问凭证不合法");
|
||||||
}
|
}
|
||||||
return storageService.resolveUrl(claims.getSubject());
|
return storageService.resolveUrl(claims.getSubject());
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ hera:
|
|||||||
bucket: "${TENCENT_COS_BUCKET:hera-1319913268}"
|
bucket: "${TENCENT_COS_BUCKET:hera-1319913268}"
|
||||||
region: "${TENCENT_COS_REGION:ap-beijing}"
|
region: "${TENCENT_COS_REGION:ap-beijing}"
|
||||||
signed-url-seconds: 900
|
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:}"
|
jwt-secret: "${HERA_JWT_SECRET:}"
|
||||||
upload-dir: "${HERA_UPLOAD_DIR:./uploads}"
|
upload-dir: "${HERA_UPLOAD_DIR:./uploads}"
|
||||||
wx:
|
wx:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
<!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">
|
<mapper namespace="com.hongzhen.hera.nfc.TiangongMapper">
|
||||||
<select id="findByUser" resultType="com.hongzhen.hera.nfc.TiangongItemView">
|
<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
|
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,
|
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}
|
(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">
|
<mapper namespace="com.hongzhen.hera.data.VirtualItemMapper">
|
||||||
|
|
||||||
<sql id="BaseColumnList">
|
<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>
|
</sql>
|
||||||
|
|
||||||
<select id="findById" resultType="com.hongzhen.hera.data.VirtualItem">
|
<select id="findById" resultType="com.hongzhen.hera.data.VirtualItem">
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert">
|
<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 (
|
VALUES (
|
||||||
#{id}, #{itemName}, #{iconUrl}, #{description}, COALESCE(#{sortOrder}, 0), COALESCE(#{enabled}, 1)
|
#{id}, #{itemName}, #{iconUrl}, #{iconDetailUrl}, #{description}, COALESCE(#{sortOrder}, 0), COALESCE(#{enabled}, 1)
|
||||||
)
|
)
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
UPDATE virtual_item
|
UPDATE virtual_item
|
||||||
SET item_name = #{itemName},
|
SET item_name = #{itemName},
|
||||||
icon_url = #{iconUrl},
|
icon_url = #{iconUrl},
|
||||||
|
icon_detail_url = #{iconDetailUrl},
|
||||||
description = #{description},
|
description = #{description},
|
||||||
sort_order = COALESCE(#{sortOrder}, sort_order),
|
sort_order = COALESCE(#{sortOrder}, sort_order),
|
||||||
enabled = COALESCE(#{enabled}, enabled)
|
enabled = COALESCE(#{enabled}, enabled)
|
||||||
@@ -36,6 +37,12 @@
|
|||||||
</update>
|
</update>
|
||||||
|
|
||||||
<delete id="deleteById">DELETE FROM virtual_item WHERE id = #{id}</delete>
|
<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 id="findEnabled" resultType="com.hongzhen.hera.data.VirtualItem">
|
||||||
SELECT <include refid="BaseColumnList"/> FROM virtual_item
|
SELECT <include refid="BaseColumnList"/> FROM virtual_item
|
||||||
WHERE enabled = 1 ORDER BY sort_order, id LIMIT #{offset}, #{limit}
|
WHERE enabled = 1 ORDER BY sort_order, id LIMIT #{offset}, #{limit}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ CREATE TABLE IF NOT EXISTS virtual_item (
|
|||||||
id INT UNSIGNED NOT NULL,
|
id INT UNSIGNED NOT NULL,
|
||||||
item_name VARCHAR(50) NOT NULL,
|
item_name VARCHAR(50) NOT NULL,
|
||||||
icon_url VARCHAR(255) NULL,
|
icon_url VARCHAR(255) NULL,
|
||||||
|
icon_detail_url VARCHAR(255) NOT NULL COMMENT '神仙详情页大图 URL',
|
||||||
description VARCHAR(255) NULL,
|
description VARCHAR(255) NULL,
|
||||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
enabled TINYINT NOT NULL DEFAULT 1,
|
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
|
@Test
|
||||||
void productionRejectsWeakSecretsWithoutPrintingTheirValues() {
|
void productionRejectsWeakSecretsWithoutPrintingTheirValues() {
|
||||||
MockEnvironment env = new MockEnvironment();
|
MockEnvironment env = new MockEnvironment();
|
||||||
@@ -62,10 +76,17 @@ class EnvironmentProfilesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private GenericApplicationContext environment(String profile) {
|
private GenericApplicationContext environment(String profile) {
|
||||||
|
return environment(profile, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenericApplicationContext environment(String profile, String fileBaseUrl) {
|
||||||
GenericApplicationContext context = new GenericApplicationContext();
|
GenericApplicationContext context = new GenericApplicationContext();
|
||||||
Map<String, Object> values = new HashMap<>();
|
Map<String, Object> values = new HashMap<>();
|
||||||
values.put("HERA_LOCAL_CONFIG", "optional:file:./target/nonexistent-profile-test.yml");
|
values.put("HERA_LOCAL_CONFIG", "optional:file:./target/nonexistent-profile-test.yml");
|
||||||
values.put("HERA_PROD_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) {
|
if (profile != null) {
|
||||||
values.put("spring.profiles.active", profile);
|
values.put("spring.profiles.active", profile);
|
||||||
values.put("HERA_JWT_SECRET", "");
|
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.UserVirtualItemOfferingMapper;
|
||||||
import com.hongzhen.hera.data.UserVirtualItemTransferMapper;
|
import com.hongzhen.hera.data.UserVirtualItemTransferMapper;
|
||||||
import com.hongzhen.hera.data.VirtualItemMapper;
|
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.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -39,6 +44,61 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
*/
|
*/
|
||||||
@EnabledIfSystemProperty(named = "hera.nfc.mysql.integration", matches = "true")
|
@EnabledIfSystemProperty(named = "hera.nfc.mysql.integration", matches = "true")
|
||||||
class NfcMySqlIntegrationTest {
|
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
|
@Test
|
||||||
void mysqlChipDefinitionIsReadOnly() throws Exception {
|
void mysqlChipDefinitionIsReadOnly() throws Exception {
|
||||||
try (Connection connection = openConnection()) {
|
try (Connection connection = openConnection()) {
|
||||||
@@ -79,12 +139,7 @@ class NfcMySqlIntegrationTest {
|
|||||||
void mysqlXmlUsesIsolatedTemporaryTablesAndReportsDeploymentSchemaGaps() throws Exception {
|
void mysqlXmlUsesIsolatedTemporaryTablesAndReportsDeploymentSchemaGaps() throws Exception {
|
||||||
try (Connection connection = openConnection()) {
|
try (Connection connection = openConnection()) {
|
||||||
reportSchema(connection);
|
reportSchema(connection);
|
||||||
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
|
SqlSessionFactoryBean factory = mapperFactory(connection);
|
||||||
factory.setDataSource(new SingleConnectionDataSource(connection, true));
|
|
||||||
Configuration config = new Configuration();
|
|
||||||
config.setMapUnderscoreToCamelCase(true);
|
|
||||||
factory.setConfiguration(config);
|
|
||||||
factory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
|
|
||||||
connection.setReadOnly(true);
|
connection.setReadOnly(true);
|
||||||
try (SqlSession session = factory.getObject().openSession()) {
|
try (SqlSession session = factory.getObject().openSession()) {
|
||||||
// limit=0 让现库解析全部 XML 查询字段,但不读取用户业务记录。
|
// limit=0 让现库解析全部 XML 查询字段,但不读取用户业务记录。
|
||||||
@@ -105,14 +160,19 @@ class NfcMySqlIntegrationTest {
|
|||||||
UserVirtualItemOfferingMapper offerings = session.getMapper(UserVirtualItemOfferingMapper.class);
|
UserVirtualItemOfferingMapper offerings = session.getMapper(UserVirtualItemOfferingMapper.class);
|
||||||
UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class);
|
UserVirtualItemTransferMapper transfers = session.getMapper(UserVirtualItemTransferMapper.class);
|
||||||
UserNfcBindingMapper bindings = session.getMapper(UserNfcBindingMapper.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),
|
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);
|
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) "
|
||||||
+ "VALUES (1, 'nfc-test-1', 'test-one', '13800000001', 'wx-one'), "
|
+ "VALUES (1, 'nfc-test-1', 'test-one', '13800000001', 'wx-one'), "
|
||||||
+ "(2, 'nfc-test-2', 'test-two', '13800000002', 'wx-two')");
|
+ "(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();
|
NfcChip chip = new NfcChip();
|
||||||
chip.setUid("temporary-test-chip");
|
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 {
|
private void createTemporaryTables(Connection connection) throws Exception {
|
||||||
String ddl;
|
String ddl;
|
||||||
try (InputStream input = new ClassPathResource("schema.sql").getInputStream()) {
|
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.common.BusinessException;
|
||||||
import com.hongzhen.hera.data.NfcChip;
|
import com.hongzhen.hera.data.NfcChip;
|
||||||
import com.hongzhen.hera.data.NfcChipMapper;
|
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 com.hongzhen.hera.security.JwtService;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -52,6 +54,7 @@ class NfcWorkflowTest {
|
|||||||
@Autowired private JwtService jwt;
|
@Autowired private JwtService jwt;
|
||||||
@Autowired private NfcService nfc;
|
@Autowired private NfcService nfc;
|
||||||
@Autowired private NfcChipMapper chips;
|
@Autowired private NfcChipMapper chips;
|
||||||
|
@Autowired private VirtualItemMapper dictionary;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void schema() throws Exception {
|
void schema() throws Exception {
|
||||||
@@ -78,12 +81,81 @@ class NfcWorkflowTest {
|
|||||||
jdbc.update("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) VALUES (?, ?, ?, ?, ?)",
|
jdbc.update("INSERT INTO user_account(id, username, nickname, mobile, wechat_openid) VALUES (?, ?, ?, ?, ?)",
|
||||||
id, "account" + id, "昵称" + id, "1380000000" + id, "openid" + id);
|
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, icon_detail_url, sort_order) VALUES "
|
||||||
jdbc.update("INSERT INTO virtual_item(id, item_name, enabled) VALUES (3, '停用项', 0)");
|
+ "(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), "
|
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)");
|
+ "('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
|
@Test
|
||||||
void serialNumberIsPersistedByCrudAndReturnedWithoutChangingUidBinding() throws Exception {
|
void serialNumberIsPersistedByCrudAndReturnedWithoutChangingUidBinding() throws Exception {
|
||||||
NfcChip chip = new NfcChip();
|
NfcChip chip = new NfcChip();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class FileAccessServiceTest {
|
|||||||
HeraProperties hera = new HeraProperties();
|
HeraProperties hera = new HeraProperties();
|
||||||
hera.setJwtSecret(SECRET);
|
hera.setJwtSecret(SECRET);
|
||||||
CosProperties cos = new CosProperties();
|
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);
|
return new FileAccessService(storage, cos, hera);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class FileAccessServiceTest {
|
|||||||
String reference = "cos://hera/avatar/1/test.png";
|
String reference = "cos://hera/avatar/1/test.png";
|
||||||
when(storage.resolveUrl(reference)).thenReturn("https://cos.example/signed");
|
when(storage.resolveUrl(reference)).thenReturn("https://cos.example/signed");
|
||||||
String url = service.resolveUrl(reference);
|
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);
|
String token = url.substring(url.indexOf("token=") + 6);
|
||||||
assertEquals("https://cos.example/signed", service.redirectUrl(token));
|
assertEquals("https://cos.example/signed", service.redirectUrl(token));
|
||||||
verify(storage).resolveUrl(reference);
|
verify(storage).resolveUrl(reference);
|
||||||
@@ -49,4 +49,16 @@ class FileAccessServiceTest {
|
|||||||
assertThrows(BusinessException.class, () -> service.redirectUrl(login));
|
assertThrows(BusinessException.class, () -> service.redirectUrl(login));
|
||||||
assertThrows(BusinessException.class, () -> service.redirectUrl("invalid"));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user