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 秒;过期后重新获取。
|
||||
历史 `/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 或无人审核的自动化中启用此参数。
|
||||
|
||||
一个芯片仅允许一个当前用户,一个微信用户可以绑定多个芯片;要求有效账号已绑定微信和手机号。
|
||||
重复绑定本人设备只刷新更新时间,不增加绑定次数,也不重复发放资产。
|
||||
每台芯片按北京时间自然年最多解绑两次,次数跟随芯片,不因换用户而重置,跨年重新计算。
|
||||
|
||||
@@ -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/}"
|
||||
@@ -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());
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user