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 以该比例为体积上限;无损格式按安全方式尽力优化)
|
- **压缩率**:1-100(JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
|
||||||
- **用户系统**:注册、登录、API Key 管理
|
- **用户系统**:注册、登录、API Key 管理
|
||||||
|
|||||||
18
docs/api.md
18
docs/api.md
@@ -272,13 +272,19 @@ Idempotency-Key: <key> # 建议
|
|||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `file` | File | 是 | 图片文件 |
|
| `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`) |
|
| `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-100;JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化,100 表示不压缩;优先级高于 `level` |
|
| `compression_rate` | Integer | 否 | 压缩率 1-100;JPEG/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`) |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
- 同一任务中失败的文件不计费。
|
- 同一任务中失败的文件不计费。
|
||||||
- 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。
|
- 同一请求重试若携带相同 `Idempotency-Key`,不重复计费(返回相同结果)。
|
||||||
- 输出格式转换(png->webp 等)不额外加价(首期),后续可按“高级功能”计价。
|
- 输出格式转换(png->webp 等)不额外加价(首期),后续可按“高级功能”计价。
|
||||||
|
- 输出体积未变小不计费;`compression_rate=100` 仅在同格式且无缩放的原样请求中免计费,格式转换或缩放后若体积变小则正常计 1 单位。
|
||||||
|
|
||||||
### 1.2 套餐(Plan)
|
### 1.2 套餐(Plan)
|
||||||
套餐是“功能 + 配额 + 限制”的集合,建议存为可配置(DB 或配置中心)。
|
套餐是“功能 + 配额 + 限制”的集合,建议存为可配置(DB 或配置中心)。
|
||||||
|
|||||||
@@ -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 <token></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>压缩率:<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>
|
</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>
|
||||||
|
|||||||
@@ -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 / ICO(GIF 仅静态)</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user