| 时间 | 状态 | task_id | 实例/桶 | object_key | 耗时 | 入出字节 | 来源 | 错误 |
|---|
本服务提供 烧水印 + 缩略图 一体化处理:从 MinIO 拉原图 → 按业务数据烧水印 → 覆盖写回原 object_key(URL 不变) → 从水印图生成缩略图(同目录 _thumb 后缀)。同步接口,单图通常几百毫秒。
url / thumb_url 才会有值。POST /v1/process,把业务数据(单号/时间/项目/操作员)通过 watermark 传入。| 项目 | 说明 |
|---|---|
| API 基地址 | -(管理页当前访问地址) |
| 主接口 | POST /v1/process(烧水印 + 缩略图,同步) |
| 健康检查 | GET /healthz(无鉴权,返回 {"ok":true,"service":"miniotool"}) |
| 请求体上限 | 256 KB(JSON) |
| 超时建议 | 调用方设 10s;超时重试安全(见幂等章节) |
所有业务接口使用请求头 X-Api-Key 携带实例专属密钥(mt_ 前缀,管理页实例卡片查看 / 复制 / 重置)。
Content-Type: application/json X-Api-Key: mt_实例名_xxxxxxxxxxxxxxxxxxxxxxxx
401 + {"code":1,"msg":"missing X-Api-Key 或 invalid API key"}。| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| task_id | string | 建议 | 任务号(≤64 字符)。用于幂等与日志对账,重试时必须传同一值 |
| source.bucket | string | 可选 | 桶名,不传用实例默认桶;必须是实例注册过的桶 |
| source.object_key | string | 必填 | 对象路径(≤1024 字符),如 2026/08/20/xxx.jpg |
| watermark | object | 可选 | 水印规格,见下一章节;不传或 enabled:false 则不烧水印只出缩略图 |
| thumbnail.width | number | 可选 | 实例已配置缩略图尺寸时此参数被忽略;实例未配置时作为单尺寸生效(后缀固定 _thumb),默认 240 |
| thumbnail.quality | number | 可选 | 缩略图 JPEG 质量 40-100,默认 80;仅实例未配置尺寸时生效 |
{"object_key":"...","bucket":"...","watermark":{...}} 仍然有效,新对接请用上方契约格式。a.jpg → 首个尺寸 a_thumb.jpg,其余 a_thumb_480.jpg;后缀可自定义,支持 {width} 占位符。thumb_url / thumb_key = 第一个配置尺寸(兼容旧对接);thumbs 数组返回全部尺寸。| 质量值 | 实际效果 | 240px 缩略图体积参考 |
|---|---|---|
| 60 | 体积最小;放大查看有明显块感/涂抹,仅适合列表小图 | 约 6-10 KB |
| 70 | 小尺寸下均衡;细节略糊 | 约 8-14 KB |
| 75-80 | 推荐;肉眼与 85+ 基本无法区分 | 约 10-18 KB |
| 85-90 | 高清预览级;点开大图细看边缘更锐 | 约 18-30 KB |
| 95-100 | 近无损;体积接近原图,缩略图场景不建议 | 30 KB 以上 |
{
"task_id": "20260820120000_a1b2c3",
"source": { "bucket": "photos", "object_key": "2026/08/20/20260820120000_a1b2c3.jpg" },
"watermark": {
"enabled": true,
"showCode": true, "showTime": true, "showDate": true, "showProject": true, "showOperator": true,
"position": "bottom-right",
"sizePercent": 12, "opacity": 51,
"bgColor": "#373434", "textColor": "#FFFFFF", "useGradient": false,
"lineGapPercent": 15, "primaryGapPercent": 0, "borderRadius": 9,
"layout": "vertical", "textAlign": "left",
"primaryFontScale": 22, "secondaryFontScale": 16,
"customText": "本系统由玖乐智远科技提供技术支持",
"info": { "code": "435316043781541", "time": "18:36:02", "date": "2026-08-18", "project": "项目名", "operator": "操作人" }
},
"thumbnail": { "width": 240, "quality": 80 }
}_thumb,如 a.jpg → a_thumb.jpg。| 字段 | 默认 | 说明 |
|---|---|---|
| enabled | true | 总开关;false 时不烧水印 |
| showCode / showTime / showDate / showProject / showOperator | true | 五行内容开关;关闭或对应值为空则该行不渲染 |
| position | bottom-right | top-left / top-right / bottom-left / bottom-right / bottom-full(底部水平居中) |
| layout | vertical | vertical = 每条信息一行;horizontal = 次行合并为一行,以两个空格+竖线+两个空格连接 |
| textAlign | left | left / center / right(相对水印块) |
| sizePercent | 12 | 字号基准:图高的百分比 |
| primaryFontScale / secondaryFontScale | 22 / 16 | 主行(单号)/ 次行字号相对 sizePercent 的二级百分比 |
| lineGapPercent | 15 | 行距:行高 = 本行字号 × (1 + lineGapPercent%) |
| primaryGapPercent | 0 | 主行与首个次行的额外间距 = 次字号 × 该百分比 |
| opacity | 51 | 背景块不透明度百分比(0-100,仅作用于背景) |
| bgColor / textColor | #373434 / #FFFFFF | 背景 / 文字颜色(#hex) |
| useGradient | false | 兼容字段;当前实现为纯色背景 |
| borderRadius | 9 | 圆角 = 水印块高度 × 该百分比(自动不超过块宽/高一半) |
| customText | "" | 自定义文案行(如技术支持声明) |
| info.code / time / date / project / operator | "" | 业务数据:单号 / 时间(HH:mm:ss) / 日期(YYYY-MM-DD) / 项目 / 操作人 |
{
"code": 0,
"url": "https://oss.example.com/2026/08/20/20260820120000_a1b2c3.jpg",
"thumb_url": "https://oss.example.com/2026/08/20/20260820120000_a1b2c3_thumb.jpg",
"object_key": "2026/08/20/20260820120000_a1b2c3.jpg",
"thumb_key": "2026/08/20/20260820120000_a1b2c3_thumb.jpg",
"thumbs": [
{ "width": 240, "height": 320, "key": "2026/08/20/20260820120000_a1b2c3_thumb.jpg", "url": "https://oss.example.com/2026/08/20/20260820120000_a1b2c3_thumb.jpg" },
{ "width": 480, "height": 640, "key": "2026/08/20/20260820120000_a1b2c3_thumb_480.jpg", "url": "https://oss.example.com/2026/08/20/20260820120000_a1b2c3_thumb_480.jpg" }
],
"width": 768,
"height": 1024,
"skipped": false,
"ms": 150,
"wm_ms": 95,
"thumb_ms": 40
}| 字段 | 说明 |
|---|---|
| code | 0 = 成功;非 0 = 失败(失败时另带 msg 字段) |
| url / thumb_url | 水印图 / 缩略图公网地址 = 实例「访问基地址」+ key;基地址未配置时为空串,可用 object_key / thumb_key 自拼 |
| thumbs | 全部尺寸缩略图数组(实例配置多尺寸时 >1 条);thumb_url = thumbs[0]。每条含 width / height / key / url |
| width / height | 水印图实际像素(与原图一致,不缩放) |
| skipped | true = 幂等命中(上次已处理,本次直接返回结果),业务上按成功对待 |
| ms | 服务端处理耗时(毫秒),端到端总量 |
| wm_ms / thumb_ms | 阶段耗时(毫秒):wm=烧水印(解码+测量+合成+编码);thumb=缩略图生成(含并行上传);ms − 两者 ≈ 网络传输(下载+写回) |
{ "code": 1, "msg": "object 不存在" }| HTTP | 场景 |
|---|---|
| 400 | 参数缺失 / 实例或桶未注册(msg 说明原因) |
| 401 | 缺少 X-Api-Key 或密钥无效 |
| 404 | object_key 不存在(原图还没传完?稍后重试) |
| 422 | object 不是受支持的图片格式 |
| 429 | 触发限流(见限流章节) |
| 500 | 服务内部错误(可重试) |
处理成功时服务端在对象元数据写入标记(task-id / watermark-hash / dims)。收到请求先查元数据:
skipped:true),绝不重烧 —— 覆盖「调用超时但实际已处理完,重试会叠第二层水印」的场景。429 + {"code":1,"msg":"too many requests"}。curl -/healthz
→ 200 {"ok":true,"service":"miniotool"}curl -H 'X-Api-Key: mt_xxx_xxx' -/v1/instances
→ {
"ok": true,
"instance": "main",
"default_bucket": "photos",
"buckets": ["photos", "docs"]
}curl -X DELETE '-/v1/delete' -H 'Content-Type: application/json' -H 'X-Api-Key: mt_xxx_xxx' -d '{"task_id":"20260820120000_a1b2c3",
"source":{"bucket":"photos","object_key":"2026/08/20/abc.jpg"}}'
→ {
"code": 0,
"deleted": [
"2026/08/20/abc.jpg",
"2026/08/20/abc_thumb.jpg",
"2026/08/20/abc_thumb_480.jpg"
],
"count": 3,
"origin_exists": true,
"ms": 45
}DELETE(标准)或 POST(别名,完全等价)——HTTP 客户端不支持 DELETE 携带 body 时用 POST,请求体完全相同。origin_exists:false 表示原图本来就不存在(只清理了孤儿缩略图),同样返回 code:0。curl -X POST '-/v1/delete/batch' -H 'Content-Type: application/json' -H 'X-Api-Key: mt_xxx_xxx' -d '{"task_id":"batch-20260821-001",
"source":{"bucket":"photos","object_keys":[
"2026/08/20/aaa.jpg",
"2026/08/20/bbb.jpg",
"2026/08/20/ccc.jpg"
]}}'
→ {
"code": 0,
"total": 3,
"ok": 3,
"failed": 0,
"results": [
{ "object_key": "2026/08/20/aaa.jpg", "code": 0, "deleted": [".../aaa.jpg",".../aaa_thumb.jpg"], "count": 2, "origin_exists": true },
{ "object_key": "2026/08/20/bbb.jpg", "code": 0, "deleted": [".../bbb.jpg",".../bbb_thumb.jpg",".../bbb_thumb_480.jpg"], "count": 3, "origin_exists": true },
{ "object_key": "2026/08/20/ccc.jpg", "code": 1, "msg": "object 不存在或权限问题" }
],
"ms": 320
}code:1 + msg,可单独重试(幂等)。ok/failed 为汇总计数。curl -X POST '-/v1/process' -H 'Content-Type: application/json' -H 'X-Api-Key: mt_xxx_xxx' -d '{
"task_id": "20260820120000_a1b2c3",
"source": {"bucket":"photos","object_key":"2026/08/20/abc.jpg"},
"watermark": {
"enabled": true, "showCode": true, "showTime": true, "showDate": true,
"showProject": true, "showOperator": true, "position": "bottom-right",
"sizePercent": 12, "opacity": 51, "bgColor": "#373434", "textColor": "#FFFFFF",
"lineGapPercent": 15, "primaryGapPercent": 0, "borderRadius": 9,
"layout": "vertical", "textAlign": "left",
"primaryFontScale": 22, "secondaryFontScale": 16,
"customText": "本系统由玖乐智远科技提供技术支持",
"info": {"code":"435316043781541","time":"18:36:02","date":"2026-08-18","project":"项目名","operator":"操作人"}
},
"thumbnail": {"width": 240, "quality": 80}
}'<?php
function processImage(string $apiKey, array $payload): ?array {
$url = 'http://127.0.0.1:9002/v1/process'; // 内网地址,按实际部署改
$attempt = function () use ($url, $apiKey, $payload) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Api-Key: ' . $apiKey],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
return $err ? null : json_decode($body, true);
};
$data = $attempt() ?? $attempt(); // 失败重试 1 次(task_id 相同,幂等安全)
return ($data['code'] ?? 1) === 0 ? $data : null; // null = 降级自身逻辑
}
$result = processImage($apiKey, [
'task_id' => $taskId,
'source' => ['bucket' => $bucket, 'object_key' => $objectKey],
'watermark' => $wmSpec, // 见水印规格章节
'thumbnail' => ['width' => 240, 'quality' => 80],
]);
if ($result) { /* 成功:$result['url'] / $result['thumb_url'] / $result['width'] ... */ }function deleteImage(string $apiKey, string $objectKey, ?string $bucket = null, ?string $taskId = null): ?array {
$ch = curl_init('http://127.0.0.1:9002/v1/delete');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Api-Key: ' . $apiKey],
CURLOPT_POSTFIELDS => json_encode(array_filter([
'task_id' => $taskId,
'source' => ['bucket' => $bucket, 'object_key' => $objectKey],
]), JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 10,
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
curl_close($ch);
$data = json_decode($body, true);
return ($data['code'] ?? 1) === 0 ? $data : null; // null = 删除失败,记日志排查
}
$r = deleteImage($apiKey, $objectKey, $bucket, $taskId);
if ($r) { /* 已删干净:$r['deleted'] 为实际删除的全部 key 清单 */ }function batchDelete(string $apiKey, array $objectKeys, ?string $bucket = null, ?string $taskId = null): array {
$all = [];
foreach (array_chunk($objectKeys, 100) as $i => $chunk) { // 每批 100 张
$ch = curl_init('http://127.0.0.1:9002/v1/delete/batch');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Api-Key: ' . $apiKey],
CURLOPT_POSTFIELDS => json_encode([
'task_id' => $taskId ? $taskId . '-b' . $i : null,
'source' => ['bucket' => $bucket, 'object_keys' => $chunk],
], JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 120, // 100 张最坏约 1-2 分钟
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
curl_close($ch);
$data = json_decode($body, true);
if (($data['code'] ?? 1) !== 0) continue; // 整批失败:下一批(本批 keys 可重试)
$all = array_merge($all, $data['results'] ?? []);
usleep(500000); // 批间隔 0.5s,远低于限流 30 批/分钟
}
return $all; // 每张一条:['object_key'=>..., 'code'=>0|1, 'deleted'=>[...], 'msg'=>...]
}
$results = batchDelete($apiKey, $objectKeyList, $bucket, 'batch-' . date('Ymd-His'));
$failed = array_filter($results, fn($r) => $r['code'] !== 0); // 失败的张:单独重试即可(幂等)async function processImage(apiUrl, apiKey, payload) {
const res = await fetch(apiUrl + '/v1/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000),
});
const data = await res.json();
if (data.code !== 0) throw new Error(data.msg || 'HTTP ' + res.status);
return data; // { url, thumb_url, width, height, skipped, ms }
}