13 Commits
0.2.3 ... 0.2.6

Author SHA1 Message Date
6b0be35832 接入minio[ci][0.2.6]
All checks were successful
CI / docker-ci (push) Successful in 3m14s
2026-01-16 15:13:57 +08:00
45005fcc92 更新工作流适配新runner[ci][0.2.5]
All checks were successful
CI / docker-ci (push) Successful in 19s
2025-12-26 00:00:35 -05:00
df18bdfa7e 使用环境变量管理模型名称[ci][0.2.5]
Some checks failed
CI / docker-ci (push) Failing after 39m44s
2025-12-24 15:46:07 +08:00
281ade6ac9 增加了进度条,提升等待感知[ci][0.2.4]
All checks were successful
CI / docker-ci (push) Successful in 34s
2025-11-27 12:21:08 +08:00
835426b133 修复了不支持webp格式的图片上传的问题
All checks were successful
CI / docker-ci (push) Has been skipped
2025-11-27 12:11:58 +08:00
d001fec21e 搞定(应该)😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 35s
2025-11-27 11:39:15 +08:00
253de3639c 😅😅😅😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 32s
2025-11-27 11:33:49 +08:00
a0507b8054 😅😅😅[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 28s
2025-11-27 11:31:38 +08:00
9f803880fa 😅😅[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 32s
2025-11-27 11:25:13 +08:00
71fe964476 😅[ci][0.2.3]
Some checks failed
CI / docker-ci (push) Failing after 59s
2025-11-27 11:22:19 +08:00
0f5c8c08ff 再试一次[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 31s
2025-11-27 11:18:00 +08:00
e032253327 使用act_runner的服务器以提供下载[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 31s
2025-11-27 11:08:34 +08:00
3f108e2138 调整了一下yml进行构建和发布[ci][0.2.3]
All checks were successful
CI / docker-ci (push) Successful in 5m31s
2025-11-26 22:33:12 +08:00
9 changed files with 354 additions and 66 deletions

View File

@@ -1,5 +1,13 @@
name: CI name: CI
# Required Secrets:
# - DJANGO_SECRET_KEY: Django Secret Key
# - token: Gitea API token for creating releases
# - ALIST_PUBLIC_URL: Public URL for AList download (e.g., http://alist.example.com/d/ci)
# - WEBDAV_URL: WebDAV upload URL (e.g., http://alist.example.com/dav/ci/)
# - WEBDAV_USER: WebDAV username
# - WEBDAV_PASSWORD: WebDAV password
on: on:
push: push:
branches: branches:
@@ -18,15 +26,17 @@ jobs:
docker-ci: docker-ci:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]')) if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[ci]'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
timeout-minutes: 40 timeout-minutes: 40
env: env:
DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }} DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
DJANGO_DEBUG: "False" DJANGO_DEBUG: "False"
DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost" DJANGO_ALLOWED_HOSTS: "127.0.0.1,localhost"
IMAGE_NAME: achievement_inputing_ci IMAGE_NAME: achievement_inputing_ci
ALIST_URL: https://alist.spdis.top ARTIFACT_DIR: artifacts
ALIST_USER: ${{ secrets.ALIST_USER }} # 请在 Secrets 中配置 ALIST_PUBLIC_URL例如 http://139.224.69.213:8080/d/ci
ALIST_PASSWORD: ${{ secrets.ALIST_PASSWORD }} DOWNLOAD_BASE: ${{ secrets.ALIST_PUBLIC_URL }}
GITEA_SERVER: ${{ github.server_url }} GITEA_SERVER: ${{ github.server_url }}
GITEA_REPO: ${{ github.repository }} GITEA_REPO: ${{ github.repository }}
RELEASE_TOKEN: ${{ secrets.token }} RELEASE_TOKEN: ${{ secrets.token }}
@@ -74,35 +84,37 @@ jobs:
ART="achievement_inputing_ci_${VERSION}.tar" ART="achievement_inputing_ci_${VERSION}.tar"
docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION" docker save -o "$GITHUB_WORKSPACE/$ART" "$IMAGE_NAME:$VERSION"
echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name" echo "$ART" > "$GITHUB_WORKSPACE/.artifact_name"
- name: Upload to Alist - name: Publish artifact locally
run: |
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
mkdir -p "$GITHUB_WORKSPACE/$ARTIFACT_DIR"
mv "$GITHUB_WORKSPACE/$ART" "$GITHUB_WORKSPACE/$ARTIFACT_DIR/"
echo "artifact: $GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
- name: Publish to WebDAV
env:
WEBDAV_URL: ${{ secrets.WEBDAV_URL }}
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASSWORD: ${{ secrets.WEBDAV_PASSWORD }}
run: | run: |
set -e set -e
ART=$(cat "$GITHUB_WORKSPACE/.artifact_name") ART=$(cat "$GITHUB_WORKSPACE/.artifact_name")
BASE="${ALIST_URL%/}" FILE_PATH="$GITHUB_WORKSPACE/$ARTIFACT_DIR/$ART"
curl -sS -o "$GITHUB_WORKSPACE/login.json" -w "%{http_code}" -X POST "$BASE/api/auth/login" -H "Content-Type: application/json" -d "{\"username\":\"$ALIST_USER\",\"password\":\"$ALIST_PASSWORD\"}" > "$GITHUB_WORKSPACE/login.code"
if [ "$(cat "$GITHUB_WORKSPACE/login.code")" != "200" ]; then # 检查必要的 secrets 是否存在
echo login_failed if [ -z "$WEBDAV_URL" ]; then
cat "$GITHUB_WORKSPACE/login.json" echo "Error: WEBDAV_URL secret is not set."
exit 1
fi
TOKEN=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/login.json")
if [ -z "$TOKEN" ]; then
TOKEN=$(sed -n 's/.*"auth":"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/login.json")
fi
if [ -z "$TOKEN" ]; then
echo token_not_found
cat "$GITHUB_WORKSPACE/login.json"
exit 1
fi
curl -sS -X POST "$BASE/api/fs/mkdir" -H "Authorization: $TOKEN" -H "Content-Type: application/json" -d "{\"path\":\"/ci\"}" >/dev/null 2>&1 || true
UPLOAD_STATUS=$(curl -sS -w "%{http_code}" -o /dev/null -X PUT "$BASE/api/fs/form" -H "Authorization: $TOKEN" -F "path=/ci" -F "file=@$GITHUB_WORKSPACE/$ART;type=application/x-tar")
if [ "$UPLOAD_STATUS" -ge 400 ]; then
UPLOAD_STATUS=$(curl -sS -w "%{http_code}" -o /dev/null -X PUT "$BASE/api/fs/form" -H "Authorization: Bearer $TOKEN" -F "path=/ci" -F "file=@$GITHUB_WORKSPACE/$ART;type=application/x-tar")
fi
if [ "$UPLOAD_STATUS" -ge 400 ]; then
echo upload_failed
exit 1 exit 1
fi fi
# 确保 URL 以 / 结尾
case "$WEBDAV_URL" in
*/) ;;
*) WEBDAV_URL="${WEBDAV_URL}/" ;;
esac
echo "Uploading $ART to $WEBDAV_URL..."
curl -f -u "$WEBDAV_USER:$WEBDAV_PASSWORD" -T "$FILE_PATH" "${WEBDAV_URL}${ART}"
echo "Upload success."
- name: Create release with download link - name: Create release with download link
if: env.RELEASE_TOKEN != '' if: env.RELEASE_TOKEN != ''
run: | run: |
@@ -110,6 +122,8 @@ jobs:
BRANCH=${GITHUB_REF#refs/heads/} BRANCH=${GITHUB_REF#refs/heads/}
TAG="$VERSION" TAG="$VERSION"
NAME="$VERSION" NAME="$VERSION"
DL="${ALIST_URL%/}/ci/$ART" BASE="${DOWNLOAD_BASE%/}"
DL="$BASE/$ART"
echo "download: $DL"
JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL") JSON=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s"}' "$TAG" "$BRANCH" "$NAME" "$DL")
curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON" curl -sS -X POST "$GITEA_SERVER/api/v1/repos/$GITEA_REPO/releases" -H "Content-Type: application/json" -H "Authorization: token $RELEASE_TOKEN" -d "$JSON"

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
'accounts', 'accounts',
'main', 'main',
'elastic', 'elastic',
'minio_storage',
'django_elasticsearch_dsl', 'django_elasticsearch_dsl',
] ]
@@ -166,3 +167,4 @@ ELASTICSEARCH_INDEX_NAMES = {
# AI Studio/OpenAI client settings # AI Studio/OpenAI client settings
AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '') AISTUDIO_API_KEY = os.environ.get('AISTUDIO_API_KEY', '')
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3') OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://aistudio.baidu.com/llm/lmapi/v3')
OPENAI_MODEL_NAME = os.environ.get('OPENAI_MODEL_NAME', 'ernie-4.5-turbo-vl-32k')

View File

@@ -328,7 +328,7 @@ function renderTable(data) {
row.innerHTML = ` row.innerHTML = `
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td> <td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
<td> <td>
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="/media/${item.image}" />` : '无图片'} ${item.image_url ? `<img src="${item.image_url}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="${item.image_url}" />` : '无图片'}
</td> </td>
<td> <td>
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre> <pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>

View File

@@ -43,17 +43,21 @@
.preview-box h3 {margin-top: 0;color: #334155; } .preview-box h3 {margin-top: 0;color: #334155; }
.preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;} .preview-box img { max-width: 100%;max-height: 300px;border: 1px solid #e2e8f0;border-radius: 8px;object-fit: contain;}
.result-box {flex: 1;} .result-box {flex: 1;}
.result-box h3 { margin-top: 0; color: #334155;} .result-box h3 { margin-top: 0; color: #334155;}
.form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;} .form-controls { display: flex;gap: 8px;margin-bottom: 12px;flex-wrap: wrap;}
#kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;} #kvForm {border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; max-height: 300px; overflow: auto;margin-bottom: 12px;background: white;}
.form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; } .form-row {display: grid;grid-template-columns: 1fr 1fr auto;gap: 8px; margin-bottom: 6px; }
.form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;} .form-row input {padding: 8px;border: 1px solid #cbd5e1;border-radius: 4px;}
#resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0; #resultBox { width: 100%;min-height: 200px;font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size: 14px; padding: 12px; border: 1px solid #e2e8f0;
border-radius: 8px; resize: vertical;box-sizing: border-box; } border-radius: 8px; resize: vertical;box-sizing: border-box; }
.status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; } .status-message { padding: 10px; margin: 10px 0; border-radius: 6px; display: none; }
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; } .status-message.error { background-color: #f8d7da;color: #721c24; border: 1px solid #f5c6cb; }
.action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; } .action-buttons { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
.progress {position: relative; height: 12px; background: #e2e8f0; border-radius: 8px; overflow: hidden;}
.progress-bar {height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5 0%, #60a5fa 100%); transition: width .2s ease;}
.progress-wrap {display:none; margin-top: 8px;}
.progress-text {margin-top: 6px; font-size: 12px; color: #334155;}
</style> </style>
</head> </head>
<body> <body>
@@ -90,6 +94,10 @@
<button type="submit" class="btn btn-primary">上传并识别</button> <button type="submit" class="btn btn-primary">上传并识别</button>
</form> </form>
<div class="status-message" id="uploadMsg"></div> <div class="status-message" id="uploadMsg"></div>
<div class="progress-wrap" id="progressWrap">
<div class="progress"><div class="progress-bar" id="progressBar"></div></div>
<div class="progress-text" id="progressText"></div>
</div>
</div> </div>
<div class="preview-container"> <div class="preview-container">
@@ -136,9 +144,51 @@ const kvForm = document.getElementById('kvForm');
const addFieldBtn = document.getElementById('addFieldBtn'); const addFieldBtn = document.getElementById('addFieldBtn');
const syncFromTextBtn = document.getElementById('syncFromTextBtn'); const syncFromTextBtn = document.getElementById('syncFromTextBtn');
const dropArea = document.getElementById('dropArea'); const dropArea = document.getElementById('dropArea');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
let currentImageRel = ''; let currentImageRel = '';
function setProgress(p, text){
const v = Math.max(0, Math.min(100, Math.round(p||0)));
progressBar.style.width = v + '%';
progressText.textContent = (text||'') + (text? ' ' : '') + v + '%';
}
function showProgress(){
progressWrap.style.display = 'block';
}
function hideProgress(){
progressWrap.style.display = 'none';
setProgress(0, '');
}
async function convertToJpeg(file){
const url = URL.createObjectURL(file);
let img;
try{
const blob = await fetch(url).then(r=>r.blob());
img = await createImageBitmap(blob);
}catch(e){
img = await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=url;});
}
URL.revokeObjectURL(url);
const maxDim = 2000;
const w = img.width;
const h = img.height;
const scale = Math.min(1, maxDim/Math.max(w,h));
const nw = Math.round(w*scale);
const nh = Math.round(h*scale);
const canvas = document.createElement('canvas');
canvas.width = nw;
canvas.height = nh;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, nw, nh);
const blob = await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.82));
const name = (file.name||'image').replace(/\.[^/.]+$/, '') + '.jpg';
return new File([blob], name, {type:'image/jpeg'});
}
// 拖拽上传功能 // 拖拽上传功能
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false); dropArea.addEventListener(eventName, preventDefaults, false);
@@ -289,20 +339,39 @@ uploadForm.addEventListener('submit', async (e) => {
return; return;
} }
showProgress();
setProgress(5, '转换为JPG');
let jpegFile = file;
try {
jpegFile = await convertToJpeg(file);
setProgress(50, '转换为JPG');
preview.src = URL.createObjectURL(jpegFile);
} catch (_) {
jpegFile = file;
setProgress(50, '转换为JPG');
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', jpegFile);
try { try {
let prog = 50;
setProgress(prog, '识别中');
const timer = setInterval(() => {
prog = Math.min(95, prog + 1);
setProgress(prog, '识别中');
}, 120);
const resp = await fetch('/elastic/upload/', { const resp = await fetch('/elastic/upload/', {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'X-CSRFToken': getCookie('csrftoken') || '' }, headers: { 'X-CSRFToken': getCookie('csrftoken') || '' },
body: formData, body: formData,
}); });
clearInterval(timer);
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || data.status !== 'success') { if (!resp.ok || data.status !== 'success') {
throw new Error(data.message || '上传识别失败'); throw new Error(data.message || '上传识别失败');
} }
setProgress(100, '识别完成');
uploadMsg.textContent = data.message || '识别成功'; uploadMsg.textContent = data.message || '识别成功';
uploadMsg.className = 'status-message success'; uploadMsg.className = 'status-message success';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
@@ -310,10 +379,12 @@ uploadForm.addEventListener('submit', async (e) => {
renderFormFromObject(data.data || {}); renderFormFromObject(data.data || {});
currentImageRel = data.image; currentImageRel = data.image;
confirmBtn.disabled = false; confirmBtn.disabled = false;
setTimeout(hideProgress, 800);
} catch (e) { } catch (e) {
uploadMsg.textContent = e.message || '发生错误'; uploadMsg.textContent = e.message || '发生错误';
uploadMsg.className = 'status-message error'; uploadMsg.className = 'status-message error';
uploadMsg.style.display = 'block'; uploadMsg.style.display = 'block';
progressText.textContent = '识别失败';
} }
}); });

View File

@@ -23,6 +23,37 @@ from .es_connect import (
from PIL import Image from PIL import Image
def _image_ref_to_url(request, image_ref: str) -> str:
s = str(image_ref or '').strip()
if not s:
return ''
if not s.startswith('minio:'):
return ''
object_name = s[len('minio:'):].lstrip('/')
if not object_name:
return ''
try:
from minio_storage.minio_connect import presigned_get_url
return presigned_get_url(object_name, expires_seconds=8 * 60 * 60)
except Exception:
return ''
def _attach_image_urls(request, items):
out = []
for it in list(items or []):
try:
d = dict(it or {})
except Exception:
continue
d['image_url'] = _image_ref_to_url(request, d.get('image', ''))
out.append(d)
return out
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@csrf_exempt @csrf_exempt
def init_index(request): def init_index(request):
@@ -59,7 +90,7 @@ def search(request):
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400) return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
results = search_data(query) results = search_data(query)
return JsonResponse({"status": "success", "data": results}) return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -73,7 +104,7 @@ def fuzzy_search(request):
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400) return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
results = search_by_any_field(keyword) results = search_by_any_field(keyword)
return JsonResponse({"status": "success", "data": results}) return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -82,7 +113,7 @@ def get_all_data(request):
"""获取所有数据""" """获取所有数据"""
try: try:
results = search_all() results = search_all()
return JsonResponse({"status": "success", "data": results}) return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -176,7 +207,9 @@ def get_data(request, doc_id):
try: try:
result = get_by_id(doc_id) result = get_by_id(doc_id)
if result: if result:
return JsonResponse({"status": "success", "data": result}) wrapped = dict(result)
wrapped['image_url'] = _image_ref_to_url(request, wrapped.get('image', ''))
return JsonResponse({"status": "success", "data": wrapped})
else: else:
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404) return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
except Exception as e: except Exception as e:
@@ -357,7 +390,7 @@ def ocr_and_extract_info(image_path: str):
], ],
}, },
], ],
model="ernie-4.5-turbo-vl-32k", model=getattr(settings, "OPENAI_MODEL_NAME", "ernie-4.5-turbo-vl-32k"),
) )
response_text = chat_completion.choices[0].message.content response_text = chat_completion.choices[0].message.content
@@ -473,29 +506,45 @@ def confirm(request):
return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400) return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400)
ensure_type_in_list(edited.get("数据类型")) ensure_type_in_list(edited.get("数据类型"))
final_image_rel = image_rel image_ref_to_store = ""
try: temp_files_to_delete = []
if image_rel: if image_rel:
images_dir = os.path.join(settings.MEDIA_ROOT, "images") images_dir = os.path.join(settings.MEDIA_ROOT, "images")
os.makedirs(images_dir, exist_ok=True) os.makedirs(images_dir, exist_ok=True)
src_abs = os.path.join(settings.MEDIA_ROOT, image_rel) src_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
base = os.path.splitext(os.path.basename(image_rel))[0] if not os.path.isfile(src_abs):
webp_name = base + ".webp" return JsonResponse({"status": "error", "message": "图片文件不存在"}, status=400)
webp_abs = os.path.join(images_dir, webp_name)
webp_name = f"{uuid.uuid4().hex}.webp"
webp_abs = os.path.join(images_dir, webp_name)
try:
with Image.open(src_abs) as im: with Image.open(src_abs) as im:
if im.mode in ("RGBA", "LA", "P"): if im.mode in ("RGBA", "LA", "P"):
im = im.convert("RGBA") im = im.convert("RGBA")
else: else:
im = im.convert("RGB") im = im.convert("RGB")
im.save(webp_abs, format="WEBP", quality=80) im.save(webp_abs, format="WEBP", quality=80)
final_image_rel = f"images/{webp_name}" except Exception:
except Exception: try:
final_image_rel = image_rel if os.path.isfile(webp_abs):
os.remove(webp_abs)
except Exception:
pass
return JsonResponse({"status": "error", "message": "图片转换WEBP失败"}, status=500)
try:
object_name = f"images/{webp_name}"
from minio_storage.minio_connect import upload_file
upload_file(webp_abs, object_name, content_type="image/webp")
image_ref_to_store = f"minio:{object_name}"
temp_files_to_delete.extend([src_abs, webp_abs])
except Exception as e:
return JsonResponse({"status": "error", "message": f"上传到MinIO失败: {e}"}, status=500)
to_store = { to_store = {
"writer_id": str(request.session.get("user_id")), "writer_id": str(request.session.get("user_id")),
"data": json_to_string(edited), "data": json_to_string(edited),
"image": final_image_rel, "image": image_ref_to_store,
} }
ok = insert_data(to_store) ok = insert_data(to_store)
@@ -503,10 +552,9 @@ def confirm(request):
return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500) return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500)
try: try:
if image_rel and final_image_rel != image_rel: for p in temp_files_to_delete:
orig_abs = os.path.join(settings.MEDIA_ROOT, image_rel) if p and os.path.isfile(p):
if os.path.isfile(orig_abs): os.remove(p)
os.remove(orig_abs)
except Exception: except Exception:
pass pass

View File

@@ -0,0 +1 @@

22
minio_storage/apps.py Normal file
View File

@@ -0,0 +1,22 @@
from django.apps import AppConfig
import os
import sys
class MinioStorageConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'minio_storage'
def ready(self):
if os.path.basename(sys.argv[0]) == 'manage.py':
if os.environ.get('RUN_MAIN') != 'true':
return
if 'runserver' not in sys.argv:
return
from .minio_connect import ensure_bucket_exists
try:
ensure_bucket_exists()
except Exception as e:
print(f"❌ MinIO 初始化失败: {e}")

View File

@@ -0,0 +1,129 @@
import os
from datetime import timedelta
import mimetypes
from urllib.parse import urlparse
from minio import Minio
from minio.error import S3Error
def _env_bool(name: str, default: bool = False) -> bool:
v = os.environ.get(name)
if v is None:
return default
return str(v).strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
def _normalize_endpoint(minio_url: str):
if not minio_url:
return None, None
u = str(minio_url).strip()
parsed = urlparse(u)
if parsed.scheme in {'http', 'https'}:
endpoint = parsed.netloc
secure = parsed.scheme == 'https'
else:
endpoint = u
secure = None
endpoint = endpoint.strip().rstrip('/')
return endpoint, secure
def _get_env(*names: str, default: str | None = None) -> str | None:
for n in names:
v = os.environ.get(n)
if v is not None and str(v).strip() != '':
return str(v).strip()
return default
def get_minio_client() -> Minio | None:
minio_url = _get_env('MINIO_URL', 'MINIO_ENDPOINT')
access_key = _get_env('MINIO_ACCESS_KEY')
secret_key = _get_env('MINIO_SECRET_KEY')
if not minio_url or not access_key or not secret_key:
return None
endpoint, secure_from_url = _normalize_endpoint(minio_url)
if not endpoint:
return None
secure = _env_bool('MINIO_SECURE', default=secure_from_url if secure_from_url is not None else False)
region = _get_env('MINIO_REGION', default=None)
return Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure,
region=region,
)
def get_bucket_name() -> str:
return _get_env('MINIO_BUCKET', default='achievement') or 'achievement'
def ensure_bucket_exists() -> bool:
client = get_minio_client()
bucket = get_bucket_name()
if client is None:
print(' MinIO 环境变量未配置,跳过桶检查')
return False
if not bucket:
print(' MINIO_BUCKET 为空,跳过桶检查')
return False
try:
exists = client.bucket_exists(bucket)
except S3Error as e:
print(f'❌ MinIO 连接失败: {e}')
return False
if exists:
print(f' MinIO 桶已存在: {bucket}')
return True
try:
region = _get_env('MINIO_REGION', default=None)
if region:
client.make_bucket(bucket, location=region)
else:
client.make_bucket(bucket)
print(f'✅ MinIO 桶已创建: {bucket}')
return True
except S3Error as e:
print(f'❌ MinIO 创建桶失败: {e}')
return False
def upload_file(file_path: str, object_name: str, content_type: str | None = None) -> str:
client = get_minio_client()
if client is None:
raise RuntimeError('MinIO 未配置')
bucket = get_bucket_name()
ensure_bucket_exists()
ct = content_type
if not ct:
guessed, _ = mimetypes.guess_type(object_name)
ct = guessed or 'application/octet-stream'
client.fput_object(bucket, object_name, file_path, content_type=ct)
return object_name
def presigned_get_url(object_name: str, expires_seconds: int = 8 * 60 * 60) -> str:
client = get_minio_client()
if client is None:
raise RuntimeError('MinIO 未配置')
bucket = get_bucket_name()
ensure_bucket_exists()
exp = max(1, int(expires_seconds or 0))
return client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=exp))

View File

@@ -7,6 +7,7 @@ requests==2.32.3
openai==1.52.2 openai==1.52.2
httpx==0.27.2 httpx==0.27.2
Pillow==10.4.0 Pillow==10.4.0
minio>=7.2.0,<8
gunicorn==21.2.0 gunicorn==21.2.0
whitenoise==6.6.0 whitenoise==6.6.0
django-browser-reload==1.21.0 django-browser-reload==1.21.0