fix(health,miniprogram): 轮播图图片改用相对路径 + wx.downloadFile 解决 HTTP 限制

问题:微信小程序 <image> 不支持 HTTP URL,签名 URL 与 upload 中间件不兼容。
修复:
1. 公开轮播图 API 返回相对路径(/uploads/...)而非签名 URL
2. 小程序用 wx.downloadFile 下载图片后使用本地临时路径
3. 移除 banner_handler 中不再需要的 base_url/Host header 逻辑
This commit is contained in:
iven
2026-05-10 20:14:43 +08:00
parent a6ec8129c9
commit 4788e19a1d
4 changed files with 27 additions and 36 deletions

View File

@@ -30,6 +30,8 @@ interface PublicBanner {
image_url?: string;
link_type?: string;
link_target?: string;
/** 下载后的本地临时路径 */
local_path?: string;
}
// ─── 访客首页 ───
@@ -67,7 +69,22 @@ function GuestHome({ modeClass }: { modeClass: string }) {
]);
if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) {
setBanners(bannerData.value);
const baseUrl = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
const fileBase = baseUrl.replace(/\/api\/v1$/, '');
const withLocal = await Promise.all(
bannerData.value.map(async (b) => {
if (!b.image_url) return b;
try {
const fullUrl = b.image_url.startsWith('http') ? b.image_url : `${fileBase}${b.image_url}`;
const res = await Taro.downloadFile({ url: fullUrl });
if (res.tempFilePath) {
return { ...b, local_path: res.tempFilePath };
}
} catch { /* ignore */ }
return b;
})
);
setBanners(withLocal);
} else {
setBanners(FALLBACK_SLIDES);
}
@@ -98,8 +115,8 @@ function GuestHome({ modeClass }: { modeClass: string }) {
{slides.map((slide, idx) => (
<SwiperItem key={slide.id || idx}>
<View className='guest-slide'>
{slide.image_url ? (
<Image className='guest-slide-image' src={slide.image_url} mode='aspectFill' />
{(slide.local_path || slide.image_url) ? (
<Image className='guest-slide-image' src={slide.local_path || slide.image_url} mode='aspectFill' />
) : (
<View className={`guest-slide-bg guest-slide-bg--${(idx % 3) + 1}`} />
)}

View File

@@ -107,6 +107,7 @@ pub struct PublicBannerResp {
pub id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
/// 相对路径如 /uploads/tenant_id/filename.png前端按需拼接 base_url
pub image_url: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,

View File

@@ -122,31 +122,14 @@ where
pub async fn list_public_banners<S>(
State(state): State<HealthState>,
Query(params): Query<PublicBannerQuery>,
headers: axum::http::HeaderMap,
) -> Result<Json<ApiResponse<Vec<PublicBannerResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
// 从 X-Tenant-Id 请求头或查询参数中解析租户 ID
let tenant_id = headers
.get("X-Tenant-Id")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<uuid::Uuid>().ok())
.or(params.tenant_id)
let tenant_id = params
.tenant_id
.ok_or_else(|| AppError::Validation("缺少 tenant_id".to_string()))?;
let base_url = headers
.get("host")
.and_then(|v| v.to_str().ok())
.map(|h| {
if h.starts_with("localhost") || h.starts_with("127.0.0.1") {
format!("http://{}", h)
} else {
format!("https://{}", h)
}
})
.unwrap_or_else(|| "http://localhost:3000".to_string());
let result = banner_service::list_public_banners(&state, tenant_id, &base_url).await?;
let result = banner_service::list_public_banners(&state, tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -20,6 +20,7 @@ use crate::state::HealthState;
type HmacSha256 = Hmac<Sha256>;
/// 获取签名 URL 密钥:优先环境变量 ERP__STORAGE__SECRET_KEY回退到开发默认值
#[allow(dead_code)]
fn signing_secret() -> String {
std::env::var("ERP__STORAGE__SECRET_KEY")
.unwrap_or_else(|_| "dev-only-secret-key-change-in-production".to_string())
@@ -271,7 +272,6 @@ pub async fn sort_banners(
pub async fn list_public_banners(
state: &HealthState,
tenant_id: Uuid,
base_url: &str,
) -> HealthResult<Vec<PublicBannerResp>> {
let now = Utc::now();
@@ -300,21 +300,10 @@ pub async fn list_public_banners(
.into_iter()
.filter_map(|b| {
let media = media_map.get(&b.media_item_id)?;
// 跳过已删除的媒体文件
if media.deleted_at.is_some() {
return None;
}
let (token, expires) =
generate_signed_url(&media.storage_path, &signing_secret(), 3600);
let clean_path = media.storage_path.trim_start_matches("./");
let image_url = format!(
"{}/{}?expires={}&token={}",
base_url.trim_end_matches('/'),
clean_path,
expires,
token
);
let image_url = media.storage_path.trim_start_matches("./").to_string();
Some(PublicBannerResp {
id: b.id,
@@ -335,6 +324,7 @@ pub async fn list_public_banners(
// ---------------------------------------------------------------------------
/// 生成 HMAC-SHA256 签名 URL token同步函数
#[allow(dead_code)]
pub fn generate_signed_url(path: &str, secret_key: &str, ttl_secs: u64) -> (String, i64) {
let expires = Utc::now().timestamp() + ttl_secs as i64;
let message = format!("{}\n{}", path, expires);