extore 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- extore/__init__.py +0 -0
- extore/app.py +1051 -0
- extore/auth.py +263 -0
- extore/card_tracking.py +557 -0
- extore/cli.py +157 -0
- extore/config.py +23 -0
- extore/db.py +165 -0
- extore/files.py +496 -0
- extore/link_access.py +360 -0
- extore/models.py +353 -0
- extore/processors.py +79 -0
- extore/sdk/__init__.py +6 -0
- extore/sdk/client.py +80 -0
- extore/sdk/script.py +99 -0
- extore/security.py +267 -0
- extore/service.py +452 -0
- extore/source.py +40 -0
- extore/static/app.js +1156 -0
- extore/static/cards.js +616 -0
- extore/static/customer-motion.js +400 -0
- extore/static/index.html +55 -0
- extore/static/logo.svg +1 -0
- extore/static/perforation.svg +1 -0
- extore/static/preferences.js +161 -0
- extore/static/product-export.js +223 -0
- extore/static/products.js +746 -0
- extore/static/source-link.js +20 -0
- extore/static/style.css +1477 -0
- extore/static/vendor/dompurify.LICENSE +202 -0
- extore/static/vendor/marked.LICENSE +44 -0
- extore/static/vendor/marked.js +82 -0
- extore/static/vendor/purify.js +7 -0
- extore/static/webmcp.js +2385 -0
- extore/variants.py +117 -0
- extore/worker.py +329 -0
- extore-0.4.0.dist-info/METADATA +173 -0
- extore-0.4.0.dist-info/RECORD +44 -0
- extore-0.4.0.dist-info/WHEEL +4 -0
- extore-0.4.0.dist-info/entry_points.txt +2 -0
- extore-0.4.0.dist-info/licenses/LICENSE +21 -0
- extore_processors/LICENSE +22 -0
- extore_processors/__init__.py +25 -0
- extore_processors/__main__.py +57 -0
- extore_processors/catalog.py +259 -0
extore/app.py
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import secrets
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request, Response
|
|
9
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
|
|
12
|
+
from . import auth
|
|
13
|
+
from .card_tracking import router as card_tracking_router
|
|
14
|
+
from .config import ORIGIN, check_config
|
|
15
|
+
from .db import audit, db, event, init, setting
|
|
16
|
+
from .files import (
|
|
17
|
+
MAX_MULTIPART_BYTES,
|
|
18
|
+
purge_job_files,
|
|
19
|
+
release_output_files,
|
|
20
|
+
)
|
|
21
|
+
from .files import (
|
|
22
|
+
router as files_router,
|
|
23
|
+
)
|
|
24
|
+
from .link_access import augment_link_view, consume_link, revoke_staff_sessions
|
|
25
|
+
from .link_access import router as link_access_router
|
|
26
|
+
from .models import (
|
|
27
|
+
BatchUpdate,
|
|
28
|
+
CodeInput,
|
|
29
|
+
IssueCards,
|
|
30
|
+
JobUpdate,
|
|
31
|
+
ManagementProduct,
|
|
32
|
+
Product,
|
|
33
|
+
ProductLinkInput,
|
|
34
|
+
QuickProductInput,
|
|
35
|
+
Redemption,
|
|
36
|
+
StaffInput,
|
|
37
|
+
TokenInput,
|
|
38
|
+
)
|
|
39
|
+
from .processors import processor_catalog, public_configuration
|
|
40
|
+
from .security import (
|
|
41
|
+
authorize_management,
|
|
42
|
+
card_digest,
|
|
43
|
+
create_session,
|
|
44
|
+
digest,
|
|
45
|
+
fail,
|
|
46
|
+
grant,
|
|
47
|
+
link_descendant_ids,
|
|
48
|
+
rate_limit,
|
|
49
|
+
session,
|
|
50
|
+
staff_authorization,
|
|
51
|
+
token,
|
|
52
|
+
verify_signature,
|
|
53
|
+
)
|
|
54
|
+
from .service import (
|
|
55
|
+
apply_update,
|
|
56
|
+
bootstrap_progress_plan,
|
|
57
|
+
freeze_product_plans,
|
|
58
|
+
freeze_product_schemas,
|
|
59
|
+
issue_cards,
|
|
60
|
+
job,
|
|
61
|
+
job_product,
|
|
62
|
+
job_view,
|
|
63
|
+
product,
|
|
64
|
+
public_product,
|
|
65
|
+
submit,
|
|
66
|
+
)
|
|
67
|
+
from .source import router as source_router
|
|
68
|
+
from .variants import card_variant, issued_variant_ids
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def lifespan(app):
|
|
73
|
+
check_config()
|
|
74
|
+
init()
|
|
75
|
+
yield
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
app = FastAPI(title="Extore API", version="0.4.0", lifespan=lifespan)
|
|
79
|
+
app.include_router(auth.router)
|
|
80
|
+
app.include_router(card_tracking_router)
|
|
81
|
+
app.include_router(files_router)
|
|
82
|
+
app.include_router(source_router)
|
|
83
|
+
app.include_router(link_access_router)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.middleware("http")
|
|
87
|
+
async def guard(request: Request, call_next):
|
|
88
|
+
if (
|
|
89
|
+
request.method not in ("GET", "HEAD", "OPTIONS")
|
|
90
|
+
and request.url.path.startswith("/api/")
|
|
91
|
+
and not request.url.path.startswith("/api/callbacks/")
|
|
92
|
+
and not request.url.path.startswith("/api/integrations/")
|
|
93
|
+
):
|
|
94
|
+
if request.headers.get("origin") != ORIGIN:
|
|
95
|
+
return JSONResponse({"detail": "请求来源不匹配"}, status_code=403)
|
|
96
|
+
# Streaming bounded read prevents unbounded webhook / JSON memory usage.
|
|
97
|
+
upload = request.url.path in ("/api/files/upload", "/api/manage/files/upload")
|
|
98
|
+
if (
|
|
99
|
+
upload
|
|
100
|
+
and request.headers.get("content-length", "").isdigit()
|
|
101
|
+
and int(request.headers["content-length"]) > MAX_MULTIPART_BYTES
|
|
102
|
+
):
|
|
103
|
+
return JSONResponse({"detail": "上传文件超过 20 MiB 限制"}, status_code=413)
|
|
104
|
+
if request.method not in ("GET", "HEAD") and not upload:
|
|
105
|
+
body = bytearray()
|
|
106
|
+
async for chunk in request.stream():
|
|
107
|
+
body.extend(chunk)
|
|
108
|
+
if len(body) > 256000:
|
|
109
|
+
return JSONResponse({"detail": "请求过大"}, status_code=413)
|
|
110
|
+
request._body = bytes(body)
|
|
111
|
+
response = await call_next(request)
|
|
112
|
+
response.headers["Cache-Control"] = "no-store"
|
|
113
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
114
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
115
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
116
|
+
response.headers.setdefault(
|
|
117
|
+
"Content-Security-Policy",
|
|
118
|
+
(
|
|
119
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
if ORIGIN.startswith("https:"):
|
|
123
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000"
|
|
124
|
+
return response
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.api_route("/health", methods=["GET", "HEAD"])
|
|
128
|
+
def health():
|
|
129
|
+
with db() as c:
|
|
130
|
+
c.execute("SELECT 1")
|
|
131
|
+
return {"status": "ok"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.get("/api/products")
|
|
135
|
+
def public_products():
|
|
136
|
+
with db() as c:
|
|
137
|
+
rows = [
|
|
138
|
+
public_product(product(c, r["id"]))
|
|
139
|
+
for r in c.execute("SELECT * FROM products ORDER BY created")
|
|
140
|
+
]
|
|
141
|
+
return [p for p in rows if p["public"]]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.post("/api/exchange")
|
|
145
|
+
def exchange(body: CodeInput, request: Request):
|
|
146
|
+
rate_limit(request, "exchange", 20, 60)
|
|
147
|
+
with db() as c:
|
|
148
|
+
card = c.execute(
|
|
149
|
+
"SELECT * FROM cards WHERE digest=?", (card_digest(body.code),)
|
|
150
|
+
).fetchone()
|
|
151
|
+
if not card or card["state"] == "revoked":
|
|
152
|
+
fail("卡密无效,请检查后重试", 404)
|
|
153
|
+
from .card_tracking import ensure_card_usable, record_verified
|
|
154
|
+
|
|
155
|
+
ensure_card_usable(c, card)
|
|
156
|
+
row = c.execute("SELECT * FROM jobs WHERE card_id=?", (card["id"],)).fetchone()
|
|
157
|
+
p = job_product(c, row) if row else product(c, card["product_id"])
|
|
158
|
+
if row and (
|
|
159
|
+
row["state"] == "destroyed"
|
|
160
|
+
or (
|
|
161
|
+
row["state"] == "succeeded"
|
|
162
|
+
and (p["view_policy"] == "once" or p["delivery"] == "service")
|
|
163
|
+
)
|
|
164
|
+
):
|
|
165
|
+
fail("卡密已使用,无法再次领取", 410)
|
|
166
|
+
value = token()
|
|
167
|
+
c.execute("DELETE FROM grants WHERE expires<?", (time.time(),))
|
|
168
|
+
c.execute(
|
|
169
|
+
"INSERT INTO grants VALUES (?,?,?)",
|
|
170
|
+
(digest(value), card["id"], time.time() + 30 * 86400),
|
|
171
|
+
)
|
|
172
|
+
record_verified(c, card["id"])
|
|
173
|
+
return {
|
|
174
|
+
"token": value,
|
|
175
|
+
"product": public_product(p),
|
|
176
|
+
"variant": card_variant(c, card),
|
|
177
|
+
"job": job_view(c, row) if row else None,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.post("/api/redeem")
|
|
182
|
+
def redeem(body: Redemption, request: Request):
|
|
183
|
+
rate_limit(request, "redeem", 20, 60)
|
|
184
|
+
with db() as c:
|
|
185
|
+
card = grant(c, body.token)
|
|
186
|
+
return job_view(c, submit(c, card, body.params))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.post("/api/receipt")
|
|
190
|
+
def receipt(body: TokenInput):
|
|
191
|
+
with db() as c:
|
|
192
|
+
card = grant(c, body.token)
|
|
193
|
+
row = c.execute("SELECT * FROM jobs WHERE card_id=?", (card["id"],)).fetchone()
|
|
194
|
+
return {
|
|
195
|
+
"product": public_product(
|
|
196
|
+
job_product(c, row) if row else product(c, card["product_id"])
|
|
197
|
+
),
|
|
198
|
+
"variant": card_variant(c, card),
|
|
199
|
+
"job": job_view(c, row) if row else None,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.post("/api/receipt/reveal")
|
|
204
|
+
def reveal(body: TokenInput):
|
|
205
|
+
with db() as c:
|
|
206
|
+
card = grant(c, body.token)
|
|
207
|
+
row = c.execute("SELECT * FROM jobs WHERE card_id=?", (card["id"],)).fetchone()
|
|
208
|
+
if not row or row["state"] != "succeeded":
|
|
209
|
+
fail("尚无可领取内容", 409)
|
|
210
|
+
p = job_product(c, row)
|
|
211
|
+
if p["delivery"] == "service":
|
|
212
|
+
return {"content": None, "output": {}}
|
|
213
|
+
if p["view_policy"] == "once" and row["revealed"]:
|
|
214
|
+
fail("内容已领取,无法再次查看", 410)
|
|
215
|
+
if not row["content"] and row["result_json"] is None:
|
|
216
|
+
fail("内容已销毁", 410)
|
|
217
|
+
content = row["content"]
|
|
218
|
+
output = (
|
|
219
|
+
json.loads(row["result_json"])
|
|
220
|
+
if row["result_json"] is not None
|
|
221
|
+
else {"content": content}
|
|
222
|
+
)
|
|
223
|
+
files = release_output_files(c, row)
|
|
224
|
+
c.execute(
|
|
225
|
+
"UPDATE jobs SET revealed=1,content=?,result_json=?,updated=? WHERE id=?",
|
|
226
|
+
(
|
|
227
|
+
None if p["view_policy"] == "once" else content,
|
|
228
|
+
None if p["view_policy"] == "once" else row["result_json"],
|
|
229
|
+
time.time(),
|
|
230
|
+
row["id"],
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
event(c, "delivery.viewed", row["product_id"], job(c, row["id"]))
|
|
234
|
+
return {
|
|
235
|
+
"content": content,
|
|
236
|
+
"output": output,
|
|
237
|
+
**({"files": files} if files else {}),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.post("/api/receipt/destroy")
|
|
242
|
+
def destroy(body: TokenInput):
|
|
243
|
+
with db() as c:
|
|
244
|
+
card = grant(c, body.token)
|
|
245
|
+
row = c.execute("SELECT * FROM jobs WHERE card_id=?", (card["id"],)).fetchone()
|
|
246
|
+
if not row or row["state"] not in ("succeeded", "destroyed"):
|
|
247
|
+
fail("只能销毁已完成的交付", 409)
|
|
248
|
+
if row["state"] == "destroyed":
|
|
249
|
+
return {"ok": True}
|
|
250
|
+
purge_job_files(c, row["id"])
|
|
251
|
+
c.execute(
|
|
252
|
+
"UPDATE jobs SET state='destroyed',content=NULL,result_json=NULL,params='{}',message='',updated=? WHERE id=?",
|
|
253
|
+
(time.time(), row["id"]),
|
|
254
|
+
)
|
|
255
|
+
# Scrub queued/history payloads of customer parameters.
|
|
256
|
+
for e in c.execute(
|
|
257
|
+
"SELECT id,payload FROM events WHERE job_id=?", (row["id"],)
|
|
258
|
+
).fetchall():
|
|
259
|
+
payload = json.loads(e["payload"])
|
|
260
|
+
payload["data"].pop("params", None)
|
|
261
|
+
c.execute(
|
|
262
|
+
"UPDATE events SET payload=? WHERE id=?", (json.dumps(payload), e["id"])
|
|
263
|
+
)
|
|
264
|
+
event(c, "delivery.destroyed", row["product_id"], job(c, row["id"]))
|
|
265
|
+
audit(c, "customer", "delivery.destroy", row["id"])
|
|
266
|
+
return {"ok": True}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.get("/api/admin/products")
|
|
270
|
+
def admin_products(request: Request):
|
|
271
|
+
session(request)
|
|
272
|
+
with db() as c:
|
|
273
|
+
return [
|
|
274
|
+
product(c, r["id"])
|
|
275
|
+
for r in c.execute("SELECT * FROM products ORDER BY created")
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.post("/api/admin/products")
|
|
280
|
+
def create_product(body: Product, request: Request):
|
|
281
|
+
session(request)
|
|
282
|
+
pid = str(uuid.uuid4())
|
|
283
|
+
with db() as c:
|
|
284
|
+
c.execute(
|
|
285
|
+
"INSERT INTO products VALUES (?,?,?)",
|
|
286
|
+
(pid, body.model_dump_json(), time.time()),
|
|
287
|
+
)
|
|
288
|
+
audit(c, "owner", "product.create", pid)
|
|
289
|
+
return {"id": pid, **body.model_dump()}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
PRODUCT_TEMPLATES = (
|
|
293
|
+
{
|
|
294
|
+
"id": "manual_content",
|
|
295
|
+
"name": "队列内容交付",
|
|
296
|
+
"description": "由人员或 AI 从队列领取任务并提供交付内容。创建后请完善商品信息与输入输出。",
|
|
297
|
+
"mode": "manual",
|
|
298
|
+
"delivery": "content",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"id": "manual_service",
|
|
302
|
+
"name": "队列服务办理",
|
|
303
|
+
"description": "由人员或 AI 从队列领取任务办理,仅显示办理状态。创建后请完善商品信息与输入。",
|
|
304
|
+
"mode": "manual",
|
|
305
|
+
"delivery": "service",
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.get("/api/admin/product-templates")
|
|
311
|
+
def product_templates(request: Request):
|
|
312
|
+
session(request)
|
|
313
|
+
return list(PRODUCT_TEMPLATES)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.get("/api/admin/processors")
|
|
317
|
+
def admin_processors(request: Request):
|
|
318
|
+
session(request)
|
|
319
|
+
return processor_catalog()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.get("/api/manage/processors")
|
|
323
|
+
def managed_processors(request: Request):
|
|
324
|
+
s = session(request, ("admin", "staff"))
|
|
325
|
+
with db() as c:
|
|
326
|
+
authorize_management(c, s, "product.edit")
|
|
327
|
+
return processor_catalog()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.post("/api/admin/products/quick")
|
|
331
|
+
def quick_product(body: QuickProductInput, request: Request):
|
|
332
|
+
s = session(request)
|
|
333
|
+
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
334
|
+
name = body.name or "未命名商品 · " + "".join(
|
|
335
|
+
secrets.choice(alphabet) for _ in range(6)
|
|
336
|
+
)
|
|
337
|
+
pid = str(uuid.uuid4())
|
|
338
|
+
with db() as c:
|
|
339
|
+
if body.template_id == "existing_product":
|
|
340
|
+
source = product(c, body.from_product_id)
|
|
341
|
+
values = {key: value for key, value in source.items() if key != "id"}
|
|
342
|
+
# A new configuration link must never inherit the source product's
|
|
343
|
+
# callback signing authority, even when the connector is copied.
|
|
344
|
+
if values["webhook_secret"]:
|
|
345
|
+
values["webhook_secret"] = secrets.token_urlsafe(32)
|
|
346
|
+
# Copy processor structure without the source's delivery secrets.
|
|
347
|
+
values["processor_config"] = public_configuration(values)
|
|
348
|
+
config = Product.model_validate({**values, "name": name, "public": False})
|
|
349
|
+
else:
|
|
350
|
+
template = next(t for t in PRODUCT_TEMPLATES if t["id"] == body.template_id)
|
|
351
|
+
config = Product(
|
|
352
|
+
name=name, mode=template["mode"], delivery=template["delivery"]
|
|
353
|
+
)
|
|
354
|
+
c.execute(
|
|
355
|
+
"INSERT INTO products VALUES (?,?,?)",
|
|
356
|
+
(pid, config.model_dump_json(), time.time()),
|
|
357
|
+
)
|
|
358
|
+
audit(c, "owner", "product.create", pid)
|
|
359
|
+
link = create_product_link(
|
|
360
|
+
c,
|
|
361
|
+
ProductLinkInput(
|
|
362
|
+
product_id=pid,
|
|
363
|
+
name=("AI 配置 · " + name)[:100],
|
|
364
|
+
days=7,
|
|
365
|
+
permissions=["product.edit", "fulfillment.configure"],
|
|
366
|
+
),
|
|
367
|
+
s,
|
|
368
|
+
)
|
|
369
|
+
return {"product": {"id": pid, **config.model_dump()}, "management_link": link}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.put("/api/admin/products/{pid}")
|
|
373
|
+
def edit_product(pid: str, body: Product, request: Request):
|
|
374
|
+
session(request)
|
|
375
|
+
with db() as c:
|
|
376
|
+
save_product(c, pid, body, "owner")
|
|
377
|
+
return {"id": pid, **body.model_dump()}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def save_product(c, pid, body, actor):
|
|
381
|
+
old = product(c, pid)
|
|
382
|
+
freeze_product_plans(c, pid, old["progress_steps"])
|
|
383
|
+
freeze_product_schemas(c, pid, old)
|
|
384
|
+
removed = issued_variant_ids(c, pid) - {v.id for v in body.variants}
|
|
385
|
+
if removed:
|
|
386
|
+
fail(
|
|
387
|
+
"已发行卡密的规格不能删除;请停用该规格:" + "、".join(sorted(removed)),
|
|
388
|
+
409,
|
|
389
|
+
)
|
|
390
|
+
# Delivery semantics and automation must not change under outstanding cards.
|
|
391
|
+
if c.execute("SELECT 1 FROM cards WHERE product_id=? LIMIT 1", (pid,)).fetchone():
|
|
392
|
+
for field in (
|
|
393
|
+
"mode",
|
|
394
|
+
"delivery",
|
|
395
|
+
"view_policy",
|
|
396
|
+
"script",
|
|
397
|
+
"webhook_secret",
|
|
398
|
+
"processor_id",
|
|
399
|
+
):
|
|
400
|
+
if old[field] != getattr(body, field):
|
|
401
|
+
fail(
|
|
402
|
+
"已发行卡密的商品不能修改处理方式、交付方式、查看规则、处理器或签名密钥;请新建商品",
|
|
403
|
+
409,
|
|
404
|
+
)
|
|
405
|
+
values = body.model_dump()
|
|
406
|
+
for field, label in (("parameters", "输入"), ("outputs", "输出")):
|
|
407
|
+
if old["mode"] != "manual" and field_schema(old[field]) != field_schema(
|
|
408
|
+
values[field]
|
|
409
|
+
):
|
|
410
|
+
fail(
|
|
411
|
+
f"已发行卡密的商品不能修改{label}字段的代码名、类型或必填规则;请新建商品",
|
|
412
|
+
409,
|
|
413
|
+
)
|
|
414
|
+
c.execute("UPDATE products SET config=? WHERE id=?", (body.model_dump_json(), pid))
|
|
415
|
+
audit(c, actor, "product.update", pid)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def field_schema(fields):
|
|
419
|
+
return {field["key"]: (field["type"], field["required"]) for field in fields}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@app.post("/api/admin/cards")
|
|
423
|
+
def cards(body: IssueCards, request: Request):
|
|
424
|
+
session(request)
|
|
425
|
+
with db() as c:
|
|
426
|
+
codes = issue_cards(
|
|
427
|
+
c,
|
|
428
|
+
body.product_id,
|
|
429
|
+
body.count,
|
|
430
|
+
label=body.label,
|
|
431
|
+
expires=body.expires,
|
|
432
|
+
variant_id=body.variant_id,
|
|
433
|
+
)
|
|
434
|
+
audit(c, "owner", "cards.issue", f"{body.product_id}:{body.count}")
|
|
435
|
+
return {"codes": codes, "batch_id": card_batch_id(c, codes)}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def card_batch_id(c, codes):
|
|
439
|
+
if not codes:
|
|
440
|
+
return None
|
|
441
|
+
row = c.execute(
|
|
442
|
+
"SELECT card_meta.batch_id FROM card_meta JOIN cards ON cards.id=card_meta.card_id WHERE cards.digest=?",
|
|
443
|
+
(card_digest(codes[0]),),
|
|
444
|
+
).fetchone()
|
|
445
|
+
return row["batch_id"] if row else None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@app.get("/api/admin/cards")
|
|
449
|
+
def list_cards(request: Request, product_id: str = "", limit: int = 100):
|
|
450
|
+
session(request)
|
|
451
|
+
with db() as c:
|
|
452
|
+
return [
|
|
453
|
+
dict(r)
|
|
454
|
+
for r in c.execute(
|
|
455
|
+
"SELECT id,product_id,state,created FROM cards WHERE (?='' OR product_id=?) ORDER BY created DESC LIMIT ?",
|
|
456
|
+
(product_id, product_id, max(1, min(limit, 500))),
|
|
457
|
+
)
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@app.post("/api/admin/cards/{cid}/revoke")
|
|
462
|
+
def revoke_card(cid: str, request: Request):
|
|
463
|
+
session(request)
|
|
464
|
+
with db() as c:
|
|
465
|
+
row = c.execute("SELECT state FROM cards WHERE id=?", (cid,)).fetchone()
|
|
466
|
+
if not row or row["state"] != "ready":
|
|
467
|
+
fail("只能撤销尚未兑换的卡密", 409)
|
|
468
|
+
c.execute("UPDATE cards SET state='revoked' WHERE id=?", (cid,))
|
|
469
|
+
c.execute("DELETE FROM grants WHERE card_id=?", (cid,))
|
|
470
|
+
audit(c, "owner", "card.revoke", cid)
|
|
471
|
+
return {"ok": True}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def link_view(row):
|
|
475
|
+
return {
|
|
476
|
+
**augment_link_view(row),
|
|
477
|
+
**{
|
|
478
|
+
key: json.loads(row[key]) if key == "permissions" else row[key]
|
|
479
|
+
for key in (
|
|
480
|
+
"id",
|
|
481
|
+
"product_id",
|
|
482
|
+
"name",
|
|
483
|
+
"expires",
|
|
484
|
+
"revoked",
|
|
485
|
+
"permissions",
|
|
486
|
+
"parent_id",
|
|
487
|
+
"created",
|
|
488
|
+
)
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def create_product_link(c, body, s):
|
|
494
|
+
pid = queue_product_id(s, body.product_id)
|
|
495
|
+
product(c, pid)
|
|
496
|
+
now = time.time()
|
|
497
|
+
parent_id = s["staff_id"] if s["role"] == "staff" else None
|
|
498
|
+
if parent_id:
|
|
499
|
+
parent = c.execute(
|
|
500
|
+
"SELECT max_uses FROM staff WHERE id=?", (parent_id,)
|
|
501
|
+
).fetchone()
|
|
502
|
+
if body.max_uses > parent["max_uses"]:
|
|
503
|
+
fail("下级链接可用次数不能超过当前链接的上限", 403)
|
|
504
|
+
if not set(body.permissions) < set(s["permissions"]):
|
|
505
|
+
fail("下级权限必须是当前商品管理权限的严格子集", 403)
|
|
506
|
+
parent_expires = s["link_expires"]
|
|
507
|
+
expires = (
|
|
508
|
+
min(now + 7 * 86400, parent_expires)
|
|
509
|
+
if body.days is None
|
|
510
|
+
else now + body.days * 86400
|
|
511
|
+
)
|
|
512
|
+
if expires > parent_expires:
|
|
513
|
+
fail("下级链接期限不能超过当前商品管理链接", 403)
|
|
514
|
+
else:
|
|
515
|
+
expires = now + (7 if body.days is None else body.days) * 86400
|
|
516
|
+
sid = str(uuid.uuid4())
|
|
517
|
+
value = token()
|
|
518
|
+
c.execute(
|
|
519
|
+
"INSERT INTO staff(id,digest,product_id,name,expires,permissions,parent_id,created,max_uses) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
520
|
+
(
|
|
521
|
+
sid,
|
|
522
|
+
digest(value),
|
|
523
|
+
pid,
|
|
524
|
+
body.name,
|
|
525
|
+
expires,
|
|
526
|
+
json.dumps(body.permissions),
|
|
527
|
+
parent_id,
|
|
528
|
+
now,
|
|
529
|
+
body.max_uses,
|
|
530
|
+
),
|
|
531
|
+
)
|
|
532
|
+
audit(c, parent_id or "owner", "staff.create", sid)
|
|
533
|
+
result = link_view(c.execute("SELECT * FROM staff WHERE id=?", (sid,)).fetchone())
|
|
534
|
+
return {**result, "url": ORIGIN + "/staff#" + value}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def revoke_product_link(c, sid, actor):
|
|
538
|
+
if not c.execute("SELECT 1 FROM staff WHERE id=?", (sid,)).fetchone():
|
|
539
|
+
fail("商品管理链接不存在", 404)
|
|
540
|
+
ids = link_descendant_ids(c, sid)
|
|
541
|
+
for target in ids:
|
|
542
|
+
c.execute("UPDATE staff SET revoked=1 WHERE id=?", (target,))
|
|
543
|
+
revoke_staff_sessions(c, target, actor)
|
|
544
|
+
# Return unfinished delegated tasks to their product queue.
|
|
545
|
+
c.execute(
|
|
546
|
+
"UPDATE jobs SET state='queued',claimed_by=NULL,lease=NULL,"
|
|
547
|
+
"progress=CASE WHEN json_array_length(COALESCE(progress_plan,'[]'))>0 "
|
|
548
|
+
"THEN progress ELSE 0 END WHERE claimed_by=? AND state='processing'",
|
|
549
|
+
(target,),
|
|
550
|
+
)
|
|
551
|
+
audit(c, actor, "staff.revoke", sid)
|
|
552
|
+
return {"ok": True, "revoked_count": len(ids)}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.post("/api/admin/staff")
|
|
556
|
+
def create_staff(body: StaffInput, request: Request):
|
|
557
|
+
s = session(request)
|
|
558
|
+
with db() as c:
|
|
559
|
+
return create_product_link(c, body, s)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@app.get("/api/admin/staff")
|
|
563
|
+
def list_staff(request: Request):
|
|
564
|
+
session(request)
|
|
565
|
+
with db() as c:
|
|
566
|
+
return [link_view(r) for r in c.execute("SELECT * FROM staff ORDER BY created")]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@app.post("/api/admin/staff/{sid}/revoke")
|
|
570
|
+
def revoke_staff(sid: str, request: Request):
|
|
571
|
+
session(request)
|
|
572
|
+
with db() as c:
|
|
573
|
+
return revoke_product_link(c, sid, "owner")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@app.post("/api/staff/login")
|
|
577
|
+
def staff_login(body: TokenInput, request: Request, response: Response):
|
|
578
|
+
rate_limit(request, "staff-login", 10, 60)
|
|
579
|
+
with db() as c:
|
|
580
|
+
row = c.execute(
|
|
581
|
+
"SELECT * FROM staff WHERE digest=?", (digest(body.token),)
|
|
582
|
+
).fetchone()
|
|
583
|
+
if not row:
|
|
584
|
+
fail("商品管理链接无效或已过期", 401)
|
|
585
|
+
staff_authorization(c, row["id"])
|
|
586
|
+
current = c.execute(
|
|
587
|
+
"SELECT * FROM sessions WHERE digest=? AND expires>? AND revoked=0",
|
|
588
|
+
(digest(request.cookies.get("extore_session", "")), time.time()),
|
|
589
|
+
).fetchone()
|
|
590
|
+
if current and current["role"] == "staff" and current["staff_id"] == row["id"]:
|
|
591
|
+
return {"role": "staff"}
|
|
592
|
+
consume_link(c, row, request)
|
|
593
|
+
create_session(c, response, "staff", row["id"], request=request)
|
|
594
|
+
return {"role": "staff"}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@app.get("/api/manage/products")
|
|
598
|
+
def managed_products(request: Request):
|
|
599
|
+
s = session(request, ("admin", "staff"))
|
|
600
|
+
with db() as c:
|
|
601
|
+
queue_staff_authorization(c, s)
|
|
602
|
+
scope = s["product_id"] if s["role"] == "staff" else ""
|
|
603
|
+
rows = c.execute(
|
|
604
|
+
"SELECT * FROM products WHERE (?='' OR id=?) ORDER BY created",
|
|
605
|
+
(scope, scope),
|
|
606
|
+
).fetchall()
|
|
607
|
+
result = []
|
|
608
|
+
for r in rows:
|
|
609
|
+
p = product(c, r["id"])
|
|
610
|
+
result.append(
|
|
611
|
+
{
|
|
612
|
+
key: p[key]
|
|
613
|
+
for key in (
|
|
614
|
+
"id",
|
|
615
|
+
"name",
|
|
616
|
+
"mode",
|
|
617
|
+
"delivery",
|
|
618
|
+
"view_policy",
|
|
619
|
+
"parameters",
|
|
620
|
+
"outputs",
|
|
621
|
+
"variants",
|
|
622
|
+
"progress_steps",
|
|
623
|
+
"support_email",
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
return result
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def queue_staff_authorization(c, s):
|
|
631
|
+
return authorize_management(c, s)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def queue_product_id(s, product_id):
|
|
635
|
+
if s["role"] == "staff":
|
|
636
|
+
if product_id and product_id != s["product_id"]:
|
|
637
|
+
fail("无权处理此商品", 403)
|
|
638
|
+
return s["product_id"]
|
|
639
|
+
if not product_id:
|
|
640
|
+
fail("请先选择商品")
|
|
641
|
+
return product_id
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@app.get("/api/manage/jobs")
|
|
645
|
+
def jobs(
|
|
646
|
+
request: Request,
|
|
647
|
+
state: str = "",
|
|
648
|
+
product_id: str = "",
|
|
649
|
+
limit: int = 100,
|
|
650
|
+
job_id: str = "",
|
|
651
|
+
):
|
|
652
|
+
s = session(request, ("admin", "staff"))
|
|
653
|
+
with db() as c:
|
|
654
|
+
authorize_management(c, s, "queue.view")
|
|
655
|
+
product_id = queue_product_id(s, product_id)
|
|
656
|
+
product(c, product_id)
|
|
657
|
+
rows = c.execute(
|
|
658
|
+
"SELECT * FROM jobs WHERE product_id=? AND (?='' OR state=?) "
|
|
659
|
+
"AND (?='' OR id=?) ORDER BY created,id LIMIT ?",
|
|
660
|
+
(product_id, state, state, job_id, job_id, max(1, min(limit, 500))),
|
|
661
|
+
).fetchall()
|
|
662
|
+
return [job_view(c, r, True) for r in rows]
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@app.post("/api/manage/batch")
|
|
666
|
+
def batch(body: BatchUpdate, request: Request):
|
|
667
|
+
s = session(request, ("admin", "staff"))
|
|
668
|
+
actor = s["staff_id"] if s["role"] == "staff" else "owner"
|
|
669
|
+
with db() as c:
|
|
670
|
+
authorize_management(
|
|
671
|
+
c, s, "queue.retry" if body.action == "retry" else "queue.process"
|
|
672
|
+
)
|
|
673
|
+
if body.progress_steps is not None:
|
|
674
|
+
authorize_management(c, s, "queue.process")
|
|
675
|
+
product_id = queue_product_id(s, body.product_id)
|
|
676
|
+
p = product(c, product_id)
|
|
677
|
+
rows = [job(c, jid) for jid in dict.fromkeys(body.ids)]
|
|
678
|
+
if any(r["product_id"] != product_id for r in rows):
|
|
679
|
+
fail("批处理只能包含所选商品的任务", 403)
|
|
680
|
+
for r in rows:
|
|
681
|
+
jid = r["id"]
|
|
682
|
+
if p["mode"] != "manual" and body.action != "retry":
|
|
683
|
+
fail("自动处理任务不能由队列处理覆盖", 409)
|
|
684
|
+
if body.progress_steps is not None:
|
|
685
|
+
bootstrap_progress_plan(
|
|
686
|
+
c, r, [step.model_dump() for step in body.progress_steps]
|
|
687
|
+
)
|
|
688
|
+
r = job(c, jid)
|
|
689
|
+
if body.action == "claim":
|
|
690
|
+
if r["state"] != "queued":
|
|
691
|
+
fail("任务已被领取或完成,请刷新列表", 409)
|
|
692
|
+
c.execute("UPDATE jobs SET claimed_by=? WHERE id=?", (actor, jid))
|
|
693
|
+
apply_update(
|
|
694
|
+
c,
|
|
695
|
+
jid,
|
|
696
|
+
JobUpdate(
|
|
697
|
+
state="processing",
|
|
698
|
+
attempt=r["attempt"],
|
|
699
|
+
progress=body.progress,
|
|
700
|
+
completed_steps=body.completed_steps,
|
|
701
|
+
message=body.message or "正在处理",
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
elif body.action in ("progress", "succeed", "fail"):
|
|
705
|
+
if r["state"] != "processing" or r["claimed_by"] != actor:
|
|
706
|
+
fail("请先领取任务,且只能处理自己领取的任务", 409)
|
|
707
|
+
apply_update(
|
|
708
|
+
c,
|
|
709
|
+
jid,
|
|
710
|
+
JobUpdate(
|
|
711
|
+
state={
|
|
712
|
+
"succeed": "succeeded",
|
|
713
|
+
"fail": "failed",
|
|
714
|
+
"progress": "processing",
|
|
715
|
+
}[body.action],
|
|
716
|
+
progress=body.progress,
|
|
717
|
+
completed_steps=body.completed_steps,
|
|
718
|
+
attempt=r["attempt"],
|
|
719
|
+
content=body.content,
|
|
720
|
+
output=body.output,
|
|
721
|
+
message=body.message,
|
|
722
|
+
retryable=body.retryable,
|
|
723
|
+
),
|
|
724
|
+
)
|
|
725
|
+
elif body.action == "retry":
|
|
726
|
+
if r["state"] != "failed":
|
|
727
|
+
fail("只能放行失败的任务", 409)
|
|
728
|
+
from .card_tracking import ensure_card_usable
|
|
729
|
+
|
|
730
|
+
ensure_card_usable(
|
|
731
|
+
c,
|
|
732
|
+
c.execute(
|
|
733
|
+
"SELECT * FROM cards WHERE id=?", (r["card_id"],)
|
|
734
|
+
).fetchone(),
|
|
735
|
+
)
|
|
736
|
+
c.execute("UPDATE jobs SET retryable=1 WHERE id=?", (jid,))
|
|
737
|
+
audit(c, actor, "job." + body.action, jid)
|
|
738
|
+
return {"ok": True}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def management_scope(c, s, product_id, permission):
|
|
742
|
+
authorize_management(c, s, permission)
|
|
743
|
+
pid = queue_product_id(s, product_id)
|
|
744
|
+
product(c, pid)
|
|
745
|
+
return pid
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def management_actor(s):
|
|
749
|
+
return s["staff_id"] if s["role"] == "staff" else "owner"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@app.get("/api/manage/product")
|
|
753
|
+
def managed_product(request: Request, product_id: str = ""):
|
|
754
|
+
s = session(request, ("admin", "staff"))
|
|
755
|
+
with db() as c:
|
|
756
|
+
pid = management_scope(c, s, product_id, "product.edit")
|
|
757
|
+
return managed_product_view(product(c, pid), s)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def managed_product_view(p, s):
|
|
761
|
+
if "fulfillment.configure" not in s["permissions"]:
|
|
762
|
+
return {**p, "webhook_secret": "", "processor_config": {}}
|
|
763
|
+
return p
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@app.put("/api/manage/product")
|
|
767
|
+
def edit_managed_product(
|
|
768
|
+
body: ManagementProduct, request: Request, product_id: str = ""
|
|
769
|
+
):
|
|
770
|
+
s = session(request, ("admin", "staff"))
|
|
771
|
+
with db() as c:
|
|
772
|
+
pid = management_scope(c, s, product_id, "product.edit")
|
|
773
|
+
old = product(c, pid)
|
|
774
|
+
values = body.model_dump()
|
|
775
|
+
if not values["webhook_secret"]:
|
|
776
|
+
values["webhook_secret"] = old["webhook_secret"]
|
|
777
|
+
if "fulfillment.configure" not in s["permissions"]:
|
|
778
|
+
if not values["processor_config"]:
|
|
779
|
+
values["processor_config"] = old["processor_config"]
|
|
780
|
+
for field in (
|
|
781
|
+
"mode",
|
|
782
|
+
"delivery",
|
|
783
|
+
"view_policy",
|
|
784
|
+
"script",
|
|
785
|
+
"webhook_url",
|
|
786
|
+
"webhook_secret",
|
|
787
|
+
"allow_retry",
|
|
788
|
+
"max_attempts",
|
|
789
|
+
"processor_id",
|
|
790
|
+
"processor_config",
|
|
791
|
+
):
|
|
792
|
+
if values[field] != old[field]:
|
|
793
|
+
fail("修改发货、查看或重试配置需要配置发货权限", 403)
|
|
794
|
+
if field_schema(values["outputs"]) != field_schema(old["outputs"]):
|
|
795
|
+
fail("修改输出字段架构需要配置发货权限", 403)
|
|
796
|
+
try:
|
|
797
|
+
updated = Product.model_validate(values)
|
|
798
|
+
except ValueError:
|
|
799
|
+
fail("商品配置格式错误", 422)
|
|
800
|
+
save_product(c, pid, updated, management_actor(s))
|
|
801
|
+
return managed_product_view({"id": pid, **updated.model_dump()}, s)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
@app.get("/api/manage/cards")
|
|
805
|
+
def managed_cards(request: Request, product_id: str = "", limit: int = 100):
|
|
806
|
+
s = session(request, ("admin", "staff"))
|
|
807
|
+
with db() as c:
|
|
808
|
+
pid = management_scope(c, s, product_id, "cards.manage")
|
|
809
|
+
return [
|
|
810
|
+
dict(r)
|
|
811
|
+
for r in c.execute(
|
|
812
|
+
"SELECT id,product_id,state,created FROM cards WHERE product_id=? ORDER BY created DESC LIMIT ?",
|
|
813
|
+
(pid, max(1, min(limit, 500))),
|
|
814
|
+
)
|
|
815
|
+
]
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@app.post("/api/manage/cards")
|
|
819
|
+
def issue_managed_cards(body: IssueCards, request: Request):
|
|
820
|
+
s = session(request, ("admin", "staff"))
|
|
821
|
+
with db() as c:
|
|
822
|
+
pid = management_scope(c, s, body.product_id, "cards.manage")
|
|
823
|
+
codes = issue_cards(
|
|
824
|
+
c,
|
|
825
|
+
pid,
|
|
826
|
+
body.count,
|
|
827
|
+
label=body.label,
|
|
828
|
+
expires=body.expires,
|
|
829
|
+
variant_id=body.variant_id,
|
|
830
|
+
)
|
|
831
|
+
audit(c, management_actor(s), "cards.issue", f"{pid}:{body.count}")
|
|
832
|
+
return {"codes": codes, "batch_id": card_batch_id(c, codes)}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@app.post("/api/manage/cards/{cid}/revoke")
|
|
836
|
+
def revoke_managed_card(cid: str, request: Request, product_id: str = ""):
|
|
837
|
+
s = session(request, ("admin", "staff"))
|
|
838
|
+
with db() as c:
|
|
839
|
+
pid = management_scope(c, s, product_id, "cards.manage")
|
|
840
|
+
row = c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone()
|
|
841
|
+
if not row:
|
|
842
|
+
fail("卡密不存在", 404)
|
|
843
|
+
if row["product_id"] != pid:
|
|
844
|
+
fail("无权管理此商品的卡密", 403)
|
|
845
|
+
if row["state"] != "ready":
|
|
846
|
+
fail("只能撤销尚未兑换的卡密", 409)
|
|
847
|
+
c.execute("UPDATE cards SET state='revoked' WHERE id=?", (cid,))
|
|
848
|
+
c.execute("DELETE FROM grants WHERE card_id=?", (cid,))
|
|
849
|
+
audit(c, management_actor(s), "card.revoke", cid)
|
|
850
|
+
return {"ok": True}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
@app.get("/api/manage/events")
|
|
854
|
+
def managed_events(request: Request, product_id: str = "", limit: int = 100):
|
|
855
|
+
s = session(request, ("admin", "staff"))
|
|
856
|
+
with db() as c:
|
|
857
|
+
pid = management_scope(c, s, product_id, "events.manage")
|
|
858
|
+
return [
|
|
859
|
+
dict(r)
|
|
860
|
+
for r in c.execute(
|
|
861
|
+
"SELECT events.id,type,job_id,product_id,created,outbox.state AS webhook_state,attempts,error FROM events LEFT JOIN outbox ON outbox.id=events.id WHERE events.product_id=? ORDER BY created DESC LIMIT ?",
|
|
862
|
+
(pid, max(1, min(limit, 500))),
|
|
863
|
+
)
|
|
864
|
+
]
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
@app.post("/api/manage/events/{eid}/retry")
|
|
868
|
+
def retry_managed_event(eid: str, request: Request, product_id: str = ""):
|
|
869
|
+
s = session(request, ("admin", "staff"))
|
|
870
|
+
with db() as c:
|
|
871
|
+
pid = management_scope(c, s, product_id, "events.manage")
|
|
872
|
+
row = c.execute(
|
|
873
|
+
"SELECT events.product_id,outbox.state FROM events LEFT JOIN outbox ON outbox.id=events.id WHERE events.id=?",
|
|
874
|
+
(eid,),
|
|
875
|
+
).fetchone()
|
|
876
|
+
if not row:
|
|
877
|
+
fail("事件不存在", 404)
|
|
878
|
+
if row["product_id"] != pid:
|
|
879
|
+
fail("无权管理此商品的事件", 403)
|
|
880
|
+
if row["state"] != "dead":
|
|
881
|
+
fail("只能重试已停止投递的事件", 409)
|
|
882
|
+
c.execute(
|
|
883
|
+
"UPDATE outbox SET state='pending',attempts=0,due=? WHERE id=?",
|
|
884
|
+
(time.time(), eid),
|
|
885
|
+
)
|
|
886
|
+
audit(c, management_actor(s), "webhook.retry", eid)
|
|
887
|
+
return {"ok": True}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
@app.get("/api/manage/links")
|
|
891
|
+
def managed_links(request: Request, product_id: str = ""):
|
|
892
|
+
s = session(request, ("admin", "staff"))
|
|
893
|
+
with db() as c:
|
|
894
|
+
pid = management_scope(c, s, product_id, "links.delegate")
|
|
895
|
+
descendants = (
|
|
896
|
+
set(link_descendant_ids(c, s["staff_id"], include_self=False))
|
|
897
|
+
if s["role"] == "staff"
|
|
898
|
+
else None
|
|
899
|
+
)
|
|
900
|
+
return [
|
|
901
|
+
link_view(r)
|
|
902
|
+
for r in c.execute(
|
|
903
|
+
"SELECT * FROM staff WHERE product_id=? ORDER BY created", (pid,)
|
|
904
|
+
)
|
|
905
|
+
if descendants is None or r["id"] in descendants
|
|
906
|
+
]
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@app.post("/api/manage/links")
|
|
910
|
+
def delegate_link(body: ProductLinkInput, request: Request):
|
|
911
|
+
s = session(request, ("admin", "staff"))
|
|
912
|
+
with db() as c:
|
|
913
|
+
authorize_management(c, s, "links.delegate")
|
|
914
|
+
return create_product_link(c, body, s)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@app.post("/api/manage/links/{sid}/revoke")
|
|
918
|
+
def revoke_managed_link(sid: str, request: Request, product_id: str = ""):
|
|
919
|
+
s = session(request, ("admin", "staff"))
|
|
920
|
+
with db() as c:
|
|
921
|
+
pid = management_scope(c, s, product_id, "links.delegate")
|
|
922
|
+
if s["role"] == "staff" and sid not in link_descendant_ids(
|
|
923
|
+
c, s["staff_id"], include_self=False
|
|
924
|
+
):
|
|
925
|
+
fail("只能撤销自己创建的下级商品管理链接", 403)
|
|
926
|
+
row = c.execute("SELECT product_id FROM staff WHERE id=?", (sid,)).fetchone()
|
|
927
|
+
if not row:
|
|
928
|
+
fail("商品管理链接不存在", 404)
|
|
929
|
+
if row["product_id"] != pid:
|
|
930
|
+
fail("无权管理此商品的链接", 403)
|
|
931
|
+
return revoke_product_link(c, sid, management_actor(s))
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@app.get("/api/admin/events")
|
|
935
|
+
def events(request: Request):
|
|
936
|
+
session(request)
|
|
937
|
+
with db() as c:
|
|
938
|
+
return [
|
|
939
|
+
dict(r)
|
|
940
|
+
for r in c.execute(
|
|
941
|
+
"SELECT events.id,type,job_id,created,outbox.state AS webhook_state,attempts,error FROM events LEFT JOIN outbox ON outbox.id=events.id ORDER BY created DESC LIMIT 100"
|
|
942
|
+
)
|
|
943
|
+
]
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@app.post("/api/admin/events/{eid}/retry")
|
|
947
|
+
def retry_event(eid: str, request: Request):
|
|
948
|
+
session(request)
|
|
949
|
+
with db() as c:
|
|
950
|
+
row = c.execute("SELECT state FROM outbox WHERE id=?", (eid,)).fetchone()
|
|
951
|
+
if not row or row["state"] != "dead":
|
|
952
|
+
fail("只能重试已停止投递的事件", 409)
|
|
953
|
+
c.execute(
|
|
954
|
+
"UPDATE outbox SET state='pending',attempts=0,due=? WHERE id=?",
|
|
955
|
+
(time.time(), eid),
|
|
956
|
+
)
|
|
957
|
+
audit(c, "owner", "webhook.retry", eid)
|
|
958
|
+
return {"ok": True}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
@app.post("/api/callbacks/{pid}/{jid}")
|
|
962
|
+
async def callback(pid: str, jid: str, request: Request):
|
|
963
|
+
body = await request.body()
|
|
964
|
+
timestamp = request.headers.get("x-extore-timestamp", "")
|
|
965
|
+
nonce = request.headers.get("x-extore-nonce", "")
|
|
966
|
+
signature = request.headers.get("x-extore-signature", "")
|
|
967
|
+
with db() as c:
|
|
968
|
+
p = product(c, pid)
|
|
969
|
+
if p["mode"] != "webhook":
|
|
970
|
+
fail("此商品不支持回调", 403)
|
|
971
|
+
verify_signature(p["webhook_secret"], timestamp, nonce, body, signature)
|
|
972
|
+
if job(c, jid)["product_id"] != pid:
|
|
973
|
+
fail("任务不属于此商品", 403)
|
|
974
|
+
c.execute("DELETE FROM callback_nonces WHERE created<?", (time.time() - 600,))
|
|
975
|
+
nonce_key = digest(pid + ":" + nonce)
|
|
976
|
+
if c.execute(
|
|
977
|
+
"SELECT 1 FROM callback_nonces WHERE nonce=?", (nonce_key,)
|
|
978
|
+
).fetchone():
|
|
979
|
+
fail("回调重复", 409)
|
|
980
|
+
try:
|
|
981
|
+
update = JobUpdate.model_validate_json(body)
|
|
982
|
+
except ValueError:
|
|
983
|
+
fail("回调格式错误", 422)
|
|
984
|
+
result = job_view(c, apply_update(c, jid, update))
|
|
985
|
+
c.execute("INSERT INTO callback_nonces VALUES (?,?)", (nonce_key, time.time()))
|
|
986
|
+
return result
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
@app.post("/api/integrations/cards")
|
|
990
|
+
def platform_cards(body: IssueCards, request: Request):
|
|
991
|
+
rate_limit(request, "integration", 60, 60)
|
|
992
|
+
with db() as c:
|
|
993
|
+
key = request.headers.get("authorization", "").removeprefix("Bearer ")
|
|
994
|
+
expected = setting(c, "integration_key")
|
|
995
|
+
if not expected or not secrets.compare_digest(digest(key), expected):
|
|
996
|
+
fail("平台密钥无效", 401)
|
|
997
|
+
key = request.headers.get("idempotency-key", "")
|
|
998
|
+
if not 8 <= len(key) <= 200:
|
|
999
|
+
fail("请提供 8–200 字符的 Idempotency-Key", 400)
|
|
1000
|
+
# Default SKU requests retain the exact pre-SKU fingerprint, including
|
|
1001
|
+
# existing label/expiry metadata, so old idempotency keys remain valid.
|
|
1002
|
+
excluded = {"variant_id"} if body.variant_id == "default" else set()
|
|
1003
|
+
if not body.label and body.expires is None:
|
|
1004
|
+
excluded.update(("label", "expires"))
|
|
1005
|
+
fingerprint = digest(body.model_dump_json(exclude=excluded))
|
|
1006
|
+
request_key = digest(key)
|
|
1007
|
+
old = c.execute(
|
|
1008
|
+
"SELECT * FROM api_requests WHERE key=?", (request_key,)
|
|
1009
|
+
).fetchone()
|
|
1010
|
+
from cryptography.fernet import Fernet
|
|
1011
|
+
|
|
1012
|
+
from .config import DATA
|
|
1013
|
+
|
|
1014
|
+
cipher = Fernet((DATA / "issuance.key").read_bytes())
|
|
1015
|
+
if old:
|
|
1016
|
+
if old["fingerprint"] != fingerprint:
|
|
1017
|
+
fail("同一个幂等键不能用于不同请求", 409)
|
|
1018
|
+
return json.loads(cipher.decrypt(old["response"]))
|
|
1019
|
+
result = {
|
|
1020
|
+
"codes": issue_cards(
|
|
1021
|
+
c,
|
|
1022
|
+
body.product_id,
|
|
1023
|
+
body.count,
|
|
1024
|
+
label=body.label,
|
|
1025
|
+
expires=body.expires,
|
|
1026
|
+
variant_id=body.variant_id,
|
|
1027
|
+
)
|
|
1028
|
+
}
|
|
1029
|
+
c.execute(
|
|
1030
|
+
"INSERT INTO api_requests VALUES (?,?,?,?)",
|
|
1031
|
+
(
|
|
1032
|
+
request_key,
|
|
1033
|
+
fingerprint,
|
|
1034
|
+
cipher.encrypt(json.dumps(result).encode()),
|
|
1035
|
+
time.time(),
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
audit(c, "platform", "cards.issue", f"{body.product_id}:{body.count}")
|
|
1039
|
+
return result
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
STATIC = Path(__file__).parent / "static"
|
|
1043
|
+
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
@app.api_route("/", methods=["GET", "HEAD"])
|
|
1047
|
+
@app.api_route("/admin", methods=["GET", "HEAD"])
|
|
1048
|
+
@app.api_route("/staff", methods=["GET", "HEAD"])
|
|
1049
|
+
@app.api_route("/receipt", methods=["GET", "HEAD"])
|
|
1050
|
+
def index():
|
|
1051
|
+
return FileResponse(STATIC / "index.html")
|