fix: harden compression boundaries and target search

This commit is contained in:
237899745
2026-07-25 12:23:35 +08:00
parent 0ff9eae56d
commit 61fa9cb820
9 changed files with 787 additions and 169 deletions

View File

@@ -6,7 +6,7 @@
### 核心功能 ### 核心功能
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态,支持格式转换) - **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO仅静态图片,支持格式转换)
- **批量处理**:支持多图片同时上传和处理 - **批量处理**:支持多图片同时上传和处理
- **压缩率**1-100JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化) - **压缩率**1-100JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
- **用户系统**注册、登录、API Key 管理 - **用户系统**注册、登录、API Key 管理

View File

@@ -272,13 +272,19 @@ Idempotency-Key: <key> # 建议
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---| |---|---|---:|---|
| `file` | File | 是 | 图片文件 | | `file` | File | 是 | 图片文件 |
| `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化100 表示不压缩;优先级高于 `level` | | `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化同格式且不缩放时100 保留原始编码;优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium` | | `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium` |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`默认保持原格式ICO 自动等比缩至 256x256 边界) | | `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`默认保持原格式ICO 自动等比缩至 256x256 边界) |
| `max_width` | Integer | 否 | 最大宽度(等比缩放) | | `max_width` | Integer | 否 | 大于 0 的最大宽度(等比缩放) |
| `max_height` | Integer | 否 | 最大高度(等比缩放) | | `max_height` | Integer | 否 | 大于 0 的最大高度(等比缩放) |
| `target_size_bytes` | Integer | 否 | 目标体积(字节),仅 `jpeg/webp/avif` 输出支持;会优先保清晰度并在必要时小幅缩放 | | `target_size_bytes` | Integer | 否 | 不小于 1024 的目标体积(字节),仅 `jpeg/webp/avif` 输出支持;不能与 `compression_rate` 同时指定 |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` | | `preserve_metadata` | Boolean | 否 | 是否保留 EXIF/ICC(默认 `false`;元数据输出仅支持 `jpeg/png/webp` |
处理约束:
- 动画 GIF/APNG/WebP/AVIF 不会静默截取首帧,而是返回 `400 UNSUPPORTED_FORMAT`
- EXIF 方向会先应用到像素,再移除或归一化方向标记;透明图片转 JPEG 时以白色合成背景。
- 目标体积搜索优先保持原分辨率,必要时在清晰度保护范围内缩放;仍无法达到时返回 `400 INVALID_REQUEST`,不会把超出目标的文件作为成功结果。
- `compression_rate=100` 只有在同格式且未指定缩放时属于免计量原样请求;格式转换或缩放后若体积变小,正常计 1 次。
响应: 响应:
```json ```json
@@ -340,7 +346,7 @@ Idempotency-Key: <key> # 建议
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---| |---|---|---:|---|
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) | | `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
| `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化100 表示不压缩;优先级高于 `level` | | `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化同格式且不缩放时100 保留原始编码;优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) | | `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) | | `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` | | `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` |

View File

@@ -24,6 +24,7 @@
- 同一任务中失败的文件不计费。 - 同一任务中失败的文件不计费。
- 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。 - 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。
- 输出格式转换png->webp 等)不额外加价(首期),后续可按“高级功能”计价。 - 输出格式转换png->webp 等)不额外加价(首期),后续可按“高级功能”计价。
- 输出体积未变小不计费;`compression_rate=100` 仅在同格式且无缩放的原样请求中免计费,格式转换或缩放后若体积变小则正常计 1 单位。
### 1.2 套餐Plan ### 1.2 套餐Plan
套餐是“功能 + 配额 + 限制”的集合建议存为可配置DB 或配置中心)。 套餐是“功能 + 配额 + 限制”的集合建议存为可配置DB 或配置中心)。

View File

@@ -19,9 +19,10 @@
<ul class="mt-2 list-disc space-y-1 pl-5"> <ul class="mt-2 list-disc space-y-1 pl-5">
<li>Base URL<code>https://ys.workyai.cn/api/v1</code></li> <li>Base URL<code>https://ys.workyai.cn/api/v1</code></li>
<li>认证方式<code>X-API-Key</code>推荐 <code>Authorization: Bearer &lt;token&gt;</code></li> <li>认证方式<code>X-API-Key</code>推荐 <code>Authorization: Bearer &lt;token&gt;</code></li>
<li>支持格式PNG / JPG / JPEG / WebP / AVIF / GIF静态/ BMP / TIFF / ICO支持 output_format 转码</li> <li>支持格式PNG / JPG / JPEG / WebP / AVIF / GIF / BMP / TIFF / ICO仅静态图片支持 output_format 转码</li>
<li>压缩率<code>compression_rate</code> 1-100JPEG/WebP/AVIF 以该比例为体积上限无损格式为尽力优化</li> <li>压缩率<code>compression_rate</code> 1-100JPEG/WebP/AVIF 以该比例为体积上限无损格式为尽力优化</li>
<li>计量成功压缩 1 个文件计 1 若体积未变小或压缩率为 100则不扣额度</li> <li>目标体积<code>target_size_bytes</code> 仅支持 JPEG/WebP/AVIF且不能与 <code>compression_rate</code> 同时提交</li>
<li>计量输出体积变小时计 1 仅同格式无缩放的 <code>compression_rate=100</code> 原样请求免计量</li>
</ul> </ul>
</div> </div>
@@ -40,6 +41,7 @@
https://ys.workyai.cn/api/v1/compress</code></pre> https://ys.workyai.cn/api/v1/compress</code></pre>
<div class="mt-3 text-xs text-slate-500"> <div class="mt-3 text-xs text-slate-500">
返回字段包含 <code>download_url</code><code>saved_percent</code> <code>billing.units_charged</code> 返回字段包含 <code>download_url</code><code>saved_percent</code> <code>billing.units_charged</code>
目标小于清晰度保护边界时返回 <code>INVALID_REQUEST</code>不会返回超过目标的文件
</div> </div>
</div> </div>
@@ -95,6 +97,7 @@ curl -H "X-API-Key: if_live_xxx" \
<div class="font-medium text-slate-900">错误处理与常见问题</div> <div class="font-medium text-slate-900">错误处理与常见问题</div>
<ul class="mt-2 list-disc space-y-1 pl-5"> <ul class="mt-2 list-disc space-y-1 pl-5">
<li><code>INVALID_REQUEST</code>参数或文件不合法检查格式与字段</li> <li><code>INVALID_REQUEST</code>参数或文件不合法检查格式与字段</li>
<li><code>UNSUPPORTED_FORMAT</code>不支持该格式或输入为动画图片</li>
<li><code>QUOTA_EXCEEDED</code>额度不足升级套餐或等待周期重置</li> <li><code>QUOTA_EXCEEDED</code>额度不足升级套餐或等待周期重置</li>
<li><code>FORBIDDEN</code>无权限下载该任务/文件认证不一致</li> <li><code>FORBIDDEN</code>无权限下载该任务/文件认证不一致</li>
<li><code>EMAIL_NOT_VERIFIED</code>登录用户需先完成邮箱验证</li> <li><code>EMAIL_NOT_VERIFIED</code>登录用户需先完成邮箱验证</li>

View File

@@ -3,7 +3,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { zipSync } from 'fflate' import { zipSync } from 'fflate'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { compressFile, getSubscription, getUsage, sendVerification, type CompressResponse } from '@/services/api' import {
compressFile,
getSubscription,
getUsage,
sendVerification,
type CompressResponse,
type OutputFormat,
} from '@/services/api'
import { ApiError } from '@/services/http' import { ApiError } from '@/services/http'
import { formatBytes } from '@/utils/format' import { formatBytes } from '@/utils/format'
@@ -22,6 +29,7 @@ const auth = useAuthStore()
type CompressionMode = 'percent' | 'size' type CompressionMode = 'percent' | 'size'
type OutputFormatOption = 'auto' | 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico' type OutputFormatOption = 'auto' | 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico'
const targetSizeFormats = new Set<OutputFormat>(['jpeg', 'webp', 'avif'])
const options = reactive({ const options = reactive({
mode: 'percent' as CompressionMode, // 压缩模式:百分比 / 目标大小 mode: 'percent' as CompressionMode, // 压缩模式:百分比 / 目标大小
@@ -121,6 +129,17 @@ function getTargetSizeBytes(): number | undefined {
return Math.round(bytes) return Math.round(bytes)
} }
function setCompressionMode(mode: CompressionMode) {
options.mode = mode
if (
mode === 'size'
&& options.outputFormat !== 'auto'
&& !targetSizeFormats.has(options.outputFormat)
) {
options.outputFormat = 'webp'
}
}
async function runOne(item: UploadItem) { async function runOne(item: UploadItem) {
item.status = 'compressing' item.status = 'compressing'
item.error = undefined item.error = undefined
@@ -133,7 +152,15 @@ async function runOne(item: UploadItem) {
return return
} }
const outputFormat = options.outputFormat === 'auto' ? undefined : options.outputFormat const outputFormat: OutputFormat | undefined = options.outputFormat === 'auto'
? (options.mode === 'size' ? 'webp' : undefined)
: options.outputFormat
if (options.mode === 'size' && outputFormat && !targetSizeFormats.has(outputFormat)) {
item.status = 'error'
item.error = '按目标大小仅支持 JPEG、WebP 或 AVIF 输出'
return
}
const compressOptions = options.mode === 'percent' const compressOptions = options.mode === 'percent'
? { ? {
@@ -347,7 +374,7 @@ async function resendVerification() {
<div class="rounded-xl border border-slate-200 bg-white p-5"> <div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-sm font-medium text-slate-900">上传图片</div> <div class="text-sm font-medium text-slate-900">上传图片</div>
<div class="text-xs text-slate-500">支持 PNG / JPG / JPEG / WebP / AVIF / GIF / BMP / TIFF / ICOGIF 仅静态</div> <div class="text-xs text-slate-500">支持 PNG / JPG / JPEG / WebP / AVIF / GIF / BMP / TIFF / ICO仅静态图片</div>
</div> </div>
<div class="mt-4 flex flex-col gap-3"> <div class="mt-4 flex flex-col gap-3">
@@ -503,7 +530,7 @@ async function resendVerification() {
type="button" type="button"
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition" class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition"
:class="options.mode === 'percent' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'" :class="options.mode === 'percent' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'"
@click="options.mode = 'percent'" @click="setCompressionMode('percent')"
> >
按百分比 按百分比
</button> </button>
@@ -511,7 +538,7 @@ async function resendVerification() {
type="button" type="button"
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition" class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition"
:class="options.mode === 'size' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'" :class="options.mode === 'size' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'"
@click="options.mode = 'size'" @click="setCompressionMode('size')"
> >
按目标大小 按目标大小
</button> </button>
@@ -556,7 +583,9 @@ async function resendVerification() {
<option value="MB">MB</option> <option value="MB">MB</option>
</select> </select>
</div> </div>
<div class="text-xs text-slate-500">直接指定压缩后的目标大小系统会自动调整质量以逼近目标 JPEG/WebP/AVIF 支持</div> <div class="text-xs text-slate-500">
仅支持 JPEG/WebP/AVIF保持原格式时会自动输出 WebP过小且无法保证清晰度的目标会被拒绝
</div>
</div> </div>
<label class="space-y-1"> <label class="space-y-1">
@@ -565,15 +594,15 @@ async function resendVerification() {
v-model="options.outputFormat" v-model="options.outputFormat"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
> >
<option value="auto">保持原格式推荐</option> <option value="auto">{{ options.mode === 'size' ? '自动选择 WebP推荐' : '保持原格式(推荐)' }}</option>
<option value="jpeg">JPEG</option> <option value="jpeg">JPEG</option>
<option value="png">PNG</option> <option value="png" :disabled="options.mode === 'size'">PNG</option>
<option value="webp">WebP</option> <option value="webp">WebP</option>
<option value="avif">AVIF</option> <option value="avif">AVIF</option>
<option value="gif">GIF仅静态</option> <option value="gif" :disabled="options.mode === 'size'">GIF仅静态</option>
<option value="bmp">BMP</option> <option value="bmp" :disabled="options.mode === 'size'">BMP</option>
<option value="tiff">TIFF</option> <option value="tiff" :disabled="options.mode === 'size'">TIFF</option>
<option value="ico">ICO</option> <option value="ico" :disabled="options.mode === 'size'">ICO</option>
</select> </select>
<div class="text-xs text-slate-500">支持按需转码目标大小模式建议配合 JPEG/WebP/AVIF</div> <div class="text-xs text-slate-500">支持按需转码目标大小模式建议配合 JPEG/WebP/AVIF</div>
</label> </label>

View File

@@ -293,7 +293,11 @@ async fn compress_json(
} else { } else {
(saved_bytes as f64) * 100.0 / (original_size as f64) (saved_bytes as f64) * 100.0 / (original_size as f64)
}; };
let skip_charge = req.compression_rate == Some(100); let skip_charge = req.compression_rate == Some(100)
&& req.target_size_bytes.is_none()
&& format_in == format_out
&& req.max_width.is_none()
&& req.max_height.is_none();
let charge_units = !skip_charge && compressed_size < original_size; let charge_units = !skip_charge && compressed_size < original_size;
if charge_units { if charge_units {
@@ -660,7 +664,11 @@ async fn compress_direct(
} else { } else {
(saved_bytes as f64) * 100.0 / (original_size as f64) (saved_bytes as f64) * 100.0 / (original_size as f64)
}; };
let skip_charge = req.compression_rate == Some(100); let skip_charge = req.compression_rate == Some(100)
&& req.target_size_bytes.is_none()
&& format_in == format_out
&& req.max_width.is_none()
&& req.max_height.is_none();
let charge_units = !skip_charge && compressed_size < original_size; let charge_units = !skip_charge && compressed_size < original_size;
if !state.config.storage_type.eq_ignore_ascii_case("local") { if !state.config.storage_type.eq_ignore_ascii_case("local") {
@@ -893,6 +901,12 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
})? { })? {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
if name == "file" { if name == "file" {
if file_bytes.is_some() {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"单文件压缩接口仅允许一个 file 字段",
));
}
file_name = Some(field.file_name().unwrap_or("upload").to_string()); file_name = Some(field.file_name().unwrap_or("upload").to_string());
let bytes = field.bytes().await.map_err(|err| { let bytes = field.bytes().await.map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err) AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err)
@@ -924,17 +938,13 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
"max_width" => { "max_width" => {
let v = text.trim(); let v = text.trim();
if !v.is_empty() { if !v.is_empty() {
max_width = Some(v.parse::<u32>().map_err(|_| { max_width = Some(compress::parse_dimension(v, "max_width")?);
AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误")
})?);
} }
} }
"max_height" => { "max_height" => {
let v = text.trim(); let v = text.trim();
if !v.is_empty() { if !v.is_empty() {
max_height = Some(v.parse::<u32>().map_err(|_| { max_height = Some(compress::parse_dimension(v, "max_height")?);
AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误")
})?);
} }
} }
"preserve_metadata" => { "preserve_metadata" => {
@@ -971,6 +981,13 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
file_bytes.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 file"))?; file_bytes.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 file"))?;
let file_name = file_name.unwrap_or_else(|| "upload".to_string()); let file_name = file_name.unwrap_or_else(|| "upload".to_string());
if compression_rate.is_some() && target_size_bytes.is_some() {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 与 target_size_bytes 不能同时指定",
));
}
Ok(CompressRequest { Ok(CompressRequest {
file_name, file_name,
file_bytes, file_bytes,

View File

@@ -530,13 +530,13 @@ async fn parse_batch_request(
"max_width" => { "max_width" => {
let v = text.trim(); let v = text.trim();
if !v.is_empty() { if !v.is_empty() {
opts.max_width = Some(match v.parse::<u32>() { opts.max_width = Some(match compress::parse_dimension(v, "max_width") {
Ok(n) => n, Ok(n) => n,
Err(_) => { Err(_) => {
cleanup_file_paths(&files).await; cleanup_file_paths(&files).await;
return Err(AppError::new( return Err(AppError::new(
ErrorCode::InvalidRequest, ErrorCode::InvalidRequest,
"max_width 格式错误", "max_width 必须为大于 0 的整数",
)); ));
} }
}); });
@@ -545,13 +545,13 @@ async fn parse_batch_request(
"max_height" => { "max_height" => {
let v = text.trim(); let v = text.trim();
if !v.is_empty() { if !v.is_empty() {
opts.max_height = Some(match v.parse::<u32>() { opts.max_height = Some(match compress::parse_dimension(v, "max_height") {
Ok(n) => n, Ok(n) => n,
Err(_) => { Err(_) => {
cleanup_file_paths(&files).await; cleanup_file_paths(&files).await;
return Err(AppError::new( return Err(AppError::new(
ErrorCode::InvalidRequest, ErrorCode::InvalidRequest,
"max_height 格式错误", "max_height 必须为大于 0 的整数",
)); ));
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -456,7 +456,10 @@ async fn process_task_file(
} else { } else {
(original_size.saturating_sub(compressed_size) as f64) * 100.0 / (original_size as f64) (original_size.saturating_sub(compressed_size) as f64) * 100.0 / (original_size as f64)
}; };
let skip_charge = compression_rate == Some(100); let skip_charge = compression_rate == Some(100)
&& format_in == format_out
&& max_width.is_none()
&& max_height.is_none();
let charge_units = !skip_charge && compressed_size < original_size; let charge_units = !skip_charge && compressed_size < original_size;
if ctx.is_anonymous && charge_units { if ctx.is_anonymous && charge_units {