ZHIWEI666 commited on
Commit
671970a
·
verified ·
1 Parent(s): 752f43e

Upload 11 files

Browse files
open_transaction.py CHANGED
@@ -1,47 +1,45 @@
1
- """开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
2
- import hmac
3
- import json
4
- from decimal import Decimal
5
-
6
- from fastapi import HTTPException
7
- from models_sql import RegisteredPlugin
8
- from tip_transaction import lock_wallet_accounts
9
-
10
-
11
- def validate_live_plugin(row, key, permission):
12
- if row is None or not row.enabled:
13
- raise HTTPException(status_code=403, detail="插件已停用或不存在")
14
- if not isinstance(row.plugin_key, str) or not isinstance(key, str) or not hmac.compare_digest(row.plugin_key.encode('utf-8'), key.encode('utf-8')):
15
- raise HTTPException(status_code=403, detail="Plugin Key 已失效")
16
- try:
17
- permissions = json.loads(row.permissions)
18
- if not isinstance(permissions, list) or any(not isinstance(value, str) for value in permissions):
19
- raise ValueError("invalid permissions")
20
- except Exception:
21
- raise HTTPException(status_code=503, detail="插件权限配置暂不可用")
22
- if permission not in permissions:
23
- raise HTTPException(status_code=403, detail="插件无权限调用该接口")
24
- return row
25
-
26
-
27
- def lock_open_wallet(session, account, plugin_id, key, permission):
28
- # 首项资金SQL取得SQLite写事务;其它方言先锁钱包再锁插件,所有开放资金路径同序。
29
- wallet = lock_wallet_accounts(session, (account,))[account]
30
- row = session.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).populate_existing().with_for_update().first()
31
- validate_live_plugin(row, key, permission)
32
- return wallet, row
33
-
34
-
35
- def positive_integer(value):
36
- if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
37
- raise HTTPException(status_code=503, detail="插件额度配置暂不可用")
38
- return value
39
-
40
-
41
- def quota_total(value):
42
- """SQL聚合可返回整数或精确Decimal;拒绝浮点/字符串/非整数与坏统计。"""
43
- if isinstance(value, Decimal) and value.is_finite() and value >= 0 and value == value.to_integral_value():
44
- return int(value)
45
- if isinstance(value, bool) or not isinstance(value, int) or value < 0:
46
- raise ValueError("invalid quota history")
47
- return value
 
1
+ """开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
2
+ import hmac
3
+ from decimal import Decimal
4
+
5
+ from fastapi import HTTPException
6
+ from models_sql import RegisteredPlugin
7
+ from plugin_permissions import parse_permissions
8
+ from tip_transaction import lock_wallet_accounts
9
+
10
+
11
+ def validate_live_plugin(row, key, permission):
12
+ if row is None or not row.enabled:
13
+ raise HTTPException(status_code=403, detail="插件已停用或不存在")
14
+ if not isinstance(row.plugin_key, str) or not isinstance(key, str) or not hmac.compare_digest(row.plugin_key.encode('utf-8'), key.encode('utf-8')):
15
+ raise HTTPException(status_code=403, detail="Plugin Key 已失效")
16
+ try:
17
+ permissions = parse_permissions(row.permissions)
18
+ except Exception:
19
+ raise HTTPException(status_code=503, detail="插件权限配置暂不可用")
20
+ if permission not in permissions:
21
+ raise HTTPException(status_code=403, detail="插件无权限调用该接口")
22
+ return row
23
+
24
+
25
+ def lock_open_wallet(session, account, plugin_id, key, permission):
26
+ # 首项资金SQL取得SQLite写事务;其它方言先锁钱包再锁插件,所有开放资金路径同序。
27
+ wallet = lock_wallet_accounts(session, (account,))[account]
28
+ row = session.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).populate_existing().with_for_update().first()
29
+ validate_live_plugin(row, key, permission)
30
+ return wallet, row
31
+
32
+
33
+ def positive_integer(value):
34
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
35
+ raise HTTPException(status_code=503, detail="插件额度配置暂不可用")
36
+ return value
37
+
38
+
39
+ def quota_total(value):
40
+ """SQL聚合可返回整数或精确Decimal;拒绝浮点/字符串/非整数与坏统计。"""
41
+ if isinstance(value, Decimal) and value.is_finite() and value >= 0 and value == value.to_integral_value():
42
+ return int(value)
43
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
44
+ raise ValueError("invalid quota history")
45
+ return value
 
 
plugin_permissions.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """插件权限存储的共用解析。"""
2
+ import json
3
+
4
+ DEFAULT_PERMISSIONS = ("verify", "balance", "deduct", "refund")
5
+
6
+
7
+ def parse_permissions(value):
8
+ try:
9
+ if not isinstance(value, str):
10
+ raise ValueError("invalid plugin permissions")
11
+ permissions = json.loads(value)
12
+ if not isinstance(permissions, list) or any(not isinstance(item, str) for item in permissions):
13
+ raise ValueError("invalid plugin permissions")
14
+ except (ValueError, TypeError):
15
+ raise ValueError("invalid plugin permissions") from None
16
+ return permissions
purchase_transaction.py CHANGED
@@ -12,6 +12,8 @@ def effective_resource_price(item):
12
  now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
13
  if now >= when:
14
  price = pending
 
 
15
  if isinstance(price, bool) or not isinstance(price, int) or price < 0:
16
  raise ValueError("invalid resource price")
17
  return price
 
12
  now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
13
  if now >= when:
14
  price = pending
15
+ if type(price) is float and price.is_integer() and 0 <= price <= 2147483647:
16
+ price = int(price)
17
  if isinstance(price, bool) or not isinstance(price, int) or price < 0:
18
  raise ValueError("invalid resource price")
19
  return price
router_admin_plugins.py CHANGED
@@ -28,6 +28,7 @@ from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydant
28
 
29
  from database_sql import get_db
30
  from models_sql import RegisteredPlugin
 
31
  from 安全认证 import require_auth, is_admin, JWT_SECRET
32
 
33
  logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
@@ -41,10 +42,6 @@ router = APIRouter()
41
  # plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
42
  _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
43
 
44
- # 默认权限列表
45
- _DEFAULT_PERMISSIONS = ["verify", "balance", "deduct", "refund"]
46
-
47
-
48
  def _audit_actor_id(account: str) -> str:
49
  """稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
50
  return hmac.new(
@@ -90,9 +87,11 @@ def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: O
90
  raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
91
  """
92
  try:
93
- permissions = json.loads(p.permissions) if p.permissions else _DEFAULT_PERMISSIONS
94
- except Exception:
95
- permissions = _DEFAULT_PERMISSIONS
 
 
96
 
97
  data = {
98
  "plugin_id": p.plugin_id,
@@ -103,6 +102,7 @@ def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: O
103
  "max_deduct_per_tx": p.max_deduct_per_tx,
104
  "max_deduct_per_day": p.max_deduct_per_day,
105
  "permissions": permissions,
 
106
  "created_at": p.created_at.isoformat() if p.created_at else None,
107
  "created_by": p.created_by,
108
  "updated_at": p.updated_at.isoformat() if p.updated_at else None,
@@ -128,7 +128,7 @@ class PluginCreateRequest(BaseModel):
128
  rate_limit: Optional[int] = Field(100, ge=1, le=10000)
129
  max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
130
  max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
131
- permissions: Optional[List[str]] = Field(default=None)
132
 
133
  @field_validator("plugin_id")
134
  @classmethod
@@ -137,12 +137,11 @@ class PluginCreateRequest(BaseModel):
137
  raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
138
  return v
139
 
140
- # 🔧 pydantic v2:field_validator 默认对默认值也生效,无需 v1 的 always=True
141
  @field_validator("permissions")
142
  @classmethod
143
  def _validate_permissions(cls, v):
144
  if v is None:
145
- return _DEFAULT_PERMISSIONS
146
  if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
147
  raise ValueError("permissions 必须为字符串数组")
148
  return v
 
28
 
29
  from database_sql import get_db
30
  from models_sql import RegisteredPlugin
31
+ from plugin_permissions import DEFAULT_PERMISSIONS, parse_permissions
32
  from 安全认证 import require_auth, is_admin, JWT_SECRET
33
 
34
  logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
 
42
  # plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
43
  _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
44
 
 
 
 
 
45
  def _audit_actor_id(account: str) -> str:
46
  """稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
47
  return hmac.new(
 
87
  raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
88
  """
89
  try:
90
+ permissions = parse_permissions(p.permissions)
91
+ permissions_valid = True
92
+ except ValueError:
93
+ permissions = []
94
+ permissions_valid = False
95
 
96
  data = {
97
  "plugin_id": p.plugin_id,
 
102
  "max_deduct_per_tx": p.max_deduct_per_tx,
103
  "max_deduct_per_day": p.max_deduct_per_day,
104
  "permissions": permissions,
105
+ "permissions_valid": permissions_valid,
106
  "created_at": p.created_at.isoformat() if p.created_at else None,
107
  "created_by": p.created_by,
108
  "updated_at": p.updated_at.isoformat() if p.updated_at else None,
 
128
  rate_limit: Optional[int] = Field(100, ge=1, le=10000)
129
  max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
130
  max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
131
+ permissions: Optional[List[str]] = Field(default_factory=lambda: list(DEFAULT_PERMISSIONS))
132
 
133
  @field_validator("plugin_id")
134
  @classmethod
 
137
  raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
138
  return v
139
 
 
140
  @field_validator("permissions")
141
  @classmethod
142
  def _validate_permissions(cls, v):
143
  if v is None:
144
+ return list(DEFAULT_PERMISSIONS)
145
  if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
146
  raise ValueError("permissions 必须为字符串数组")
147
  return v
router_items.py CHANGED
@@ -42,7 +42,7 @@ def _apply_effective_price(item: dict) -> None:
42
  price = effective_resource_price(item)
43
  pending = item.get('pending_price')
44
  effective = item.get('pending_price_effective_at')
45
- if type(pending) is int and pending == price and effective:
46
  when = datetime.datetime.fromisoformat(effective)
47
  now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
48
  if now >= when:
 
42
  price = effective_resource_price(item)
43
  pending = item.get('pending_price')
44
  effective = item.get('pending_price_effective_at')
45
+ if type(pending) in (int, float) and pending == price and effective:
46
  when = datetime.datetime.fromisoformat(effective)
47
  now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
48
  if now >= when:
router_open_api.py CHANGED
@@ -34,6 +34,7 @@ from pydantic import BaseModel, Field
34
  from database_sql import get_db, SessionLocal
35
  from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
36
  from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
 
37
  from tip_store import run_tip_effect
38
  from 安全认证 import verify_token_with_fallback
39
  import 数据库连接 as json_db
@@ -87,9 +88,10 @@ def load_plugins_from_db():
87
  logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
88
  continue
89
  try:
90
- perms = json.loads(p.permissions) if p.permissions else ["verify", "balance", "deduct", "refund"]
91
- except Exception:
92
- perms = ["verify", "balance", "deduct", "refund"]
 
93
  new_index[p.plugin_key] = {
94
  "id": p.plugin_id,
95
  "key": p.plugin_key,
 
34
  from database_sql import get_db, SessionLocal
35
  from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
36
  from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
37
+ from plugin_permissions import parse_permissions
38
  from tip_store import run_tip_effect
39
  from 安全认证 import verify_token_with_fallback
40
  import 数据库连接 as json_db
 
88
  logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
89
  continue
90
  try:
91
+ perms = parse_permissions(p.permissions)
92
+ except ValueError:
93
+ logger.warning("OPEN_API_PLUGIN_PERMISSIONS_INVALID")
94
+ continue
95
  new_index[p.plugin_key] = {
96
  "id": p.plugin_id,
97
  "key": p.plugin_key,
task_read.py CHANGED
@@ -1,214 +1,216 @@
1
- """任务页面只读快照:合并持久状态,不改钱包、JSON或投影确认。"""
2
- import copy
3
- import json
4
- from collections import defaultdict
5
- from sqlalchemy import func
6
- from models_sql import (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
7
- TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement,
8
- TaskResolution, Transaction)
9
- from task_terms import validate_patch, funded_amount
10
- from task_delivery import delivery_patch
11
- from task_dispute import dispute_payload
12
- from task_resolution import distribution
13
-
14
-
15
- MODELS = (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
16
- TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, TaskResolution)
17
- CLOSING = {'TASK_PAYMENT', 'TASK_INCOME', 'TASK_REFUND', 'TASK_CANCEL_REFUND'}
18
-
19
-
20
- def ledger(rows, identifier, kind, account, related, amount):
21
- matches = [r for r in rows if r.tx_id == identifier]
22
- if (len(matches) != 1 or matches[0].tx_type != kind or matches[0].account != account
23
- or matches[0].related_account != related or type(matches[0].amount) is not int
24
- or matches[0].amount != amount):
25
- raise ValueError('task closing ledger mismatch')
26
-
27
-
28
- def merged_task(current, records, funds):
29
- publication, revision, assignment, delivery, dispute, response, settlement, resolution = records
30
- if current is None and publication is None:
31
- raise ValueError('original task missing')
32
- original = json.loads(publication.payload) if publication is not None else None
33
- if publication is not None:
34
- if (not isinstance(original, dict) or original.get('id') != publication.task_id
35
- or original.get('publisher') != publication.publisher):
36
- raise ValueError('invalid task publication')
37
- freezes = [r for r in funds if r.tx_type == 'TASK_FREEZE']
38
- if (len(freezes) != 1 or freezes[0].account != publication.publisher
39
- or type(original.get('total_price')) is not int or original['total_price'] < 0
40
- or type(freezes[0].amount) is not int or freezes[0].amount != -original['total_price']):
41
- raise ValueError('invalid original task funding')
42
- result = copy.deepcopy(current if current is not None else original)
43
- publisher = publication.publisher if publication is not None else result.get('publisher')
44
- if not isinstance(publisher, str) or not publisher.strip() or result.get('publisher') != publisher:
45
- raise ValueError('task publisher mismatch')
46
- identifier = result.get('id')
47
- for row in records:
48
- if row is not None and (row.task_id != identifier or row.publisher != publisher):
49
- raise ValueError('durable task identity mismatch')
50
- if original is not None:
51
- for key in ('title', 'description', 'reference_images', 'reference_link', 'total_price',
52
- 'frozen_amount', 'deposit_ratio', 'deposit_amount', 'deadline', 'created_at'):
53
- if key in original:
54
- result[key] = copy.deepcopy(original[key])
55
- if revision is not None:
56
- if type(revision.revision) is not int or revision.revision < 1:
57
- raise ValueError('invalid task revision')
58
- patch = json.loads(revision.payload)
59
- validate_patch(patch)
60
- result.update(copy.deepcopy(patch))
61
- durable = any(r is not None for r in records)
62
- amount = funded_amount(funds, publisher) if durable or funds else None
63
- if amount is not None and (type(result.get('total_price')) is not int or result['total_price'] != amount):
64
- raise ValueError('task price differs from funding')
65
- # 持久指派优先于尚未同步的空承接人;已有不同承接人不能被悄悄覆盖。
66
- participants = [r.assignee for r in (assignment, delivery, dispute, response, settlement, resolution) if r is not None]
67
- assignee = participants[0] if participants else result.get('assignee')
68
- if participants and (any(v != assignee for v in participants) or not isinstance(assignee, str)
69
- or not assignee.strip() or assignee == publisher or result.get('assignee') not in (None, assignee)):
70
- raise ValueError('task assignee mismatch')
71
- deposits = [r for r in funds if r.tx_type == 'TASK_DEPOSIT']
72
- if participants or deposits:
73
- deposit = result.get('deposit_amount')
74
- if (len(deposits) != 1 or type(deposit) is not int or amount is None or not 0 <= deposit <= amount
75
- or deposits[0].account != publisher or deposits[0].related_account != assignee
76
- or type(deposits[0].amount) is not int or deposits[0].amount != -deposit):
77
- raise ValueError('task deposit mismatch')
78
- result.update(assignee=assignee, deposit_paid=deposit)
79
- if assignment is not None:
80
- if type(assignment.deposit) is not int or assignment.deposit != deposit or type(assignment.assigned_at) is not int:
81
- raise ValueError('invalid assignment')
82
- result['assigned_at'] = assignment.assigned_at
83
- if result.get('status') == 'open':
84
- result['status'] = 'in_progress'
85
- if delivery is not None:
86
- patch = copy.deepcopy(delivery_patch(delivery, result))
87
- if result.get('status') in {'completed', 'disputed', 'cancelled', 'expired'}:
88
- patch.pop('status')
89
- patch.pop('feedback')
90
- result.update(patch)
91
- dispute_id = None
92
- if dispute is not None:
93
- dispute_payload(dispute) # 核实私有原始记录,不将理由/证据输出至公开任务。
94
- dispute_id = dispute.dispute_id
95
- if response is not None:
96
- payload = json.loads(response.payload)
97
- from task_dispute_response import response_payload, ORIGINAL_FIELDS
98
- if not isinstance(payload, dict):
99
- raise ValueError('invalid dispute response')
100
- private = dispute_payload(dispute) if dispute is not None else dict(
101
- id=response.dispute_id, task_id=identifier, publisher=publisher, assignee=assignee,
102
- **{k: payload.get(k) for k in ORIGINAL_FIELDS})
103
- response_payload(response, private, result)
104
- if dispute_id is not None and dispute_id != response.dispute_id:
105
- raise ValueError('dispute linkage mismatch')
106
- dispute_id = response.dispute_id
107
- if dispute_id is not None:
108
- if result.get('dispute_id') not in (None, dispute_id):
109
- raise ValueError('task dispute differs from sql')
110
- result.update(status='disputed', dispute_id=dispute_id)
111
- closed = [r for r in funds if r.tx_type in CLOSING]
112
- if settlement is not None:
113
- if resolution is not None or dispute_id is not None:
114
- raise ValueError('conflicting task closures')
115
- if (type(settlement.amount) is not int or settlement.amount != amount
116
- or type(settlement.deposit) is not int or settlement.deposit != deposit or len(closed) != 2):
117
- raise ValueError('invalid task settlement')
118
- ledger(closed, settlement.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
119
- ledger(closed, settlement.income_tx_id, 'TASK_INCOME', assignee, publisher, amount)
120
- result.update(status='completed', completed_at=settlement.completed_at, feedback=settlement.feedback)
121
- elif resolution is not None:
122
- if (dispute_id is not None and resolution.dispute_id != dispute_id
123
- or result.get('dispute_id') not in (None, resolution.dispute_id)
124
- or type(resolution.amount) is not int or resolution.amount != amount
125
- or type(resolution.deposit) is not int or resolution.deposit != deposit or len(closed) != 3
126
- or distribution(amount, resolution.initiator_role, resolution.resolution, resolution.ratio)
127
- != (resolution.publisher_refund, resolution.assignee_income)):
128
- raise ValueError('invalid task resolution')
129
- original_role = dispute_payload(dispute)['initiator_role'] if dispute is not None else (
130
- json.loads(response.payload)['initiator_role'] if response is not None else resolution.initiator_role)
131
- if original_role != resolution.initiator_role:
132
- raise ValueError('resolution role mismatch')
133
- ledger(closed, resolution.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
134
- ledger(closed, resolution.refund_tx_id, 'TASK_REFUND', publisher, assignee, resolution.publisher_refund)
135
- ledger(closed, resolution.income_tx_id, 'TASK_INCOME', assignee, publisher, resolution.assignee_income)
136
- result.update(status='completed', completed_at=resolution.resolved_at, dispute_id=resolution.dispute_id,
137
- feedback=f"申诉裁决:{resolution.note or '无'}(结果:{resolution.resolution})")
138
- elif closed:
139
- # 开放任务退款无独立状态表,以唯一完整退款流水为事实。
140
- if len(closed) == 1 and not deposits and not participants and closed[0].tx_type in {'TASK_REFUND', 'TASK_CANCEL_REFUND'}:
141
- row = closed[0]
142
- ledger(closed, row.tx_id, row.tx_type, publisher, None, amount)
143
- status = 'cancelled' if row.tx_type == 'TASK_CANCEL_REFUND' else 'expired'
144
- result.update(status=status, refunded=True, refund_amount=amount)
145
- elif (not durable and result.get('status') == 'completed' and len(closed) == 2
146
- and len([r for r in closed if r.tx_type == 'TASK_PAYMENT']) == 1
147
- and len([r for r in closed if r.tx_type == 'TASK_INCOME']) == 1):
148
- # 旧已完成任务可以按有效双流水读取,不生成猜测的完成时间或反馈。
149
- payment = next(r for r in closed if r.tx_type == 'TASK_PAYMENT')
150
- income = next(r for r in closed if r.tx_type == 'TASK_INCOME')
151
- ledger(closed, payment.tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
152
- ledger(closed, income.tx_id, 'TASK_INCOME', assignee, publisher, amount)
153
- else:
154
- raise ValueError('closing task lacks matching durable state')
155
- elif durable and result.get('status') in {'completed', 'cancelled', 'expired'}:
156
- raise ValueError('closed task lacks closing funds')
157
- applicants = result.get('applicants', [])
158
- if result.get('status') not in {'open', 'in_progress', 'submitted', 'disputed', 'completed', 'cancelled', 'expired'}:
159
- raise ValueError('invalid task status')
160
- if (not isinstance(applicants, list) or any(not isinstance(a, dict) or not isinstance(a.get('account'), str)
161
- or not a['account'].strip() for a in applicants)
162
- or len({a['account'] for a in applicants}) != len(applicants)):
163
- raise ValueError('invalid task applicants')
164
- return result
165
-
166
-
167
- def merge_task_source(session, source, task_id=None):
168
- """合并调用方已经严格读取的快照;不保存或复用跨轮次缓存。"""
169
- if not isinstance(source, list):
170
- raise ValueError('invalid task source')
171
- current = {}
172
- for task in source:
173
- if not isinstance(task, dict) or not isinstance(task.get('id'), str) or not task['id'] or task['id'] in current:
174
- raise ValueError('invalid or duplicate task')
175
- current[task['id']] = task
176
- identifiers = set(current) if task_id is None else ({task_id} if task_id in current else set())
177
- maps = []
178
- for model in MODELS:
179
- query = session.query(model)
180
- if model is TaskRevision:
181
- latest = session.query(TaskRevision.task_id, func.max(TaskRevision.revision).label('version'))
182
- if task_id is not None:
183
- latest = latest.filter(TaskRevision.task_id == task_id)
184
- latest = latest.group_by(TaskRevision.task_id).subquery()
185
- query = query.join(latest, (TaskRevision.task_id == latest.c.task_id)
186
- & (TaskRevision.revision == latest.c.version))
187
- if task_id is not None:
188
- query = query.filter(model.task_id == task_id)
189
- rows = query.populate_existing().all()
190
- records = {}
191
- for row in rows:
192
- if model is TaskRevision:
193
- prior = records.get(row.task_id)
194
- if prior is not None and prior.revision > row.revision:
195
- continue
196
- elif row.task_id in records:
197
- raise ValueError('duplicate durable task state')
198
- records[row.task_id] = row
199
- identifiers.add(row.task_id)
200
- maps.append(records)
201
- query = session.query(Transaction).filter(Transaction.tx_type.in_(
202
- ['TASK_FREEZE', 'TASK_PRICE_ADJUST', 'TASK_DEPOSIT', *CLOSING]))
203
- if task_id is not None:
204
- query = query.filter(Transaction.item_id == task_id)
205
- funds = defaultdict(list)
206
- for row in query.populate_existing().all():
207
- funds[row.item_id].append(row)
208
- return [merged_task(current.get(identifier), tuple(m.get(identifier) for m in maps), funds[identifier])
209
- for identifier in sorted(identifiers)]
210
-
211
-
212
- def read_tasks(session, store, task_id=None):
213
- source = store.load_data('tasks.json', default_data=[], strict=True, skip_cache=True)
214
- return merge_task_source(session, source, task_id)
 
 
 
1
+ """任务页面只读快照:合并持久状态,不改钱包、JSON或投影确认。"""
2
+ import copy
3
+ import json
4
+ from collections import defaultdict
5
+ from sqlalchemy import func
6
+ from models_sql import (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
7
+ TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement,
8
+ TaskResolution, Transaction)
9
+ from task_terms import validate_patch, funded_amount
10
+ from task_delivery import delivery_patch
11
+ from task_dispute import dispute_payload
12
+ from task_resolution import distribution
13
+
14
+
15
+ MODELS = (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery,
16
+ TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, TaskResolution)
17
+ CLOSING = {'TASK_PAYMENT', 'TASK_INCOME', 'TASK_REFUND', 'TASK_CANCEL_REFUND'}
18
+
19
+
20
+ def ledger(rows, identifier, kind, account, related, amount):
21
+ matches = [r for r in rows if r.tx_id == identifier]
22
+ if (len(matches) != 1 or matches[0].tx_type != kind or matches[0].account != account
23
+ or matches[0].related_account != related or type(matches[0].amount) is not int
24
+ or matches[0].amount != amount):
25
+ raise ValueError('task closing ledger mismatch')
26
+
27
+
28
+ def merged_task(current, records, funds):
29
+ publication, revision, assignment, delivery, dispute, response, settlement, resolution = records
30
+ if current is None and publication is None:
31
+ raise ValueError('original task missing')
32
+ original = json.loads(publication.payload) if publication is not None else None
33
+ if publication is not None:
34
+ if (not isinstance(original, dict) or original.get('id') != publication.task_id
35
+ or original.get('publisher') != publication.publisher):
36
+ raise ValueError('invalid task publication')
37
+ freezes = [r for r in funds if r.tx_type == 'TASK_FREEZE']
38
+ if (len(freezes) != 1 or freezes[0].account != publication.publisher
39
+ or type(original.get('total_price')) is not int or original['total_price'] < 0
40
+ or type(freezes[0].amount) is not int or freezes[0].amount != -original['total_price']):
41
+ raise ValueError('invalid original task funding')
42
+ result = copy.deepcopy(current if current is not None else original)
43
+ publisher = publication.publisher if publication is not None else result.get('publisher')
44
+ if not isinstance(publisher, str) or not publisher.strip() or result.get('publisher') != publisher:
45
+ raise ValueError('task publisher mismatch')
46
+ identifier = result.get('id')
47
+ for row in records:
48
+ if row is not None and (row.task_id != identifier or row.publisher != publisher):
49
+ raise ValueError('durable task identity mismatch')
50
+ if original is not None:
51
+ for key in ('title', 'description', 'reference_images', 'reference_link', 'total_price',
52
+ 'frozen_amount', 'deposit_ratio', 'deposit_amount', 'deadline', 'created_at'):
53
+ if key in original:
54
+ result[key] = copy.deepcopy(original[key])
55
+ if revision is not None:
56
+ if type(revision.revision) is not int or revision.revision < 1:
57
+ raise ValueError('invalid task revision')
58
+ patch = json.loads(revision.payload)
59
+ validate_patch(patch)
60
+ result.update(copy.deepcopy(patch))
61
+ durable = any(r is not None for r in records)
62
+ amount = funded_amount(funds, publisher) if durable or funds else None
63
+ if amount is not None and (type(result.get('total_price')) is not int or result['total_price'] != amount):
64
+ raise ValueError('task price differs from funding')
65
+ # 持久指派优先于尚未同步的空承接人;已有不同承接人不能被悄悄覆盖。
66
+ participants = [r.assignee for r in (assignment, delivery, dispute, response, settlement, resolution) if r is not None]
67
+ assignee = participants[0] if participants else result.get('assignee')
68
+ if participants and (any(v != assignee for v in participants) or not isinstance(assignee, str)
69
+ or not assignee.strip() or assignee == publisher or result.get('assignee') not in (None, assignee)):
70
+ raise ValueError('task assignee mismatch')
71
+ deposits = [r for r in funds if r.tx_type == 'TASK_DEPOSIT']
72
+ if participants or deposits:
73
+ deposit = result.get('deposit_amount')
74
+ if type(deposit) is float and deposit.is_integer() and 0 <= deposit <= 2147483647:
75
+ deposit = int(deposit)
76
+ if (len(deposits) != 1 or type(deposit) is not int or amount is None or not 0 <= deposit <= amount
77
+ or deposits[0].account != publisher or deposits[0].related_account != assignee
78
+ or type(deposits[0].amount) is not int or deposits[0].amount != -deposit):
79
+ raise ValueError('task deposit mismatch')
80
+ result.update(assignee=assignee, deposit_amount=deposit, deposit_paid=deposit)
81
+ if assignment is not None:
82
+ if type(assignment.deposit) is not int or assignment.deposit != deposit or type(assignment.assigned_at) is not int:
83
+ raise ValueError('invalid assignment')
84
+ result['assigned_at'] = assignment.assigned_at
85
+ if result.get('status') == 'open':
86
+ result['status'] = 'in_progress'
87
+ if delivery is not None:
88
+ patch = copy.deepcopy(delivery_patch(delivery, result))
89
+ if result.get('status') in {'completed', 'disputed', 'cancelled', 'expired'}:
90
+ patch.pop('status')
91
+ patch.pop('feedback')
92
+ result.update(patch)
93
+ dispute_id = None
94
+ if dispute is not None:
95
+ dispute_payload(dispute) # 核实私有原始记录,不将理由/证据输出至公开任务。
96
+ dispute_id = dispute.dispute_id
97
+ if response is not None:
98
+ payload = json.loads(response.payload)
99
+ from task_dispute_response import response_payload, ORIGINAL_FIELDS
100
+ if not isinstance(payload, dict):
101
+ raise ValueError('invalid dispute response')
102
+ private = dispute_payload(dispute) if dispute is not None else dict(
103
+ id=response.dispute_id, task_id=identifier, publisher=publisher, assignee=assignee,
104
+ **{k: payload.get(k) for k in ORIGINAL_FIELDS})
105
+ response_payload(response, private, result)
106
+ if dispute_id is not None and dispute_id != response.dispute_id:
107
+ raise ValueError('dispute linkage mismatch')
108
+ dispute_id = response.dispute_id
109
+ if dispute_id is not None:
110
+ if result.get('dispute_id') not in (None, dispute_id):
111
+ raise ValueError('task dispute differs from sql')
112
+ result.update(status='disputed', dispute_id=dispute_id)
113
+ closed = [r for r in funds if r.tx_type in CLOSING]
114
+ if settlement is not None:
115
+ if resolution is not None or dispute_id is not None:
116
+ raise ValueError('conflicting task closures')
117
+ if (type(settlement.amount) is not int or settlement.amount != amount
118
+ or type(settlement.deposit) is not int or settlement.deposit != deposit or len(closed) != 2):
119
+ raise ValueError('invalid task settlement')
120
+ ledger(closed, settlement.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
121
+ ledger(closed, settlement.income_tx_id, 'TASK_INCOME', assignee, publisher, amount)
122
+ result.update(status='completed', completed_at=settlement.completed_at, feedback=settlement.feedback)
123
+ elif resolution is not None:
124
+ if (dispute_id is not None and resolution.dispute_id != dispute_id
125
+ or result.get('dispute_id') not in (None, resolution.dispute_id)
126
+ or type(resolution.amount) is not int or resolution.amount != amount
127
+ or type(resolution.deposit) is not int or resolution.deposit != deposit or len(closed) != 3
128
+ or distribution(amount, resolution.initiator_role, resolution.resolution, resolution.ratio)
129
+ != (resolution.publisher_refund, resolution.assignee_income)):
130
+ raise ValueError('invalid task resolution')
131
+ original_role = dispute_payload(dispute)['initiator_role'] if dispute is not None else (
132
+ json.loads(response.payload)['initiator_role'] if response is not None else resolution.initiator_role)
133
+ if original_role != resolution.initiator_role:
134
+ raise ValueError('resolution role mismatch')
135
+ ledger(closed, resolution.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
136
+ ledger(closed, resolution.refund_tx_id, 'TASK_REFUND', publisher, assignee, resolution.publisher_refund)
137
+ ledger(closed, resolution.income_tx_id, 'TASK_INCOME', assignee, publisher, resolution.assignee_income)
138
+ result.update(status='completed', completed_at=resolution.resolved_at, dispute_id=resolution.dispute_id,
139
+ feedback=f"申诉裁决:{resolution.note or '无'}(结果:{resolution.resolution})")
140
+ elif closed:
141
+ # 开放任务退款无独立状态表,以唯一完整退款流水为事实。
142
+ if len(closed) == 1 and not deposits and not participants and closed[0].tx_type in {'TASK_REFUND', 'TASK_CANCEL_REFUND'}:
143
+ row = closed[0]
144
+ ledger(closed, row.tx_id, row.tx_type, publisher, None, amount)
145
+ status = 'cancelled' if row.tx_type == 'TASK_CANCEL_REFUND' else 'expired'
146
+ result.update(status=status, refunded=True, refund_amount=amount)
147
+ elif (not durable and result.get('status') == 'completed' and len(closed) == 2
148
+ and len([r for r in closed if r.tx_type == 'TASK_PAYMENT']) == 1
149
+ and len([r for r in closed if r.tx_type == 'TASK_INCOME']) == 1):
150
+ # 旧已完成任务可以按有效双流水读取,不生成猜测的完成时间或反馈。
151
+ payment = next(r for r in closed if r.tx_type == 'TASK_PAYMENT')
152
+ income = next(r for r in closed if r.tx_type == 'TASK_INCOME')
153
+ ledger(closed, payment.tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit))
154
+ ledger(closed, income.tx_id, 'TASK_INCOME', assignee, publisher, amount)
155
+ else:
156
+ raise ValueError('closing task lacks matching durable state')
157
+ elif durable and result.get('status') in {'completed', 'cancelled', 'expired'}:
158
+ raise ValueError('closed task lacks closing funds')
159
+ applicants = result.get('applicants', [])
160
+ if result.get('status') not in {'open', 'in_progress', 'submitted', 'disputed', 'completed', 'cancelled', 'expired'}:
161
+ raise ValueError('invalid task status')
162
+ if (not isinstance(applicants, list) or any(not isinstance(a, dict) or not isinstance(a.get('account'), str)
163
+ or not a['account'].strip() for a in applicants)
164
+ or len({a['account'] for a in applicants}) != len(applicants)):
165
+ raise ValueError('invalid task applicants')
166
+ return result
167
+
168
+
169
+ def merge_task_source(session, source, task_id=None):
170
+ """合并调用方已经严格读取的快照;不保存或复用跨轮次缓存。"""
171
+ if not isinstance(source, list):
172
+ raise ValueError('invalid task source')
173
+ current = {}
174
+ for task in source:
175
+ if not isinstance(task, dict) or not isinstance(task.get('id'), str) or not task['id'] or task['id'] in current:
176
+ raise ValueError('invalid or duplicate task')
177
+ current[task['id']] = task
178
+ identifiers = set(current) if task_id is None else ({task_id} if task_id in current else set())
179
+ maps = []
180
+ for model in MODELS:
181
+ query = session.query(model)
182
+ if model is TaskRevision:
183
+ latest = session.query(TaskRevision.task_id, func.max(TaskRevision.revision).label('version'))
184
+ if task_id is not None:
185
+ latest = latest.filter(TaskRevision.task_id == task_id)
186
+ latest = latest.group_by(TaskRevision.task_id).subquery()
187
+ query = query.join(latest, (TaskRevision.task_id == latest.c.task_id)
188
+ & (TaskRevision.revision == latest.c.version))
189
+ if task_id is not None:
190
+ query = query.filter(model.task_id == task_id)
191
+ rows = query.populate_existing().all()
192
+ records = {}
193
+ for row in rows:
194
+ if model is TaskRevision:
195
+ prior = records.get(row.task_id)
196
+ if prior is not None and prior.revision > row.revision:
197
+ continue
198
+ elif row.task_id in records:
199
+ raise ValueError('duplicate durable task state')
200
+ records[row.task_id] = row
201
+ identifiers.add(row.task_id)
202
+ maps.append(records)
203
+ query = session.query(Transaction).filter(Transaction.tx_type.in_(
204
+ ['TASK_FREEZE', 'TASK_PRICE_ADJUST', 'TASK_DEPOSIT', *CLOSING]))
205
+ if task_id is not None:
206
+ query = query.filter(Transaction.item_id == task_id)
207
+ funds = defaultdict(list)
208
+ for row in query.populate_existing().all():
209
+ funds[row.item_id].append(row)
210
+ return [merged_task(current.get(identifier), tuple(m.get(identifier) for m in maps), funds[identifier])
211
+ for identifier in sorted(identifiers)]
212
+
213
+
214
+ def read_tasks(session, store, task_id=None):
215
+ source = store.load_data('tasks.json', default_data=[], strict=True, skip_cache=True)
216
+ return merge_task_source(session, source, task_id)
管理界面/app.js CHANGED
@@ -9,6 +9,84 @@
9
  // 新增反馈集中在双语词典;管理页语言遵循文档lang。
10
  const ADMIN_MESSAGES = {
11
  zh: {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  broadcast_subtitle: '发布全站系统公告,管理顶部广告横幅展示。',
13
  announcement_title: '📢 全站系统公告', announcement_content: '公告内容',
14
  announcement_placeholder: '输入要向全体用户推送的公告内容…', announcement_publish: '🚀 确认发布',
@@ -165,6 +243,8 @@
165
  config_read_unavailable: '无法确认当前配置,请稍后重新读取。',
166
  session_changed: '登录状态已变化,请在当前会话重新操作。',
167
  network_unavailable: '网络异常,请检查连接后重试。',
 
 
168
  auth_expired: '登录已失效,请重新登录。',
169
  admin_verification_unavailable: '无法确认管理员权限,请稍后重新登录。',
170
  admin_data_unavailable: '无法确认返回的数据,请稍后重新读取。',
@@ -172,6 +252,84 @@
172
  banner_active: '广告展示中', banner_inactive: '无活跃广告', banner_unknown: '状态未知',
173
  },
174
  en: {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  broadcast_subtitle: 'Publish announcements for all users and manage the top advertisement banner.',
176
  announcement_title: '📢 Site announcements', announcement_content: 'Announcement content',
177
  announcement_placeholder: 'Enter an announcement for all users…', announcement_publish: '🚀 Publish announcement',
@@ -328,6 +486,8 @@
328
  config_read_unavailable: 'The current configuration could not be confirmed. Reload it later.',
329
  session_changed: 'The session has changed. Continue in the current session.',
330
  network_unavailable: 'Network unavailable. Check your connection before retrying.',
 
 
331
  auth_expired: 'Your session has expired. Sign in again.',
332
  admin_verification_unavailable: 'Administrator access could not be confirmed. Sign in again later.',
333
  admin_data_unavailable: 'The returned data could not be confirmed. Reload it later.',
@@ -347,10 +507,10 @@
347
  const USER_KEY = 'ranking_admin_user';
348
  const ALL_PERMS = ['verify', 'balance', 'deduct', 'refund'];
349
  const PERM_LABELS = {
350
- verify: '验证 Key',
351
- balance: '查询余额',
352
- deduct: '扣除积分',
353
- refund: '退款回滚'
354
  };
355
 
356
  // -------------------- 状态 --------------------
@@ -769,6 +929,10 @@
769
 
770
  // -------------------- API 客户端 --------------------
771
  let adminSessionGeneration = 0;
 
 
 
 
772
  const adminRequests = new Set();
773
  function revokeAdminRequests() {
774
  for (const controller of Array.from(adminRequests)) {
@@ -786,18 +950,72 @@
786
  throw error;
787
  }
788
  }
789
- async function performAdminRequest(path, options) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  const session = captureAdminSession();
791
  const controller = new AbortController();
792
  const headers = { Accept: 'application/json', ...options.headers };
793
  if (session.token) headers.Authorization = 'Bearer ' + session.token;
794
  adminRequests.add(controller);
 
 
 
 
 
 
 
795
  try {
796
  let response;
797
  try {
798
- response = await fetch(API_BASE + path, { ...options, headers, signal: controller.signal });
799
  } catch (err) {
800
  assertAdminSession(session);
 
801
  throw new ApiError(adminMessage('network_unavailable'), 0);
802
  }
803
  assertAdminSession(session);
@@ -807,9 +1025,11 @@
807
  }
808
  let text;
809
  try {
810
- text = await response.text();
811
  } catch (err) {
 
812
  assertAdminSession(session);
 
813
  throw new ApiError(adminMessage('network_unavailable'), 0);
814
  }
815
  assertAdminSession(session);
@@ -828,6 +1048,7 @@
828
  }
829
  return payload;
830
  } finally {
 
831
  adminRequests.delete(controller);
832
  }
833
  }
@@ -852,7 +1073,7 @@
852
 
853
  // 🆕 multipart 上传(广告图片):FormData 不序列化,复用同一会话和取消边界。
854
  async function apiUpload(path, formData) {
855
- return performAdminRequest(path, { method: 'POST', body: formData });
856
  }
857
 
858
  // -------------------- 视图切换 --------------------
@@ -1008,11 +1229,16 @@
1008
  }
1009
 
1010
  // -------------------- 插件列表 --------------------
 
 
 
1011
  function setListState(stateName, message) {
 
1012
  $('#list-loading').hidden = stateName !== 'loading';
1013
  $('#list-empty').hidden = stateName !== 'empty';
1014
  $('#list-error').hidden = stateName !== 'error';
1015
  $('#plugin-grid').hidden = stateName !== 'data';
 
1016
  if (stateName === 'error' && message) {
1017
  $('#list-error-msg').textContent = message;
1018
  }
@@ -1071,6 +1297,8 @@
1071
  }
1072
 
1073
  function renderList() {
 
 
1074
  const grid = $('#plugin-grid');
1075
  const tpl = $('#tpl-plugin-card');
1076
  if (!grid || !tpl) return;
@@ -1083,14 +1311,16 @@
1083
  grid.innerHTML = '';
1084
 
1085
  if (!state.filtered.length) {
1086
- grid.innerHTML = '<div class="state-block" style="grid-column: 1/-1;">' +
1087
- '<div class="empty-mark">∅</div><h3>未匹配到插件</h3><p>尝试调整搜索关键字。</p></div>';
 
1088
  return;
1089
  }
1090
 
1091
  const frag = document.createDocumentFragment();
1092
  state.filtered.forEach((plugin) => {
1093
  const node = tpl.content.firstElementChild.cloneNode(true);
 
1094
  const id = plugin.plugin_id || plugin.id || '';
1095
  const name = plugin.name || id;
1096
 
@@ -1101,10 +1331,10 @@
1101
 
1102
  const badge = $('.js-status', node);
1103
  if (plugin.enabled) {
1104
- badge.textContent = '启用中';
1105
  badge.classList.add('is-on');
1106
  } else {
1107
- badge.textContent = '已禁用';
1108
  badge.classList.add('is-off');
1109
  }
1110
 
@@ -1114,19 +1344,25 @@
1114
  $('.js-created', node).textContent = formatDate(plugin.created_at || plugin.create_time || plugin.createdAt);
1115
 
1116
  const permsEl = $('.js-perms', node);
1117
- const perms = Array.isArray(plugin.permissions) ? plugin.permissions : [];
1118
- if (perms.length) {
1119
- permsEl.innerHTML = perms.map((p) => `<span class="perm-chip" title="${escapeHtml(p)}">${escapeHtml(PERM_LABELS[p] || p)}</span>`).join('');
 
 
1120
  } else {
1121
- permsEl.textContent = '—';
1122
  }
1123
 
1124
  const toggleBtn = $('.js-toggle', node);
1125
- toggleBtn.textContent = plugin.enabled ? '禁用' : '启用';
 
 
1126
 
1127
  // 操作绑定
1128
  $$('button[data-act]', node).forEach((btn) => {
1129
- btn.addEventListener('click', () => onCardAction(btn.dataset.act, plugin, btn));
 
 
1130
  });
1131
 
1132
  frag.appendChild(node);
@@ -1134,12 +1370,12 @@
1134
  grid.appendChild(frag);
1135
  }
1136
 
1137
- function onCardAction(act, plugin, btn) {
1138
- const id = plugin.plugin_id || plugin.id;
1139
- if (act === 'edit') return openEditModal(plugin);
1140
- if (act === 'rotate') return rotateKey(plugin, btn);
1141
- if (act === 'toggle') return togglePlugin(plugin, btn);
1142
- if (act === 'delete') return deletePlugin(plugin, btn);
1143
  }
1144
 
1145
  // -------------------- 模态框 --------------------
@@ -1151,7 +1387,11 @@
1151
  adminModalFocus().open(el);
1152
  }
1153
  function closeModal(id) {
1154
- if (id === 'modal-key') resetAdminKeyCopy();
 
 
 
 
1155
  if (id === 'modal-confirm') state.confirmHandler = null;
1156
  if (id === 'modal-dispute') adminReadOperations.delete('dispute-detail');
1157
  const el = document.getElementById(id) || adminModalFocusController?.findMask(id);
@@ -1209,22 +1449,29 @@
1209
  const fd = new FormData(form);
1210
  const pluginId = String(fd.get('plugin_id') || '').trim();
1211
  const name = String(fd.get('name') || '').trim();
1212
- if (!/^[A-Za-z0-9_]{3,50}$/.test(pluginId)) {
1213
- showError(errBox, 'plugin_id 仅允许英文/数字/下划线,长度 3-50。');
 
1214
  return;
1215
  }
1216
  if (!name) {
1217
- showError(errBox, '请填写插件名称。');
 
 
 
 
 
 
 
 
 
1218
  return;
1219
  }
1220
 
1221
  const payload = {
1222
  plugin_id: pluginId,
1223
  name: name,
1224
- description: String(fd.get('description') || '').trim(),
1225
- rate_limit: toIntOr(fd.get('rate_limit'), 100),
1226
- max_deduct_per_tx: toIntOr(fd.get('max_deduct_per_tx'), 100),
1227
- max_deduct_per_day: toIntOr(fd.get('max_deduct_per_day'), 1000),
1228
  permissions: fd.getAll('permissions'),
1229
  };
1230
 
@@ -1238,24 +1485,56 @@
1238
  modal.assertCurrent();
1239
  const result = confirmAdminPluginWrite(data, pluginId, 'create');
1240
  closeModal('modal-create');
1241
- showKeyModal(result.plugin_key, '请妥善保存此 Key,关闭后将无法再次查看。');
1242
  await loadPlugins();
1243
  } catch (err) {
1244
  if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
1245
- showError(errBox, err.message || '创建失败');
1246
  } finally {
1247
  finishBusy();
1248
  }
1249
  });
1250
  }
1251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1252
  function toIntOr(value, fallback) {
1253
  const num = parseInt(value, 10);
1254
  return isNaN(num) ? fallback : num;
1255
  }
1256
 
1257
  // -------------------- 编辑插件 --------------------
1258
- function openEditModal(plugin) {
 
 
 
1259
  const form = $('#form-edit');
1260
  form.reset();
1261
  showError($('#form-edit-error'), '');
@@ -1270,11 +1549,14 @@
1270
  form.querySelector('[name="max_deduct_per_tx"]').value = plugin.max_deduct_per_tx ?? 100;
1271
  form.querySelector('[name="max_deduct_per_day"]').value = plugin.max_deduct_per_day ?? 1000;
1272
 
1273
- const perms = Array.isArray(plugin.permissions) ? plugin.permissions : [];
1274
  $$('#form-edit input[name="permissions"]').forEach((cb) => {
1275
  cb.checked = perms.includes(cb.value);
1276
  });
1277
 
 
 
 
1278
  $('#edit-key-display').textContent = plugin.plugin_key || maskKey(id);
1279
 
1280
  openModal('modal-edit');
@@ -1284,22 +1566,29 @@
1284
  const form = $('#form-edit');
1285
  form.addEventListener('submit', async (event) => {
1286
  event.preventDefault();
 
 
1287
  const errBox = $('#form-edit-error');
1288
  showError(errBox, '');
1289
 
1290
  const id = form.dataset.pluginId;
1291
- if (!id) { showError(errBox, '插件标识缺失'); return; }
1292
 
1293
  const fd = new FormData(form);
1294
  const name = String(fd.get('name') || '').trim();
1295
- if (!name) { showError(errBox, '请填写插件名称。'); return; }
 
 
 
 
 
 
 
 
1296
 
1297
  const payload = {
1298
  name: name,
1299
- description: String(fd.get('description') || '').trim(),
1300
- rate_limit: toIntOr(fd.get('rate_limit'), 100),
1301
- max_deduct_per_tx: toIntOr(fd.get('max_deduct_per_tx'), 100),
1302
- max_deduct_per_day: toIntOr(fd.get('max_deduct_per_day'), 1000),
1303
  permissions: fd.getAll('permissions'),
1304
  };
1305
 
@@ -1311,13 +1600,14 @@
1311
  const response = await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
1312
  finishBusy.assertCurrent();
1313
  modal.assertCurrent();
 
1314
  confirmAdminPluginWrite(response, id, 'edit');
1315
  closeModal('modal-edit');
1316
- toast('修改已保存', 'success');
1317
  await loadPlugins();
1318
  } catch (err) {
1319
- if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
1320
- showError(errBox, err.message || '保存失败');
1321
  } finally {
1322
  finishBusy();
1323
  }
@@ -1325,53 +1615,67 @@
1325
  }
1326
 
1327
  // -------------------- 轮换 / 切换 / 删除 --------------------
1328
- function rotateKey(plugin, btn) {
 
 
 
1329
  const id = plugin.plugin_id || plugin.id;
1330
  confirmAction({
1331
- title: '轮换 Key',
1332
- message: `轮换后旧 Key 立即失效,确定继续?\n插件:${plugin.name || id}`,
1333
- okText: '确认轮换',
1334
  danger: false,
1335
  onConfirm: async (assertCurrent) => {
 
 
1336
  const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
1337
  assertCurrent();
 
1338
  const result = confirmAdminPluginWrite(data, id, 'rotate');
1339
- showKeyModal(result.plugin_key, '新 Key 已生成,请妥善保存。旧 Key 已失效。');
1340
  await loadPlugins();
1341
  },
1342
  });
1343
  }
1344
 
1345
- function togglePlugin(plugin, btn) {
 
1346
  const id = plugin.plugin_id || plugin.id;
1347
  const finishBusy = startAdminBusy(btn);
1348
  if (!finishBusy) return;
1349
  apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
1350
  .then(async (response) => {
1351
  finishBusy.assertCurrent();
 
1352
  const result = confirmAdminPluginWrite(response, id, 'toggle');
1353
- toast(result.enabled ? '插件已启用' : '插件已禁用', 'success');
1354
  await loadPlugins();
1355
  })
1356
  .catch((err) => {
1357
- if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
1358
- toast(err.message || '切换状态失败', 'error');
1359
  })
1360
  .finally(() => finishBusy());
1361
  }
1362
 
1363
- function deletePlugin(plugin, btn) {
 
 
 
1364
  const id = plugin.plugin_id || plugin.id;
1365
  confirmAction({
1366
- title: '删除插件',
1367
- message: `确定删除插件「${plugin.name || id}」?此操作不可逆。`,
1368
- okText: '确认删除',
1369
  danger: true,
1370
  onConfirm: async (assertCurrent) => {
 
 
1371
  const response = await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
1372
  assertCurrent();
 
1373
  confirmAdminPluginWrite(response, id, 'delete');
1374
- toast('插件已删除', 'success');
1375
  await loadPlugins();
1376
  },
1377
  });
@@ -2462,7 +2766,18 @@
2462
  }
2463
 
2464
  // -------------------- 主视图绑定 --------------------
 
 
 
 
 
 
 
 
 
 
2465
  function bindMain() {
 
2466
  $('#btn-logout').addEventListener('click', () => {
2467
  confirmAction({
2468
  title: adminMessage('logout_title'),
@@ -2484,12 +2799,12 @@
2484
  try {
2485
  await loadPlugins();
2486
  finishBusy.assertCurrent();
2487
- toast('已刷新', 'success');
2488
  } catch (err) {
2489
  if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
2490
  if (!(err instanceof ApiError && err.status === 401)) {
2491
- setListState('error', err.message || '加载失败');
2492
- toast(err.message || '加载失败', 'error');
2493
  }
2494
  } finally {
2495
  finishBusy();
@@ -2501,7 +2816,7 @@
2501
  catch (err) {
2502
  if (isStaleSessionError(err)) return;
2503
  if (!(err instanceof ApiError && err.status === 401)) {
2504
- setListState('error', err.message || '加载失败');
2505
  }
2506
  }
2507
  });
 
9
  // 新增反馈集中在双语词典;管理页语言遵循文档lang。
10
  const ADMIN_MESSAGES = {
11
  zh: {
12
+ plugin_edit_title: "编辑插件",
13
+ plugin_id_hint: "用于标识插件,创建后不可修改。",
14
+ plugin_id_placeholder: "小写英文/数字/下划线,3-50 字符",
15
+ plugin_name: "名称",
16
+ plugin_name_placeholder: "例如:示例插件",
17
+ plugin_description: "描述",
18
+ plugin_description_placeholder: "对该插件的简要说明(可选)",
19
+ plugin_rate_label: "每分钟限流",
20
+ plugin_tx_label: "单笔扣款上限",
21
+ plugin_day_label: "日扣款上限",
22
+ plugin_cancel: "取消",
23
+ plugin_create_submit: "创建插件",
24
+ plugin_edit_submit: "保存修改",
25
+ plugin_id_fixed: "不可修改。",
26
+ plugin_key_hidden_hint: "完整 Key 已隐藏。如需更换请使用「轮换 Key」。",
27
+ plugin_key_title: "插件 Key 已生成",
28
+ plugin_key_save_title: "请妥善保存此 Key",
29
+ plugin_key_save_hint: "关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。",
30
+ plugin_key_rotated_hint: "新 Key 已生成,请妥善保存。旧 Key 已失效。",
31
+ plugin_key_done: "我已保存",
32
+ plugin_id_invalid: "plugin_id 仅允许小写英文、数字和下划线,长度 3-50。",
33
+ plugin_name_required: "请填写插件名称。",
34
+ plugin_id_missing: "插件标识缺失",
35
+ plugin_name_long: "插件名称不能超过100个字符。",
36
+ plugin_description_long: "插件描述不能超过500个字符。",
37
+ plugin_create_failed: "创建失败",
38
+ plugin_edit_failed: "保存失败",
39
+ plugin_saved: "修改已保存",
40
+ plugin_limit_invalid: "{field}必须是1至{max}之间的整数。",
41
+ plugin_rotate_message: "轮换后旧 Key 立即失效,确定继续?\n插件:{plugin}",
42
+ plugin_rotate_confirm: "确认轮换",
43
+ plugin_deleted: "插件已删除",
44
+ plugin_delete_message: "确定删除插件「{plugin}」?此操作不可逆。",
45
+ plugin_delete_confirm: "确认删除",
46
+ plugin_now_enabled: "插件已启用",
47
+ plugin_now_disabled: "插件已禁用",
48
+ plugin_toggle_failed: "切换状态失败",
49
+ plugin_description_empty: "暂无描述",
50
+ plugin_title: "第三方插件",
51
+ plugin_subtitle: "管理 plugin_key、限流配额、权限与启用状态。",
52
+ plugin_console: "控制台",
53
+ plugin_location: "位置",
54
+ plugin_search: "搜索插件",
55
+ plugin_search_placeholder: "搜索 plugin_id 或名称…",
56
+ plugin_refresh: "刷新",
57
+ plugin_create: "创建新插件",
58
+ plugin_total: "插件总数",
59
+ plugin_enabled: "已启用",
60
+ plugin_disabled: "已禁用",
61
+ plugin_loading: "正在加载插件列表…",
62
+ plugin_empty: "暂无插件",
63
+ plugin_empty_hint: "点击「创建新插件」开始接入第三方应用。",
64
+ plugin_load_failed: "加载失败",
65
+ plugin_retry_hint: "请稍后重试。",
66
+ plugin_retry: "重新加载",
67
+ plugin_no_match: "未匹配到插件",
68
+ plugin_no_match_hint: "尝试调整搜索关键字。",
69
+ plugin_rate: "限流",
70
+ plugin_rate_unit: "次/分钟",
71
+ plugin_tx_limit: "单笔限额",
72
+ plugin_day_limit: "日限额",
73
+ plugin_permissions: "权限",
74
+ plugin_permissions_unavailable: "权限配置不可用",
75
+ plugin_permissions_none: "未授予权限",
76
+ plugin_permissions_review: "权限配置无法读取,请重新确认权限后保存。",
77
+ plugin_created: "创建时间",
78
+ plugin_edit: "编辑",
79
+ plugin_rotate: "轮换 Key",
80
+ plugin_enable: "启用",
81
+ plugin_disable: "禁用",
82
+ plugin_delete: "删除",
83
+ plugin_active: "启用中",
84
+ plugin_refreshed: "已刷新",
85
+ plugin_perm_verify: "验证 Key",
86
+ plugin_perm_balance: "查询余额",
87
+ plugin_perm_deduct: "扣除积分",
88
+ plugin_perm_refund: "退款回滚",
89
+ plugin_action_name: "{action}:{plugin}",
90
  broadcast_subtitle: '发布全站系统公告,管理顶部广告横幅展示。',
91
  announcement_title: '📢 全站系统公告', announcement_content: '公告内容',
92
  announcement_placeholder: '输入要向全体用户推送的公告内容…', announcement_publish: '🚀 确认发布',
 
243
  config_read_unavailable: '无法确认当前配置,请稍后重新读取。',
244
  session_changed: '登录状态已变化,请在当前会话重新操作。',
245
  network_unavailable: '网络异常,请检查连接后重试。',
246
+ request_timed_out: '请求超时,结果暂未确认。请先检查当前状态,再决定是否重试。',
247
+ response_unavailable: '无法确认服务器响应。请先核实当前状态,再决定是否重试。',
248
  auth_expired: '登录已失效,请重新登录。',
249
  admin_verification_unavailable: '无法确认管理员权限,请稍后重新登录。',
250
  admin_data_unavailable: '无法确认返回的数据,请稍后重新读取。',
 
252
  banner_active: '广告展示中', banner_inactive: '无活跃广告', banner_unknown: '状态未知',
253
  },
254
  en: {
255
+ plugin_edit_title: "Edit plugin",
256
+ plugin_id_hint: "Identifies the plugin and cannot be changed after creation.",
257
+ plugin_id_placeholder: "Lowercase letters, digits or _, 3–50 chars",
258
+ plugin_name: "Name",
259
+ plugin_name_placeholder: "For example: Sample plugin",
260
+ plugin_description: "Description",
261
+ plugin_description_placeholder: "Brief plugin description (optional)",
262
+ plugin_rate_label: "Requests per minute",
263
+ plugin_tx_label: "Maximum credits per transaction",
264
+ plugin_day_label: "Maximum credits per day",
265
+ plugin_cancel: "Cancel",
266
+ plugin_create_submit: "Create plugin",
267
+ plugin_edit_submit: "Save changes",
268
+ plugin_id_fixed: "Cannot be changed.",
269
+ plugin_key_hidden_hint: "The full key is hidden. Use “Rotate key” to replace it.",
270
+ plugin_key_title: "Plugin key generated",
271
+ plugin_key_save_title: "Keep this key safe",
272
+ plugin_key_save_hint: "You cannot view the full key again after closing this dialog. If lost, use “Rotate key” to generate a new credential.",
273
+ plugin_key_rotated_hint: "A new key has been generated. Keep it safe. The previous key is no longer valid.",
274
+ plugin_key_done: "I have saved it",
275
+ plugin_id_invalid: "Plugin ID must contain 3–50 lowercase letters, digits or underscores.",
276
+ plugin_name_required: "Enter a plugin name.",
277
+ plugin_id_missing: "Plugin ID is missing.",
278
+ plugin_name_long: "Plugin name must not exceed 100 characters.",
279
+ plugin_description_long: "Plugin description must not exceed 500 characters.",
280
+ plugin_create_failed: "Unable to create plugin.",
281
+ plugin_edit_failed: "Unable to save plugin.",
282
+ plugin_saved: "Changes saved.",
283
+ plugin_limit_invalid: "{field} must be a whole number from 1 to {max}.",
284
+ plugin_rotate_message: "The previous key will stop working immediately. Continue?\nPlugin: {plugin}",
285
+ plugin_rotate_confirm: "Rotate key",
286
+ plugin_deleted: "Plugin deleted.",
287
+ plugin_delete_message: "Delete plugin “{plugin}”? This cannot be undone.",
288
+ plugin_delete_confirm: "Delete plugin",
289
+ plugin_now_enabled: "Plugin enabled.",
290
+ plugin_now_disabled: "Plugin disabled.",
291
+ plugin_toggle_failed: "Unable to change plugin status.",
292
+ plugin_description_empty: "No description",
293
+ plugin_title: "Third-party plugins",
294
+ plugin_subtitle: "Manage plugin keys, quotas, permissions and availability.",
295
+ plugin_console: "Console",
296
+ plugin_location: "Location",
297
+ plugin_search: "Search plugins",
298
+ plugin_search_placeholder: "Search plugin ID or name…",
299
+ plugin_refresh: "Refresh",
300
+ plugin_create: "Create plugin",
301
+ plugin_total: "Total plugins",
302
+ plugin_enabled: "Enabled",
303
+ plugin_disabled: "Disabled",
304
+ plugin_loading: "Loading plugins…",
305
+ plugin_empty: "No plugins yet",
306
+ plugin_empty_hint: "Select “Create plugin” to connect a third-party app.",
307
+ plugin_load_failed: "Unable to load plugins",
308
+ plugin_retry_hint: "Please try again later.",
309
+ plugin_retry: "Try again",
310
+ plugin_no_match: "No matching plugins",
311
+ plugin_no_match_hint: "Try a different search.",
312
+ plugin_rate: "Rate limit",
313
+ plugin_rate_unit: "requests/min",
314
+ plugin_tx_limit: "Per-transaction limit",
315
+ plugin_day_limit: "Daily limit",
316
+ plugin_permissions: "Permissions",
317
+ plugin_permissions_unavailable: "Permission configuration unavailable",
318
+ plugin_permissions_none: "No permissions granted",
319
+ plugin_permissions_review: "Permissions could not be read. Review the permissions before saving.",
320
+ plugin_created: "Created",
321
+ plugin_edit: "Edit",
322
+ plugin_rotate: "Rotate key",
323
+ plugin_enable: "Enable",
324
+ plugin_disable: "Disable",
325
+ plugin_delete: "Delete",
326
+ plugin_active: "Active",
327
+ plugin_refreshed: "Refreshed",
328
+ plugin_perm_verify: "Verify key",
329
+ plugin_perm_balance: "Read balance",
330
+ plugin_perm_deduct: "Deduct credits",
331
+ plugin_perm_refund: "Refund credits",
332
+ plugin_action_name: "{action}: {plugin}",
333
  broadcast_subtitle: 'Publish announcements for all users and manage the top advertisement banner.',
334
  announcement_title: '📢 Site announcements', announcement_content: 'Announcement content',
335
  announcement_placeholder: 'Enter an announcement for all users…', announcement_publish: '🚀 Publish announcement',
 
486
  config_read_unavailable: 'The current configuration could not be confirmed. Reload it later.',
487
  session_changed: 'The session has changed. Continue in the current session.',
488
  network_unavailable: 'Network unavailable. Check your connection before retrying.',
489
+ request_timed_out: 'Request timed out. The result is unconfirmed. Check the current state before deciding whether to retry.',
490
+ response_unavailable: 'The server response could not be confirmed. Check the current state before deciding whether to retry.',
491
  auth_expired: 'Your session has expired. Sign in again.',
492
  admin_verification_unavailable: 'Administrator access could not be confirmed. Sign in again later.',
493
  admin_data_unavailable: 'The returned data could not be confirmed. Reload it later.',
 
507
  const USER_KEY = 'ranking_admin_user';
508
  const ALL_PERMS = ['verify', 'balance', 'deduct', 'refund'];
509
  const PERM_LABELS = {
510
+ verify: 'plugin_perm_verify',
511
+ balance: 'plugin_perm_balance',
512
+ deduct: 'plugin_perm_deduct',
513
+ refund: 'plugin_perm_refund'
514
  };
515
 
516
  // -------------------- 状态 --------------------
 
929
 
930
  // -------------------- API 客户端 --------------------
931
  let adminSessionGeneration = 0;
932
+ const ADMIN_REQUEST_TIMEOUT = 45000;
933
+ const ADMIN_UPLOAD_TIMEOUT = 420000;
934
+ const ADMIN_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
935
+ const ADMIN_RESPONSE_MAX_CHUNKS = 16384;
936
  const adminRequests = new Set();
937
  function revokeAdminRequests() {
938
  for (const controller of Array.from(adminRequests)) {
 
950
  throw error;
951
  }
952
  }
953
+ async function readAdminResponse(response, deadline, session) {
954
+ if (response.body === null) return "";
955
+ if (!response.body || typeof response.body.getReader !== 'function') {
956
+ throw new ApiError(adminMessage('response_unavailable'), 0);
957
+ }
958
+ const reader = response.body.getReader();
959
+ let complete = false;
960
+ try {
961
+ const length = response.headers?.get?.('Content-Length');
962
+ if (length !== null && length !== undefined &&
963
+ (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)) || Number(length) > ADMIN_RESPONSE_MAX_BYTES)) {
964
+ throw new ApiError(adminMessage('response_unavailable'), 0);
965
+ }
966
+ const decoder = new TextDecoder('utf-8', { fatal: true });
967
+ const parts = [];
968
+ let bytes = 0, chunks = 0, text = "";
969
+ while (true) {
970
+ assertAdminSession(session);
971
+ const chunk = await Promise.race([reader.read(), deadline]);
972
+ assertAdminSession(session);
973
+ if (!chunk || typeof chunk.done !== 'boolean') throw new Error('Invalid response stream');
974
+ if (chunk.done) break;
975
+ if (!(chunk.value instanceof Uint8Array) || ++chunks > ADMIN_RESPONSE_MAX_CHUNKS ||
976
+ chunk.value.byteLength > ADMIN_RESPONSE_MAX_BYTES - bytes) {
977
+ throw new ApiError(adminMessage('response_unavailable'), 0);
978
+ }
979
+ bytes += chunk.value.byteLength;
980
+ text += decoder.decode(chunk.value, { stream: true });
981
+ if (text.length >= 65536) { parts.push(text); text = ""; }
982
+ }
983
+ text += decoder.decode();
984
+ parts.push(text);
985
+ complete = true;
986
+ return parts.join("");
987
+ } catch (error) {
988
+ assertAdminSession(session);
989
+ if (error instanceof ApiError) throw error;
990
+ throw new ApiError(adminMessage('response_unavailable'), 0);
991
+ } finally {
992
+ if (!complete) {
993
+ try { Promise.resolve(reader.cancel()).catch(() => {}); } catch (_) { /* 继续释放读锁。 */ }
994
+ }
995
+ try { reader.releaseLock(); } catch (_) { /* 异常流不阻止界面收尾。 */ }
996
+ }
997
+ }
998
+
999
+ async function performAdminRequest(path, options, upload = false) {
1000
  const session = captureAdminSession();
1001
  const controller = new AbortController();
1002
  const headers = { Accept: 'application/json', ...options.headers };
1003
  if (session.token) headers.Authorization = 'Bearer ' + session.token;
1004
  adminRequests.add(controller);
1005
+ let timer;
1006
+ const deadline = new Promise((resolve, reject) => {
1007
+ timer = setTimeout(() => {
1008
+ reject(new ApiError(adminMessage('request_timed_out'), 0));
1009
+ try { controller.abort(); } catch (_) { /* 撤销异常不阻止等待结束。 */ }
1010
+ }, upload ? ADMIN_UPLOAD_TIMEOUT : ADMIN_REQUEST_TIMEOUT);
1011
+ });
1012
  try {
1013
  let response;
1014
  try {
1015
+ response = await Promise.race([fetch(API_BASE + path, { ...options, headers, signal: controller.signal }), deadline]);
1016
  } catch (err) {
1017
  assertAdminSession(session);
1018
+ if (err instanceof ApiError) throw err;
1019
  throw new ApiError(adminMessage('network_unavailable'), 0);
1020
  }
1021
  assertAdminSession(session);
 
1025
  }
1026
  let text;
1027
  try {
1028
+ text = await readAdminResponse(response, deadline, session);
1029
  } catch (err) {
1030
+ try { controller.abort(); } catch (_) { /* 保留原读取错误。 */ }
1031
  assertAdminSession(session);
1032
+ if (err instanceof ApiError) throw err;
1033
  throw new ApiError(adminMessage('network_unavailable'), 0);
1034
  }
1035
  assertAdminSession(session);
 
1048
  }
1049
  return payload;
1050
  } finally {
1051
+ clearTimeout(timer);
1052
  adminRequests.delete(controller);
1053
  }
1054
  }
 
1073
 
1074
  // 🆕 multipart 上传(广告图片):FormData 不序列化,复用同一会话和取消边界。
1075
  async function apiUpload(path, formData) {
1076
+ return performAdminRequest(path, { method: 'POST', body: formData }, true);
1077
  }
1078
 
1079
  // -------------------- 视图切换 --------------------
 
1229
  }
1230
 
1231
  // -------------------- 插件列表 --------------------
1232
+ let pluginListGeneration = 0;
1233
+ let editPluginCurrent = () => true;
1234
+
1235
  function setListState(stateName, message) {
1236
+ if (stateName === 'loading') pluginListGeneration++;
1237
  $('#list-loading').hidden = stateName !== 'loading';
1238
  $('#list-empty').hidden = stateName !== 'empty';
1239
  $('#list-error').hidden = stateName !== 'error';
1240
  $('#plugin-grid').hidden = stateName !== 'data';
1241
+ $('#plugin-grid').setAttribute('aria-busy', String(stateName === 'loading'));
1242
  if (stateName === 'error' && message) {
1243
  $('#list-error-msg').textContent = message;
1244
  }
 
1297
  }
1298
 
1299
  function renderList() {
1300
+ const generation = ++pluginListGeneration;
1301
+ const session = captureAdminSession();
1302
  const grid = $('#plugin-grid');
1303
  const tpl = $('#tpl-plugin-card');
1304
  if (!grid || !tpl) return;
 
1311
  grid.innerHTML = '';
1312
 
1313
  if (!state.filtered.length) {
1314
+ grid.innerHTML = '<div class="state-block" role="status" aria-atomic="true" style="grid-column: 1/-1;">' +
1315
+ '<div class="empty-mark" aria-hidden="true">∅</div><h3>' + escapeHtml(adminMessage('plugin_no_match')) +
1316
+ '</h3><p>' + escapeHtml(adminMessage('plugin_no_match_hint')) + '</p></div>';
1317
  return;
1318
  }
1319
 
1320
  const frag = document.createDocumentFragment();
1321
  state.filtered.forEach((plugin) => {
1322
  const node = tpl.content.firstElementChild.cloneNode(true);
1323
+ localizeAdminPluginUI(node);
1324
  const id = plugin.plugin_id || plugin.id || '';
1325
  const name = plugin.name || id;
1326
 
 
1331
 
1332
  const badge = $('.js-status', node);
1333
  if (plugin.enabled) {
1334
+ badge.textContent = adminMessage('plugin_active');
1335
  badge.classList.add('is-on');
1336
  } else {
1337
+ badge.textContent = adminMessage('plugin_disabled');
1338
  badge.classList.add('is-off');
1339
  }
1340
 
 
1344
  $('.js-created', node).textContent = formatDate(plugin.created_at || plugin.create_time || plugin.createdAt);
1345
 
1346
  const permsEl = $('.js-perms', node);
1347
+ const perms = plugin.permissions_valid !== false && Array.isArray(plugin.permissions) ? plugin.permissions : [];
1348
+ if (plugin.permissions_valid === false) {
1349
+ permsEl.textContent = adminMessage('plugin_permissions_unavailable');
1350
+ } else if (perms.length) {
1351
+ permsEl.innerHTML = perms.map((p) => `<span class="perm-chip" title="${escapeHtml(p)}">${escapeHtml(ALL_PERMS.includes(p) ? adminMessage(PERM_LABELS[p]) : p)}</span>`).join('');
1352
  } else {
1353
+ permsEl.textContent = adminMessage('plugin_permissions_none');
1354
  }
1355
 
1356
  const toggleBtn = $('.js-toggle', node);
1357
+ toggleBtn.textContent = adminMessage(plugin.enabled ? 'plugin_disable' : 'plugin_enable');
1358
+
1359
+ const isCurrent = () => generation === pluginListGeneration && node.isConnected && session.isCurrent();
1360
 
1361
  // 操作绑定
1362
  $$('button[data-act]', node).forEach((btn) => {
1363
+ btn.setAttribute('aria-label', adminMessage('plugin_action_name')
1364
+ .replace('{action}', () => btn.textContent).replace('{plugin}', () => name));
1365
+ btn.addEventListener('click', () => onCardAction(btn.dataset.act, plugin, btn, isCurrent));
1366
  });
1367
 
1368
  frag.appendChild(node);
 
1370
  grid.appendChild(frag);
1371
  }
1372
 
1373
+ function onCardAction(act, plugin, btn, parentCurrent = () => true) {
1374
+ if (!parentCurrent()) return;
1375
+ if (act === 'edit') return openEditModal(plugin, parentCurrent);
1376
+ if (act === 'rotate') return rotateKey(plugin, btn, parentCurrent);
1377
+ if (act === 'toggle') return togglePlugin(plugin, btn, parentCurrent);
1378
+ if (act === 'delete') return deletePlugin(plugin, btn, parentCurrent);
1379
  }
1380
 
1381
  // -------------------- 模态框 --------------------
 
1387
  adminModalFocus().open(el);
1388
  }
1389
  function closeModal(id) {
1390
+ if (id === 'modal-key') {
1391
+ resetAdminKeyCopy();
1392
+ const key = $('#key-display');
1393
+ if (key) key.textContent = '';
1394
+ }
1395
  if (id === 'modal-confirm') state.confirmHandler = null;
1396
  if (id === 'modal-dispute') adminReadOperations.delete('dispute-detail');
1397
  const el = document.getElementById(id) || adminModalFocusController?.findMask(id);
 
1449
  const fd = new FormData(form);
1450
  const pluginId = String(fd.get('plugin_id') || '').trim();
1451
  const name = String(fd.get('name') || '').trim();
1452
+ if (!/^[a-z0-9_]{3,50}$/.test(pluginId)) {
1453
+ showError(errBox, adminMessage('plugin_id_invalid'));
1454
+ form.querySelector('[name="plugin_id"]').focus();
1455
  return;
1456
  }
1457
  if (!name) {
1458
+ showError(errBox, adminMessage('plugin_name_required'));
1459
+ form.querySelector('[name="name"]').focus();
1460
+ return;
1461
+ }
1462
+
1463
+ let fields;
1464
+ try { fields = readPluginFields(fd, name); }
1465
+ catch (error) {
1466
+ showError(errBox, error.message);
1467
+ form.querySelector('[name="' + error.field + '"]').focus();
1468
  return;
1469
  }
1470
 
1471
  const payload = {
1472
  plugin_id: pluginId,
1473
  name: name,
1474
+ ...fields,
 
 
 
1475
  permissions: fd.getAll('permissions'),
1476
  };
1477
 
 
1485
  modal.assertCurrent();
1486
  const result = confirmAdminPluginWrite(data, pluginId, 'create');
1487
  closeModal('modal-create');
1488
+ showKeyModal(result.plugin_key, adminMessage('plugin_key_save_hint'));
1489
  await loadPlugins();
1490
  } catch (err) {
1491
  if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
1492
+ showError(errBox, err.message || adminMessage('plugin_create_failed'));
1493
  } finally {
1494
  finishBusy();
1495
  }
1496
  });
1497
  }
1498
 
1499
+ const PLUGIN_LIMITS = {
1500
+ rate_limit: { initial: 100, max: 10000, labelKey: 'plugin_rate_label' },
1501
+ max_deduct_per_tx: { initial: 100, max: 10000, labelKey: 'plugin_tx_label' },
1502
+ max_deduct_per_day: { initial: 1000, max: 100000, labelKey: 'plugin_day_label' }
1503
+ };
1504
+
1505
+ function readPluginFields(fd, name) {
1506
+ const reject = (field, message) => {
1507
+ const error = new ApiError(message, 0);
1508
+ error.field = field;
1509
+ throw error;
1510
+ };
1511
+ if (Array.from(name).length > 100) reject('name', adminMessage('plugin_name_long'));
1512
+ const description = String(fd.get('description') || '').trim();
1513
+ if (Array.from(description).length > 500) reject('description', adminMessage('plugin_description_long'));
1514
+ const fields = { description };
1515
+ for (const [field, rule] of Object.entries(PLUGIN_LIMITS)) {
1516
+ const raw = fd.get(field);
1517
+ const value = raw === null || raw === '' ? rule.initial : typeof raw === 'string' ? Number(raw) : NaN;
1518
+ if (!Number.isSafeInteger(value) || value < 1 || value > rule.max) {
1519
+ const message = adminMessage('plugin_limit_invalid')
1520
+ .replace('{field}', () => adminMessage(rule.labelKey)).replace('{max}', () => rule.max);
1521
+ reject(field, message);
1522
+ }
1523
+ fields[field] = value;
1524
+ }
1525
+ return fields;
1526
+ }
1527
+
1528
  function toIntOr(value, fallback) {
1529
  const num = parseInt(value, 10);
1530
  return isNaN(num) ? fallback : num;
1531
  }
1532
 
1533
  // -------------------- 编辑插件 --------------------
1534
+ function openEditModal(plugin, parentCurrent = () => true) {
1535
+ if (!parentCurrent()) return;
1536
+ const session = captureAdminSession();
1537
+ editPluginCurrent = () => session.isCurrent() && parentCurrent();
1538
  const form = $('#form-edit');
1539
  form.reset();
1540
  showError($('#form-edit-error'), '');
 
1549
  form.querySelector('[name="max_deduct_per_tx"]').value = plugin.max_deduct_per_tx ?? 100;
1550
  form.querySelector('[name="max_deduct_per_day"]').value = plugin.max_deduct_per_day ?? 1000;
1551
 
1552
+ const perms = plugin.permissions_valid !== false && Array.isArray(plugin.permissions) ? plugin.permissions : [];
1553
  $$('#form-edit input[name="permissions"]').forEach((cb) => {
1554
  cb.checked = perms.includes(cb.value);
1555
  });
1556
 
1557
+ if (plugin.permissions_valid === false) {
1558
+ showError($('#form-edit-error'), adminMessage('plugin_permissions_review'));
1559
+ }
1560
  $('#edit-key-display').textContent = plugin.plugin_key || maskKey(id);
1561
 
1562
  openModal('modal-edit');
 
1566
  const form = $('#form-edit');
1567
  form.addEventListener('submit', async (event) => {
1568
  event.preventDefault();
1569
+ const isCurrent = editPluginCurrent;
1570
+ if (!isCurrent()) return;
1571
  const errBox = $('#form-edit-error');
1572
  showError(errBox, '');
1573
 
1574
  const id = form.dataset.pluginId;
1575
+ if (!id) { showError(errBox, adminMessage('plugin_id_missing')); return; }
1576
 
1577
  const fd = new FormData(form);
1578
  const name = String(fd.get('name') || '').trim();
1579
+ if (!name) { showError(errBox, adminMessage('plugin_name_required')); form.querySelector('[name="name"]').focus(); return; }
1580
+
1581
+ let fields;
1582
+ try { fields = readPluginFields(fd, name); }
1583
+ catch (error) {
1584
+ showError(errBox, error.message);
1585
+ form.querySelector('[name="' + error.field + '"]').focus();
1586
+ return;
1587
+ }
1588
 
1589
  const payload = {
1590
  name: name,
1591
+ ...fields,
 
 
 
1592
  permissions: fd.getAll('permissions'),
1593
  };
1594
 
 
1600
  const response = await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
1601
  finishBusy.assertCurrent();
1602
  modal.assertCurrent();
1603
+ if (!isCurrent()) return;
1604
  confirmAdminPluginWrite(response, id, 'edit');
1605
  closeModal('modal-edit');
1606
+ toast(adminMessage('plugin_saved'), 'success');
1607
  await loadPlugins();
1608
  } catch (err) {
1609
+ if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent() || !isCurrent()) return;
1610
+ showError(errBox, err.message || adminMessage('plugin_edit_failed'));
1611
  } finally {
1612
  finishBusy();
1613
  }
 
1615
  }
1616
 
1617
  // -------------------- 轮换 / 切换 / 删除 --------------------
1618
+ function rotateKey(plugin, btn, parentCurrent = () => true) {
1619
+ if (!parentCurrent()) return;
1620
+ const session = captureAdminSession();
1621
+ const isCurrent = () => session.isCurrent() && parentCurrent();
1622
  const id = plugin.plugin_id || plugin.id;
1623
  confirmAction({
1624
+ title: adminMessage('plugin_rotate'),
1625
+ message: adminMessage('plugin_rotate_message').replace('{plugin}', () => plugin.name || id),
1626
+ okText: adminMessage('plugin_rotate_confirm'),
1627
  danger: false,
1628
  onConfirm: async (assertCurrent) => {
1629
+ assertAdminSession({ isCurrent });
1630
+ assertCurrent();
1631
  const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
1632
  assertCurrent();
1633
+ assertAdminSession({ isCurrent });
1634
  const result = confirmAdminPluginWrite(data, id, 'rotate');
1635
+ showKeyModal(result.plugin_key, adminMessage('plugin_key_rotated_hint'));
1636
  await loadPlugins();
1637
  },
1638
  });
1639
  }
1640
 
1641
+ function togglePlugin(plugin, btn, parentCurrent = () => true) {
1642
+ if (!parentCurrent()) return;
1643
  const id = plugin.plugin_id || plugin.id;
1644
  const finishBusy = startAdminBusy(btn);
1645
  if (!finishBusy) return;
1646
  apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
1647
  .then(async (response) => {
1648
  finishBusy.assertCurrent();
1649
+ if (!parentCurrent()) return;
1650
  const result = confirmAdminPluginWrite(response, id, 'toggle');
1651
+ toast(adminMessage(result.enabled ? 'plugin_now_enabled' : 'plugin_now_disabled'), 'success');
1652
  await loadPlugins();
1653
  })
1654
  .catch((err) => {
1655
+ if (isStaleSessionError(err) || !finishBusy.isCurrent() || !parentCurrent()) return;
1656
+ toast(err.message || adminMessage('plugin_toggle_failed'), 'error');
1657
  })
1658
  .finally(() => finishBusy());
1659
  }
1660
 
1661
+ function deletePlugin(plugin, btn, parentCurrent = () => true) {
1662
+ if (!parentCurrent()) return;
1663
+ const session = captureAdminSession();
1664
+ const isCurrent = () => session.isCurrent() && parentCurrent();
1665
  const id = plugin.plugin_id || plugin.id;
1666
  confirmAction({
1667
+ title: adminMessage('plugin_delete'),
1668
+ message: adminMessage('plugin_delete_message').replace('{plugin}', () => plugin.name || id),
1669
+ okText: adminMessage('plugin_delete_confirm'),
1670
  danger: true,
1671
  onConfirm: async (assertCurrent) => {
1672
+ assertAdminSession({ isCurrent });
1673
+ assertCurrent();
1674
  const response = await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
1675
  assertCurrent();
1676
+ assertAdminSession({ isCurrent });
1677
  confirmAdminPluginWrite(response, id, 'delete');
1678
+ toast(adminMessage('plugin_deleted'), 'success');
1679
  await loadPlugins();
1680
  },
1681
  });
 
2766
  }
2767
 
2768
  // -------------------- 主视图绑定 --------------------
2769
+ function localizeAdminPluginUI(root = document) {
2770
+ $$('[data-admin-plugin-text]', root).forEach(node => { node.textContent = adminMessage(node.dataset.adminPluginText); });
2771
+ for (const [kind, attribute] of [['placeholder', 'placeholder'], ['label', 'aria-label'], ['title', 'title'], ['empty', 'data-empty-description']]) {
2772
+ $$('[data-admin-plugin-' + kind + ']', root).forEach(node => {
2773
+ const key = node.dataset['adminPlugin' + kind[0].toUpperCase() + kind.slice(1)];
2774
+ node.setAttribute(attribute, adminMessage(key));
2775
+ });
2776
+ }
2777
+ }
2778
+
2779
  function bindMain() {
2780
+ localizeAdminPluginUI();
2781
  $('#btn-logout').addEventListener('click', () => {
2782
  confirmAction({
2783
  title: adminMessage('logout_title'),
 
2799
  try {
2800
  await loadPlugins();
2801
  finishBusy.assertCurrent();
2802
+ toast(adminMessage('plugin_refreshed'), 'success');
2803
  } catch (err) {
2804
  if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
2805
  if (!(err instanceof ApiError && err.status === 401)) {
2806
+ setListState('error', err.message || adminMessage('plugin_load_failed'));
2807
+ toast(err.message || adminMessage('plugin_load_failed'), 'error');
2808
  }
2809
  } finally {
2810
  finishBusy();
 
2816
  catch (err) {
2817
  if (isStaleSessionError(err)) return;
2818
  if (!(err instanceof ApiError && err.status === 401)) {
2819
+ setListState('error', err.message || adminMessage('plugin_load_failed'));
2820
  }
2821
  }
2822
  });
管理界面/index.html CHANGED
@@ -81,8 +81,8 @@
81
  <div class="brand-sub">Plugin Admin</div>
82
  </div>
83
  </div>
84
- <nav class="crumbs" aria-label="位置">
85
- <span>控制台</span>
86
  <span class="crumbs-sep">/</span>
87
  <span class="crumbs-current">第三方插件</span>
88
  </nav>
@@ -112,53 +112,53 @@
112
  <div class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins" tabindex="0">
113
  <section class="page-head">
114
  <div class="page-head-text">
115
- <h1>第三方插件</h1>
116
- <p class="page-sub">管理 plugin_key、限流配额、权限与启用状态。</p>
117
  </div>
118
  <div class="page-head-actions">
119
  <div class="search-wrap">
120
- <input type="search" id="search-input" placeholder="搜索 plugin_id 或 名称…">
121
  </div>
122
- <button type="button" id="btn-refresh" class="btn btn-secondary" title="刷新">
123
- <span class="btn-label">刷新</span>
124
  </button>
125
  <button type="button" id="btn-create" class="btn btn-primary">
126
  <span class="btn-icon" aria-hidden="true">+</span>
127
- <span class="btn-label">创建新插件</span>
128
  </button>
129
  </div>
130
  </section>
131
 
132
  <section class="stat-row" id="stat-row">
133
  <div class="stat-card">
134
- <div class="stat-label">插件总数</div>
135
  <div class="stat-value" id="stat-total">—</div>
136
  </div>
137
  <div class="stat-card">
138
- <div class="stat-label">已启用</div>
139
  <div class="stat-value text-success" id="stat-enabled">—</div>
140
  </div>
141
  <div class="stat-card">
142
- <div class="stat-label">已禁用</div>
143
  <div class="stat-value text-warning" id="stat-disabled">—</div>
144
  </div>
145
  </section>
146
 
147
  <section class="list-shell">
148
- <div id="list-loading" class="state-block" hidden>
149
  <div class="spinner-lg" aria-hidden="true"></div>
150
- <p>正在加载插件列表…</p>
151
  </div>
152
- <div id="list-empty" class="state-block" hidden>
153
  <div class="empty-mark">∅</div>
154
- <h3>暂无插件</h3>
155
- <p>点击右上角「创建新插件」开始接入第三方应用。</p>
156
  </div>
157
- <div id="list-error" class="state-block state-error" hidden>
158
  <div class="empty-mark">!</div>
159
- <h3>加载失败</h3>
160
- <p id="list-error-msg">请稍后重试。</p>
161
- <button type="button" class="btn btn-secondary" id="btn-retry">重新加载</button>
162
  </div>
163
  <div id="plugin-grid" class="plugin-grid"></div>
164
  </section>
@@ -482,59 +482,59 @@
482
  <div id="modal-create" class="modal-mask" hidden>
483
  <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
484
  <header class="modal-header">
485
- <h3 id="modal-create-title">创建新插件</h3>
486
- <button type="button" class="modal-close" data-close="modal-create" aria-label="关闭">×</button>
487
  </header>
488
  <form id="form-create" class="modal-body" novalidate>
 
489
  <div class="grid-2">
490
  <label class="field">
491
  <span class="field-label">plugin_id <span class="required">*</span></span>
492
- <input type="text" name="plugin_id" required pattern="^[A-Za-z0-9_]{3,50}$"
493
- placeholder="英文/数字/下划线,3-50 字符">
494
- <span class="field-hint">用于标识插件,创建后不可修改。</span>
495
  </label>
496
  <label class="field">
497
- <span class="field-label">名称 <span class="required">*</span></span>
498
- <input type="text" name="name" required placeholder="例如:示例插件">
499
  </label>
500
  </div>
501
 
502
  <label class="field">
503
- <span class="field-label">描述</span>
504
- <textarea name="description" rows="2" placeholder="对该插件的简要说明(可选)"></textarea>
505
  </label>
506
 
507
  <div class="grid-3">
508
  <label class="field">
509
- <span class="field-label">每分钟限流</span>
510
- <input type="number" name="rate_limit" min="1" value="100">
511
  </label>
512
  <label class="field">
513
- <span class="field-label">单笔扣款上限</span>
514
- <input type="number" name="max_deduct_per_tx" min="0" value="100">
515
  </label>
516
  <label class="field">
517
- <span class="field-label">日扣款上限</span>
518
- <input type="number" name="max_deduct_per_day" min="0" value="1000">
519
  </label>
520
  </div>
521
 
522
  <fieldset class="field perm-field">
523
- <legend class="field-label">权限</legend>
524
  <div class="perm-grid">
525
- <label class="perm-item"><input type="checkbox" name="permissions" value="verify" checked><span>验证 Key</span></label>
526
- <label class="perm-item"><input type="checkbox" name="permissions" value="balance" checked><span>查询余额</span></label>
527
- <label class="perm-item"><input type="checkbox" name="permissions" value="deduct" checked><span>扣除积分</span></label>
528
- <label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span>退款回滚</span></label>
529
  </div>
530
  </fieldset>
531
 
532
- <div class="form-error" id="form-create-error" hidden></div>
533
  </form>
534
  <footer class="modal-footer">
535
- <button type="button" class="btn btn-ghost" data-close="modal-create">取消</button>
536
  <button type="submit" form="form-create" id="btn-create-submit" class="btn btn-primary">
537
- <span class="btn-label">创建插件</span>
538
  <span class="btn-spinner" aria-hidden="true"></span>
539
  </button>
540
  </footer>
@@ -545,64 +545,64 @@
545
  <div id="modal-edit" class="modal-mask" hidden>
546
  <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
547
  <header class="modal-header">
548
- <h3 id="modal-edit-title">编辑插件</h3>
549
- <button type="button" class="modal-close" data-close="modal-edit" aria-label="关闭">×</button>
550
  </header>
551
  <form id="form-edit" class="modal-body" novalidate>
 
552
  <div class="grid-2">
553
  <label class="field">
554
  <span class="field-label">plugin_id</span>
555
  <input type="text" name="plugin_id" disabled>
556
- <span class="field-hint">不可修改。</span>
557
  </label>
558
  <label class="field">
559
- <span class="field-label">名称 <span class="required">*</span></span>
560
  <input type="text" name="name" required>
561
  </label>
562
  </div>
563
 
564
  <label class="field">
565
- <span class="field-label">描述</span>
566
  <textarea name="description" rows="2"></textarea>
567
  </label>
568
 
569
  <div class="grid-3">
570
  <label class="field">
571
- <span class="field-label">每分钟限流</span>
572
- <input type="number" name="rate_limit" min="1">
573
  </label>
574
  <label class="field">
575
- <span class="field-label">单笔扣款上限</span>
576
- <input type="number" name="max_deduct_per_tx" min="0">
577
  </label>
578
  <label class="field">
579
- <span class="field-label">日扣款上限</span>
580
- <input type="number" name="max_deduct_per_day" min="0">
581
  </label>
582
  </div>
583
 
584
  <fieldset class="field perm-field">
585
- <legend class="field-label">权限</legend>
586
  <div class="perm-grid">
587
- <label class="perm-item"><input type="checkbox" name="permissions" value="verify"><span>验证 Key</span></label>
588
- <label class="perm-item"><input type="checkbox" name="permissions" value="balance"><span>查询余额</span></label>
589
- <label class="perm-item"><input type="checkbox" name="permissions" value="deduct"><span>扣除积分</span></label>
590
- <label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span>退款回滚</span></label>
591
  </div>
592
  </fieldset>
593
 
594
  <div class="field">
595
  <span class="field-label">plugin_key</span>
596
  <div class="static-key" id="edit-key-display">—</div>
597
- <span class="field-hint">出于安全考虑,完整 Key 已隐藏。如需更换请使用「轮换 Key」。</span>
598
  </div>
599
 
600
- <div class="form-error" id="form-edit-error" hidden></div>
601
  </form>
602
  <footer class="modal-footer">
603
- <button type="button" class="btn btn-ghost" data-close="modal-edit">取消</button>
604
  <button type="submit" form="form-edit" id="btn-edit-submit" class="btn btn-primary">
605
- <span class="btn-label">保存修改</span>
606
  <span class="btn-spinner" aria-hidden="true"></span>
607
  </button>
608
  </footer>
@@ -613,24 +613,24 @@
613
  <div id="modal-key" class="modal-mask" hidden>
614
  <div class="modal modal-md" role="dialog" aria-modal="true" aria-labelledby="modal-key-title">
615
  <header class="modal-header">
616
- <h3 id="modal-key-title">插件 Key 已生成</h3>
617
- <button type="button" class="modal-close" data-close="modal-key" aria-label="关闭">×</button>
618
  </header>
619
  <div class="modal-body">
620
  <div class="key-callout">
621
  <div class="key-callout-label">PLUGIN KEY</div>
622
  <div class="key-callout-value" id="key-display">—</div>
623
  <button type="button" id="btn-copy-key" class="btn btn-secondary btn-copy">
624
- <span class="btn-label">复制 Key</span>
625
  </button>
626
  </div>
627
  <div class="key-warn">
628
- <strong>请妥善保存此 Key</strong>
629
- <span>关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。</span>
630
  </div>
631
  </div>
632
  <footer class="modal-footer">
633
- <button type="button" class="btn btn-primary" data-close="modal-key">我已保存</button>
634
  </footer>
635
  </div>
636
  </div>
@@ -687,23 +687,23 @@
687
  </div>
688
  <span class="badge js-status">—</span>
689
  </header>
690
- <p class="plugin-desc js-desc"></p>
691
  <div class="plugin-key-row">
692
  <span class="key-label">KEY</span>
693
  <code class="js-key">—</code>
694
  </div>
695
  <dl class="plugin-meta">
696
- <div><dt>限流</dt><dd><span class="js-rate">—</span> <em>次/分钟</em></dd></div>
697
- <div><dt>单笔限额</dt><dd class="js-tx">—</dd></div>
698
- <div><dt>日限额</dt><dd class="js-day">—</dd></div>
699
- <div><dt>权限</dt><dd class="js-perms">—</dd></div>
700
- <div class="plugin-meta-full"><dt>创建时间</dt><dd class="js-created">—</dd></div>
701
  </dl>
702
  <footer class="plugin-card-foot">
703
- <button type="button" class="btn btn-ghost btn-sm" data-act="edit">编辑</button>
704
- <button type="button" class="btn btn-ghost btn-sm" data-act="rotate">轮换 Key</button>
705
- <button type="button" class="btn btn-ghost btn-sm js-toggle" data-act="toggle">启用</button>
706
- <button type="button" class="btn btn-ghost btn-sm btn-danger-text" data-act="delete">删除</button>
707
  </footer>
708
  </article>
709
  </template>
 
81
  <div class="brand-sub">Plugin Admin</div>
82
  </div>
83
  </div>
84
+ <nav class="crumbs" aria-label="位置" data-admin-plugin-label="plugin_location">
85
+ <span data-admin-plugin-text="plugin_console">控制台</span>
86
  <span class="crumbs-sep">/</span>
87
  <span class="crumbs-current">第三方插件</span>
88
  </nav>
 
112
  <div class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins" tabindex="0">
113
  <section class="page-head">
114
  <div class="page-head-text">
115
+ <h1 data-admin-plugin-text="plugin_title">第三方插件</h1>
116
+ <p class="page-sub" data-admin-plugin-text="plugin_subtitle">管理 plugin_key、限流配额、权限与启用状态。</p>
117
  </div>
118
  <div class="page-head-actions">
119
  <div class="search-wrap">
120
+ <input type="search" id="search-input" data-admin-plugin-label="plugin_search" data-admin-plugin-placeholder="plugin_search_placeholder" placeholder="搜索 plugin_id 或 名称…">
121
  </div>
122
+ <button type="button" id="btn-refresh" data-admin-plugin-title="plugin_refresh" class="btn btn-secondary" title="刷新">
123
+ <span class="btn-label" data-admin-plugin-text="plugin_refresh">刷新</span>
124
  </button>
125
  <button type="button" id="btn-create" class="btn btn-primary">
126
  <span class="btn-icon" aria-hidden="true">+</span>
127
+ <span class="btn-label" data-admin-plugin-text="plugin_create">创建新插件</span>
128
  </button>
129
  </div>
130
  </section>
131
 
132
  <section class="stat-row" id="stat-row">
133
  <div class="stat-card">
134
+ <div class="stat-label" data-admin-plugin-text="plugin_total">插件总数</div>
135
  <div class="stat-value" id="stat-total">—</div>
136
  </div>
137
  <div class="stat-card">
138
+ <div class="stat-label" data-admin-plugin-text="plugin_enabled">已启用</div>
139
  <div class="stat-value text-success" id="stat-enabled">—</div>
140
  </div>
141
  <div class="stat-card">
142
+ <div class="stat-label" data-admin-plugin-text="plugin_disabled">已禁用</div>
143
  <div class="stat-value text-warning" id="stat-disabled">—</div>
144
  </div>
145
  </section>
146
 
147
  <section class="list-shell">
148
+ <div id="list-loading" role="status" aria-atomic="true" class="state-block" hidden>
149
  <div class="spinner-lg" aria-hidden="true"></div>
150
+ <p data-admin-plugin-text="plugin_loading">正在加载插件列表…</p>
151
  </div>
152
+ <div id="list-empty" role="status" aria-atomic="true" class="state-block" hidden>
153
  <div class="empty-mark">∅</div>
154
+ <h3 data-admin-plugin-text="plugin_empty">暂无插件</h3>
155
+ <p data-admin-plugin-text="plugin_empty_hint">点击右上角「创建新插件」开始接入第三方应用。</p>
156
  </div>
157
+ <div id="list-error" role="alert" aria-atomic="true" class="state-block state-error" hidden>
158
  <div class="empty-mark">!</div>
159
+ <h3 data-admin-plugin-text="plugin_load_failed">加载失败</h3>
160
+ <p id="list-error-msg" data-admin-plugin-text="plugin_retry_hint">请稍后重试。</p>
161
+ <button type="button" class="btn btn-secondary" id="btn-retry" data-admin-plugin-text="plugin_retry">重新加载</button>
162
  </div>
163
  <div id="plugin-grid" class="plugin-grid"></div>
164
  </section>
 
482
  <div id="modal-create" class="modal-mask" hidden>
483
  <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
484
  <header class="modal-header">
485
+ <h3 id="modal-create-title" data-admin-plugin-text="plugin_create">创建新插件</h3>
486
+ <button type="button" class="modal-close" data-close="modal-create" aria-label="关闭" data-admin-plugin-label="close_dialog">×</button>
487
  </header>
488
  <form id="form-create" class="modal-body" novalidate>
489
+ <div class="form-error" id="form-create-error" role="alert" aria-atomic="true" hidden></div>
490
  <div class="grid-2">
491
  <label class="field">
492
  <span class="field-label">plugin_id <span class="required">*</span></span>
493
+ <input type="text" name="plugin_id" required pattern="^[a-z0-9_]{3,50}$" maxlength="50"
494
+ placeholder="小写英文/数字/下划线,3-50 字符" data-admin-plugin-placeholder="plugin_id_placeholder">
495
+ <span class="field-hint" data-admin-plugin-text="plugin_id_hint">用于标识插件,创建后不可修改。</span>
496
  </label>
497
  <label class="field">
498
+ <span class="field-label"><span data-admin-plugin-text="plugin_name">名称</span> <span class="required">*</span></span>
499
+ <input type="text" name="name" required placeholder="例如:示例插件" data-admin-plugin-placeholder="plugin_name_placeholder">
500
  </label>
501
  </div>
502
 
503
  <label class="field">
504
+ <span class="field-label" data-admin-plugin-text="plugin_description">描述</span>
505
+ <textarea name="description" rows="2" placeholder="对该插件的简要说明(可选)" data-admin-plugin-placeholder="plugin_description_placeholder"></textarea>
506
  </label>
507
 
508
  <div class="grid-3">
509
  <label class="field">
510
+ <span class="field-label" data-admin-plugin-text="plugin_rate_label">每分钟限流</span>
511
+ <input type="number" name="rate_limit" min="1" max="10000" step="1" value="100">
512
  </label>
513
  <label class="field">
514
+ <span class="field-label" data-admin-plugin-text="plugin_tx_label">单笔扣款上限</span>
515
+ <input type="number" name="max_deduct_per_tx" min="1" max="10000" step="1" value="100">
516
  </label>
517
  <label class="field">
518
+ <span class="field-label" data-admin-plugin-text="plugin_day_label">日扣款上限</span>
519
+ <input type="number" name="max_deduct_per_day" min="1" max="100000" step="1" value="1000">
520
  </label>
521
  </div>
522
 
523
  <fieldset class="field perm-field">
524
+ <legend class="field-label" data-admin-plugin-text="plugin_permissions">权限</legend>
525
  <div class="perm-grid">
526
+ <label class="perm-item"><input type="checkbox" name="permissions" value="verify" checked><span data-admin-plugin-text="plugin_perm_verify">验证 Key</span></label>
527
+ <label class="perm-item"><input type="checkbox" name="permissions" value="balance" checked><span data-admin-plugin-text="plugin_perm_balance">查询余额</span></label>
528
+ <label class="perm-item"><input type="checkbox" name="permissions" value="deduct" checked><span data-admin-plugin-text="plugin_perm_deduct">扣除积分</span></label>
529
+ <label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span data-admin-plugin-text="plugin_perm_refund">退款回滚</span></label>
530
  </div>
531
  </fieldset>
532
 
 
533
  </form>
534
  <footer class="modal-footer">
535
+ <button type="button" class="btn btn-ghost" data-close="modal-create" data-admin-plugin-text="plugin_cancel">取消</button>
536
  <button type="submit" form="form-create" id="btn-create-submit" class="btn btn-primary">
537
+ <span class="btn-label" data-admin-plugin-text="plugin_create_submit">创建插件</span>
538
  <span class="btn-spinner" aria-hidden="true"></span>
539
  </button>
540
  </footer>
 
545
  <div id="modal-edit" class="modal-mask" hidden>
546
  <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
547
  <header class="modal-header">
548
+ <h3 id="modal-edit-title" data-admin-plugin-text="plugin_edit_title">编辑插件</h3>
549
+ <button type="button" class="modal-close" data-close="modal-edit" aria-label="关闭" data-admin-plugin-label="close_dialog">×</button>
550
  </header>
551
  <form id="form-edit" class="modal-body" novalidate>
552
+ <div class="form-error" id="form-edit-error" role="alert" aria-atomic="true" hidden></div>
553
  <div class="grid-2">
554
  <label class="field">
555
  <span class="field-label">plugin_id</span>
556
  <input type="text" name="plugin_id" disabled>
557
+ <span class="field-hint" data-admin-plugin-text="plugin_id_fixed">不可修改。</span>
558
  </label>
559
  <label class="field">
560
+ <span class="field-label"><span data-admin-plugin-text="plugin_name">名称</span> <span class="required">*</span></span>
561
  <input type="text" name="name" required>
562
  </label>
563
  </div>
564
 
565
  <label class="field">
566
+ <span class="field-label" data-admin-plugin-text="plugin_description">描述</span>
567
  <textarea name="description" rows="2"></textarea>
568
  </label>
569
 
570
  <div class="grid-3">
571
  <label class="field">
572
+ <span class="field-label" data-admin-plugin-text="plugin_rate_label">每分钟限流</span>
573
+ <input type="number" name="rate_limit" min="1" max="10000" step="1">
574
  </label>
575
  <label class="field">
576
+ <span class="field-label" data-admin-plugin-text="plugin_tx_label">单笔扣款上限</span>
577
+ <input type="number" name="max_deduct_per_tx" min="1" max="10000" step="1">
578
  </label>
579
  <label class="field">
580
+ <span class="field-label" data-admin-plugin-text="plugin_day_label">日扣款上限</span>
581
+ <input type="number" name="max_deduct_per_day" min="1" max="100000" step="1">
582
  </label>
583
  </div>
584
 
585
  <fieldset class="field perm-field">
586
+ <legend class="field-label" data-admin-plugin-text="plugin_permissions">权限</legend>
587
  <div class="perm-grid">
588
+ <label class="perm-item"><input type="checkbox" name="permissions" value="verify"><span data-admin-plugin-text="plugin_perm_verify">验证 Key</span></label>
589
+ <label class="perm-item"><input type="checkbox" name="permissions" value="balance"><span data-admin-plugin-text="plugin_perm_balance">查询余额</span></label>
590
+ <label class="perm-item"><input type="checkbox" name="permissions" value="deduct"><span data-admin-plugin-text="plugin_perm_deduct">扣除积分</span></label>
591
+ <label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span data-admin-plugin-text="plugin_perm_refund">退款回滚</span></label>
592
  </div>
593
  </fieldset>
594
 
595
  <div class="field">
596
  <span class="field-label">plugin_key</span>
597
  <div class="static-key" id="edit-key-display">—</div>
598
+ <span class="field-hint" data-admin-plugin-text="plugin_key_hidden_hint">出于安全考虑,完整 Key 已隐藏。如需更换请使用「轮换 Key」。</span>
599
  </div>
600
 
 
601
  </form>
602
  <footer class="modal-footer">
603
+ <button type="button" class="btn btn-ghost" data-close="modal-edit" data-admin-plugin-text="plugin_cancel">取消</button>
604
  <button type="submit" form="form-edit" id="btn-edit-submit" class="btn btn-primary">
605
+ <span class="btn-label" data-admin-plugin-text="plugin_edit_submit">保存修改</span>
606
  <span class="btn-spinner" aria-hidden="true"></span>
607
  </button>
608
  </footer>
 
613
  <div id="modal-key" class="modal-mask" hidden>
614
  <div class="modal modal-md" role="dialog" aria-modal="true" aria-labelledby="modal-key-title">
615
  <header class="modal-header">
616
+ <h3 id="modal-key-title" data-admin-plugin-text="plugin_key_title">插件 Key 已生成</h3>
617
+ <button type="button" class="modal-close" data-close="modal-key" aria-label="关闭" data-admin-plugin-label="close_dialog">×</button>
618
  </header>
619
  <div class="modal-body">
620
  <div class="key-callout">
621
  <div class="key-callout-label">PLUGIN KEY</div>
622
  <div class="key-callout-value" id="key-display">—</div>
623
  <button type="button" id="btn-copy-key" class="btn btn-secondary btn-copy">
624
+ <span class="btn-label" data-admin-plugin-text="copy_key">复制 Key</span>
625
  </button>
626
  </div>
627
  <div class="key-warn">
628
+ <strong data-admin-plugin-text="plugin_key_save_title">请妥善保存此 Key</strong>
629
+ <span data-admin-plugin-text="plugin_key_save_hint">关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。</span>
630
  </div>
631
  </div>
632
  <footer class="modal-footer">
633
+ <button type="button" class="btn btn-primary" data-close="modal-key" data-admin-plugin-text="plugin_key_done">我已保存</button>
634
  </footer>
635
  </div>
636
  </div>
 
687
  </div>
688
  <span class="badge js-status">—</span>
689
  </header>
690
+ <p class="plugin-desc js-desc" data-admin-plugin-empty="plugin_description_empty" data-empty-description="暂无描述"></p>
691
  <div class="plugin-key-row">
692
  <span class="key-label">KEY</span>
693
  <code class="js-key">—</code>
694
  </div>
695
  <dl class="plugin-meta">
696
+ <div><dt data-admin-plugin-text="plugin_rate">限流</dt><dd><span class="js-rate">—</span> <em data-admin-plugin-text="plugin_rate_unit">次/分钟</em></dd></div>
697
+ <div><dt data-admin-plugin-text="plugin_tx_limit">单笔限额</dt><dd class="js-tx">—</dd></div>
698
+ <div><dt data-admin-plugin-text="plugin_day_limit">日限额</dt><dd class="js-day">—</dd></div>
699
+ <div><dt data-admin-plugin-text="plugin_permissions">权限</dt><dd class="js-perms">—</dd></div>
700
+ <div class="plugin-meta-full"><dt data-admin-plugin-text="plugin_created">创建时间</dt><dd class="js-created">—</dd></div>
701
  </dl>
702
  <footer class="plugin-card-foot">
703
+ <button type="button" class="btn btn-ghost btn-sm" data-act="edit" data-admin-plugin-text="plugin_edit">编辑</button>
704
+ <button type="button" class="btn btn-ghost btn-sm" data-act="rotate" data-admin-plugin-text="plugin_rotate">轮换 Key</button>
705
+ <button type="button" class="btn btn-ghost btn-sm js-toggle" data-act="toggle" data-admin-plugin-text="plugin_enable">启用</button>
706
+ <button type="button" class="btn btn-ghost btn-sm btn-danger-text" data-act="delete" data-admin-plugin-text="plugin_delete">删除</button>
707
  </footer>
708
  </article>
709
  </template>
管理界面/style.css CHANGED
@@ -647,7 +647,7 @@ code, pre { font-family: var(--font-mono); }
647
  min-height: 1.55em;
648
  }
649
  .plugin-desc:empty::before {
650
- content: "暂无描述";
651
  color: var(--text-dim);
652
  font-style: italic;
653
  }
@@ -800,6 +800,8 @@ code, pre { font-family: var(--font-mono); }
800
  font-size: 14px;
801
  color: var(--text-soft);
802
  line-height: 1.6;
 
 
803
  }
804
 
805
  /* ---------- Key 高亮展示 ---------- */
 
647
  min-height: 1.55em;
648
  }
649
  .plugin-desc:empty::before {
650
+ content: attr(data-empty-description);
651
  color: var(--text-dim);
652
  font-style: italic;
653
  }
 
800
  font-size: 14px;
801
  color: var(--text-soft);
802
  line-height: 1.6;
803
+ white-space: pre-wrap;
804
+ overflow-wrap: anywhere;
805
  }
806
 
807
  /* ---------- Key 高亮展示 ---------- */