fix: harden compression boundaries and target search
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态,支持格式转换)
|
||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(仅静态图片,支持格式转换)
|
||||
- **批量处理**:支持多图片同时上传和处理
|
||||
- **压缩率**:1-100(JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
|
||||
- **用户系统**:注册、登录、API Key 管理
|
||||
|
||||
18
docs/api.md
18
docs/api.md
@@ -272,13 +272,19 @@ Idempotency-Key: <key> # 建议
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `file` | File | 是 | 图片文件 |
|
||||
| `compression_rate` | Integer | 否 | 压缩率 1-100;JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化,100 表示不压缩;优先级高于 `level` |
|
||||
| `compression_rate` | Integer | 否 | 压缩率 1-100;JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化;同格式且不缩放时,100 保留原始编码;优先级高于 `level` |
|
||||
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium`) |
|
||||
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式;ICO 自动等比缩至 256x256 边界) |
|
||||
| `max_width` | Integer | 否 | 最大宽度(等比缩放) |
|
||||
| `max_height` | Integer | 否 | 最大高度(等比缩放) |
|
||||
| `target_size_bytes` | Integer | 否 | 目标体积(字节),仅 `jpeg/webp/avif` 输出支持;会优先保清晰度并在必要时小幅缩放 |
|
||||
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false`) |
|
||||
| `max_width` | Integer | 否 | 大于 0 的最大宽度(等比缩放) |
|
||||
| `max_height` | Integer | 否 | 大于 0 的最大高度(等比缩放) |
|
||||
| `target_size_bytes` | Integer | 否 | 不小于 1024 的目标体积(字节),仅 `jpeg/webp/avif` 输出支持;不能与 `compression_rate` 同时指定 |
|
||||
| `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
|
||||
@@ -340,7 +346,7 @@ Idempotency-Key: <key> # 建议
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
|
||||
| `compression_rate` | Integer | 否 | 压缩率 1-100;JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化,100 表示不压缩;优先级高于 `level` |
|
||||
| `compression_rate` | Integer | 否 | 压缩率 1-100;JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化;同格式且不缩放时,100 保留原始编码;优先级高于 `level` |
|
||||
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
|
||||
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) |
|
||||
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false`) |
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- 同一任务中失败的文件不计费。
|
||||
- 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。
|
||||
- 输出格式转换(png->webp 等)不额外加价(首期),后续可按“高级功能”计价。
|
||||
- 输出体积未变小不计费;`compression_rate=100` 仅在同格式且无缩放的原样请求中免计费,格式转换或缩放后若体积变小则正常计 1 单位。
|
||||
|
||||
### 1.2 套餐(Plan)
|
||||
套餐是“功能 + 配额 + 限制”的集合,建议存为可配置(DB 或配置中心)。
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Base URL:<code>https://ys.workyai.cn/api/v1</code></li>
|
||||
<li>认证方式:<code>X-API-Key</code>(推荐)或 <code>Authorization: Bearer <token></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-100;JPEG/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>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +41,7 @@
|
||||
https://ys.workyai.cn/api/v1/compress</code></pre>
|
||||
<div class="mt-3 text-xs text-slate-500">
|
||||
返回字段包含 <code>download_url</code>、<code>saved_percent</code> 与 <code>billing.units_charged</code>。
|
||||
目标小于清晰度保护边界时返回 <code>INVALID_REQUEST</code>,不会返回超过目标的文件。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +97,7 @@ curl -H "X-API-Key: if_live_xxx" \
|
||||
<div class="font-medium text-slate-900">错误处理与常见问题</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||
<li><code>INVALID_REQUEST</code>:参数或文件不合法(检查格式与字段)</li>
|
||||
<li><code>UNSUPPORTED_FORMAT</code>:不支持该格式或输入为动画图片</li>
|
||||
<li><code>QUOTA_EXCEEDED</code>:额度不足(升级套餐或等待周期重置)</li>
|
||||
<li><code>FORBIDDEN</code>:无权限下载该任务/文件(认证不一致)</li>
|
||||
<li><code>EMAIL_NOT_VERIFIED</code>:登录用户需先完成邮箱验证</li>
|
||||
|
||||
@@ -3,7 +3,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { zipSync } from 'fflate'
|
||||
|
||||
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 { formatBytes } from '@/utils/format'
|
||||
|
||||
@@ -22,6 +29,7 @@ const auth = useAuthStore()
|
||||
|
||||
type CompressionMode = 'percent' | 'size'
|
||||
type OutputFormatOption = 'auto' | 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico'
|
||||
const targetSizeFormats = new Set<OutputFormat>(['jpeg', 'webp', 'avif'])
|
||||
|
||||
const options = reactive({
|
||||
mode: 'percent' as CompressionMode, // 压缩模式:百分比 / 目标大小
|
||||
@@ -121,6 +129,17 @@ function getTargetSizeBytes(): number | undefined {
|
||||
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) {
|
||||
item.status = 'compressing'
|
||||
item.error = undefined
|
||||
@@ -133,7 +152,15 @@ async function runOne(item: UploadItem) {
|
||||
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'
|
||||
? {
|
||||
@@ -347,7 +374,7 @@ async function resendVerification() {
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<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 / ICO(GIF 仅静态)</div>
|
||||
<div class="text-xs text-slate-500">支持 PNG / JPG / JPEG / WebP / AVIF / GIF / BMP / TIFF / ICO(仅静态图片)</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
@@ -503,7 +530,7 @@ async function resendVerification() {
|
||||
type="button"
|
||||
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'"
|
||||
@click="options.mode = 'percent'"
|
||||
@click="setCompressionMode('percent')"
|
||||
>
|
||||
按百分比
|
||||
</button>
|
||||
@@ -511,7 +538,7 @@ async function resendVerification() {
|
||||
type="button"
|
||||
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'"
|
||||
@click="options.mode = 'size'"
|
||||
@click="setCompressionMode('size')"
|
||||
>
|
||||
按目标大小
|
||||
</button>
|
||||
@@ -556,7 +583,9 @@ async function resendVerification() {
|
||||
<option value="MB">MB</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<label class="space-y-1">
|
||||
@@ -565,15 +594,15 @@ async function resendVerification() {
|
||||
v-model="options.outputFormat"
|
||||
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="png">PNG</option>
|
||||
<option value="png" :disabled="options.mode === 'size'">PNG</option>
|
||||
<option value="webp">WebP</option>
|
||||
<option value="avif">AVIF</option>
|
||||
<option value="gif">GIF(仅静态)</option>
|
||||
<option value="bmp">BMP</option>
|
||||
<option value="tiff">TIFF</option>
|
||||
<option value="ico">ICO</option>
|
||||
<option value="gif" :disabled="options.mode === 'size'">GIF(仅静态)</option>
|
||||
<option value="bmp" :disabled="options.mode === 'size'">BMP</option>
|
||||
<option value="tiff" :disabled="options.mode === 'size'">TIFF</option>
|
||||
<option value="ico" :disabled="options.mode === 'size'">ICO</option>
|
||||
</select>
|
||||
<div class="text-xs text-slate-500">支持按需转码。目标大小模式建议配合 JPEG/WebP/AVIF。</div>
|
||||
</label>
|
||||
|
||||
@@ -293,7 +293,11 @@ async fn compress_json(
|
||||
} else {
|
||||
(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;
|
||||
|
||||
if charge_units {
|
||||
@@ -660,7 +664,11 @@ async fn compress_direct(
|
||||
} else {
|
||||
(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;
|
||||
|
||||
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();
|
||||
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());
|
||||
let bytes = field.bytes().await.map_err(|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" => {
|
||||
let v = text.trim();
|
||||
if !v.is_empty() {
|
||||
max_width = Some(v.parse::<u32>().map_err(|_| {
|
||||
AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误")
|
||||
})?);
|
||||
max_width = Some(compress::parse_dimension(v, "max_width")?);
|
||||
}
|
||||
}
|
||||
"max_height" => {
|
||||
let v = text.trim();
|
||||
if !v.is_empty() {
|
||||
max_height = Some(v.parse::<u32>().map_err(|_| {
|
||||
AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误")
|
||||
})?);
|
||||
max_height = Some(compress::parse_dimension(v, "max_height")?);
|
||||
}
|
||||
}
|
||||
"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"))?;
|
||||
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 {
|
||||
file_name,
|
||||
file_bytes,
|
||||
|
||||
@@ -530,13 +530,13 @@ async fn parse_batch_request(
|
||||
"max_width" => {
|
||||
let v = text.trim();
|
||||
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,
|
||||
Err(_) => {
|
||||
cleanup_file_paths(&files).await;
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"max_width 格式错误",
|
||||
"max_width 必须为大于 0 的整数",
|
||||
));
|
||||
}
|
||||
});
|
||||
@@ -545,13 +545,13 @@ async fn parse_batch_request(
|
||||
"max_height" => {
|
||||
let v = text.trim();
|
||||
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,
|
||||
Err(_) => {
|
||||
cleanup_file_paths(&files).await;
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"max_height 格式错误",
|
||||
"max_height 必须为大于 0 的整数",
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -456,7 +456,10 @@ async fn process_task_file(
|
||||
} else {
|
||||
(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;
|
||||
|
||||
if ctx.is_anonymous && charge_units {
|
||||
|
||||
Reference in New Issue
Block a user