Skip to content

Commit ab54f3c

Browse files
committed
release: 1.2.1
- 图片管理页:归属三桶模型(用户/管理员/未归属)+ 卡片用户名徽章 + 范围日期筛选 - 号池管理页:卡片视图与列表/卡片切换、药丸式进度条与健康指示 - 接口层:/api/* 与 /v1/* 强制 no-store,避免浏览器启发式缓存 - 前端:日历选区 range_middle/start/end 样式修正,Owner 下拉支持搜索与置顶 - 其它:image_owners 服务、用户密钥/注册流程小幅改动
1 parent 0279bc6 commit ab54f3c

29 files changed

Lines changed: 3738 additions & 302 deletions

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0
1+
1.2.1

‎api/accounts.py‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
22

3-
from fastapi import APIRouter, Header, HTTPException
3+
from fastapi import APIRouter, Header, HTTPException, Response
44
from fastapi.concurrency import run_in_threadpool
55
from pydantic import BaseModel, Field
66

77
from services.auth_service import auth_service
88

99
from api.support import (
1010
require_admin,
11+
require_identity,
1112
sanitize_cpa_pool,
1213
sanitize_cpa_pools,
1314
sanitize_sub2api_server,
@@ -26,12 +27,17 @@
2627

2728
class UserKeyCreateRequest(BaseModel):
2829
name: str = ""
30+
quota: int = 0
31+
unlimited: bool = False
2932

3033

3134
class UserKeyUpdateRequest(BaseModel):
3235
name: str | None = None
3336
enabled: bool | None = None
3437
key: str | None = None
38+
quota: int | None = None
39+
unlimited: bool | None = None
40+
reset_used: bool | None = None
3541

3642

3743
class AccountCreateRequest(BaseModel):
@@ -99,11 +105,41 @@ async def list_user_keys(authorization: str | None = Header(default=None)):
99105
require_admin(authorization)
100106
return {"items": auth_service.list_keys(role="user")}
101107

108+
@router.get("/api/auth/me")
109+
async def get_my_identity(response: Response, authorization: str | None = Header(default=None)):
110+
# 余额会随每次画图动态变化,不能让浏览器/SPA 兜底 HTML 缓存住,
111+
# 否则前端永远显示老值。明确禁止任何中间层缓存。
112+
response.headers["Cache-Control"] = "no-store"
113+
identity = require_identity(authorization)
114+
# admin 走 _legacy_admin_identity 不走 auth_service,没 id;前端按 unlimited=True 处理。
115+
item_id = str(identity.get("id") or "").strip()
116+
if not item_id or item_id == "admin":
117+
return {
118+
"identity": {
119+
"id": item_id,
120+
"name": identity.get("name"),
121+
"role": identity.get("role"),
122+
"quota": 0,
123+
"used": 0,
124+
"unlimited": True,
125+
"remaining": None,
126+
}
127+
}
128+
record = auth_service.get_by_id(item_id)
129+
if record is None:
130+
raise HTTPException(status_code=404, detail={"error": "用户不存在"})
131+
return {"identity": record}
132+
102133
@router.post("/api/auth/users")
103134
async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
104135
require_admin(authorization)
105136
try:
106-
item, raw_key = auth_service.create_key(role="user", name=body.name)
137+
item, raw_key = auth_service.create_key(
138+
role="user",
139+
name=body.name,
140+
quota=max(0, int(body.quota or 0)),
141+
unlimited=bool(body.unlimited),
142+
)
107143
except ValueError as exc:
108144
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
109145
return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
@@ -121,6 +157,9 @@ async def update_user_key(
121157
"name": body.name,
122158
"enabled": body.enabled,
123159
"key": body.key,
160+
"quota": body.quota,
161+
"unlimited": body.unlimited,
162+
"reset_used": body.reset_used,
124163
}.items()
125164
if value is not None
126165
}

‎api/ai.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from fastapi.concurrency import run_in_threadpool
55
from pydantic import BaseModel, ConfigDict, Field
66

7-
from api.support import require_identity, resolve_image_base_url
7+
from api.support import consume_user_quota, require_identity, resolve_image_base_url
88
from services.content_filter import check_request, request_text
9+
from services.image_owners_service import record_owner_for_result
910
from services.log_service import LoggedCall
1011
from services.protocol import (
1112
anthropic_v1_messages,
@@ -80,11 +81,17 @@ async def generate_images(
8081
authorization: str | None = Header(default=None),
8182
):
8283
identity = require_identity(authorization)
84+
# /v1 入口按 n 整体扣,1 次提交 = n 张。失败直接 402,不进 call.run。
85+
consume_user_quota(identity, max(1, int(body.n or 1)))
8386
payload = body.model_dump(mode="python")
8487
payload["base_url"] = resolve_image_base_url(request)
8588
call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
8689
await filter_or_log(call, body.prompt)
87-
return await call.run(openai_v1_image_generations.handle, payload)
90+
result = await call.run(openai_v1_image_generations.handle, payload)
91+
# 对接 dict 返回时把图片归属也写一下;StreamingResponse 不动。
92+
if isinstance(result, dict):
93+
record_owner_for_result(identity, result.get("data"))
94+
return result
8895

8996
@router.post("/v1/images/edits")
9097
async def edit_images(
@@ -103,6 +110,8 @@ async def edit_images(
103110
call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
104111
if n < 1 or n > 4:
105112
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
113+
# 同样按 n 整体扣,校验过 n 范围之后再扣,避免无效请求也被记账。
114+
consume_user_quota(identity, max(1, int(n)))
106115
await filter_or_log(call, prompt)
107116
uploads = [*(image or []), *(image_list or [])]
108117
if not uploads:
@@ -123,7 +132,10 @@ async def edit_images(
123132
"stream": stream,
124133
"base_url": resolve_image_base_url(request),
125134
}
126-
return await call.run(openai_v1_image_edit.handle, payload)
135+
result = await call.run(openai_v1_image_edit.handle, payload)
136+
if isinstance(result, dict):
137+
record_owner_for_result(identity, result.get("data"))
138+
return result
127139

128140
@router.post("/v1/chat/completions")
129141
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):

‎api/app.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from contextlib import asynccontextmanager
44
from threading import Event
55

6-
from fastapi import FastAPI, HTTPException
6+
from fastapi import FastAPI, HTTPException, Request
77
from fastapi.middleware.cors import CORSMiddleware
88
from fastapi.responses import FileResponse
99
from fastapi.staticfiles import StaticFiles
@@ -38,6 +38,17 @@ async def lifespan(_: FastAPI):
3838
allow_methods=["*"],
3939
allow_headers=["*"],
4040
)
41+
42+
# 动态接口禁缓存:/api/* 与 /v1/* 都是后端业务数据,浏览器若自行启发式缓存
43+
# 会出现"改了配置/数据但 UI 不更新"的诡异现象。一律 no-store 让每次都打到后端。
44+
@app.middleware("http")
45+
async def _no_store_for_api(request: Request, call_next):
46+
response = await call_next(request)
47+
path = request.url.path
48+
if path.startswith("/api/") or path.startswith("/v1/"):
49+
response.headers["Cache-Control"] = "no-store"
50+
return response
51+
4152
app.include_router(ai.create_router())
4253
app.include_router(accounts.create_router())
4354
app.include_router(image_tasks.create_router())

‎api/image_tasks.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi.concurrency import run_in_threadpool
55
from pydantic import BaseModel, Field
66

7-
from api.support import require_identity, resolve_image_base_url
7+
from api.support import consume_user_quota, require_identity, resolve_image_base_url
88
from services.content_filter import check_request
99
from services.image_task_service import image_task_service
1010
from services.log_service import LoggedCall
@@ -17,6 +17,10 @@ class ImageGenerationTaskRequest(BaseModel):
1717
size: str | None = None
1818

1919

20+
class ImageTaskCancelRequest(BaseModel):
21+
ids: list[str] = Field(default_factory=list)
22+
23+
2024
def _parse_task_ids(value: str) -> list[str]:
2125
return [item.strip() for item in value.split(",") if item.strip()]
2226

@@ -40,13 +44,25 @@ async def list_image_tasks(
4044
identity = require_identity(authorization)
4145
return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
4246

47+
@router.post("/api/image-tasks/cancel")
48+
async def cancel_image_tasks(
49+
body: ImageTaskCancelRequest,
50+
authorization: str | None = Header(default=None),
51+
):
52+
identity = require_identity(authorization)
53+
ids = [task_id.strip() for task_id in body.ids if task_id and task_id.strip()]
54+
return await run_in_threadpool(image_task_service.cancel_tasks, identity, ids)
55+
4356
@router.post("/api/image-tasks/generations")
4457
async def create_generation_task(
4558
body: ImageGenerationTaskRequest,
4659
request: Request,
4760
authorization: str | None = Header(default=None),
4861
):
4962
identity = require_identity(authorization)
63+
# 前端每张图独立提交一次任务,按 1 扣;额度不足直接 402,
64+
# 不要等 submit_generation 跑完才发现没额度。
65+
consume_user_quota(identity, 1)
5066
await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
5167
try:
5268
return await run_in_threadpool(
@@ -73,6 +89,8 @@ async def create_edit_task(
7389
size: str | None = Form(default=None),
7490
):
7591
identity = require_identity(authorization)
92+
# 同样按 1 张扣;前端会拆成多次提交,所以这里不需要乘以 n。
93+
consume_user_quota(identity, 1)
7694
await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
7795
uploads = [*(image or []), *(image_list or [])]
7896
if not uploads:

‎api/support.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ def raise_image_quota_error(exc: Exception) -> None:
5757
raise HTTPException(status_code=502, detail={"error": message}) from exc
5858

5959

60+
def consume_user_quota(identity: dict[str, object], amount: int) -> None:
61+
"""画图入口处扣减用户密钥额度。admin / unlimited 直接放行;
62+
普通用户额度不足直接 402,让前端把按钮禁用并提示联系管理员加额度。"""
63+
role = str(identity.get("role") or "").strip().lower()
64+
item_id = str(identity.get("id") or "").strip()
65+
if role == "admin" or not item_id or item_id == "admin":
66+
return
67+
result = auth_service.consume_quota(item_id, max(1, int(amount or 1)))
68+
if not result.get("ok"):
69+
reason = str(result.get("reason") or "额度不足")
70+
raise HTTPException(status_code=402, detail={"error": reason})
71+
72+
6073
def sanitize_cpa_pool(pool: dict | None) -> dict | None:
6174
if not isinstance(pool, dict):
6275
return None

‎api/system.py‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,44 @@
88
from pydantic import BaseModel, ConfigDict
99

1010
from api.support import require_admin, require_identity, resolve_image_base_url
11+
from services.auth_service import auth_service
1112
from services.backup_service import BackupError, backup_service
1213
from services.config import config
13-
from services.image_service import delete_images, download_images_zip, get_image_download_response, get_thumbnail_response, list_images
14+
from services.image_owners_service import owner_counts
15+
from services.image_service import count_total_images, delete_images, download_images_zip, get_image_download_response, get_thumbnail_response, list_images
1416
from services.image_tags_service import delete_tag, get_all_tags, set_tags
1517
from services.log_service import log_service
1618
from services.proxy_service import test_proxy
1719

1820

21+
def _admin_owner_ids() -> set[str]:
22+
"""收集所有可能落在 image_owners.json 里的 admin id:
23+
- "admin":旧 auth_key(CHATGPT2API_AUTH_KEY / config.json.auth-key)的固定 id
24+
- 其余:通过 auth_service 创建的 admin 角色密钥
25+
用来把"管理员生成"和"真孤儿"两个桶区分开,别再混在一起。
26+
"""
27+
ids: set[str] = {"admin"}
28+
for item in auth_service.list_keys(role="admin"):
29+
uid = str(item.get("id") or "").strip()
30+
if uid:
31+
ids.add(uid)
32+
return ids
33+
34+
35+
def _admin_owner_ids() -> set[str]:
36+
"""收集所有可能落在 image_owners.json 里的 admin id:
37+
- "admin":旧 auth_key(CHATGPT2API_AUTH_KEY / config.json.auth-key)的固定 id
38+
- 其余:通过 auth_service 创建的 admin 角色密钥
39+
用来把"管理员生成"和"真孤儿"两个桶区分开,别再混在一起。
40+
"""
41+
ids: set[str] = {"admin"}
42+
for item in auth_service.list_keys(role="admin"):
43+
uid = str(item.get("id") or "").strip()
44+
if uid:
45+
ids.add(uid)
46+
return ids
47+
48+
1949
class SettingsUpdateRequest(BaseModel):
2050
model_config = ConfigDict(extra="allow")
2151

@@ -28,6 +58,7 @@ class ImageDeleteRequest(BaseModel):
2858
paths: list[str] = []
2959
start_date: str = ""
3060
end_date: str = ""
61+
owner: str = ""
3162
all_matching: bool = False
3263

3364
class ImageDownloadRequest(BaseModel):
@@ -72,9 +103,68 @@ async def save_settings(body: SettingsUpdateRequest, authorization: str | None =
72103
return {"config": config.update(body.model_dump(mode="python"))}
73104

74105
@router.get("/api/images")
75-
async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
106+
async def get_images(
107+
request: Request,
108+
start_date: str = "",
109+
end_date: str = "",
110+
owner: str = "",
111+
authorization: str | None = Header(default=None),
112+
):
113+
require_admin(authorization)
114+
return list_images(
115+
resolve_image_base_url(request),
116+
start_date=start_date.strip(),
117+
end_date=end_date.strip(),
118+
owner=owner.strip(),
119+
admin_ids=_admin_owner_ids(),
120+
)
121+
122+
@router.get("/api/images/owners")
123+
async def get_image_owners(authorization: str | None = Header(default=None)):
124+
"""图片管理页用户筛选下拉的数据源。
125+
三类语义,互不混淆:
126+
1. 普通用户:列出所有用户密钥(即便 count=0),admin 期望"我建过的密钥都能筛"
127+
2. 管理员(__admin__):所有 admin 角色(含旧 auth_key 的 "admin" id)生成的图聚合
128+
3. 未归属(__unowned__):image_owners.json 里没记录的真孤儿,多半是老数据
129+
孤儿 user id(用户密钥已被删但归属表还留着)单列出来,标记 deleted=true。
130+
"""
76131
require_admin(authorization)
77-
return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
132+
counts = owner_counts()
133+
admin_ids = _admin_owner_ids()
134+
users = auth_service.list_keys(role="user")
135+
items: list[dict[str, object]] = []
136+
seen_ids: set[str] = set()
137+
for user in users:
138+
uid = str(user.get("id") or "").strip()
139+
if not uid:
140+
continue
141+
seen_ids.add(uid)
142+
items.append({
143+
"id": uid,
144+
"name": user.get("name") or uid,
145+
"deleted": False,
146+
"count": int(counts.get(uid, 0)),
147+
})
148+
# admin 集合本身已经独立成一桶,所以 seen_ids 里要带上 admin_ids 防止重复
149+
seen_ids |= admin_ids
150+
admin_count = sum(int(c) for k, c in counts.items() if k in admin_ids)
151+
for owner_id, count in counts.items():
152+
if not owner_id or owner_id in seen_ids:
153+
continue
154+
items.append({
155+
"id": owner_id,
156+
"name": owner_id,
157+
"deleted": True,
158+
"count": int(count),
159+
})
160+
items.sort(key=lambda x: (-int(x.get("count") or 0), str(x.get("name") or "")))
161+
# 真孤儿 = 总图片数 − 已挂归属的所有图(含 admin / 用户 / 已删用户)
162+
owned_total = sum(int(v) for v in counts.values())
163+
unowned_count = max(0, count_total_images() - owned_total)
164+
# 两个固定桶;前端会把它们置顶到列表最上方。
165+
items.append({"id": "__admin__", "name": "管理员", "deleted": False, "count": admin_count})
166+
items.append({"id": "__unowned__", "name": "未归属", "deleted": False, "count": unowned_count})
167+
return {"items": items}
78168

79169
@router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
80170
async def get_image_thumbnail(image_path: str):
@@ -83,7 +173,14 @@ async def get_image_thumbnail(image_path: str):
83173
@router.post("/api/images/delete")
84174
async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
85175
require_admin(authorization)
86-
return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
176+
return delete_images(
177+
body.paths,
178+
start_date=body.start_date.strip(),
179+
end_date=body.end_date.strip(),
180+
owner=body.owner.strip(),
181+
all_matching=body.all_matching,
182+
admin_ids=_admin_owner_ids(),
183+
)
87184

88185
@router.post("/api/images/download")
89186
async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "chatgpt2api"
3-
version = "1.2.0"
3+
version = "1.2.1"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.13"

0 commit comments

Comments
 (0)