Compare commits

..

4 Commits

Author SHA1 Message Date
guke 5871c02055 Merge branch 'main' into fix-bugfix0731 2026-08-01 23:23:14 +08:00
linkeyu ab2de6ec79 修复:统一比价记录状态枚举口径 (#209)
## 变更说明

- below_minimum 归入成功,保留原始业务结局
- store_closed/store_not_found/items_not_found/no_delivery/unsupported 归入失败
- running 保持进行中生命周期状态
- 历史细分状态迁移为三态终态,迁移可逆
- 后台成功/失败筛选及汇总兼容迁移前历史值

## 验证

- 相关回归:48 passed
- Ruff:通过
- Alembic:单一 head

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: linkeyu <798648091@qq.com>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #209
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 23:09:21 +08:00
guke 14a9473323 Merge branch 'main' into fix-bugfix0731 2026-08-01 23:06:46 +08:00
exinglang 2a98e5147c fix(反馈): 优化图片处理与静态资源缓存 2026-07-31 22:31:45 +08:00
9 changed files with 132 additions and 410 deletions
-211
View File
@@ -1,211 +0,0 @@
"""correct DeepSeek V4 Flash token price and frozen historical costs
Revision ID: deepseek_v4_flash_price
Revises: comparison_below_min_success
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from typing import Any
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "deepseek_v4_flash_price"
down_revision: str | Sequence[str] | None = "comparison_below_min_success"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_CONFIG_KEY = "llm_token_price"
_MODEL = "deepseek-v4-flash"
_OLD_INPUT_PRICE = 3.0
_OLD_OUTPUT_PRICE = 15.0
_NEW_INPUT_PRICE = 1.0
_NEW_OUTPUT_PRICE = 2.0
_CORRECTION_MARKER = "deepseek_v4_flash_price"
_CONFIG_MARKER_KEY = "migration_deepseek_v4_flash_price"
def _decode_object(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
return None
def _model_tokens(calls: Any) -> tuple[int, int]:
if not isinstance(calls, list):
return 0, 0
input_tokens = 0
output_tokens = 0
for call in calls:
if not isinstance(call, dict) or call.get("error") or call.get("model") != _MODEL:
continue
usage = _decode_object(call.get("usage"))
if usage is None:
continue
input_tokens += int(usage.get("prompt_tokens") or 0)
output_tokens += int(usage.get("completion_tokens") or 0)
return input_tokens, output_tokens
def _app_config_table() -> sa.TableClause:
return sa.table(
"app_config",
sa.column("key", sa.String(64)),
sa.column("value", _JSON),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def _comparison_table() -> sa.TableClause:
return sa.table(
"comparison_record",
sa.column("id", sa.Integer),
sa.column("llm_calls", _JSON),
sa.column("llm_cost_yuan", sa.Float),
sa.column("llm_price_snapshot", _JSON),
)
def _update_config(conn, *, upgrade: bool) -> None:
table = _app_config_table()
row = conn.execute(
sa.select(table.c.value).where(table.c.key == _CONFIG_KEY)
).mappings().first()
if row is None or not isinstance(row["value"], dict):
return
config = dict(row["value"])
per_model = dict(config.get("per_model") or {})
current = per_model.get(_MODEL)
if upgrade:
# Preserve an operator's explicit model price. The production defect is specifically
# the missing key falling through to the generic 3/15 price.
if current is not None:
return
per_model[_MODEL] = {
"input_per_1m": _NEW_INPUT_PRICE,
"output_per_1m": _NEW_OUTPUT_PRICE,
}
conn.execute(table.insert().values(key=_CONFIG_MARKER_KEY, value=True))
else:
marker_exists = conn.execute(
sa.select(table.c.key).where(table.c.key == _CONFIG_MARKER_KEY)
).scalar_one_or_none()
if marker_exists is None:
return
if current != {
"input_per_1m": _NEW_INPUT_PRICE,
"output_per_1m": _NEW_OUTPUT_PRICE,
}:
conn.execute(table.delete().where(table.c.key == _CONFIG_MARKER_KEY))
return
per_model.pop(_MODEL, None)
conn.execute(table.delete().where(table.c.key == _CONFIG_MARKER_KEY))
config["per_model"] = per_model
conn.execute(
table.update()
.where(table.c.key == _CONFIG_KEY)
# 这是对历史误配置的追溯修正,不是从部署时刻开始的新价格。保留原 updated_at,
# 否则缺失成本回填会把部署前的记录全部排除。
.values(value=config)
)
def _correct_frozen_costs(conn, *, upgrade: bool) -> None:
table = _comparison_table()
rows = conn.execute(
sa.select(
table.c.id,
table.c.llm_calls,
table.c.llm_cost_yuan,
table.c.llm_price_snapshot,
).where(
table.c.llm_cost_yuan.is_not(None),
table.c.llm_price_snapshot.is_not(None),
)
).mappings()
for row in rows:
snapshot = row["llm_price_snapshot"]
if not isinstance(snapshot, dict):
continue
prices = snapshot.get("prices")
if not isinstance(prices, dict):
continue
model_price = prices.get(_MODEL)
if not isinstance(model_price, dict):
continue
if upgrade:
if not (
model_price.get("_source") == "default"
and model_price.get("input_per_1m") == _OLD_INPUT_PRICE
and model_price.get("output_per_1m") == _OLD_OUTPUT_PRICE
):
continue
elif snapshot.get("pricing_correction") != _CORRECTION_MARKER:
continue
input_tokens, output_tokens = _model_tokens(row["llm_calls"])
if input_tokens == 0 and output_tokens == 0:
continue
if upgrade:
delta = (
input_tokens / 1_000_000 * (_OLD_INPUT_PRICE - _NEW_INPUT_PRICE)
+ output_tokens / 1_000_000 * (_OLD_OUTPUT_PRICE - _NEW_OUTPUT_PRICE)
)
corrected_price = {
"input_per_1m": _NEW_INPUT_PRICE,
"output_per_1m": _NEW_OUTPUT_PRICE,
"_source": "per_model",
}
snapshot["pricing_correction"] = _CORRECTION_MARKER
new_cost = max(0.0, float(row["llm_cost_yuan"]) - delta)
else:
delta = (
input_tokens / 1_000_000 * (_OLD_INPUT_PRICE - _NEW_INPUT_PRICE)
+ output_tokens / 1_000_000 * (_OLD_OUTPUT_PRICE - _NEW_OUTPUT_PRICE)
)
corrected_price = {
"input_per_1m": _OLD_INPUT_PRICE,
"output_per_1m": _OLD_OUTPUT_PRICE,
"_source": "default",
}
snapshot.pop("pricing_correction", None)
new_cost = float(row["llm_cost_yuan"]) + delta
updated_prices = dict(prices)
updated_prices[_MODEL] = corrected_price
updated_snapshot = dict(snapshot)
updated_snapshot["prices"] = updated_prices
conn.execute(
table.update()
.where(table.c.id == row["id"])
.values(
llm_cost_yuan=round(new_cost, 6),
llm_price_snapshot=updated_snapshot,
)
)
def upgrade() -> None:
conn = op.get_bind()
_update_config(conn, upgrade=True)
_correct_frozen_costs(conn, upgrade=True)
def downgrade() -> None:
conn = op.get_bind()
_correct_frozen_costs(conn, upgrade=False)
_update_config(conn, upgrade=False)
+4 -2
View File
@@ -54,11 +54,13 @@ def _app_status(db_status: str) -> str:
def _record_out(fb) -> FeedbackRecordOut:
images = fb.images or []
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [],
images=images,
image_thumbnails=[media.feedback_thumbnail_url(url) for url in images],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
@@ -84,7 +86,7 @@ async def submit_feedback(
device_model: str = Form(default=""),
rom_name: str = Form(default=""),
android_version: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
images: list[UploadFile] = File(default=[]), # noqa: B008 - FastAPI dependency declaration
) -> FeedbackOut:
content = content.strip()
contact = contact.strip()
+1 -5
View File
@@ -227,11 +227,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
"llm_token_price": {
"default": {
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
# DashScope 华北 2 公网调用原价;必须显式配置,不能落到 3/15 的未知模型兜底价。
"deepseek-v4-flash": {"input_per_1m": 1.0, "output_per_1m": 2.0},
},
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY", "unit": "per_1m_tokens",
},
+90 -2
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import json
import logging
import os
import secrets
import subprocess
@@ -18,8 +19,17 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from PIL import Image, ImageOps
from app.core.config import settings
logger = logging.getLogger("shagua.media")
_FEEDBACK_DIR = "feedback"
_FEEDBACK_THUMB_DIR = "feedback_thumbs"
_FEEDBACK_THUMB_MAX_PX = 256
_FEEDBACK_THUMB_QUALITY = 78
class MediaError(Exception):
"""上传文件不合法(类型/大小)。调用方转 400。"""
@@ -68,8 +78,86 @@ def save_avatar(user_id: int, data: bytes) -> str:
def save_feedback_image(user_id: int, data: bytes) -> str:
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)"""
return _save_image("feedback", user_id, data)
"""保存反馈截图并预生成历史页缩略图,返回原图相对 URL"""
url = _save_image(_FEEDBACK_DIR, user_id, data)
# 缩略图失败不影响反馈受理;读取缩略图 URL 时会按需重试并回退原图。
ensure_feedback_thumbnail(url)
return url
def feedback_thumbnail_url(image_url: str) -> str:
"""把反馈原图 URL 映射成确定的缩略图 URL,不在 records 接口内做图片解码。
上传文件名由服务端生成且不会覆盖;旧数据在客户端真正请求可见图片时按需补图。
"""
paths = _feedback_thumbnail_paths(image_url)
return paths[2] if paths is not None else image_url
def _feedback_thumbnail_paths(image_url: str) -> tuple[Path, Path, str] | None:
prefix = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/"
if not image_url.startswith(prefix):
return None
filename = image_url.removeprefix(prefix)
# 只接受当前目录下的单个文件名,避免数据库脏数据造成路径穿越。
if not filename or Path(filename).name != filename:
return None
source = _media_dir(_FEEDBACK_DIR) / filename
thumb_name = f"{Path(filename).stem}.jpg"
thumb = _media_dir(_FEEDBACK_THUMB_DIR) / thumb_name
thumb_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_THUMB_DIR}/{thumb_name}"
return source, thumb, thumb_url
def ensure_feedback_thumbnail(image_url: str) -> Path | None:
"""确保缩略图存在并返回文件;生成失败时回退原图,供动态缩略图路由使用。"""
paths = _feedback_thumbnail_paths(image_url)
if paths is None:
return None
source, thumb, _ = paths
if thumb.is_file():
return thumb
if not source.is_file():
return None
temp = thumb.with_name(f".{thumb.name}.{secrets.token_hex(4)}.tmp")
try:
with Image.open(source) as opened:
image = ImageOps.exif_transpose(opened)
image.thumbnail(
(_FEEDBACK_THUMB_MAX_PX, _FEEDBACK_THUMB_MAX_PX),
Image.Resampling.LANCZOS,
)
if image.mode != "RGB":
image = image.convert("RGB")
image.save(
temp,
format="JPEG",
quality=_FEEDBACK_THUMB_QUALITY,
optimize=True,
)
os.replace(temp, thumb)
return thumb
except (Image.DecompressionBombError, OSError, ValueError):
logger.warning("生成反馈缩略图失败: %s", source, exc_info=True)
return source
finally:
temp.unlink(missing_ok=True)
def feedback_thumbnail_file(filename: str) -> Path | None:
"""由缩略图文件名找到原反馈图并按需生成,非法/不存在返回 None。"""
if not filename or Path(filename).name != filename or Path(filename).suffix.lower() != ".jpg":
return None
stem = Path(filename).stem
for ext in (".jpg", ".png", ".webp"):
original = _media_dir(_FEEDBACK_DIR) / f"{stem}{ext}"
if original.is_file():
original_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/{original.name}"
return ensure_feedback_thumbnail(original_url)
return None
def save_report_image(user_id: int, data: bytes) -> str:
+32 -2
View File
@@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -44,6 +44,7 @@ from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core import media
from app.core.config import settings
from app.core.cps_reconcile_worker import (
start_cps_reconcile_worker,
@@ -82,6 +83,19 @@ setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.main")
class FeedbackMediaStaticFiles(StaticFiles):
"""反馈原图/缩略图文件名不可变,可长期缓存,避免列表反复回源。"""
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
media_path = path.replace("\\", "/").lstrip("/")
if response.status_code == 200 and media_path.startswith(
("feedback/", "feedback_thumbs/")
):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
@@ -212,8 +226,24 @@ def download_apk() -> FileResponse:
)
@app.get(
f"{settings.MEDIA_URL_PREFIX}/feedback_thumbs/{{filename}}",
tags=["feedback"],
include_in_schema=False,
)
def feedback_thumbnail(filename: str) -> FileResponse:
"""旧反馈图按首次可见请求补缩略图;新图上传时已预生成。"""
path = media.feedback_thumbnail_file(filename)
if path is None:
raise HTTPException(status_code=404, detail="图片不存在")
return FileResponse(
path,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=str(_media_root)),
FeedbackMediaStaticFiles(directory=str(_media_root)),
name="media",
)
+2
View File
@@ -33,6 +33,8 @@ class FeedbackRecordOut(BaseModel):
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list)
# 与 images 下标一一对应;生成失败时该项回退原图 URL,兼容历史数据。
image_thumbnails: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
+3
View File
@@ -35,6 +35,9 @@ dependencies = [
# multipart form (FastAPI 表单上传依赖)
"python-multipart>=0.0.9",
# 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图
"pillow>=11.0.0",
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
"bcrypt>=4.0.0",
-184
View File
@@ -1,184 +0,0 @@
from __future__ import annotations
import importlib.util
from datetime import datetime
from pathlib import Path
import sqlalchemy as sa
def _load_migration():
path = (
Path(__file__).parents[1]
/ "alembic"
/ "versions"
/ "deepseek_v4_flash_price.py"
)
spec = importlib.util.spec_from_file_location("deepseek_v4_flash_price", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_migration_follows_comparison_status_normalization():
migration = _load_migration()
assert migration.down_revision == "comparison_below_min_success"
def test_migration_adds_price_and_corrects_only_mispriced_snapshot(monkeypatch):
migration = _load_migration()
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
app_config = sa.Table(
"app_config",
metadata,
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("value", sa.JSON, nullable=False),
sa.Column("updated_at", sa.DateTime),
)
comparison = sa.Table(
"comparison_record",
metadata,
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("llm_calls", sa.JSON),
sa.Column("llm_cost_yuan", sa.Float),
sa.Column("llm_price_snapshot", sa.JSON),
)
metadata.create_all(engine)
calls = [
{
"model": "deepseek-v4-flash",
"error": None,
"usage": {"prompt_tokens": 12031, "completion_tokens": 125},
},
{
"model": "qwen3.5-flash",
"error": None,
"usage": {"prompt_tokens": 2416, "completion_tokens": 119},
},
]
snapshot = {
"mode": "per_model",
"prices": {
"deepseek-v4-flash": {
"input_per_1m": 3.0,
"output_per_1m": 15.0,
"_source": "default",
},
"qwen3.5-flash": {
"input_per_1m": 0.2,
"output_per_1m": 2.0,
"_source": "per_model",
},
},
}
with engine.begin() as conn:
original_updated_at = datetime(2026, 7, 13, 18, 20, 10)
conn.execute(
app_config.insert().values(
key="llm_token_price",
value={
"per_model": {
"qwen3.5-flash": {
"input_per_1m": 0.2,
"output_per_1m": 2.0,
}
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
},
updated_at=original_updated_at,
)
)
conn.execute(
comparison.insert().values(
id=1,
llm_calls=calls,
llm_cost_yuan=0.038689,
llm_price_snapshot=snapshot,
)
)
monkeypatch.setattr(migration.op, "get_bind", lambda: conn)
migration.upgrade()
config = conn.execute(
sa.select(app_config.c.value).where(
app_config.c.key == "llm_token_price"
)
).scalar_one()
assert config["per_model"]["deepseek-v4-flash"] == {
"input_per_1m": 1.0,
"output_per_1m": 2.0,
}
assert conn.execute(
sa.select(app_config.c.updated_at).where(
app_config.c.key == "llm_token_price"
)
).scalar_one() == original_updated_at
corrected = conn.execute(sa.select(comparison)).mappings().one()
assert corrected["llm_cost_yuan"] == 0.013002
assert corrected["llm_price_snapshot"]["prices"]["deepseek-v4-flash"] == {
"input_per_1m": 1.0,
"output_per_1m": 2.0,
"_source": "per_model",
}
assert corrected["llm_price_snapshot"]["pricing_correction"] == (
"deepseek_v4_flash_price"
)
migration.downgrade()
reverted_config = conn.execute(
sa.select(app_config.c.value).where(
app_config.c.key == "llm_token_price"
)
).scalar_one()
assert "deepseek-v4-flash" not in reverted_config["per_model"]
reverted = conn.execute(sa.select(comparison)).mappings().one()
assert reverted["llm_cost_yuan"] == 0.038689
assert "pricing_correction" not in reverted["llm_price_snapshot"]
def test_downgrade_preserves_price_that_existed_before_upgrade(monkeypatch):
migration = _load_migration()
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
app_config = sa.Table(
"app_config",
metadata,
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("value", sa.JSON, nullable=False),
sa.Column("updated_at", sa.DateTime),
)
sa.Table(
"comparison_record",
metadata,
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("llm_calls", sa.JSON),
sa.Column("llm_cost_yuan", sa.Float),
sa.Column("llm_price_snapshot", sa.JSON),
)
metadata.create_all(engine)
explicit_price = {"input_per_1m": 1.0, "output_per_1m": 2.0}
with engine.begin() as conn:
conn.execute(
app_config.insert().values(
key="llm_token_price",
value={
"per_model": {"deepseek-v4-flash": explicit_price},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
},
)
)
monkeypatch.setattr(migration.op, "get_bind", lambda: conn)
migration.upgrade()
migration.downgrade()
config = conn.execute(sa.select(app_config.c.value)).scalar_one()
assert config["per_model"]["deepseek-v4-flash"] == explicit_price
-4
View File
@@ -108,10 +108,6 @@ def test_get_llm_prices_falls_back_to_default_then_uses_override():
# 无 override → CONFIG_DEFS 默认(含 per_model / default)
prices = get_llm_prices(db)
assert "per_model" in prices and "default" in prices
assert prices["per_model"]["deepseek-v4-flash"] == {
"input_per_1m": 1.0,
"output_per_1m": 2.0,
}
# 有 override → 用 DB 值
app_config.set_value(
db, "llm_token_price",