xiaolongmr commited on
Commit
eaf2aba
·
1 Parent(s): 41fee80

更新卡密

Browse files
.codex/config.toml DELETED
@@ -1 +0,0 @@
1
- sandbox_mode = "workspace-write"
 
 
.gitignore CHANGED
@@ -16,6 +16,7 @@ edit
16
  docker-compose-local.yml
17
 
18
  .claude/settings.local.json
 
19
 
20
  # Environment variables
21
  .env
 
16
  docker-compose-local.yml
17
 
18
  .claude/settings.local.json
19
+ .codex/
20
 
21
  # Environment variables
22
  .env
api-dev-8000.err.log CHANGED
@@ -1,16 +1,4 @@
1
- INFO: Started server process [40272]
2
  INFO: Waiting for application startup.
3
  INFO: Application startup complete.
4
  INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
5
- Exception in callback _ProactorBasePipeTransport._call_connection_lost()
6
- handle: <Handle _ProactorBasePipeTransport._call_connection_lost()>
7
- Traceback (most recent call last):
8
- File "D:\python\Lib\asyncio\events.py", line 89, in _run
9
- self._context.run(self._callback, *self._args)
10
- ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
11
- File "D:\python\Lib\asyncio\proactor_events.py", line 165, in _call_connection_lost
12
- self._sock.shutdown(socket.SHUT_RDWR)
13
- ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
14
- ConnectionResetError: [WinError 10054] Զ������ǿ�ȹر���һ�����е����ӡ�
15
- [INFO] {"event": "image_stream_resolve_start", "conversation_id": "6a4a75a7-69c4-83e8-b9d8-ecfa6fde267f", "file_ids": ["file_00000000d2d071fd8f47613807227388"], "sediment_ids": ["file_00000000d2d071fd8f47613807227388"], "tool_invoked": false, "turn_use_case": "image gen"}
16
- [INFO] {"event": "image_stream_resolve_start", "conversation_id": "6a4a77a2-d920-83e8-b6ed-619b8d8240c5", "file_ids": ["file_000000005f2c71fda3a38dd984c85153"], "sediment_ids": ["file_000000005f2c71fda3a38dd984c85153"], "tool_invoked": false, "turn_use_case": "image gen"}
 
1
+ INFO: Started server process [42208]
2
  INFO: Waiting for application startup.
3
  INFO: Application startup complete.
4
  INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
 
 
 
 
 
 
 
 
 
 
 
 
api/ai.py CHANGED
@@ -179,6 +179,7 @@ class CreditReservation:
179
  self.amount = max(0, int(amount or 0))
180
  self.daily_amount = 0
181
  self.paid_amount = 0
 
182
  self.grant_reservations: list[dict[str, object]] = []
183
  self.settled = False
184
 
@@ -194,6 +195,7 @@ class CreditReservation:
194
  details = {}
195
  reservation.daily_amount = max(0, int(details.get("daily") or 0))
196
  reservation.paid_amount = max(0, int(details.get("permanent_paid") if "permanent_paid" in details else details.get("paid") or 0))
 
197
  reservation.grant_reservations = details.get("grants") if isinstance(details.get("grants"), list) else []
198
  return reservation
199
 
@@ -207,6 +209,8 @@ class CreditReservation:
207
  consume_count = min(self.amount, max(0, int(success_count or 0)))
208
  daily_to_consume = min(self.daily_amount, consume_count)
209
  remaining = consume_count - daily_to_consume
 
 
210
  grants_to_consume, grants_to_refund = _split_grants(self.grant_reservations, remaining)
211
  remaining -= sum(max(0, int(item.get("amount") or 0)) for item in grants_to_consume)
212
  paid_to_consume = min(self.paid_amount, remaining)
@@ -220,6 +224,7 @@ class CreditReservation:
220
  consume_count,
221
  daily_amount=daily_to_consume,
222
  paid_amount=paid_to_consume,
 
223
  grant_reservations=grants_to_consume,
224
  )
225
  refund_count = self.amount - consume_count
@@ -229,6 +234,7 @@ class CreditReservation:
229
  refund_count,
230
  daily_amount=daily_to_refund,
231
  paid_amount=paid_to_refund,
 
232
  grant_reservations=grants_to_refund,
233
  )
234
  self.settled = True
 
179
  self.amount = max(0, int(amount or 0))
180
  self.daily_amount = 0
181
  self.paid_amount = 0
182
+ self.package_reservations: list[dict[str, object]] = []
183
  self.grant_reservations: list[dict[str, object]] = []
184
  self.settled = False
185
 
 
195
  details = {}
196
  reservation.daily_amount = max(0, int(details.get("daily") or 0))
197
  reservation.paid_amount = max(0, int(details.get("permanent_paid") if "permanent_paid" in details else details.get("paid") or 0))
198
+ reservation.package_reservations = details.get("packages") if isinstance(details.get("packages"), list) else []
199
  reservation.grant_reservations = details.get("grants") if isinstance(details.get("grants"), list) else []
200
  return reservation
201
 
 
209
  consume_count = min(self.amount, max(0, int(success_count or 0)))
210
  daily_to_consume = min(self.daily_amount, consume_count)
211
  remaining = consume_count - daily_to_consume
212
+ packages_to_consume, packages_to_refund = _split_grants(self.package_reservations, remaining)
213
+ remaining -= sum(max(0, int(item.get("amount") or 0)) for item in packages_to_consume)
214
  grants_to_consume, grants_to_refund = _split_grants(self.grant_reservations, remaining)
215
  remaining -= sum(max(0, int(item.get("amount") or 0)) for item in grants_to_consume)
216
  paid_to_consume = min(self.paid_amount, remaining)
 
224
  consume_count,
225
  daily_amount=daily_to_consume,
226
  paid_amount=paid_to_consume,
227
+ package_reservations=packages_to_consume,
228
  grant_reservations=grants_to_consume,
229
  )
230
  refund_count = self.amount - consume_count
 
234
  refund_count,
235
  daily_amount=daily_to_refund,
236
  paid_amount=paid_to_refund,
237
+ package_reservations=packages_to_refund,
238
  grant_reservations=grants_to_refund,
239
  )
240
  self.settled = True
api/shop.py CHANGED
@@ -22,12 +22,29 @@ class CodeBatchCreateRequest(BaseModel):
22
  validity_days: int | None = Field(default=None, ge=1, le=3650)
23
  credit_expires_days: int | None = Field(default=None, ge=1, le=3650)
24
  batch_single_use: bool = False
 
 
 
 
 
 
 
 
25
 
26
 
27
  class CodeDisableRequest(BaseModel):
28
  code_id: str = ""
29
 
30
 
 
 
 
 
 
 
 
 
 
31
  class CreditAdjustRequest(BaseModel):
32
  delta: int
33
  note: str = ""
@@ -64,6 +81,13 @@ def create_router() -> APIRouter:
64
  identity = require_identity(authorization)
65
  return {"credits": auth_service.credit_summary(identity)}
66
 
 
 
 
 
 
 
 
67
  @router.get("/api/shop/public")
68
  async def get_shop_public_config(authorization: str | None = Header(default=None)):
69
  require_identity(authorization)
@@ -72,8 +96,8 @@ def create_router() -> APIRouter:
72
  @router.get("/api/me/api-key")
73
  async def get_my_api_key(authorization: str | None = Header(default=None)):
74
  identity = require_identity(authorization)
75
- if str(identity.get("role") or "").strip().lower() != "normal":
76
- raise HTTPException(status_code=403, detail={"error": "只有普通用户需要查看外部调用密钥"})
77
  api_key = auth_service.get_api_key_display(str(identity.get("id") or ""))
78
  if not api_key:
79
  raise HTTPException(status_code=404, detail={"error": "当前用户密钥无法回显,请联系管理员重置密钥"})
@@ -113,6 +137,14 @@ def create_router() -> APIRouter:
113
  validity_days=body.validity_days,
114
  credit_expires_days=body.credit_expires_days,
115
  batch_single_use=body.batch_single_use,
 
 
 
 
 
 
 
 
116
  )
117
  except ValueError as exc:
118
  raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
@@ -144,6 +176,21 @@ def create_router() -> APIRouter:
144
  raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
145
  return {"item": item, "items": shop_service.list_codes()}
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  @router.get("/api/shop/ledger")
148
  async def list_ledger(authorization: str | None = Header(default=None)):
149
  require_admin(authorization)
 
22
  validity_days: int | None = Field(default=None, ge=1, le=3650)
23
  credit_expires_days: int | None = Field(default=None, ge=1, le=3650)
24
  batch_single_use: bool = False
25
+ code_type: str = "normal"
26
+ daily_gift_date: str = ""
27
+ package_name: str = ""
28
+ package_duration_days: int = Field(default=1, ge=1, le=3650)
29
+ package_daily_limit: int | None = Field(default=None, ge=0, le=1000000)
30
+ package_weekly_limit: int | None = Field(default=None, ge=0, le=1000000)
31
+ package_monthly_limit: int | None = Field(default=None, ge=0, le=1000000)
32
+ package_total_limit: int | None = Field(default=None, ge=0, le=1000000)
33
 
34
 
35
  class CodeDisableRequest(BaseModel):
36
  code_id: str = ""
37
 
38
 
39
+ class CodeCleanupRequest(BaseModel):
40
+ delete_expired: bool = True
41
+ delete_disabled: bool = False
42
+ archive_redeemed: bool = False
43
+ redeemed_older_than_days: int = Field(default=90, ge=1, le=3650)
44
+ limit: int = Field(default=1000, ge=1, le=20000)
45
+ dry_run: bool = True
46
+
47
+
48
  class CreditAdjustRequest(BaseModel):
49
  delta: int
50
  note: str = ""
 
81
  identity = require_identity(authorization)
82
  return {"credits": auth_service.credit_summary(identity)}
83
 
84
+ @router.get("/api/me/redeem-codes")
85
+ async def get_my_redeem_codes(authorization: str | None = Header(default=None)):
86
+ identity = require_identity(authorization)
87
+ if str(identity.get("role") or "").strip().lower() != "normal":
88
+ raise HTTPException(status_code=403, detail={"error": "只有普通用户可以查看自己的卡密"})
89
+ return {"items": shop_service.list_user_codes(str(identity.get("id") or ""))}
90
+
91
  @router.get("/api/shop/public")
92
  async def get_shop_public_config(authorization: str | None = Header(default=None)):
93
  require_identity(authorization)
 
96
  @router.get("/api/me/api-key")
97
  async def get_my_api_key(authorization: str | None = Header(default=None)):
98
  identity = require_identity(authorization)
99
+ if str(identity.get("role") or "").strip().lower() not in {"normal", "user"}:
100
+ raise HTTPException(status_code=403, detail={"error": "只有微信用户或内部用户可以查看密钥"})
101
  api_key = auth_service.get_api_key_display(str(identity.get("id") or ""))
102
  if not api_key:
103
  raise HTTPException(status_code=404, detail={"error": "当前用户密钥无法回显,请联系管理员重置密钥"})
 
137
  validity_days=body.validity_days,
138
  credit_expires_days=body.credit_expires_days,
139
  batch_single_use=body.batch_single_use,
140
+ code_type=body.code_type,
141
+ daily_gift_date=body.daily_gift_date,
142
+ package_name=body.package_name,
143
+ package_duration_days=body.package_duration_days,
144
+ package_daily_limit=body.package_daily_limit,
145
+ package_weekly_limit=body.package_weekly_limit,
146
+ package_monthly_limit=body.package_monthly_limit,
147
+ package_total_limit=body.package_total_limit,
148
  )
149
  except ValueError as exc:
150
  raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
 
176
  raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
177
  return {"item": item, "items": shop_service.list_codes()}
178
 
179
+ @router.post("/api/shop/codes/cleanup")
180
+ async def cleanup_codes(body: CodeCleanupRequest, authorization: str | None = Header(default=None)):
181
+ require_admin(authorization)
182
+ try:
183
+ return shop_service.cleanup_codes(
184
+ delete_expired=body.delete_expired,
185
+ delete_disabled=body.delete_disabled,
186
+ delete_redeemed=body.archive_redeemed,
187
+ redeemed_older_than_days=body.redeemed_older_than_days,
188
+ limit=body.limit,
189
+ dry_run=body.dry_run,
190
+ )
191
+ except ValueError as exc:
192
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
193
+
194
  @router.get("/api/shop/ledger")
195
  async def list_ledger(authorization: str | None = Header(default=None)):
196
  require_admin(authorization)
api/support.py CHANGED
@@ -88,11 +88,18 @@ def start_limited_account_watcher(stop_event: Event) -> Thread:
88
  def worker() -> None:
89
  last_refresh_at = 0.0
90
  last_free_cleanup_at = 0.0
 
91
  while not stop_event.is_set():
92
  refresh_interval_seconds = max(60, config.refresh_account_interval_minute * 60)
93
  free_cleanup_settings = config.get_free_account_cleanup_settings()
94
  free_cleanup_enabled = bool(free_cleanup_settings.get("enabled"))
95
  free_cleanup_interval_seconds = max(60, int(free_cleanup_settings.get("interval_minutes") or 10) * 60)
 
 
 
 
 
 
96
  now = time.monotonic()
97
  try:
98
  if now - last_refresh_at >= refresh_interval_seconds:
@@ -112,14 +119,38 @@ def start_limited_account_watcher(stop_event: Event) -> Thread:
112
  f"errors {len(result.get('errors') or [])}"
113
  )
114
  last_free_cleanup_at = now
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  except Exception as exc:
116
  print(f"[account-limited-watcher] fail {exc}")
117
  last_refresh_at = now
118
  if free_cleanup_enabled:
119
  last_free_cleanup_at = now
 
 
120
  wait_candidates = [last_refresh_at + refresh_interval_seconds]
121
  if free_cleanup_enabled:
122
  wait_candidates.append(last_free_cleanup_at + free_cleanup_interval_seconds)
 
 
123
  wait_seconds = max(1.0, min(wait_candidates) - time.monotonic())
124
  stop_event.wait(wait_seconds)
125
 
 
88
  def worker() -> None:
89
  last_refresh_at = 0.0
90
  last_free_cleanup_at = 0.0
91
+ last_redeem_code_cleanup_at = 0.0
92
  while not stop_event.is_set():
93
  refresh_interval_seconds = max(60, config.refresh_account_interval_minute * 60)
94
  free_cleanup_settings = config.get_free_account_cleanup_settings()
95
  free_cleanup_enabled = bool(free_cleanup_settings.get("enabled"))
96
  free_cleanup_interval_seconds = max(60, int(free_cleanup_settings.get("interval_minutes") or 10) * 60)
97
+ redeem_code_cleanup_settings = config.get_redeem_code_cleanup_settings()
98
+ redeem_code_cleanup_enabled = bool(redeem_code_cleanup_settings.get("enabled"))
99
+ redeem_code_cleanup_interval_seconds = max(
100
+ 60,
101
+ int(redeem_code_cleanup_settings.get("interval_minutes") or 1440) * 60,
102
+ )
103
  now = time.monotonic()
104
  try:
105
  if now - last_refresh_at >= refresh_interval_seconds:
 
119
  f"errors {len(result.get('errors') or [])}"
120
  )
121
  last_free_cleanup_at = now
122
+ if redeem_code_cleanup_enabled and now - last_redeem_code_cleanup_at >= redeem_code_cleanup_interval_seconds:
123
+ from services.shop_service import shop_service
124
+
125
+ result = shop_service.cleanup_codes(
126
+ delete_expired=bool(redeem_code_cleanup_settings.get("delete_expired")),
127
+ delete_disabled=bool(redeem_code_cleanup_settings.get("delete_disabled")),
128
+ delete_redeemed=bool(redeem_code_cleanup_settings.get("archive_redeemed")),
129
+ redeemed_older_than_days=int(redeem_code_cleanup_settings.get("redeemed_older_than_days") or 90),
130
+ limit=int(redeem_code_cleanup_settings.get("limit") or 1000),
131
+ dry_run=False,
132
+ )
133
+ summary = result.get("summary") or {}
134
+ deleted = int(summary.get("deleted") or 0)
135
+ if deleted:
136
+ print(
137
+ "[redeem-code-cleanup-watcher] "
138
+ f"deleted {deleted} codes, archived {summary.get('archived', 0)}, "
139
+ f"archive {summary.get('archive_path') or '-'}"
140
+ )
141
+ last_redeem_code_cleanup_at = now
142
  except Exception as exc:
143
  print(f"[account-limited-watcher] fail {exc}")
144
  last_refresh_at = now
145
  if free_cleanup_enabled:
146
  last_free_cleanup_at = now
147
+ if redeem_code_cleanup_enabled:
148
+ last_redeem_code_cleanup_at = now
149
  wait_candidates = [last_refresh_at + refresh_interval_seconds]
150
  if free_cleanup_enabled:
151
  wait_candidates.append(last_free_cleanup_at + free_cleanup_interval_seconds)
152
+ if redeem_code_cleanup_enabled:
153
+ wait_candidates.append(last_redeem_code_cleanup_at + redeem_code_cleanup_interval_seconds)
154
  wait_seconds = max(1.0, min(wait_candidates) - time.monotonic())
155
  stop_event.wait(wait_seconds)
156
 
services/auth_service.py CHANGED
@@ -29,6 +29,18 @@ def _today_key() -> str:
29
  return datetime.now(tz).strftime("%Y-%m-%d")
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _hash_key(value: str) -> str:
33
  return hashlib.sha256(value.encode("utf-8")).hexdigest()
34
 
@@ -93,6 +105,114 @@ def _expiring_credit_reserved(item: dict[str, object]) -> int:
93
  return sum(_to_nonnegative_int(grant.get("reserved")) for grant in _normalize_credit_grants(item.get("credit_grants")))
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  class AuthService:
97
  def __init__(self, storage: StorageBackend):
98
  self.storage = storage
@@ -139,8 +259,10 @@ class AuthService:
139
  "used_credits": _to_nonnegative_int(raw.get("used_credits")),
140
  "total_recharged": _to_nonnegative_int(raw.get("total_recharged")),
141
  "credit_grants": _normalize_credit_grants(raw.get("credit_grants")),
 
142
  "daily_credits_date": self._clean(raw.get("daily_credits_date")),
143
  "daily_credits_used": _to_nonnegative_int(raw.get("daily_credits_used")),
 
144
  "daily_reserved_credits": _to_nonnegative_int(raw.get("daily_reserved_credits")),
145
  "api_key_display": self._clean(raw.get("api_key_display")),
146
  "wechat_openid": self._clean(raw.get("wechat_openid")),
@@ -215,10 +337,29 @@ class AuthService:
215
  return 0, 0
216
  return _to_nonnegative_int(item.get("daily_credits_used")), reserved
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  @staticmethod
219
  def _daily_remaining(item: dict[str, object]) -> int:
220
  used, reserved = AuthService._daily_state(item)
221
- return max(0, config.normal_user_daily_credits - used - reserved)
222
 
223
  @staticmethod
224
  def _reset_daily_if_needed(item: dict[str, object]) -> dict[str, object]:
@@ -232,6 +373,7 @@ class AuthService:
232
  next_item = dict(item)
233
  next_item["daily_credits_date"] = today
234
  next_item["daily_credits_used"] = 0
 
235
  next_item["daily_reserved_credits"] = 0
236
  return next_item
237
 
@@ -240,9 +382,12 @@ class AuthService:
240
  permanent_credits = _to_nonnegative_int(item.get("credits"))
241
  expiring_credits = _expiring_credit_remaining(item)
242
  paid_credits = permanent_credits + expiring_credits
 
243
  daily_used, daily_reserved = AuthService._daily_state(item)
 
 
244
  daily_remaining = AuthService._daily_remaining(item)
245
- total_available = paid_credits + daily_remaining if item.get("role") == "normal" else paid_credits
246
  return {
247
  "id": item.get("id"),
248
  "name": item.get("name"),
@@ -256,6 +401,33 @@ class AuthService:
256
  "permanent_credits": permanent_credits,
257
  "expiring_credits": expiring_credits,
258
  "expiring_reserved_credits": _expiring_credit_reserved(item),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  "credit_grants": [
260
  {
261
  "id": grant.get("id"),
@@ -270,9 +442,10 @@ class AuthService:
270
  "reserved_credits": _to_nonnegative_int(item.get("reserved_credits")),
271
  "used_credits": _to_nonnegative_int(item.get("used_credits")),
272
  "total_recharged": _to_nonnegative_int(item.get("total_recharged")),
273
- "daily_credits": config.normal_user_daily_credits if item.get("role") == "normal" else 0,
274
  "daily_credits_remaining": daily_remaining,
275
  "daily_credits_used": daily_used,
 
276
  "daily_reserved_credits": daily_reserved,
277
  "daily_credits_date": _today_key() if item.get("role") == "normal" else "",
278
  "has_api_key_display": bool(AuthService._clean(item.get("api_key_display"))),
@@ -393,10 +566,12 @@ class AuthService:
393
  "used_credits": 0,
394
  "total_recharged": 0,
395
  "credit_grants": [],
 
396
  "daily_credits_date": _today_key() if role == "normal" else "",
397
  "daily_credits_used": 0,
 
398
  "daily_reserved_credits": 0,
399
- "api_key_display": raw_key if role == "normal" else "",
400
  "wechat_openid": "",
401
  "wechat_nickname": "",
402
  "wechat_avatar": "",
@@ -470,8 +645,10 @@ class AuthService:
470
  "used_credits": 0,
471
  "total_recharged": 0,
472
  "credit_grants": [],
 
473
  "daily_credits_date": _today_key(),
474
  "daily_credits_used": 0,
 
475
  "daily_reserved_credits": 0,
476
  "api_key_display": raw_key,
477
  "wechat_openid": normalized_openid,
@@ -495,6 +672,7 @@ class AuthService:
495
  "id": item.get("id"),
496
  "name": item.get("name"),
497
  "role": item.get("role"),
 
498
  "account_pool_enabled": bool(item.get("account_pool_enabled")),
499
  "wechat_openid": self._clean(item.get("wechat_openid")),
500
  "wechat_bound_at": item.get("wechat_bound_at"),
@@ -513,7 +691,7 @@ class AuthService:
513
  with self._lock:
514
  self._reload_locked()
515
  for item in self._items:
516
- if item.get("id") == normalized_id and item.get("role") == "normal":
517
  return self._clean(item.get("api_key_display"))
518
  return ""
519
 
@@ -550,7 +728,7 @@ class AuthService:
550
  if "key" in updates and updates.get("key") is not None:
551
  raw_key = str(updates.get("key") or "")
552
  next_item["key_hash"] = self._build_key_hash_locked(raw_key, exclude_id=normalized_id)
553
- if next_role == "normal":
554
  next_item["api_key_display"] = self._clean(raw_key)
555
  next_items = list(items)
556
  next_items[index] = next_item
@@ -590,9 +768,12 @@ class AuthService:
590
  "expiring_credits": 0,
591
  "expiring_reserved_credits": 0,
592
  "credit_grants": [],
 
 
593
  "daily_credits": 0,
594
  "daily_credits_remaining": 0,
595
  "daily_credits_used": None,
 
596
  "daily_reserved_credits": 0,
597
  "daily_credits_date": "",
598
  }
@@ -610,9 +791,12 @@ class AuthService:
610
  "expiring_credits": 0,
611
  "expiring_reserved_credits": 0,
612
  "credit_grants": [],
 
 
613
  "daily_credits": config.normal_user_daily_credits,
614
  "daily_credits_remaining": 0,
615
  "daily_credits_used": 0,
 
616
  "daily_reserved_credits": 0,
617
  "daily_credits_date": _today_key(),
618
  }
@@ -631,9 +815,12 @@ class AuthService:
631
  "expiring_credits": _to_nonnegative_int(item.get("expiring_credits")),
632
  "expiring_reserved_credits": _to_nonnegative_int(item.get("expiring_reserved_credits")),
633
  "credit_grants": item.get("credit_grants") if isinstance(item.get("credit_grants"), list) else [],
 
 
634
  "daily_credits": _to_nonnegative_int(item.get("daily_credits")),
635
  "daily_credits_remaining": _to_nonnegative_int(item.get("daily_credits_remaining")),
636
  "daily_credits_used": _to_nonnegative_int(item.get("daily_credits_used")),
 
637
  "daily_reserved_credits": _to_nonnegative_int(item.get("daily_reserved_credits")),
638
  "daily_credits_date": self._clean(item.get("daily_credits_date")),
639
  }
@@ -702,6 +889,116 @@ class AuthService:
702
  with self._lock:
703
  return self._update_items_locked(mutate)
704
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
  def reserve_credits(self, key_id: str, amount: int) -> dict[str, object]:
706
  amount = max(1, int(amount))
707
  normalized_id = self._clean(key_id)
@@ -713,6 +1010,7 @@ class AuthService:
713
  normalized_item = self._reset_daily_if_needed(dict(item))
714
  credits = _to_nonnegative_int(normalized_item.get("credits"))
715
  grants = _normalize_credit_grants(normalized_item.get("credit_grants"))
 
716
  expiring_available = sum(
717
  _to_nonnegative_int(grant.get("remaining"))
718
  for grant in grants
@@ -720,7 +1018,25 @@ class AuthService:
720
  )
721
  daily_available = self._daily_remaining(normalized_item)
722
  daily_to_reserve = min(amount, daily_available)
723
- paid_to_reserve = amount - daily_to_reserve
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  if credits + expiring_available < paid_to_reserve:
725
  raise ValueError("剩余次数不足,请先充值")
726
  next_item = dict(normalized_item)
@@ -742,9 +1058,11 @@ class AuthService:
742
  next_item["reserved_credits"] = _to_nonnegative_int(normalized_item.get("reserved_credits")) + permanent_to_reserve
743
  next_item["daily_reserved_credits"] = _to_nonnegative_int(normalized_item.get("daily_reserved_credits")) + daily_to_reserve
744
  next_item["credit_grants"] = grants
 
745
  result = self._public_item(next_item)
746
  result["_credit_reservation"] = {
747
  "daily": daily_to_reserve,
 
748
  "paid": paid_to_reserve,
749
  "permanent_paid": permanent_to_reserve,
750
  "grants": grant_reservations,
@@ -763,6 +1081,7 @@ class AuthService:
763
  amount: int,
764
  *,
765
  daily_amount: int | None = None,
 
766
  paid_amount: int | None = None,
767
  grant_reservations: list[dict[str, object]] | None = None,
768
  ) -> dict[str, object] | None:
@@ -777,6 +1096,41 @@ class AuthService:
777
  daily_reserved = _to_nonnegative_int(normalized_item.get("daily_reserved_credits"))
778
  paid_reserved = _to_nonnegative_int(normalized_item.get("reserved_credits"))
779
  grants = _normalize_credit_grants(normalized_item.get("credit_grants"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
780
  grant_consumed = 0
781
  for reservation in grant_reservations or []:
782
  if not isinstance(reservation, dict):
@@ -804,7 +1158,8 @@ class AuthService:
804
  next_item["daily_credits_used"] = _to_nonnegative_int(normalized_item.get("daily_credits_used")) + daily_consumed
805
  next_item["reserved_credits"] = paid_reserved - paid_consumed
806
  next_item["credit_grants"] = grants
807
- next_item["used_credits"] = _to_nonnegative_int(normalized_item.get("used_credits")) + consumed + grant_consumed
 
808
  next_items = list(items)
809
  next_items[index] = next_item
810
  return next_items, self._public_item(next_item)
@@ -820,6 +1175,7 @@ class AuthService:
820
  *,
821
  daily_amount: int | None = None,
822
  paid_amount: int | None = None,
 
823
  grant_reservations: list[dict[str, object]] | None = None,
824
  ) -> dict[str, object] | None:
825
  amount = max(1, int(amount))
@@ -833,6 +1189,30 @@ class AuthService:
833
  daily_reserved = _to_nonnegative_int(normalized_item.get("daily_reserved_credits"))
834
  paid_reserved = _to_nonnegative_int(normalized_item.get("reserved_credits"))
835
  grants = _normalize_credit_grants(normalized_item.get("credit_grants"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  for reservation in grant_reservations or []:
837
  if not isinstance(reservation, dict):
838
  continue
@@ -858,6 +1238,7 @@ class AuthService:
858
  next_item["reserved_credits"] = paid_reserved - paid_refunded
859
  next_item["credits"] = _to_nonnegative_int(normalized_item.get("credits")) + paid_refunded
860
  next_item["credit_grants"] = grants
 
861
  next_items = list(items)
862
  next_items[index] = next_item
863
  return next_items, self._public_item(next_item)
 
29
  return datetime.now(tz).strftime("%Y-%m-%d")
30
 
31
 
32
+ def _china_now() -> datetime:
33
+ tz = ZoneInfo("Asia/Shanghai") if ZoneInfo else timezone(timedelta(hours=8))
34
+ return datetime.now(tz)
35
+
36
+
37
+ def _period_keys(now: datetime | None = None) -> tuple[str, str, str]:
38
+ current = now or _china_now()
39
+ date_key = current.strftime("%Y-%m-%d")
40
+ iso_year, iso_week, _ = current.isocalendar()
41
+ return date_key, f"{iso_year}-W{iso_week:02d}", current.strftime("%Y-%m")
42
+
43
+
44
  def _hash_key(value: str) -> str:
45
  return hashlib.sha256(value.encode("utf-8")).hexdigest()
46
 
 
105
  return sum(_to_nonnegative_int(grant.get("reserved")) for grant in _normalize_credit_grants(item.get("credit_grants")))
106
 
107
 
108
+ PACKAGE_UNLIMITED_SENTINEL = 10**9
109
+
110
+
111
+ def _normalize_package_grants(value: object) -> list[dict[str, object]]:
112
+ source = value if isinstance(value, list) else []
113
+ grants: list[dict[str, object]] = []
114
+ for raw in source:
115
+ if not isinstance(raw, dict):
116
+ continue
117
+ grant_id = str(raw.get("id") or "").strip() or uuid.uuid4().hex[:12]
118
+ expires_at = str(raw.get("expires_at") or "").strip()
119
+ if not expires_at:
120
+ continue
121
+ grants.append(
122
+ {
123
+ "id": grant_id,
124
+ "name": str(raw.get("name") or "套餐").strip() or "套餐",
125
+ "created_at": str(raw.get("created_at") or "").strip() or _now_iso(),
126
+ "starts_at": str(raw.get("starts_at") or "").strip(),
127
+ "expires_at": expires_at,
128
+ "source": str(raw.get("source") or "").strip(),
129
+ "code_id": str(raw.get("code_id") or "").strip(),
130
+ "daily_limit": _to_nonnegative_int(raw.get("daily_limit")),
131
+ "weekly_limit": _to_nonnegative_int(raw.get("weekly_limit")),
132
+ "monthly_limit": _to_nonnegative_int(raw.get("monthly_limit")),
133
+ "total_limit": _to_nonnegative_int(raw.get("total_limit")),
134
+ "used_total": _to_nonnegative_int(raw.get("used_total")),
135
+ "reserved_total": _to_nonnegative_int(raw.get("reserved_total")),
136
+ "daily_key": str(raw.get("daily_key") or "").strip(),
137
+ "used_daily": _to_nonnegative_int(raw.get("used_daily")),
138
+ "reserved_daily": _to_nonnegative_int(raw.get("reserved_daily")),
139
+ "weekly_key": str(raw.get("weekly_key") or "").strip(),
140
+ "used_weekly": _to_nonnegative_int(raw.get("used_weekly")),
141
+ "reserved_weekly": _to_nonnegative_int(raw.get("reserved_weekly")),
142
+ "monthly_key": str(raw.get("monthly_key") or "").strip(),
143
+ "used_monthly": _to_nonnegative_int(raw.get("used_monthly")),
144
+ "reserved_monthly": _to_nonnegative_int(raw.get("reserved_monthly")),
145
+ }
146
+ )
147
+ grants.sort(key=lambda item: str(item.get("expires_at") or ""))
148
+ return grants
149
+
150
+
151
+ def _reset_package_periods(grant: dict[str, object], now: datetime | None = None) -> dict[str, object]:
152
+ date_key, week_key, month_key = _period_keys(now)
153
+ next_grant = dict(grant)
154
+ if str(next_grant.get("daily_key") or "") != date_key:
155
+ next_grant["daily_key"] = date_key
156
+ next_grant["used_daily"] = 0
157
+ next_grant["reserved_daily"] = 0
158
+ if str(next_grant.get("weekly_key") or "") != week_key:
159
+ next_grant["weekly_key"] = week_key
160
+ next_grant["used_weekly"] = 0
161
+ next_grant["reserved_weekly"] = 0
162
+ if str(next_grant.get("monthly_key") or "") != month_key:
163
+ next_grant["monthly_key"] = month_key
164
+ next_grant["used_monthly"] = 0
165
+ next_grant["reserved_monthly"] = 0
166
+ return next_grant
167
+
168
+
169
+ def _package_active(grant: dict[str, object]) -> bool:
170
+ starts_at = str(grant.get("starts_at") or "").strip()
171
+ parsed_starts_at = _parse_iso_datetime(starts_at) if starts_at else None
172
+ return (parsed_starts_at is None or parsed_starts_at <= datetime.now(timezone.utc)) and _is_future_datetime(grant.get("expires_at"))
173
+
174
+
175
+ def _package_available(grant: dict[str, object]) -> int:
176
+ if not _package_active(grant):
177
+ return 0
178
+ normalized = _reset_package_periods(grant)
179
+ candidates: list[int] = []
180
+ limit_pairs = [
181
+ ("total_limit", "used_total", "reserved_total"),
182
+ ("daily_limit", "used_daily", "reserved_daily"),
183
+ ("weekly_limit", "used_weekly", "reserved_weekly"),
184
+ ("monthly_limit", "used_monthly", "reserved_monthly"),
185
+ ]
186
+ for limit_key, used_key, reserved_key in limit_pairs:
187
+ limit = _to_nonnegative_int(normalized.get(limit_key))
188
+ if limit <= 0:
189
+ continue
190
+ candidates.append(max(0, limit - _to_nonnegative_int(normalized.get(used_key)) - _to_nonnegative_int(normalized.get(reserved_key))))
191
+ if not candidates:
192
+ return PACKAGE_UNLIMITED_SENTINEL
193
+ return min(candidates)
194
+
195
+
196
+ def _active_package_grants(item: dict[str, object]) -> list[dict[str, object]]:
197
+ grants: list[dict[str, object]] = []
198
+ for grant in _normalize_package_grants(item.get("package_grants")):
199
+ grant = _reset_package_periods(grant)
200
+ if _package_active(grant):
201
+ grants.append(grant)
202
+ return grants
203
+
204
+
205
+ def _settleable_package_grants(item: dict[str, object]) -> list[dict[str, object]]:
206
+ return [_reset_package_periods(grant) for grant in _normalize_package_grants(item.get("package_grants"))]
207
+
208
+
209
+ def _package_credit_available(item: dict[str, object]) -> int:
210
+ total = 0
211
+ for grant in _active_package_grants(item):
212
+ total += _package_available(grant)
213
+ return min(total, PACKAGE_UNLIMITED_SENTINEL)
214
+
215
+
216
  class AuthService:
217
  def __init__(self, storage: StorageBackend):
218
  self.storage = storage
 
259
  "used_credits": _to_nonnegative_int(raw.get("used_credits")),
260
  "total_recharged": _to_nonnegative_int(raw.get("total_recharged")),
261
  "credit_grants": _normalize_credit_grants(raw.get("credit_grants")),
262
+ "package_grants": _normalize_package_grants(raw.get("package_grants")),
263
  "daily_credits_date": self._clean(raw.get("daily_credits_date")),
264
  "daily_credits_used": _to_nonnegative_int(raw.get("daily_credits_used")),
265
+ "daily_bonus_credits": _to_nonnegative_int(raw.get("daily_bonus_credits")),
266
  "daily_reserved_credits": _to_nonnegative_int(raw.get("daily_reserved_credits")),
267
  "api_key_display": self._clean(raw.get("api_key_display")),
268
  "wechat_openid": self._clean(raw.get("wechat_openid")),
 
337
  return 0, 0
338
  return _to_nonnegative_int(item.get("daily_credits_used")), reserved
339
 
340
+ @staticmethod
341
+ def _daily_bonus_credits(item: dict[str, object]) -> int:
342
+ if item.get("role") != "normal":
343
+ return 0
344
+ bonus = _to_nonnegative_int(item.get("daily_bonus_credits"))
345
+ if bonus <= 0:
346
+ return 0
347
+ if AuthService._clean(item.get("daily_credits_date")) == _today_key():
348
+ return bonus
349
+ if _to_nonnegative_int(item.get("daily_reserved_credits")) > 0:
350
+ return bonus
351
+ return 0
352
+
353
+ @staticmethod
354
+ def _daily_total(item: dict[str, object]) -> int:
355
+ if item.get("role") != "normal":
356
+ return 0
357
+ return config.normal_user_daily_credits + AuthService._daily_bonus_credits(item)
358
+
359
  @staticmethod
360
  def _daily_remaining(item: dict[str, object]) -> int:
361
  used, reserved = AuthService._daily_state(item)
362
+ return max(0, AuthService._daily_total(item) - used - reserved)
363
 
364
  @staticmethod
365
  def _reset_daily_if_needed(item: dict[str, object]) -> dict[str, object]:
 
373
  next_item = dict(item)
374
  next_item["daily_credits_date"] = today
375
  next_item["daily_credits_used"] = 0
376
+ next_item["daily_bonus_credits"] = 0
377
  next_item["daily_reserved_credits"] = 0
378
  return next_item
379
 
 
382
  permanent_credits = _to_nonnegative_int(item.get("credits"))
383
  expiring_credits = _expiring_credit_remaining(item)
384
  paid_credits = permanent_credits + expiring_credits
385
+ package_credits = _package_credit_available(item)
386
  daily_used, daily_reserved = AuthService._daily_state(item)
387
+ daily_total = AuthService._daily_total(item)
388
+ daily_bonus = AuthService._daily_bonus_credits(item)
389
  daily_remaining = AuthService._daily_remaining(item)
390
+ total_available = paid_credits + package_credits + daily_remaining if item.get("role") == "normal" else paid_credits
391
  return {
392
  "id": item.get("id"),
393
  "name": item.get("name"),
 
401
  "permanent_credits": permanent_credits,
402
  "expiring_credits": expiring_credits,
403
  "expiring_reserved_credits": _expiring_credit_reserved(item),
404
+ "package_credits": package_credits if item.get("role") == "normal" else 0,
405
+ "package_grants": [
406
+ {
407
+ "id": grant.get("id"),
408
+ "name": grant.get("name"),
409
+ "remaining": _package_available(grant),
410
+ "created_at": grant.get("created_at"),
411
+ "starts_at": grant.get("starts_at"),
412
+ "expires_at": grant.get("expires_at"),
413
+ "source": grant.get("source"),
414
+ "code_id": grant.get("code_id"),
415
+ "daily_limit": _to_nonnegative_int(grant.get("daily_limit")),
416
+ "weekly_limit": _to_nonnegative_int(grant.get("weekly_limit")),
417
+ "monthly_limit": _to_nonnegative_int(grant.get("monthly_limit")),
418
+ "total_limit": _to_nonnegative_int(grant.get("total_limit")),
419
+ "used_total": _to_nonnegative_int(grant.get("used_total")),
420
+ "reserved_total": _to_nonnegative_int(grant.get("reserved_total")),
421
+ "used_daily": _to_nonnegative_int(grant.get("used_daily")),
422
+ "reserved_daily": _to_nonnegative_int(grant.get("reserved_daily")),
423
+ "used_weekly": _to_nonnegative_int(grant.get("used_weekly")),
424
+ "reserved_weekly": _to_nonnegative_int(grant.get("reserved_weekly")),
425
+ "used_monthly": _to_nonnegative_int(grant.get("used_monthly")),
426
+ "reserved_monthly": _to_nonnegative_int(grant.get("reserved_monthly")),
427
+ }
428
+ for grant in _active_package_grants(item)
429
+ if _package_available(grant) > 0
430
+ ],
431
  "credit_grants": [
432
  {
433
  "id": grant.get("id"),
 
442
  "reserved_credits": _to_nonnegative_int(item.get("reserved_credits")),
443
  "used_credits": _to_nonnegative_int(item.get("used_credits")),
444
  "total_recharged": _to_nonnegative_int(item.get("total_recharged")),
445
+ "daily_credits": daily_total if item.get("role") == "normal" else 0,
446
  "daily_credits_remaining": daily_remaining,
447
  "daily_credits_used": daily_used,
448
+ "daily_bonus_credits": daily_bonus,
449
  "daily_reserved_credits": daily_reserved,
450
  "daily_credits_date": _today_key() if item.get("role") == "normal" else "",
451
  "has_api_key_display": bool(AuthService._clean(item.get("api_key_display"))),
 
566
  "used_credits": 0,
567
  "total_recharged": 0,
568
  "credit_grants": [],
569
+ "package_grants": [],
570
  "daily_credits_date": _today_key() if role == "normal" else "",
571
  "daily_credits_used": 0,
572
+ "daily_bonus_credits": 0,
573
  "daily_reserved_credits": 0,
574
+ "api_key_display": raw_key if role in {"normal", "user"} else "",
575
  "wechat_openid": "",
576
  "wechat_nickname": "",
577
  "wechat_avatar": "",
 
645
  "used_credits": 0,
646
  "total_recharged": 0,
647
  "credit_grants": [],
648
+ "package_grants": [],
649
  "daily_credits_date": _today_key(),
650
  "daily_credits_used": 0,
651
+ "daily_bonus_credits": 0,
652
  "daily_reserved_credits": 0,
653
  "api_key_display": raw_key,
654
  "wechat_openid": normalized_openid,
 
672
  "id": item.get("id"),
673
  "name": item.get("name"),
674
  "role": item.get("role"),
675
+ "created_at": item.get("created_at"),
676
  "account_pool_enabled": bool(item.get("account_pool_enabled")),
677
  "wechat_openid": self._clean(item.get("wechat_openid")),
678
  "wechat_bound_at": item.get("wechat_bound_at"),
 
691
  with self._lock:
692
  self._reload_locked()
693
  for item in self._items:
694
+ if item.get("id") == normalized_id and item.get("role") in {"normal", "user"}:
695
  return self._clean(item.get("api_key_display"))
696
  return ""
697
 
 
728
  if "key" in updates and updates.get("key") is not None:
729
  raw_key = str(updates.get("key") or "")
730
  next_item["key_hash"] = self._build_key_hash_locked(raw_key, exclude_id=normalized_id)
731
+ if next_role in {"normal", "user"}:
732
  next_item["api_key_display"] = self._clean(raw_key)
733
  next_items = list(items)
734
  next_items[index] = next_item
 
768
  "expiring_credits": 0,
769
  "expiring_reserved_credits": 0,
770
  "credit_grants": [],
771
+ "package_credits": 0,
772
+ "package_grants": [],
773
  "daily_credits": 0,
774
  "daily_credits_remaining": 0,
775
  "daily_credits_used": None,
776
+ "daily_bonus_credits": 0,
777
  "daily_reserved_credits": 0,
778
  "daily_credits_date": "",
779
  }
 
791
  "expiring_credits": 0,
792
  "expiring_reserved_credits": 0,
793
  "credit_grants": [],
794
+ "package_credits": 0,
795
+ "package_grants": [],
796
  "daily_credits": config.normal_user_daily_credits,
797
  "daily_credits_remaining": 0,
798
  "daily_credits_used": 0,
799
+ "daily_bonus_credits": 0,
800
  "daily_reserved_credits": 0,
801
  "daily_credits_date": _today_key(),
802
  }
 
815
  "expiring_credits": _to_nonnegative_int(item.get("expiring_credits")),
816
  "expiring_reserved_credits": _to_nonnegative_int(item.get("expiring_reserved_credits")),
817
  "credit_grants": item.get("credit_grants") if isinstance(item.get("credit_grants"), list) else [],
818
+ "package_credits": _to_nonnegative_int(item.get("package_credits")),
819
+ "package_grants": item.get("package_grants") if isinstance(item.get("package_grants"), list) else [],
820
  "daily_credits": _to_nonnegative_int(item.get("daily_credits")),
821
  "daily_credits_remaining": _to_nonnegative_int(item.get("daily_credits_remaining")),
822
  "daily_credits_used": _to_nonnegative_int(item.get("daily_credits_used")),
823
+ "daily_bonus_credits": _to_nonnegative_int(item.get("daily_bonus_credits")),
824
  "daily_reserved_credits": _to_nonnegative_int(item.get("daily_reserved_credits")),
825
  "daily_credits_date": self._clean(item.get("daily_credits_date")),
826
  }
 
889
  with self._lock:
890
  return self._update_items_locked(mutate)
891
 
892
+ def _add_daily_credits_in_items(
893
+ self,
894
+ items: list[dict[str, object]],
895
+ key_id: str,
896
+ amount: int,
897
+ *,
898
+ source: str = "",
899
+ code_id: str = "",
900
+ ) -> tuple[list[dict[str, object]], dict[str, object]]:
901
+ normalized_amount = max(1, _to_nonnegative_int(amount))
902
+ today = _today_key()
903
+ for index, item in enumerate(items):
904
+ if item.get("id") != key_id or item.get("role") != "normal":
905
+ continue
906
+ normalized_item = self._reset_daily_if_needed(dict(item))
907
+ next_item = dict(normalized_item)
908
+ next_item["daily_credits_date"] = today
909
+ next_item["daily_bonus_credits"] = _to_nonnegative_int(normalized_item.get("daily_bonus_credits")) + normalized_amount
910
+ next_items = list(items)
911
+ next_items[index] = next_item
912
+ return next_items, self._public_item(next_item)
913
+ raise ValueError("普通用户不存在")
914
+
915
+ def add_daily_credits(self, key_id: str, amount: int, *, source: str = "", code_id: str = "") -> dict[str, object]:
916
+ normalized_id = self._clean(key_id)
917
+ if not normalized_id:
918
+ raise ValueError("用户不存在")
919
+
920
+ def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]:
921
+ return self._add_daily_credits_in_items(
922
+ items,
923
+ normalized_id,
924
+ int(amount),
925
+ source=source,
926
+ code_id=code_id,
927
+ )
928
+
929
+ with self._lock:
930
+ return self._update_items_locked(mutate)
931
+
932
+ def _add_package_grant_in_items(
933
+ self,
934
+ items: list[dict[str, object]],
935
+ key_id: str,
936
+ package_plan: dict[str, object],
937
+ *,
938
+ source: str = "",
939
+ code_id: str = "",
940
+ ) -> tuple[list[dict[str, object]], dict[str, object]]:
941
+ duration_days = max(1, _to_nonnegative_int(package_plan.get("duration_days")) or 1)
942
+ now = datetime.now(timezone.utc)
943
+ grant = {
944
+ "id": uuid.uuid4().hex[:12],
945
+ "name": self._clean(package_plan.get("name")) or "套餐",
946
+ "created_at": now.isoformat(),
947
+ "starts_at": now.isoformat(),
948
+ "expires_at": (now + timedelta(days=duration_days)).isoformat(),
949
+ "source": self._clean(source),
950
+ "code_id": self._clean(code_id),
951
+ "daily_limit": _to_nonnegative_int(package_plan.get("daily_limit")),
952
+ "weekly_limit": _to_nonnegative_int(package_plan.get("weekly_limit")),
953
+ "monthly_limit": _to_nonnegative_int(package_plan.get("monthly_limit")),
954
+ "total_limit": _to_nonnegative_int(package_plan.get("total_limit")),
955
+ "used_total": 0,
956
+ "reserved_total": 0,
957
+ "used_daily": 0,
958
+ "reserved_daily": 0,
959
+ "used_weekly": 0,
960
+ "reserved_weekly": 0,
961
+ "used_monthly": 0,
962
+ "reserved_monthly": 0,
963
+ }
964
+ if max(
965
+ _to_nonnegative_int(grant.get("daily_limit")),
966
+ _to_nonnegative_int(grant.get("weekly_limit")),
967
+ _to_nonnegative_int(grant.get("monthly_limit")),
968
+ _to_nonnegative_int(grant.get("total_limit")),
969
+ ) <= 0:
970
+ raise ValueError("套餐卡密至少需要设置一个日/周/月/总限额")
971
+ grant = _reset_package_periods(grant)
972
+ for index, item in enumerate(items):
973
+ if item.get("id") != key_id or item.get("role") != "normal":
974
+ continue
975
+ normalized_item = self._reset_daily_if_needed(dict(item))
976
+ grants = _active_package_grants(normalized_item)
977
+ grants.append(grant)
978
+ next_item = dict(normalized_item)
979
+ next_item["package_grants"] = grants
980
+ next_items = list(items)
981
+ next_items[index] = next_item
982
+ return next_items, self._public_item(next_item)
983
+ raise ValueError("普通用户不存在")
984
+
985
+ def add_package_grant(self, key_id: str, package_plan: dict[str, object], *, source: str = "", code_id: str = "") -> dict[str, object]:
986
+ normalized_id = self._clean(key_id)
987
+ if not normalized_id:
988
+ raise ValueError("用户不存在")
989
+
990
+ def mutate(items: list[dict[str, object]]) -> tuple[list[dict[str, object]], dict[str, object]]:
991
+ return self._add_package_grant_in_items(
992
+ items,
993
+ normalized_id,
994
+ package_plan,
995
+ source=source,
996
+ code_id=code_id,
997
+ )
998
+
999
+ with self._lock:
1000
+ return self._update_items_locked(mutate)
1001
+
1002
  def reserve_credits(self, key_id: str, amount: int) -> dict[str, object]:
1003
  amount = max(1, int(amount))
1004
  normalized_id = self._clean(key_id)
 
1010
  normalized_item = self._reset_daily_if_needed(dict(item))
1011
  credits = _to_nonnegative_int(normalized_item.get("credits"))
1012
  grants = _normalize_credit_grants(normalized_item.get("credit_grants"))
1013
+ package_grants = _active_package_grants(normalized_item)
1014
  expiring_available = sum(
1015
  _to_nonnegative_int(grant.get("remaining"))
1016
  for grant in grants
 
1018
  )
1019
  daily_available = self._daily_remaining(normalized_item)
1020
  daily_to_reserve = min(amount, daily_available)
1021
+ remaining_to_reserve = amount - daily_to_reserve
1022
+ package_reservations: list[dict[str, object]] = []
1023
+ for grant in package_grants:
1024
+ if remaining_to_reserve <= 0:
1025
+ break
1026
+ take = min(_package_available(grant), remaining_to_reserve)
1027
+ if take <= 0:
1028
+ continue
1029
+ if _to_nonnegative_int(grant.get("total_limit")) > 0:
1030
+ grant["reserved_total"] = _to_nonnegative_int(grant.get("reserved_total")) + take
1031
+ if _to_nonnegative_int(grant.get("daily_limit")) > 0:
1032
+ grant["reserved_daily"] = _to_nonnegative_int(grant.get("reserved_daily")) + take
1033
+ if _to_nonnegative_int(grant.get("weekly_limit")) > 0:
1034
+ grant["reserved_weekly"] = _to_nonnegative_int(grant.get("reserved_weekly")) + take
1035
+ if _to_nonnegative_int(grant.get("monthly_limit")) > 0:
1036
+ grant["reserved_monthly"] = _to_nonnegative_int(grant.get("reserved_monthly")) + take
1037
+ package_reservations.append({"id": grant.get("id"), "amount": take})
1038
+ remaining_to_reserve -= take
1039
+ paid_to_reserve = remaining_to_reserve
1040
  if credits + expiring_available < paid_to_reserve:
1041
  raise ValueError("剩余次数不足,请先充值")
1042
  next_item = dict(normalized_item)
 
1058
  next_item["reserved_credits"] = _to_nonnegative_int(normalized_item.get("reserved_credits")) + permanent_to_reserve
1059
  next_item["daily_reserved_credits"] = _to_nonnegative_int(normalized_item.get("daily_reserved_credits")) + daily_to_reserve
1060
  next_item["credit_grants"] = grants
1061
+ next_item["package_grants"] = package_grants
1062
  result = self._public_item(next_item)
1063
  result["_credit_reservation"] = {
1064
  "daily": daily_to_reserve,
1065
+ "packages": package_reservations,
1066
  "paid": paid_to_reserve,
1067
  "permanent_paid": permanent_to_reserve,
1068
  "grants": grant_reservations,
 
1081
  amount: int,
1082
  *,
1083
  daily_amount: int | None = None,
1084
+ package_reservations: list[dict[str, object]] | None = None,
1085
  paid_amount: int | None = None,
1086
  grant_reservations: list[dict[str, object]] | None = None,
1087
  ) -> dict[str, object] | None:
 
1096
  daily_reserved = _to_nonnegative_int(normalized_item.get("daily_reserved_credits"))
1097
  paid_reserved = _to_nonnegative_int(normalized_item.get("reserved_credits"))
1098
  grants = _normalize_credit_grants(normalized_item.get("credit_grants"))
1099
+ package_grants = _settleable_package_grants(normalized_item)
1100
+ package_consumed = 0
1101
+ for reservation in package_reservations or []:
1102
+ if not isinstance(reservation, dict):
1103
+ continue
1104
+ package_id = self._clean(reservation.get("id"))
1105
+ requested = _to_nonnegative_int(reservation.get("amount"))
1106
+ if not package_id or requested <= 0:
1107
+ continue
1108
+ for grant in package_grants:
1109
+ if self._clean(grant.get("id")) != package_id:
1110
+ continue
1111
+ consumed_from_package = 0
1112
+ if _to_nonnegative_int(grant.get("total_limit")) > 0:
1113
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_total")))
1114
+ grant["reserved_total"] = _to_nonnegative_int(grant.get("reserved_total")) - take
1115
+ grant["used_total"] = _to_nonnegative_int(grant.get("used_total")) + take
1116
+ consumed_from_package = max(consumed_from_package, take)
1117
+ if _to_nonnegative_int(grant.get("daily_limit")) > 0:
1118
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_daily")))
1119
+ grant["reserved_daily"] = _to_nonnegative_int(grant.get("reserved_daily")) - take
1120
+ grant["used_daily"] = _to_nonnegative_int(grant.get("used_daily")) + take
1121
+ consumed_from_package = max(consumed_from_package, take)
1122
+ if _to_nonnegative_int(grant.get("weekly_limit")) > 0:
1123
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_weekly")))
1124
+ grant["reserved_weekly"] = _to_nonnegative_int(grant.get("reserved_weekly")) - take
1125
+ grant["used_weekly"] = _to_nonnegative_int(grant.get("used_weekly")) + take
1126
+ consumed_from_package = max(consumed_from_package, take)
1127
+ if _to_nonnegative_int(grant.get("monthly_limit")) > 0:
1128
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_monthly")))
1129
+ grant["reserved_monthly"] = _to_nonnegative_int(grant.get("reserved_monthly")) - take
1130
+ grant["used_monthly"] = _to_nonnegative_int(grant.get("used_monthly")) + take
1131
+ consumed_from_package = max(consumed_from_package, take)
1132
+ package_consumed += consumed_from_package
1133
+ break
1134
  grant_consumed = 0
1135
  for reservation in grant_reservations or []:
1136
  if not isinstance(reservation, dict):
 
1158
  next_item["daily_credits_used"] = _to_nonnegative_int(normalized_item.get("daily_credits_used")) + daily_consumed
1159
  next_item["reserved_credits"] = paid_reserved - paid_consumed
1160
  next_item["credit_grants"] = grants
1161
+ next_item["package_grants"] = package_grants
1162
+ next_item["used_credits"] = _to_nonnegative_int(normalized_item.get("used_credits")) + consumed + grant_consumed + package_consumed
1163
  next_items = list(items)
1164
  next_items[index] = next_item
1165
  return next_items, self._public_item(next_item)
 
1175
  *,
1176
  daily_amount: int | None = None,
1177
  paid_amount: int | None = None,
1178
+ package_reservations: list[dict[str, object]] | None = None,
1179
  grant_reservations: list[dict[str, object]] | None = None,
1180
  ) -> dict[str, object] | None:
1181
  amount = max(1, int(amount))
 
1189
  daily_reserved = _to_nonnegative_int(normalized_item.get("daily_reserved_credits"))
1190
  paid_reserved = _to_nonnegative_int(normalized_item.get("reserved_credits"))
1191
  grants = _normalize_credit_grants(normalized_item.get("credit_grants"))
1192
+ package_grants = _settleable_package_grants(normalized_item)
1193
+ for reservation in package_reservations or []:
1194
+ if not isinstance(reservation, dict):
1195
+ continue
1196
+ package_id = self._clean(reservation.get("id"))
1197
+ requested = _to_nonnegative_int(reservation.get("amount"))
1198
+ if not package_id or requested <= 0:
1199
+ continue
1200
+ for grant in package_grants:
1201
+ if self._clean(grant.get("id")) != package_id:
1202
+ continue
1203
+ if _to_nonnegative_int(grant.get("total_limit")) > 0:
1204
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_total")))
1205
+ grant["reserved_total"] = _to_nonnegative_int(grant.get("reserved_total")) - take
1206
+ if _to_nonnegative_int(grant.get("daily_limit")) > 0:
1207
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_daily")))
1208
+ grant["reserved_daily"] = _to_nonnegative_int(grant.get("reserved_daily")) - take
1209
+ if _to_nonnegative_int(grant.get("weekly_limit")) > 0:
1210
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_weekly")))
1211
+ grant["reserved_weekly"] = _to_nonnegative_int(grant.get("reserved_weekly")) - take
1212
+ if _to_nonnegative_int(grant.get("monthly_limit")) > 0:
1213
+ take = min(requested, _to_nonnegative_int(grant.get("reserved_monthly")))
1214
+ grant["reserved_monthly"] = _to_nonnegative_int(grant.get("reserved_monthly")) - take
1215
+ break
1216
  for reservation in grant_reservations or []:
1217
  if not isinstance(reservation, dict):
1218
  continue
 
1238
  next_item["reserved_credits"] = paid_reserved - paid_refunded
1239
  next_item["credits"] = _to_nonnegative_int(normalized_item.get("credits")) + paid_refunded
1240
  next_item["credit_grants"] = grants
1241
+ next_item["package_grants"] = package_grants
1242
  next_items = list(items)
1243
  next_items[index] = next_item
1244
  return next_items, self._public_item(next_item)
services/config.py CHANGED
@@ -55,6 +55,16 @@ DEFAULT_FREE_ACCOUNT_CLEANUP = {
55
  "action": "mark_abnormal",
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
58
  BRANDING_KEYS = (
59
  "site_title",
60
  "site_logo_url",
@@ -215,6 +225,39 @@ def _normalize_free_account_cleanup_settings(value: object) -> dict[str, object]
215
  }
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  def _normalize_image_proxy_base_url(value: object) -> str:
219
  text = str(value or "").strip().rstrip("/")
220
  if not text:
@@ -501,6 +544,9 @@ class ConfigStore:
501
  def get_free_account_cleanup_settings(self) -> dict[str, object]:
502
  return _normalize_free_account_cleanup_settings(self.data.get("free_account_cleanup"))
503
 
 
 
 
504
  @property
505
  def log_levels(self) -> list[str]:
506
  levels = self.data.get("log_levels")
@@ -593,6 +639,7 @@ class ConfigStore:
593
  data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
594
  data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
595
  data["free_account_cleanup"] = self.get_free_account_cleanup_settings()
 
596
  data["log_levels"] = self.log_levels
597
  data["sensitive_words"] = self.sensitive_words
598
  data["image_proxy_base_url"] = self.image_proxy_base_url
@@ -620,6 +667,10 @@ class ConfigStore:
620
  next_data["free_account_cleanup"] = _normalize_free_account_cleanup_settings(
621
  next_data.get("free_account_cleanup")
622
  )
 
 
 
 
623
  if "image_proxy_base_url" in next_data:
624
  next_data["image_proxy_base_url"] = _normalize_image_proxy_base_url(next_data.get("image_proxy_base_url"))
625
  if any(key in incoming_data for key in BRANDING_KEYS):
 
55
  "action": "mark_abnormal",
56
  }
57
 
58
+ DEFAULT_REDEEM_CODE_CLEANUP = {
59
+ "enabled": False,
60
+ "interval_minutes": 1440,
61
+ "delete_expired": True,
62
+ "delete_disabled": False,
63
+ "archive_redeemed": False,
64
+ "redeemed_older_than_days": 90,
65
+ "limit": 1000,
66
+ }
67
+
68
  BRANDING_KEYS = (
69
  "site_title",
70
  "site_logo_url",
 
225
  }
226
 
227
 
228
+ def _normalize_redeem_code_cleanup_settings(value: object) -> dict[str, object]:
229
+ source = value if isinstance(value, dict) else {}
230
+ return {
231
+ "enabled": _normalize_bool(source.get("enabled"), bool(DEFAULT_REDEEM_CODE_CLEANUP["enabled"])),
232
+ "interval_minutes": _normalize_positive_int(
233
+ source.get("interval_minutes"),
234
+ int(DEFAULT_REDEEM_CODE_CLEANUP["interval_minutes"]),
235
+ 1,
236
+ ),
237
+ "delete_expired": _normalize_bool(
238
+ source.get("delete_expired"),
239
+ bool(DEFAULT_REDEEM_CODE_CLEANUP["delete_expired"]),
240
+ ),
241
+ "delete_disabled": _normalize_bool(
242
+ source.get("delete_disabled"),
243
+ bool(DEFAULT_REDEEM_CODE_CLEANUP["delete_disabled"]),
244
+ ),
245
+ "archive_redeemed": _normalize_bool(
246
+ source.get("archive_redeemed"),
247
+ bool(DEFAULT_REDEEM_CODE_CLEANUP["archive_redeemed"]),
248
+ ),
249
+ "redeemed_older_than_days": _normalize_positive_int(
250
+ source.get("redeemed_older_than_days"),
251
+ int(DEFAULT_REDEEM_CODE_CLEANUP["redeemed_older_than_days"]),
252
+ 1,
253
+ ),
254
+ "limit": min(
255
+ 20000,
256
+ _normalize_positive_int(source.get("limit"), int(DEFAULT_REDEEM_CODE_CLEANUP["limit"]), 1),
257
+ ),
258
+ }
259
+
260
+
261
  def _normalize_image_proxy_base_url(value: object) -> str:
262
  text = str(value or "").strip().rstrip("/")
263
  if not text:
 
544
  def get_free_account_cleanup_settings(self) -> dict[str, object]:
545
  return _normalize_free_account_cleanup_settings(self.data.get("free_account_cleanup"))
546
 
547
+ def get_redeem_code_cleanup_settings(self) -> dict[str, object]:
548
+ return _normalize_redeem_code_cleanup_settings(self.data.get("redeem_code_cleanup"))
549
+
550
  @property
551
  def log_levels(self) -> list[str]:
552
  levels = self.data.get("log_levels")
 
639
  data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
640
  data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
641
  data["free_account_cleanup"] = self.get_free_account_cleanup_settings()
642
+ data["redeem_code_cleanup"] = self.get_redeem_code_cleanup_settings()
643
  data["log_levels"] = self.log_levels
644
  data["sensitive_words"] = self.sensitive_words
645
  data["image_proxy_base_url"] = self.image_proxy_base_url
 
667
  next_data["free_account_cleanup"] = _normalize_free_account_cleanup_settings(
668
  next_data.get("free_account_cleanup")
669
  )
670
+ if "redeem_code_cleanup" in next_data:
671
+ next_data["redeem_code_cleanup"] = _normalize_redeem_code_cleanup_settings(
672
+ next_data.get("redeem_code_cleanup")
673
+ )
674
  if "image_proxy_base_url" in next_data:
675
  next_data["image_proxy_base_url"] = _normalize_image_proxy_base_url(next_data.get("image_proxy_base_url"))
676
  if any(key in incoming_data for key in BRANDING_KEYS):
services/image_task_service.py CHANGED
@@ -258,10 +258,12 @@ class ImageTaskService:
258
  reservation = {}
259
  daily_reserved = max(0, int(reservation.get("daily") or 0))
260
  paid_reserved = max(0, int(reservation.get("permanent_paid") if "permanent_paid" in reservation else reservation.get("paid") or 0))
 
261
  grant_reservations = reservation.get("grants") if isinstance(reservation.get("grants"), list) else []
262
  else:
263
  daily_reserved = 0
264
  paid_reserved = 0
 
265
  grant_reservations = []
266
  task = {
267
  "id": task_id,
@@ -278,6 +280,7 @@ class ImageTaskService:
278
  task["credit_reserved"] = normalized_credit_amount
279
  task["credit_daily_reserved"] = daily_reserved
280
  task["credit_paid_reserved"] = paid_reserved
 
281
  task["credit_grant_reservations"] = grant_reservations
282
  task["credit_settled"] = False
283
  self._tasks[key] = task
@@ -406,9 +409,11 @@ class ImageTaskService:
406
  amount = max(0, int(task.get("credit_reserved") or 0))
407
  daily_amount = max(0, int(task.get("credit_daily_reserved") or 0))
408
  paid_amount = max(0, int(task.get("credit_paid_reserved") or 0))
 
409
  grant_reservations = task.get("credit_grant_reservations") if isinstance(task.get("credit_grant_reservations"), list) else []
 
410
  grant_amount = sum(max(0, int(item.get("amount") or 0)) for item in grant_reservations if isinstance(item, dict))
411
- if amount > 0 and daily_amount + paid_amount + grant_amount == 0:
412
  paid_amount = amount
413
  if not user_id or amount <= 0:
414
  task["credit_settled"] = True
@@ -423,6 +428,7 @@ class ImageTaskService:
423
  amount,
424
  daily_amount=daily_amount,
425
  paid_amount=paid_amount,
 
426
  grant_reservations=grant_reservations,
427
  )
428
  else:
@@ -431,6 +437,7 @@ class ImageTaskService:
431
  amount,
432
  daily_amount=daily_amount,
433
  paid_amount=paid_amount,
 
434
  grant_reservations=grant_reservations,
435
  )
436
  finally:
@@ -477,6 +484,7 @@ class ImageTaskService:
477
  task["credit_reserved"] = credit_reserved
478
  task["credit_daily_reserved"] = max(0, int(item.get("credit_daily_reserved") or 0))
479
  task["credit_paid_reserved"] = max(0, int(item.get("credit_paid_reserved") or 0))
 
480
  task["credit_grant_reservations"] = item.get("credit_grant_reservations") if isinstance(item.get("credit_grant_reservations"), list) else []
481
  task["credit_settled"] = bool(item.get("credit_settled"))
482
  data = item.get("data")
@@ -512,9 +520,11 @@ class ImageTaskService:
512
  amount = max(0, int(task.get("credit_reserved") or 0))
513
  daily_amount = max(0, int(task.get("credit_daily_reserved") or 0))
514
  paid_amount = max(0, int(task.get("credit_paid_reserved") or 0))
 
515
  grant_reservations = task.get("credit_grant_reservations") if isinstance(task.get("credit_grant_reservations"), list) else []
 
516
  grant_amount = sum(max(0, int(item.get("amount") or 0)) for item in grant_reservations if isinstance(item, dict))
517
- if amount > 0 and daily_amount + paid_amount + grant_amount == 0:
518
  paid_amount = amount
519
  if not user_id or amount <= 0:
520
  task["credit_settled"] = True
@@ -527,6 +537,7 @@ class ImageTaskService:
527
  amount,
528
  daily_amount=daily_amount,
529
  paid_amount=paid_amount,
 
530
  grant_reservations=grant_reservations,
531
  )
532
  finally:
 
258
  reservation = {}
259
  daily_reserved = max(0, int(reservation.get("daily") or 0))
260
  paid_reserved = max(0, int(reservation.get("permanent_paid") if "permanent_paid" in reservation else reservation.get("paid") or 0))
261
+ package_reservations = reservation.get("packages") if isinstance(reservation.get("packages"), list) else []
262
  grant_reservations = reservation.get("grants") if isinstance(reservation.get("grants"), list) else []
263
  else:
264
  daily_reserved = 0
265
  paid_reserved = 0
266
+ package_reservations = []
267
  grant_reservations = []
268
  task = {
269
  "id": task_id,
 
280
  task["credit_reserved"] = normalized_credit_amount
281
  task["credit_daily_reserved"] = daily_reserved
282
  task["credit_paid_reserved"] = paid_reserved
283
+ task["credit_package_reservations"] = package_reservations
284
  task["credit_grant_reservations"] = grant_reservations
285
  task["credit_settled"] = False
286
  self._tasks[key] = task
 
409
  amount = max(0, int(task.get("credit_reserved") or 0))
410
  daily_amount = max(0, int(task.get("credit_daily_reserved") or 0))
411
  paid_amount = max(0, int(task.get("credit_paid_reserved") or 0))
412
+ package_reservations = task.get("credit_package_reservations") if isinstance(task.get("credit_package_reservations"), list) else []
413
  grant_reservations = task.get("credit_grant_reservations") if isinstance(task.get("credit_grant_reservations"), list) else []
414
+ package_amount = sum(max(0, int(item.get("amount") or 0)) for item in package_reservations if isinstance(item, dict))
415
  grant_amount = sum(max(0, int(item.get("amount") or 0)) for item in grant_reservations if isinstance(item, dict))
416
+ if amount > 0 and daily_amount + paid_amount + package_amount + grant_amount == 0:
417
  paid_amount = amount
418
  if not user_id or amount <= 0:
419
  task["credit_settled"] = True
 
428
  amount,
429
  daily_amount=daily_amount,
430
  paid_amount=paid_amount,
431
+ package_reservations=package_reservations,
432
  grant_reservations=grant_reservations,
433
  )
434
  else:
 
437
  amount,
438
  daily_amount=daily_amount,
439
  paid_amount=paid_amount,
440
+ package_reservations=package_reservations,
441
  grant_reservations=grant_reservations,
442
  )
443
  finally:
 
484
  task["credit_reserved"] = credit_reserved
485
  task["credit_daily_reserved"] = max(0, int(item.get("credit_daily_reserved") or 0))
486
  task["credit_paid_reserved"] = max(0, int(item.get("credit_paid_reserved") or 0))
487
+ task["credit_package_reservations"] = item.get("credit_package_reservations") if isinstance(item.get("credit_package_reservations"), list) else []
488
  task["credit_grant_reservations"] = item.get("credit_grant_reservations") if isinstance(item.get("credit_grant_reservations"), list) else []
489
  task["credit_settled"] = bool(item.get("credit_settled"))
490
  data = item.get("data")
 
520
  amount = max(0, int(task.get("credit_reserved") or 0))
521
  daily_amount = max(0, int(task.get("credit_daily_reserved") or 0))
522
  paid_amount = max(0, int(task.get("credit_paid_reserved") or 0))
523
+ package_reservations = task.get("credit_package_reservations") if isinstance(task.get("credit_package_reservations"), list) else []
524
  grant_reservations = task.get("credit_grant_reservations") if isinstance(task.get("credit_grant_reservations"), list) else []
525
+ package_amount = sum(max(0, int(item.get("amount") or 0)) for item in package_reservations if isinstance(item, dict))
526
  grant_amount = sum(max(0, int(item.get("amount") or 0)) for item in grant_reservations if isinstance(item, dict))
527
+ if amount > 0 and daily_amount + paid_amount + package_amount + grant_amount == 0:
528
  paid_amount = amount
529
  if not user_id or amount <= 0:
530
  task["credit_settled"] = True
 
537
  amount,
538
  daily_amount=daily_amount,
539
  paid_amount=paid_amount,
540
+ package_reservations=package_reservations,
541
  grant_reservations=grant_reservations,
542
  )
543
  finally:
services/shop_service.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import hashlib
 
4
  import secrets
5
  import uuid
6
  from datetime import datetime, timedelta, timezone
@@ -8,7 +9,7 @@ from threading import Lock
8
  from typing import Any, Callable
9
 
10
  from services.auth_service import auth_service
11
- from services.config import config
12
  from services.storage.base import StorageBackend
13
 
14
  REDEEM_BATCH_ALREADY_USED_MESSAGE = "这批福利卡密您已兑换过一个,留给别人吧"
@@ -29,6 +30,36 @@ def _to_positive_int(value: object, default: int = 1) -> int:
29
  return default
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _parse_iso_datetime(value: object) -> datetime | None:
33
  raw = _clean(value)
34
  if not raw:
@@ -49,6 +80,16 @@ def _is_expired(item: dict[str, Any], *, now: datetime | None = None) -> bool:
49
  return expires_at <= current
50
 
51
 
 
 
 
 
 
 
 
 
 
 
52
  def normalize_redeem_code(value: str) -> str:
53
  return "".join(str(value or "").upper().split())
54
 
@@ -57,7 +98,77 @@ def hash_redeem_code(value: str) -> str:
57
  return hashlib.sha256(normalize_redeem_code(value).encode("utf-8")).hexdigest()
58
 
59
 
60
- def _public_code(item: dict[str, Any], users_by_id: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  redeemed_by = _clean(item.get("redeemed_by"))
62
  redeemed_user = (users_by_id or {}).get(redeemed_by) if redeemed_by else None
63
  result = {
@@ -65,18 +176,25 @@ def _public_code(item: dict[str, Any], users_by_id: dict[str, dict[str, Any]] |
65
  "prefix": item.get("prefix"),
66
  "credits": item.get("credits"),
67
  "status": item.get("status"),
 
68
  "batch": item.get("batch"),
69
  "batch_id": item.get("batch_id"),
70
  "batch_single_use": bool(item.get("batch_single_use")),
71
  "note": item.get("note"),
72
  "created_at": item.get("created_at"),
 
73
  "expires_at": item.get("expires_at"),
 
 
74
  "credit_expires_days": item.get("credit_expires_days"),
75
  "credit_expires_at": item.get("credit_expires_at"),
 
76
  "is_expired": _is_expired(item),
77
  "redeemed_by": item.get("redeemed_by"),
78
  "redeemed_at": item.get("redeemed_at"),
79
  }
 
 
80
  if redeemed_user:
81
  result["redeemed_user"] = {
82
  "id": redeemed_user.get("id"),
@@ -164,7 +282,20 @@ class ShopService:
164
  with self._lock:
165
  codes = self._load_codes_locked()
166
  users_by_id = {str(item.get("id") or ""): item for item in auth_service.list_keys(role="normal")}
167
- return [_public_code(item, users_by_id) for item in codes]
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  def list_ledger(self) -> list[dict[str, Any]]:
170
  with self._lock:
@@ -201,10 +332,13 @@ class ShopService:
201
  if callable(has_redeemed_code_in_batch) and batch_id:
202
  batch_used = bool(has_redeemed_code_in_batch(batch_id, user_id))
203
  public_item = _public_code(item)
204
- redeemable = item.get("status") == "active" and not _is_expired(item) and not batch_used
 
205
  message = "卡密可兑换"
206
  if item.get("status") != "active":
207
  message = "这张卡密已使用或已失效"
 
 
208
  elif _is_expired(item):
209
  message = "这张卡密已过期"
210
  elif batch_used:
@@ -216,10 +350,13 @@ class ShopService:
216
  if item.get("code_hash") != code_hash:
217
  continue
218
  public_item = _public_code(item)
219
- redeemable = item.get("status") == "active" and not _is_expired(item) and not _redeem_batch_used_by_user(state, item, user_id)
 
220
  message = "卡密可兑换"
221
  if item.get("status") != "active":
222
  message = "这张卡密已使用或已失效"
 
 
223
  elif _is_expired(item):
224
  message = "这张卡密已过期"
225
  elif _redeem_batch_used_by_user(state, item, user_id):
@@ -238,15 +375,55 @@ class ShopService:
238
  validity_days: int | None = None,
239
  credit_expires_days: int | None = None,
240
  batch_single_use: bool = False,
 
 
 
 
 
 
 
 
241
  ) -> dict[str, Any]:
242
  count = min(1000, _to_positive_int(count))
243
  credits = _to_positive_int(credits)
244
  normalized_validity_days = None if validity_days is None else min(3650, _to_positive_int(validity_days))
245
  normalized_credit_expires_days = None if credit_expires_days is None else min(3650, _to_positive_int(credit_expires_days))
246
  normalized_prefix = "".join(_clean(prefix).upper().split("-"))[:12] or "CS"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  now_dt = datetime.now(timezone.utc)
248
  now = now_dt.isoformat()
249
- expires_at = (now_dt + timedelta(days=normalized_validity_days)).isoformat() if normalized_validity_days else None
 
 
 
 
 
250
  batch_id = uuid.uuid4().hex[:12]
251
 
252
  def mutate(state: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
@@ -265,15 +442,20 @@ class ShopService:
265
  item = {
266
  "id": uuid.uuid4().hex[:12],
267
  "code_hash": code_hash,
 
268
  "prefix": normalized_prefix,
269
- "credits": credits,
270
  "status": "active",
 
271
  "batch": _clean(batch),
272
  "batch_id": batch_id,
273
  "batch_single_use": bool(batch_single_use),
274
  "note": _clean(note),
275
  "created_at": now,
 
276
  "expires_at": expires_at,
 
 
277
  "credit_expires_days": normalized_credit_expires_days,
278
  "redeemed_by": None,
279
  "redeemed_at": None,
@@ -288,18 +470,22 @@ class ShopService:
288
  "time": now,
289
  "type": "create_codes",
290
  "delta": count,
291
- "credits": credits,
 
292
  "batch": _clean(batch),
293
  "batch_id": batch_id,
294
  "batch_single_use": bool(batch_single_use),
 
295
  "expires_at": expires_at,
 
 
296
  "credit_expires_days": normalized_credit_expires_days,
297
  "note": _clean(note),
298
  }
299
  )
300
  return state, {
301
  "codes": plaintext_codes,
302
- "items": [_public_code(item) for item in created_items],
303
  }
304
 
305
  with self._lock:
@@ -321,7 +507,7 @@ class ShopService:
321
  state["ledger"].append(
322
  {"id": uuid.uuid4().hex[:12], "time": _now_iso(), "type": "disable_code", "code_id": normalized_id}
323
  )
324
- return state, _public_code(item)
325
  raise ValueError("卡密不存在")
326
 
327
  with self._lock:
@@ -341,7 +527,7 @@ class ShopService:
341
  state["ledger"].append(
342
  {"id": uuid.uuid4().hex[:12], "time": _now_iso(), "type": "enable_code", "code_id": normalized_id}
343
  )
344
- return state, _public_code(item)
345
  raise ValueError("卡密不存在")
346
 
347
  with self._lock:
@@ -368,25 +554,143 @@ class ShopService:
368
  "batch": _clean(removed.get("batch")),
369
  }
370
  )
371
- return state, _public_code(removed)
372
  raise ValueError("卡密不存在")
373
 
374
  with self._lock:
375
  return self._update_codes_state_locked(mutate)
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  def _redeem_in_state(
378
  self,
379
  state: dict[str, Any],
380
  *,
381
  user_id: str,
382
  code_hash: str,
383
- credit_updater: Callable[[int, str, str | None], dict[str, object]],
384
  ) -> tuple[dict[str, Any], dict[str, Any]]:
385
  for item in state["codes"]:
386
  if item.get("code_hash") != code_hash:
387
  continue
388
  if item.get("status") != "active":
389
  raise ValueError("这张卡密已使用或已失效")
 
 
390
  if _is_expired(item):
391
  raise ValueError("这张卡密已过期")
392
  if _redeem_batch_used_by_user(state, item, user_id):
@@ -394,23 +698,31 @@ class ShopService:
394
  credits = _to_positive_int(item.get("credits"))
395
  now = _now_iso()
396
  code_id = _clean(item.get("id"))
 
 
 
397
  credit_expires_days = item.get("credit_expires_days")
398
  credit_expires_at = None
399
- if credit_expires_days is not None:
400
  credit_expires_at = (datetime.now(timezone.utc) + timedelta(days=_to_positive_int(credit_expires_days))).isoformat()
401
- updated_user = credit_updater(credits, code_id, credit_expires_at)
402
  item["status"] = "redeemed"
403
  item["redeemed_by"] = user_id
404
  item["redeemed_at"] = now
 
405
  item["credit_expires_at"] = credit_expires_at
 
406
  state["ledger"].append(
407
  {
408
  "id": uuid.uuid4().hex[:12],
409
  "time": now,
410
  "type": "redeem",
 
411
  "user_id": user_id,
412
  "code_id": item.get("id"),
 
413
  "delta": credits,
 
414
  "credit_expires_at": credit_expires_at,
415
  }
416
  )
@@ -446,6 +758,8 @@ class ShopService:
446
  item = dict(raw_code_item)
447
  if item.get("status") != "active":
448
  raise ValueError("这张卡密已使用或已失效")
 
 
449
  if _is_expired(item):
450
  raise ValueError("这张卡密已过期")
451
  if item.get("batch_single_use") and any(
@@ -457,33 +771,58 @@ class ShopService:
457
  credits = _to_positive_int(item.get("credits"))
458
  now = _now_iso()
459
  code_id = _clean(item.get("id"))
 
 
 
460
  credit_expires_days = item.get("credit_expires_days")
461
  credit_expires_at = None
462
- if credit_expires_days is not None:
463
  credit_expires_at = (datetime.now(timezone.utc) + timedelta(days=_to_positive_int(credit_expires_days))).isoformat()
464
- auth_items, updated_user = auth_service._adjust_credits_in_items(
465
- auth_items,
466
- user_id,
467
- credits,
468
- note=f"redeem:{code_id}",
469
- expires_at=credit_expires_at,
470
- source="redeem",
471
- code_id=code_id,
472
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
  item["status"] = "redeemed"
475
  item["redeemed_by"] = user_id
476
  item["redeemed_at"] = now
 
477
  item["credit_expires_at"] = credit_expires_at
 
478
  state = self._normalize_state(raw_state)
479
  state["ledger"].append(
480
  {
481
  "id": uuid.uuid4().hex[:12],
482
  "time": now,
483
  "type": "redeem",
 
484
  "user_id": user_id,
485
  "code_id": item.get("id"),
 
486
  "delta": credits,
 
487
  "credit_expires_at": credit_expires_at,
488
  }
489
  )
@@ -508,17 +847,34 @@ class ShopService:
508
  ) -> tuple[list[dict[str, object]], dict[str, Any], dict[str, Any]]:
509
  auth_items = auth_service._normalize_items(raw_auth_items)
510
 
511
- def credit_updater(credits: int, code_id: str, credit_expires_at: str | None) -> dict[str, object]:
512
  nonlocal auth_items
513
- auth_items, updated_user = auth_service._adjust_credits_in_items(
514
- auth_items,
515
- user_id,
516
- credits,
517
- note=f"redeem:{code_id}",
518
- expires_at=credit_expires_at,
519
- source="redeem",
520
- code_id=code_id,
521
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  return updated_user
523
 
524
  next_state, result = self._redeem_in_state(
@@ -533,13 +889,27 @@ class ShopService:
533
  return update_user_and_shop_state_locked(mutate_combined)
534
 
535
  def mutate(state: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
536
- def credit_updater(credits: int, code_id: str, credit_expires_at: str | None) -> dict[str, object]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  return auth_service.adjust_credits(
538
  user_id,
539
  credits,
540
  note=f"redeem:{code_id}",
541
  expires_at=credit_expires_at,
542
- source="redeem",
543
  code_id=code_id,
544
  )
545
 
 
1
  from __future__ import annotations
2
 
3
  import hashlib
4
+ import json
5
  import secrets
6
  import uuid
7
  from datetime import datetime, timedelta, timezone
 
9
  from typing import Any, Callable
10
 
11
  from services.auth_service import auth_service
12
+ from services.config import DATA_DIR, config
13
  from services.storage.base import StorageBackend
14
 
15
  REDEEM_BATCH_ALREADY_USED_MESSAGE = "这批福利卡密您已兑换过一个,留给别人吧"
 
30
  return default
31
 
32
 
33
+ def _to_nonnegative_int(value: object) -> int:
34
+ try:
35
+ return max(0, int(value or 0))
36
+ except (TypeError, ValueError):
37
+ return 0
38
+
39
+
40
+ def _normalize_package_plan(value: object, *, require_limits: bool = False) -> dict[str, Any] | None:
41
+ if not isinstance(value, dict):
42
+ if require_limits:
43
+ raise ValueError("套餐卡密缺少套餐配置")
44
+ return None
45
+ plan = {
46
+ "name": _clean(value.get("name")) or "套餐",
47
+ "duration_days": min(3650, _to_positive_int(value.get("duration_days"), 1)),
48
+ "daily_limit": min(1_000_000, _to_nonnegative_int(value.get("daily_limit"))),
49
+ "weekly_limit": min(1_000_000, _to_nonnegative_int(value.get("weekly_limit"))),
50
+ "monthly_limit": min(1_000_000, _to_nonnegative_int(value.get("monthly_limit"))),
51
+ "total_limit": min(1_000_000, _to_nonnegative_int(value.get("total_limit"))),
52
+ }
53
+ if require_limits and max(
54
+ int(plan["daily_limit"]),
55
+ int(plan["weekly_limit"]),
56
+ int(plan["monthly_limit"]),
57
+ int(plan["total_limit"]),
58
+ ) <= 0:
59
+ raise ValueError("套餐卡密至少需要设置一个日/周/月/总限额")
60
+ return plan
61
+
62
+
63
  def _parse_iso_datetime(value: object) -> datetime | None:
64
  raw = _clean(value)
65
  if not raw:
 
80
  return expires_at <= current
81
 
82
 
83
+ def _is_not_started(item: dict[str, Any], *, now: datetime | None = None) -> bool:
84
+ starts_at = _parse_iso_datetime(item.get("starts_at"))
85
+ if starts_at is None:
86
+ return False
87
+ current = now or datetime.now(timezone.utc)
88
+ if starts_at.tzinfo is None:
89
+ starts_at = starts_at.replace(tzinfo=timezone.utc)
90
+ return starts_at > current
91
+
92
+
93
  def normalize_redeem_code(value: str) -> str:
94
  return "".join(str(value or "").upper().split())
95
 
 
98
  return hashlib.sha256(normalize_redeem_code(value).encode("utf-8")).hexdigest()
99
 
100
 
101
+ def _normalize_code_type(value: object) -> str:
102
+ normalized = _clean(value).lower().replace("-", "_")
103
+ if normalized in {"daily_gift", "limited_gift", "welfare", "gift", "daily"}:
104
+ return "daily_gift"
105
+ if normalized in {"package", "plan", "subscription", "membership"}:
106
+ return "package"
107
+ return "normal"
108
+
109
+
110
+ def _code_credit_source(code_type: object) -> str:
111
+ normalized = _normalize_code_type(code_type)
112
+ if normalized == "daily_gift":
113
+ return "daily_gift_code"
114
+ if normalized == "package":
115
+ return "package_code"
116
+ return "redeem"
117
+
118
+
119
+ def _china_day_window(value: object, *, now_dt: datetime) -> tuple[str, str, str]:
120
+ china_tz = timezone(timedelta(hours=8))
121
+ today = now_dt.astimezone(china_tz).date()
122
+ raw = _clean(value)
123
+ if raw:
124
+ try:
125
+ target_day = datetime.strptime(raw[:10], "%Y-%m-%d").date()
126
+ except ValueError as exc:
127
+ raise ValueError("每日赠送卡密日期格式应为 YYYY-MM-DD") from exc
128
+ else:
129
+ target_day = today
130
+ if target_day < today:
131
+ raise ValueError("每日赠送卡密不能创建过去日期")
132
+ day_text = target_day.isoformat()
133
+ start = datetime(target_day.year, target_day.month, target_day.day, 0, 0, 0, 0, tzinfo=china_tz)
134
+ end = datetime(target_day.year, target_day.month, target_day.day, 23, 59, 59, 999000, tzinfo=china_tz)
135
+ return day_text, start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()
136
+
137
+
138
+ def _plaintext_code(item: dict[str, Any]) -> str:
139
+ for key in ("code_plaintext", "plain_code", "code"):
140
+ value = _clean(item.get(key))
141
+ if value:
142
+ return value
143
+ return ""
144
+
145
+
146
+ def _archive_codes(matches: list[tuple[dict[str, Any], str]], *, archived_at: str, run_id: str) -> dict[str, Any]:
147
+ if not matches:
148
+ return {"archived": 0, "path": None}
149
+ archive_dt = _parse_iso_datetime(archived_at) or datetime.now(timezone.utc)
150
+ archive_dir = DATA_DIR / "redeem_code_archives"
151
+ archive_dir.mkdir(parents=True, exist_ok=True)
152
+ archive_path = archive_dir / f"redeem-codes-{archive_dt:%Y-%m}.jsonl"
153
+ with archive_path.open("a", encoding="utf-8") as file:
154
+ for item, reason in matches:
155
+ payload = {
156
+ "version": 1,
157
+ "archived_at": archived_at,
158
+ "run_id": run_id,
159
+ "reason": reason,
160
+ "item": dict(item),
161
+ }
162
+ file.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n")
163
+ return {"archived": len(matches), "path": str(archive_path)}
164
+
165
+
166
+ def _public_code(
167
+ item: dict[str, Any],
168
+ users_by_id: dict[str, dict[str, Any]] | None = None,
169
+ *,
170
+ include_plaintext: bool = False,
171
+ ) -> dict[str, Any]:
172
  redeemed_by = _clean(item.get("redeemed_by"))
173
  redeemed_user = (users_by_id or {}).get(redeemed_by) if redeemed_by else None
174
  result = {
 
176
  "prefix": item.get("prefix"),
177
  "credits": item.get("credits"),
178
  "status": item.get("status"),
179
+ "code_type": _normalize_code_type(item.get("code_type")),
180
  "batch": item.get("batch"),
181
  "batch_id": item.get("batch_id"),
182
  "batch_single_use": bool(item.get("batch_single_use")),
183
  "note": item.get("note"),
184
  "created_at": item.get("created_at"),
185
+ "starts_at": item.get("starts_at"),
186
  "expires_at": item.get("expires_at"),
187
+ "daily_gift_date": item.get("daily_gift_date"),
188
+ "package_plan": _normalize_package_plan(item.get("package_plan")),
189
  "credit_expires_days": item.get("credit_expires_days"),
190
  "credit_expires_at": item.get("credit_expires_at"),
191
+ "is_not_started": _is_not_started(item),
192
  "is_expired": _is_expired(item),
193
  "redeemed_by": item.get("redeemed_by"),
194
  "redeemed_at": item.get("redeemed_at"),
195
  }
196
+ if include_plaintext:
197
+ result["code"] = _plaintext_code(item) or None
198
  if redeemed_user:
199
  result["redeemed_user"] = {
200
  "id": redeemed_user.get("id"),
 
282
  with self._lock:
283
  codes = self._load_codes_locked()
284
  users_by_id = {str(item.get("id") or ""): item for item in auth_service.list_keys(role="normal")}
285
+ return [_public_code(item, users_by_id, include_plaintext=True) for item in codes]
286
+
287
+ def list_user_codes(self, user_id: str) -> list[dict[str, Any]]:
288
+ normalized_user_id = _clean(user_id)
289
+ if not normalized_user_id:
290
+ return []
291
+ with self._lock:
292
+ items = [
293
+ _public_code(item, include_plaintext=True)
294
+ for item in self._load_codes_locked()
295
+ if item.get("status") == "redeemed" and _clean(item.get("redeemed_by")) == normalized_user_id
296
+ ]
297
+ items.sort(key=lambda item: str(item.get("redeemed_at") or item.get("created_at") or ""), reverse=True)
298
+ return items[:100]
299
 
300
  def list_ledger(self) -> list[dict[str, Any]]:
301
  with self._lock:
 
332
  if callable(has_redeemed_code_in_batch) and batch_id:
333
  batch_used = bool(has_redeemed_code_in_batch(batch_id, user_id))
334
  public_item = _public_code(item)
335
+ not_started = _is_not_started(item)
336
+ redeemable = item.get("status") == "active" and not not_started and not _is_expired(item) and not batch_used
337
  message = "卡密可兑换"
338
  if item.get("status") != "active":
339
  message = "这张卡密已使用或已失效"
340
+ elif not_started:
341
+ message = "这张每日赠送卡密还没到兑换日期"
342
  elif _is_expired(item):
343
  message = "这张卡密已过期"
344
  elif batch_used:
 
350
  if item.get("code_hash") != code_hash:
351
  continue
352
  public_item = _public_code(item)
353
+ not_started = _is_not_started(item)
354
+ redeemable = item.get("status") == "active" and not not_started and not _is_expired(item) and not _redeem_batch_used_by_user(state, item, user_id)
355
  message = "卡密可兑换"
356
  if item.get("status") != "active":
357
  message = "这张卡密已使用或已失效"
358
+ elif not_started:
359
+ message = "这张每日赠送卡密还没到兑换日期"
360
  elif _is_expired(item):
361
  message = "这张卡密已过期"
362
  elif _redeem_batch_used_by_user(state, item, user_id):
 
375
  validity_days: int | None = None,
376
  credit_expires_days: int | None = None,
377
  batch_single_use: bool = False,
378
+ code_type: str = "normal",
379
+ daily_gift_date: str = "",
380
+ package_name: str = "",
381
+ package_duration_days: int = 1,
382
+ package_daily_limit: int | None = None,
383
+ package_weekly_limit: int | None = None,
384
+ package_monthly_limit: int | None = None,
385
+ package_total_limit: int | None = None,
386
  ) -> dict[str, Any]:
387
  count = min(1000, _to_positive_int(count))
388
  credits = _to_positive_int(credits)
389
  normalized_validity_days = None if validity_days is None else min(3650, _to_positive_int(validity_days))
390
  normalized_credit_expires_days = None if credit_expires_days is None else min(3650, _to_positive_int(credit_expires_days))
391
  normalized_prefix = "".join(_clean(prefix).upper().split("-"))[:12] or "CS"
392
+ normalized_code_type = _normalize_code_type(code_type)
393
+ package_plan = None
394
+ if normalized_code_type == "package":
395
+ package_plan = _normalize_package_plan(
396
+ {
397
+ "name": package_name,
398
+ "duration_days": package_duration_days,
399
+ "daily_limit": package_daily_limit,
400
+ "weekly_limit": package_weekly_limit,
401
+ "monthly_limit": package_monthly_limit,
402
+ "total_limit": package_total_limit,
403
+ },
404
+ require_limits=True,
405
+ )
406
+ normalized_credit_expires_days = None
407
+ if normalized_code_type == "daily_gift":
408
+ normalized_credit_expires_days = None
409
+ batch_single_use = True
410
+ display_credits = credits
411
+ if package_plan is not None:
412
+ display_credits = (
413
+ int(package_plan.get("total_limit") or 0)
414
+ or int(package_plan.get("monthly_limit") or 0)
415
+ or int(package_plan.get("weekly_limit") or 0)
416
+ or int(package_plan.get("daily_limit") or 0)
417
+ or credits
418
+ )
419
  now_dt = datetime.now(timezone.utc)
420
  now = now_dt.isoformat()
421
+ if normalized_code_type == "daily_gift":
422
+ normalized_daily_gift_date, starts_at, expires_at = _china_day_window(daily_gift_date, now_dt=now_dt)
423
+ else:
424
+ normalized_daily_gift_date = ""
425
+ starts_at = None
426
+ expires_at = (now_dt + timedelta(days=normalized_validity_days)).isoformat() if normalized_validity_days else None
427
  batch_id = uuid.uuid4().hex[:12]
428
 
429
  def mutate(state: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
 
442
  item = {
443
  "id": uuid.uuid4().hex[:12],
444
  "code_hash": code_hash,
445
+ "code_plaintext": plaintext,
446
  "prefix": normalized_prefix,
447
+ "credits": display_credits,
448
  "status": "active",
449
+ "code_type": normalized_code_type,
450
  "batch": _clean(batch),
451
  "batch_id": batch_id,
452
  "batch_single_use": bool(batch_single_use),
453
  "note": _clean(note),
454
  "created_at": now,
455
+ "starts_at": starts_at,
456
  "expires_at": expires_at,
457
+ "daily_gift_date": normalized_daily_gift_date,
458
+ "package_plan": package_plan,
459
  "credit_expires_days": normalized_credit_expires_days,
460
  "redeemed_by": None,
461
  "redeemed_at": None,
 
470
  "time": now,
471
  "type": "create_codes",
472
  "delta": count,
473
+ "credits": display_credits,
474
+ "code_type": normalized_code_type,
475
  "batch": _clean(batch),
476
  "batch_id": batch_id,
477
  "batch_single_use": bool(batch_single_use),
478
+ "starts_at": starts_at,
479
  "expires_at": expires_at,
480
+ "daily_gift_date": normalized_daily_gift_date,
481
+ "package_plan": package_plan,
482
  "credit_expires_days": normalized_credit_expires_days,
483
  "note": _clean(note),
484
  }
485
  )
486
  return state, {
487
  "codes": plaintext_codes,
488
+ "items": [_public_code(item, include_plaintext=True) for item in created_items],
489
  }
490
 
491
  with self._lock:
 
507
  state["ledger"].append(
508
  {"id": uuid.uuid4().hex[:12], "time": _now_iso(), "type": "disable_code", "code_id": normalized_id}
509
  )
510
+ return state, _public_code(item, include_plaintext=True)
511
  raise ValueError("卡密不存在")
512
 
513
  with self._lock:
 
527
  state["ledger"].append(
528
  {"id": uuid.uuid4().hex[:12], "time": _now_iso(), "type": "enable_code", "code_id": normalized_id}
529
  )
530
+ return state, _public_code(item, include_plaintext=True)
531
  raise ValueError("卡密不存在")
532
 
533
  with self._lock:
 
554
  "batch": _clean(removed.get("batch")),
555
  }
556
  )
557
+ return state, _public_code(removed, include_plaintext=True)
558
  raise ValueError("卡密不存在")
559
 
560
  with self._lock:
561
  return self._update_codes_state_locked(mutate)
562
 
563
+ def cleanup_codes(
564
+ self,
565
+ *,
566
+ delete_expired: bool = True,
567
+ delete_disabled: bool = False,
568
+ delete_redeemed: bool = False,
569
+ redeemed_older_than_days: int = 90,
570
+ limit: int = 1000,
571
+ dry_run: bool = True,
572
+ ) -> dict[str, Any]:
573
+ normalized_limit = max(1, min(20000, _to_positive_int(limit, 1000)))
574
+ normalized_redeemed_days = max(1, min(3650, _to_positive_int(redeemed_older_than_days, 90)))
575
+ now_dt = datetime.now(timezone.utc)
576
+ redeemed_cutoff = now_dt - timedelta(days=normalized_redeemed_days)
577
+
578
+ def match_reason(item: dict[str, Any]) -> str:
579
+ status = _clean(item.get("status"))
580
+ if delete_expired and status == "active" and _is_expired(item, now=now_dt):
581
+ return "expired"
582
+ if delete_disabled and status == "disabled":
583
+ return "disabled"
584
+ if delete_redeemed and status == "redeemed":
585
+ redeemed_at = _parse_iso_datetime(item.get("redeemed_at"))
586
+ if redeemed_at is None:
587
+ return ""
588
+ if redeemed_at.tzinfo is None:
589
+ redeemed_at = redeemed_at.replace(tzinfo=timezone.utc)
590
+ if redeemed_at <= redeemed_cutoff:
591
+ return "redeemed"
592
+ return ""
593
+
594
+ def summarize(
595
+ matches: list[tuple[dict[str, Any], str]],
596
+ deleted: int,
597
+ archive: dict[str, Any] | None = None,
598
+ ) -> dict[str, Any]:
599
+ reason_counts = {"expired": 0, "disabled": 0, "redeemed": 0}
600
+ for _item, reason in matches:
601
+ if reason in reason_counts:
602
+ reason_counts[reason] += 1
603
+ archive = archive or {}
604
+ return {
605
+ "matched": len(matches),
606
+ "deleted": deleted,
607
+ "archived": int(archive.get("archived") or 0),
608
+ "archive_path": archive.get("path"),
609
+ "expired": reason_counts["expired"],
610
+ "disabled": reason_counts["disabled"],
611
+ "redeemed": reason_counts["redeemed"],
612
+ "limit": normalized_limit,
613
+ }
614
+
615
+ def collect_matches(codes: list[dict[str, Any]]) -> list[tuple[dict[str, Any], str]]:
616
+ matches: list[tuple[dict[str, Any], str]] = []
617
+ for item in codes:
618
+ if len(matches) >= normalized_limit:
619
+ break
620
+ reason = match_reason(item)
621
+ if reason:
622
+ matches.append((item, reason))
623
+ return matches
624
+
625
+ if dry_run:
626
+ with self._lock:
627
+ matches = collect_matches(self._load_codes_locked())
628
+ return {
629
+ "dry_run": True,
630
+ "summary": summarize(matches, 0),
631
+ "archive": {"archived": 0, "path": None},
632
+ "items": [_public_code(item, include_plaintext=True) | {"cleanup_reason": reason} for item, reason in matches],
633
+ }
634
+
635
+ def mutate(state: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
636
+ matches = collect_matches(state["codes"])
637
+ run_id = uuid.uuid4().hex[:12]
638
+ archived_at = _now_iso()
639
+ archive = _archive_codes(matches, archived_at=archived_at, run_id=run_id) if matches else {"archived": 0, "path": None}
640
+ remove_keys = {
641
+ (_clean(item.get("id")), _clean(item.get("code_hash")))
642
+ for item, _reason in matches
643
+ }
644
+ next_codes = [
645
+ item
646
+ for item in state["codes"]
647
+ if (_clean(item.get("id")), _clean(item.get("code_hash"))) not in remove_keys
648
+ ]
649
+ state["codes"] = next_codes
650
+ summary = summarize(matches, len(matches), archive)
651
+ if matches:
652
+ state["ledger"].append(
653
+ {
654
+ "id": run_id,
655
+ "time": archived_at,
656
+ "type": "cleanup_codes",
657
+ "deleted": summary["deleted"],
658
+ "archived": summary["archived"],
659
+ "archive_path": summary["archive_path"],
660
+ "expired": summary["expired"],
661
+ "disabled": summary["disabled"],
662
+ "redeemed": summary["redeemed"],
663
+ "redeemed_older_than_days": normalized_redeemed_days,
664
+ "limit": normalized_limit,
665
+ }
666
+ )
667
+ return state, {
668
+ "dry_run": False,
669
+ "summary": summary,
670
+ "archive": archive,
671
+ "items": [_public_code(item, include_plaintext=True) | {"cleanup_reason": reason} for item, reason in matches],
672
+ }
673
+
674
+ with self._lock:
675
+ result = self._update_codes_state_locked(mutate)
676
+ result["all_items"] = self.list_codes()
677
+ return result
678
+
679
  def _redeem_in_state(
680
  self,
681
  state: dict[str, Any],
682
  *,
683
  user_id: str,
684
  code_hash: str,
685
+ credit_updater: Callable[[int, str, str | None, str, dict[str, Any] | None], dict[str, object]],
686
  ) -> tuple[dict[str, Any], dict[str, Any]]:
687
  for item in state["codes"]:
688
  if item.get("code_hash") != code_hash:
689
  continue
690
  if item.get("status") != "active":
691
  raise ValueError("这张卡密已使用或已失效")
692
+ if _is_not_started(item):
693
+ raise ValueError("这张每日赠送卡密还没到兑换日期")
694
  if _is_expired(item):
695
  raise ValueError("这张卡密已过期")
696
  if _redeem_batch_used_by_user(state, item, user_id):
 
698
  credits = _to_positive_int(item.get("credits"))
699
  now = _now_iso()
700
  code_id = _clean(item.get("id"))
701
+ code_type = _normalize_code_type(item.get("code_type"))
702
+ credit_source = _code_credit_source(code_type)
703
+ package_plan = _normalize_package_plan(item.get("package_plan"), require_limits=code_type == "package")
704
  credit_expires_days = item.get("credit_expires_days")
705
  credit_expires_at = None
706
+ if code_type == "normal" and credit_expires_days is not None:
707
  credit_expires_at = (datetime.now(timezone.utc) + timedelta(days=_to_positive_int(credit_expires_days))).isoformat()
708
+ updated_user = credit_updater(credits, code_id, credit_expires_at, credit_source, package_plan)
709
  item["status"] = "redeemed"
710
  item["redeemed_by"] = user_id
711
  item["redeemed_at"] = now
712
+ item["code_type"] = code_type
713
  item["credit_expires_at"] = credit_expires_at
714
+ item["package_plan"] = package_plan
715
  state["ledger"].append(
716
  {
717
  "id": uuid.uuid4().hex[:12],
718
  "time": now,
719
  "type": "redeem",
720
+ "source": credit_source,
721
  "user_id": user_id,
722
  "code_id": item.get("id"),
723
+ "code_type": code_type,
724
  "delta": credits,
725
+ "package_plan": package_plan,
726
  "credit_expires_at": credit_expires_at,
727
  }
728
  )
 
758
  item = dict(raw_code_item)
759
  if item.get("status") != "active":
760
  raise ValueError("这张卡密已使用或已失效")
761
+ if _is_not_started(item):
762
+ raise ValueError("这张每日赠送卡密还没到兑换日期")
763
  if _is_expired(item):
764
  raise ValueError("这张卡密已过期")
765
  if item.get("batch_single_use") and any(
 
771
  credits = _to_positive_int(item.get("credits"))
772
  now = _now_iso()
773
  code_id = _clean(item.get("id"))
774
+ code_type = _normalize_code_type(item.get("code_type"))
775
+ credit_source = _code_credit_source(code_type)
776
+ package_plan = _normalize_package_plan(item.get("package_plan"), require_limits=code_type == "package")
777
  credit_expires_days = item.get("credit_expires_days")
778
  credit_expires_at = None
779
+ if code_type == "normal" and credit_expires_days is not None:
780
  credit_expires_at = (datetime.now(timezone.utc) + timedelta(days=_to_positive_int(credit_expires_days))).isoformat()
781
+ if code_type == "daily_gift":
782
+ auth_items, updated_user = auth_service._add_daily_credits_in_items(
783
+ auth_items,
784
+ user_id,
785
+ credits,
786
+ source=credit_source,
787
+ code_id=code_id,
788
+ )
789
+ elif code_type == "package":
790
+ auth_items, updated_user = auth_service._add_package_grant_in_items(
791
+ auth_items,
792
+ user_id,
793
+ package_plan or {},
794
+ source=credit_source,
795
+ code_id=code_id,
796
+ )
797
+ else:
798
+ auth_items, updated_user = auth_service._adjust_credits_in_items(
799
+ auth_items,
800
+ user_id,
801
+ credits,
802
+ note=f"redeem:{code_id}",
803
+ expires_at=credit_expires_at,
804
+ source=credit_source,
805
+ code_id=code_id,
806
+ )
807
 
808
  item["status"] = "redeemed"
809
  item["redeemed_by"] = user_id
810
  item["redeemed_at"] = now
811
+ item["code_type"] = code_type
812
  item["credit_expires_at"] = credit_expires_at
813
+ item["package_plan"] = package_plan
814
  state = self._normalize_state(raw_state)
815
  state["ledger"].append(
816
  {
817
  "id": uuid.uuid4().hex[:12],
818
  "time": now,
819
  "type": "redeem",
820
+ "source": credit_source,
821
  "user_id": user_id,
822
  "code_id": item.get("id"),
823
+ "code_type": code_type,
824
  "delta": credits,
825
+ "package_plan": package_plan,
826
  "credit_expires_at": credit_expires_at,
827
  }
828
  )
 
847
  ) -> tuple[list[dict[str, object]], dict[str, Any], dict[str, Any]]:
848
  auth_items = auth_service._normalize_items(raw_auth_items)
849
 
850
+ def credit_updater(credits: int, code_id: str, credit_expires_at: str | None, credit_source: str, package_plan: dict[str, Any] | None) -> dict[str, object]:
851
  nonlocal auth_items
852
+ if credit_source == "daily_gift_code":
853
+ auth_items, updated_user = auth_service._add_daily_credits_in_items(
854
+ auth_items,
855
+ user_id,
856
+ credits,
857
+ source=credit_source,
858
+ code_id=code_id,
859
+ )
860
+ elif credit_source == "package_code":
861
+ auth_items, updated_user = auth_service._add_package_grant_in_items(
862
+ auth_items,
863
+ user_id,
864
+ package_plan or {},
865
+ source=credit_source,
866
+ code_id=code_id,
867
+ )
868
+ else:
869
+ auth_items, updated_user = auth_service._adjust_credits_in_items(
870
+ auth_items,
871
+ user_id,
872
+ credits,
873
+ note=f"redeem:{code_id}",
874
+ expires_at=credit_expires_at,
875
+ source=credit_source,
876
+ code_id=code_id,
877
+ )
878
  return updated_user
879
 
880
  next_state, result = self._redeem_in_state(
 
889
  return update_user_and_shop_state_locked(mutate_combined)
890
 
891
  def mutate(state: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
892
+ def credit_updater(credits: int, code_id: str, credit_expires_at: str | None, credit_source: str, package_plan: dict[str, Any] | None) -> dict[str, object]:
893
+ if credit_source == "daily_gift_code":
894
+ return auth_service.add_daily_credits(
895
+ user_id,
896
+ credits,
897
+ source=credit_source,
898
+ code_id=code_id,
899
+ )
900
+ if credit_source == "package_code":
901
+ return auth_service.add_package_grant(
902
+ user_id,
903
+ package_plan or {},
904
+ source=credit_source,
905
+ code_id=code_id,
906
+ )
907
  return auth_service.adjust_credits(
908
  user_id,
909
  credits,
910
  note=f"redeem:{code_id}",
911
  expires_at=credit_expires_at,
912
+ source=credit_source,
913
  code_id=code_id,
914
  )
915
 
test/test_account_image_capabilities.py CHANGED
@@ -6,6 +6,7 @@ import sqlite3
6
  import tempfile
7
  import time
8
  import unittest
 
9
  from pathlib import Path
10
  from unittest.mock import PropertyMock, patch
11
 
@@ -853,6 +854,423 @@ class AuthServiceTests(unittest.TestCase):
853
  shop_module.auth_service = original_auth_service
854
  backend.engine.dispose()
855
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856
  def test_account_pool_storage_splits_site_and_personal_accounts(self) -> None:
857
  with tempfile.TemporaryDirectory() as tmp_dir:
858
  tmp_path = Path(tmp_dir)
 
6
  import tempfile
7
  import time
8
  import unittest
9
+ from datetime import datetime, timedelta, timezone
10
  from pathlib import Path
11
  from unittest.mock import PropertyMock, patch
12
 
 
854
  shop_module.auth_service = original_auth_service
855
  backend.engine.dispose()
856
 
857
+ def test_generate_codes_keeps_plaintext_for_admin_list(self) -> None:
858
+ with tempfile.TemporaryDirectory() as tmp_dir:
859
+ backend = JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json", Path(tmp_dir) / "shop_state.json")
860
+ shop = ShopService(backend)
861
+
862
+ batch = shop.generate_codes(count=1, credits=7, prefix="CS")
863
+ listed = shop.list_codes()
864
+
865
+ self.assertEqual(batch["items"][0]["code"], batch["codes"][0])
866
+ self.assertEqual(listed[0]["code"], batch["codes"][0])
867
+ self.assertEqual(backend.load_shop_state()["codes"][0]["code_plaintext"], batch["codes"][0])
868
+
869
+ def test_daily_gift_codes_redeem_into_daily_pool(self) -> None:
870
+ with tempfile.TemporaryDirectory() as tmp_dir:
871
+ backend = JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json", Path(tmp_dir) / "shop_state.json")
872
+ auth = AuthService(backend)
873
+ shop = ShopService(backend)
874
+ original_auth_service = shop_module.auth_service
875
+ shop_module.auth_service = auth
876
+ try:
877
+ user, _ = auth.create_key(role="normal", name="Buyer")
878
+ before = auth.credit_summary({"id": user["id"], "role": "normal"})
879
+ batch = shop.generate_codes(
880
+ count=1,
881
+ credits=7,
882
+ prefix="CS",
883
+ code_type="daily_gift",
884
+ validity_days=30,
885
+ credit_expires_days=30,
886
+ batch_single_use=False,
887
+ )
888
+
889
+ result = shop.redeem(batch["codes"][0], {"id": user["id"], "role": "normal"})
890
+ state = backend.load_shop_state()
891
+ stored_user = auth.get_key(str(user["id"]), role="normal")
892
+ stored_code = state["codes"][0]
893
+ expires_at = datetime.fromisoformat(str(stored_code["expires_at"]).replace("Z", "+00:00"))
894
+ china_expires_at = expires_at.astimezone(timezone(timedelta(hours=8)))
895
+ today_china = datetime.now(timezone(timedelta(hours=8))).date()
896
+
897
+ self.assertEqual(batch["items"][0]["code_type"], "daily_gift")
898
+ self.assertEqual(batch["items"][0]["daily_gift_date"], today_china.isoformat())
899
+ self.assertTrue(batch["items"][0]["batch_single_use"])
900
+ self.assertIsNone(batch["items"][0]["credit_expires_days"])
901
+ self.assertEqual(result["item"]["code_type"], "daily_gift")
902
+ self.assertEqual(stored_code["code_type"], "daily_gift")
903
+ self.assertEqual(stored_code["daily_gift_date"], today_china.isoformat())
904
+ self.assertTrue(stored_code["batch_single_use"])
905
+ self.assertIsNone(stored_code["credit_expires_days"])
906
+ self.assertEqual(china_expires_at.date(), today_china)
907
+ self.assertEqual((china_expires_at.hour, china_expires_at.minute), (23, 59))
908
+ self.assertEqual(state["ledger"][-1]["code_type"], "daily_gift")
909
+ self.assertEqual(state["ledger"][-1]["source"], "daily_gift_code")
910
+ self.assertIsNotNone(stored_user)
911
+ self.assertEqual(result["credits"]["daily_credits"], before["daily_credits"] + 7)
912
+ self.assertEqual(result["credits"]["daily_credits_remaining"], before["daily_credits_remaining"] + 7)
913
+ self.assertEqual(result["credits"]["daily_bonus_credits"], 7)
914
+ self.assertEqual(result["credits"]["paid_credits"], before["paid_credits"])
915
+ self.assertEqual(result["credits"]["total_recharged"], before["total_recharged"])
916
+ self.assertEqual(stored_user["daily_bonus_credits"], 7)
917
+ self.assertEqual(stored_user["credit_grants"], [])
918
+ finally:
919
+ shop_module.auth_service = original_auth_service
920
+
921
+ def test_future_daily_gift_codes_wait_until_activity_date(self) -> None:
922
+ with tempfile.TemporaryDirectory() as tmp_dir:
923
+ backend = JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json", Path(tmp_dir) / "shop_state.json")
924
+ auth = AuthService(backend)
925
+ shop = ShopService(backend)
926
+ original_auth_service = shop_module.auth_service
927
+ shop_module.auth_service = auth
928
+ try:
929
+ user, _ = auth.create_key(role="normal", name="Buyer")
930
+ future_date = (datetime.now(timezone(timedelta(hours=8))) + timedelta(days=2)).date().isoformat()
931
+ batch = shop.generate_codes(count=1, credits=7, prefix="CS", code_type="daily_gift", daily_gift_date=future_date)
932
+ stored_code = backend.load_shop_state()["codes"][0]
933
+ starts_at = datetime.fromisoformat(str(stored_code["starts_at"]).replace("Z", "+00:00")).astimezone(timezone(timedelta(hours=8)))
934
+ expires_at = datetime.fromisoformat(str(stored_code["expires_at"]).replace("Z", "+00:00")).astimezone(timezone(timedelta(hours=8)))
935
+
936
+ preview = shop.preview_code(batch["codes"][0], {"id": user["id"], "role": "normal"})
937
+
938
+ self.assertEqual(stored_code["daily_gift_date"], future_date)
939
+ self.assertEqual(starts_at.date().isoformat(), future_date)
940
+ self.assertEqual((starts_at.hour, starts_at.minute), (0, 0))
941
+ self.assertEqual(expires_at.date().isoformat(), future_date)
942
+ self.assertEqual((expires_at.hour, expires_at.minute), (23, 59))
943
+ self.assertFalse(preview["redeemable"])
944
+ self.assertTrue(preview["item"]["is_not_started"])
945
+ finally:
946
+ shop_module.auth_service = original_auth_service
947
+
948
+ def test_database_daily_gift_codes_redeem_into_daily_pool(self) -> None:
949
+ with tempfile.TemporaryDirectory() as tmp_dir:
950
+ backend = DatabaseStorageBackend(f"sqlite:///{(Path(tmp_dir) / 'shared.db').as_posix()}")
951
+ auth = AuthService(backend)
952
+ shop = ShopService(backend)
953
+ original_auth_service = shop_module.auth_service
954
+ shop_module.auth_service = auth
955
+ try:
956
+ user, _ = auth.create_key(role="normal", name="Buyer")
957
+ before = auth.credit_summary({"id": user["id"], "role": "normal"})
958
+ batch = shop.generate_codes(
959
+ count=1,
960
+ credits=7,
961
+ prefix="CS",
962
+ code_type="daily_gift",
963
+ validity_days=30,
964
+ credit_expires_days=30,
965
+ batch_single_use=False,
966
+ )
967
+
968
+ result = shop.redeem(batch["codes"][0], {"id": user["id"], "role": "normal"})
969
+ stored_user = auth.get_key(str(user["id"]), role="normal")
970
+ stored_code = backend.get_redeem_code_by_hash(shop_module.hash_redeem_code(batch["codes"][0]))
971
+
972
+ self.assertEqual(result["credits"]["daily_credits"], before["daily_credits"] + 7)
973
+ self.assertEqual(result["credits"]["daily_credits_remaining"], before["daily_credits_remaining"] + 7)
974
+ self.assertEqual(result["credits"]["daily_bonus_credits"], 7)
975
+ self.assertEqual(result["credits"]["paid_credits"], before["paid_credits"])
976
+ self.assertEqual(result["credits"]["total_recharged"], before["total_recharged"])
977
+ self.assertIsNotNone(stored_user)
978
+ self.assertEqual(stored_user["daily_bonus_credits"], 7)
979
+ self.assertIsNotNone(stored_code)
980
+ self.assertEqual(stored_code["code_type"], "daily_gift")
981
+ self.assertTrue(stored_code["batch_single_use"])
982
+ self.assertIsNone(stored_code["credit_expires_days"])
983
+ self.assertEqual(stored_code["status"], "redeemed")
984
+ finally:
985
+ shop_module.auth_service = original_auth_service
986
+ backend.engine.dispose()
987
+
988
+ def test_package_codes_redeem_into_package_grant_and_meter_first(self) -> None:
989
+ with tempfile.TemporaryDirectory() as tmp_dir:
990
+ backend = JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json", Path(tmp_dir) / "shop_state.json")
991
+ original_auth_service = shop_module.auth_service
992
+ with (
993
+ patch.object(type(config_module.config), "normal_user_default_credits", new_callable=PropertyMock, return_value=0),
994
+ patch.object(type(config_module.config), "normal_user_daily_credits", new_callable=PropertyMock, return_value=0),
995
+ ):
996
+ auth = AuthService(backend)
997
+ shop = ShopService(backend)
998
+ shop_module.auth_service = auth
999
+ try:
1000
+ user, _ = auth.create_key(role="normal", name="Buyer")
1001
+ before = auth.credit_summary({"id": user["id"], "role": "normal"})
1002
+ batch = shop.generate_codes(
1003
+ count=1,
1004
+ credits=99,
1005
+ prefix="CS",
1006
+ code_type="package",
1007
+ package_name="高级周卡",
1008
+ package_duration_days=7,
1009
+ package_daily_limit=2,
1010
+ package_total_limit=5,
1011
+ )
1012
+
1013
+ result = shop.redeem(batch["codes"][0], {"id": user["id"], "role": "normal"})
1014
+ stored_user = auth.get_key(str(user["id"]), role="normal")
1015
+ reservation = auth.reserve_credits(str(user["id"]), 1)["_credit_reservation"]
1016
+ auth.consume_reserved_credits(
1017
+ str(user["id"]),
1018
+ 1,
1019
+ daily_amount=0,
1020
+ paid_amount=0,
1021
+ package_reservations=reservation["packages"],
1022
+ grant_reservations=[],
1023
+ )
1024
+ after_consume = auth.get_key(str(user["id"]), role="normal")
1025
+
1026
+ self.assertEqual(batch["items"][0]["code_type"], "package")
1027
+ self.assertEqual(batch["items"][0]["package_plan"]["name"], "高级周卡")
1028
+ self.assertEqual(result["credits"]["paid_credits"], before["paid_credits"])
1029
+ self.assertEqual(result["credits"]["total_recharged"], before["total_recharged"])
1030
+ self.assertEqual(result["credits"]["package_credits"], 2)
1031
+ self.assertIsNotNone(stored_user)
1032
+ self.assertEqual(len(stored_user["package_grants"]), 1)
1033
+ self.assertEqual(reservation["daily"], 0)
1034
+ self.assertEqual(reservation["paid"], 0)
1035
+ self.assertEqual(reservation["packages"][0]["amount"], 1)
1036
+ self.assertIsNotNone(after_consume)
1037
+ grant = after_consume["package_grants"][0]
1038
+ self.assertEqual(grant["used_daily"], 1)
1039
+ self.assertEqual(grant["used_total"], 1)
1040
+ self.assertEqual(after_consume["used_credits"], 1)
1041
+ second_reservation = auth.reserve_credits(str(user["id"]), 1)["_credit_reservation"]
1042
+ auth.consume_reserved_credits(
1043
+ str(user["id"]),
1044
+ 1,
1045
+ daily_amount=0,
1046
+ paid_amount=0,
1047
+ package_reservations=second_reservation["packages"],
1048
+ grant_reservations=[],
1049
+ )
1050
+ with self.assertRaises(ValueError):
1051
+ auth.reserve_credits(str(user["id"]), 1)
1052
+ finally:
1053
+ shop_module.auth_service = original_auth_service
1054
+
1055
+ def test_package_reserved_credits_can_be_refunded(self) -> None:
1056
+ with tempfile.TemporaryDirectory() as tmp_dir:
1057
+ backend = JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json", Path(tmp_dir) / "shop_state.json")
1058
+ with (
1059
+ patch.object(type(config_module.config), "normal_user_default_credits", new_callable=PropertyMock, return_value=0),
1060
+ patch.object(type(config_module.config), "normal_user_daily_credits", new_callable=PropertyMock, return_value=0),
1061
+ ):
1062
+ auth = AuthService(backend)
1063
+ user, _ = auth.create_key(role="normal", name="Buyer")
1064
+ auth.add_package_grant(
1065
+ str(user["id"]),
1066
+ {
1067
+ "name": "普通日卡",
1068
+ "duration_days": 1,
1069
+ "daily_limit": 3,
1070
+ "weekly_limit": 0,
1071
+ "monthly_limit": 0,
1072
+ "total_limit": 3,
1073
+ },
1074
+ source="test",
1075
+ code_id="code-1",
1076
+ )
1077
+
1078
+ reservation = auth.reserve_credits(str(user["id"]), 2)["_credit_reservation"]
1079
+ reserved_user = auth.get_key(str(user["id"]), role="normal")
1080
+ auth.refund_reserved_credits(
1081
+ str(user["id"]),
1082
+ 2,
1083
+ daily_amount=0,
1084
+ paid_amount=0,
1085
+ package_reservations=reservation["packages"],
1086
+ grant_reservations=[],
1087
+ )
1088
+ refunded_user = auth.get_key(str(user["id"]), role="normal")
1089
+
1090
+ self.assertIsNotNone(reserved_user)
1091
+ self.assertEqual(reserved_user["package_grants"][0]["reserved_daily"], 2)
1092
+ self.assertIsNotNone(refunded_user)
1093
+ grant = refunded_user["package_grants"][0]
1094
+ self.assertEqual(grant["reserved_daily"], 0)
1095
+ self.assertEqual(grant["reserved_total"], 0)
1096
+ self.assertEqual(refunded_user["package_credits"], 3)
1097
+
1098
+ def test_database_package_codes_redeem_into_package_grant(self) -> None:
1099
+ with tempfile.TemporaryDirectory() as tmp_dir:
1100
+ backend = DatabaseStorageBackend(f"sqlite:///{(Path(tmp_dir) / 'shared.db').as_posix()}")
1101
+ original_auth_service = shop_module.auth_service
1102
+ with (
1103
+ patch.object(type(config_module.config), "normal_user_default_credits", new_callable=PropertyMock, return_value=0),
1104
+ patch.object(type(config_module.config), "normal_user_daily_credits", new_callable=PropertyMock, return_value=0),
1105
+ ):
1106
+ auth = AuthService(backend)
1107
+ shop = ShopService(backend)
1108
+ shop_module.auth_service = auth
1109
+ try:
1110
+ user, _ = auth.create_key(role="normal", name="Buyer")
1111
+ batch = shop.generate_codes(
1112
+ count=1,
1113
+ credits=50,
1114
+ prefix="CS",
1115
+ code_type="package",
1116
+ package_name="普通月卡",
1117
+ package_duration_days=30,
1118
+ package_monthly_limit=50,
1119
+ )
1120
+
1121
+ result = shop.redeem(batch["codes"][0], {"id": user["id"], "role": "normal"})
1122
+ stored_user = auth.get_key(str(user["id"]), role="normal")
1123
+ stored_code = backend.get_redeem_code_by_hash(shop_module.hash_redeem_code(batch["codes"][0]))
1124
+
1125
+ self.assertEqual(result["credits"]["package_credits"], 50)
1126
+ self.assertEqual(result["credits"]["total_recharged"], 0)
1127
+ self.assertIsNotNone(stored_user)
1128
+ self.assertEqual(stored_user["package_grants"][0]["name"], "普通月卡")
1129
+ self.assertIsNotNone(stored_code)
1130
+ self.assertEqual(stored_code["code_type"], "package")
1131
+ self.assertEqual(stored_code["status"], "redeemed")
1132
+ self.assertEqual(stored_code["package_plan"]["monthly_limit"], 50)
1133
+ finally:
1134
+ shop_module.auth_service = original_auth_service
1135
+ backend.engine.dispose()
1136
+
1137
+ def test_user_can_list_own_redeemed_codes(self) -> None:
1138
+ with tempfile.TemporaryDirectory() as tmp_dir:
1139
+ backend = JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json", Path(tmp_dir) / "shop_state.json")
1140
+ auth = AuthService(backend)
1141
+ shop = ShopService(backend)
1142
+ original_auth_service = shop_module.auth_service
1143
+ shop_module.auth_service = auth
1144
+ try:
1145
+ first_user, _ = auth.create_key(role="normal", name="Buyer 1")
1146
+ second_user, _ = auth.create_key(role="normal", name="Buyer 2")
1147
+ first_batch = shop.generate_codes(
1148
+ count=1,
1149
+ credits=20,
1150
+ prefix="CS",
1151
+ code_type="package",
1152
+ package_name="高级周卡",
1153
+ package_duration_days=7,
1154
+ package_weekly_limit=20,
1155
+ )
1156
+ second_batch = shop.generate_codes(count=1, credits=5, prefix="CS")
1157
+
1158
+ shop.redeem(first_batch["codes"][0], {"id": first_user["id"], "role": "normal"})
1159
+ shop.redeem(second_batch["codes"][0], {"id": second_user["id"], "role": "normal"})
1160
+
1161
+ first_items = shop.list_user_codes(str(first_user["id"]))
1162
+ second_items = shop.list_user_codes(str(second_user["id"]))
1163
+
1164
+ self.assertEqual(len(first_items), 1)
1165
+ self.assertEqual(first_items[0]["code"], first_batch["codes"][0])
1166
+ self.assertEqual(first_items[0]["code_type"], "package")
1167
+ self.assertEqual(first_items[0]["package_plan"]["name"], "高级周卡")
1168
+ self.assertEqual(len(second_items), 1)
1169
+ self.assertEqual(second_items[0]["code"], second_batch["codes"][0])
1170
+ self.assertNotEqual(first_items[0]["code"], second_items[0]["code"])
1171
+ finally:
1172
+ shop_module.auth_service = original_auth_service
1173
+
1174
+ def test_cleanup_codes_archives_matches_before_deleting(self) -> None:
1175
+ with tempfile.TemporaryDirectory() as tmp_dir:
1176
+ tmp_path = Path(tmp_dir)
1177
+ backend = JSONStorageBackend(tmp_path / "accounts.json", tmp_path / "auth_keys.json", tmp_path / "shop_state.json")
1178
+ shop = ShopService(backend)
1179
+ now = datetime.now(timezone.utc)
1180
+ old_redeemed_at = (now - timedelta(days=120)).isoformat()
1181
+ recent_redeemed_at = (now - timedelta(days=7)).isoformat()
1182
+ future_expires_at = (now + timedelta(days=7)).isoformat()
1183
+ yesterday = (now - timedelta(days=1)).isoformat()
1184
+ codes = [
1185
+ {
1186
+ "id": "expired-code",
1187
+ "code_hash": shop_module.hash_redeem_code("CS-EXPIRED-CODE"),
1188
+ "code_plaintext": "CS-EXPIRED-CODE",
1189
+ "prefix": "CS",
1190
+ "credits": 1,
1191
+ "status": "active",
1192
+ "created_at": old_redeemed_at,
1193
+ "expires_at": yesterday,
1194
+ },
1195
+ {
1196
+ "id": "disabled-code",
1197
+ "code_hash": shop_module.hash_redeem_code("CS-DISABLED-CODE"),
1198
+ "code_plaintext": "CS-DISABLED-CODE",
1199
+ "prefix": "CS",
1200
+ "credits": 1,
1201
+ "status": "disabled",
1202
+ "created_at": old_redeemed_at,
1203
+ },
1204
+ {
1205
+ "id": "old-redeemed-code",
1206
+ "code_hash": shop_module.hash_redeem_code("CS-OLD-REDEEMED"),
1207
+ "code_plaintext": "CS-OLD-REDEEMED",
1208
+ "prefix": "CS",
1209
+ "credits": 1,
1210
+ "status": "redeemed",
1211
+ "created_at": old_redeemed_at,
1212
+ "redeemed_at": old_redeemed_at,
1213
+ "redeemed_by": "user-1",
1214
+ },
1215
+ {
1216
+ "id": "recent-redeemed-code",
1217
+ "code_hash": shop_module.hash_redeem_code("CS-RECENT-REDEEMED"),
1218
+ "code_plaintext": "CS-RECENT-REDEEMED",
1219
+ "prefix": "CS",
1220
+ "credits": 1,
1221
+ "status": "redeemed",
1222
+ "created_at": recent_redeemed_at,
1223
+ "redeemed_at": recent_redeemed_at,
1224
+ "redeemed_by": "user-1",
1225
+ },
1226
+ {
1227
+ "id": "active-code",
1228
+ "code_hash": shop_module.hash_redeem_code("CS-ACTIVE-CODE"),
1229
+ "code_plaintext": "CS-ACTIVE-CODE",
1230
+ "prefix": "CS",
1231
+ "credits": 1,
1232
+ "status": "active",
1233
+ "created_at": recent_redeemed_at,
1234
+ "expires_at": future_expires_at,
1235
+ },
1236
+ ]
1237
+ backend.save_shop_state({"codes": codes, "ledger": []})
1238
+ previous_data_dir = shop_module.DATA_DIR
1239
+ shop_module.DATA_DIR = tmp_path
1240
+ try:
1241
+ preview = shop.cleanup_codes(
1242
+ delete_expired=True,
1243
+ delete_disabled=True,
1244
+ delete_redeemed=True,
1245
+ redeemed_older_than_days=90,
1246
+ limit=1000,
1247
+ dry_run=True,
1248
+ )
1249
+ self.assertEqual(preview["summary"]["matched"], 3)
1250
+ self.assertEqual(len(backend.load_shop_state()["codes"]), 5)
1251
+
1252
+ result = shop.cleanup_codes(
1253
+ delete_expired=True,
1254
+ delete_disabled=True,
1255
+ delete_redeemed=True,
1256
+ redeemed_older_than_days=90,
1257
+ limit=1000,
1258
+ dry_run=False,
1259
+ )
1260
+
1261
+ remaining_ids = {item["id"] for item in backend.load_shop_state()["codes"]}
1262
+ self.assertEqual(remaining_ids, {"recent-redeemed-code", "active-code"})
1263
+ self.assertEqual(result["summary"]["deleted"], 3)
1264
+ self.assertEqual(result["summary"]["archived"], 3)
1265
+ archive_path = Path(result["summary"]["archive_path"])
1266
+ self.assertTrue(archive_path.exists())
1267
+ archived = [json.loads(line) for line in archive_path.read_text(encoding="utf-8").splitlines()]
1268
+ self.assertEqual(len(archived), 3)
1269
+ self.assertEqual({item["reason"] for item in archived}, {"expired", "disabled", "redeemed"})
1270
+ self.assertEqual(archived[0]["item"]["code_plaintext"], "CS-EXPIRED-CODE")
1271
+ finally:
1272
+ shop_module.DATA_DIR = previous_data_dir
1273
+
1274
  def test_account_pool_storage_splits_site_and_personal_accounts(self) -> None:
1275
  with tempfile.TemporaryDirectory() as tmp_dir:
1276
  tmp_path = Path(tmp_dir)
web/.next-dev-3000.err.log CHANGED
@@ -1,280 +1,342 @@
1
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
2
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
3
- at ignore-listed frames
4
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
5
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
6
- at ignore-listed frames
7
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
8
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
9
- at ignore-listed frames
10
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
11
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
12
- at ignore-listed frames
13
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
14
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
15
- at ignore-listed frames
16
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
17
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
18
- at ignore-listed frames
19
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
20
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
21
- at ignore-listed frames
22
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
23
-
24
- - A server/client branch `if (typeof window !== 'undefined')`.
25
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
26
- - Date formatting in a user's locale which doesn't match the server.
27
- - External changing data without sending a snapshot of it along with the HTML.
28
- - Invalid HTML tag nesting.
29
-
30
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
31
-
32
- https://react.dev/link/hydration-mismatch
33
-
34
- ...
35
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
36
- <AppDevOverlayErrorBoundary globalError={[...]}>
37
- <ReplaySsrOnlyErrors>
38
- <DevRootHTTPAccessFallbackBoundary>
39
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
40
- <HTTPAccessFallbackErrorBoundary pathname="/settings/" notFound={<NotAllowedRootHTTPFallbackError>} ...>
41
- <RedirectBoundary>
42
- <RedirectErrorBoundary router={{...}}>
43
- <Head>
44
- <__next_root_layout_boundary__>
45
- <SegmentViewNode type="layout" pagePath="layout.tsx">
46
- <SegmentTrieNode>
47
- <link>
48
- <RootLayout>
49
- <html lang="zh-CN" suppressHydrationWarning={true}>
50
- <body
51
- className="antialiased"
52
- style={{fontFamily:"\"SF Pro D..."}}
53
- - mpa-version="12.3.4"
54
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
55
- >
56
- ...
57
-
58
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
59
-
60
- - A server/client branch `if (typeof window !== 'undefined')`.
61
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
62
- - Date formatting in a user's locale which doesn't match the server.
63
- - External changing data without sending a snapshot of it along with the HTML.
64
- - Invalid HTML tag nesting.
65
-
66
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
67
-
68
- https://react.dev/link/hydration-mismatch
69
-
70
- ...
71
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
72
- <AppDevOverlayErrorBoundary globalError={[...]}>
73
- <ReplaySsrOnlyErrors>
74
- <DevRootHTTPAccessFallbackBoundary>
75
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
76
- <HTTPAccessFallbackErrorBoundary pathname="/login/" notFound={<NotAllowedRootHTTPFallbackError>} ...>
77
- <RedirectBoundary>
78
- <RedirectErrorBoundary router={{...}}>
79
- <Head>
80
- <__next_root_layout_boundary__>
81
- <SegmentViewNode type="layout" pagePath="layout.tsx">
82
- <SegmentTrieNode>
83
- <link>
84
- <RootLayout>
85
- <html lang="zh-CN" suppressHydrationWarning={true}>
86
- <body
87
- className="antialiased"
88
- style={{fontFamily:"\"SF Pro D..."}}
89
- - mpa-version="12.3.4"
90
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
91
- >
92
- ...
93
-
94
  [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
95
  at Array.some (<anonymous>)
96
  [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
97
  at Array.some (<anonymous>)
98
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
99
-
100
- - A server/client branch `if (typeof window !== 'undefined')`.
101
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
102
- - Date formatting in a user's locale which doesn't match the server.
103
- - External changing data without sending a snapshot of it along with the HTML.
104
- - Invalid HTML tag nesting.
105
-
106
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
107
-
108
- https://react.dev/link/hydration-mismatch
109
-
110
- ...
111
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
112
- <AppDevOverlayErrorBoundary globalError={[...]}>
113
- <ReplaySsrOnlyErrors>
114
- <DevRootHTTPAccessFallbackBoundary>
115
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
116
- <HTTPAccessFallbackErrorBoundary pathname="/login/" notFound={<NotAllowedRootHTTPFallbackError>} ...>
117
- <RedirectBoundary>
118
- <RedirectErrorBoundary router={{...}}>
119
- <Head>
120
- <__next_root_layout_boundary__>
121
- <SegmentViewNode type="layout" pagePath="layout.tsx">
122
- <SegmentTrieNode>
123
- <link>
124
- <RootLayout>
125
- <html lang="zh-CN" suppressHydrationWarning={true}>
126
- <body
127
- className="antialiased"
128
- style={{fontFamily:"\"SF Pro D..."}}
129
- - mpa-version="12.3.4"
130
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
131
- >
132
- ...
133
-
134
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
135
-
136
- - A server/client branch `if (typeof window !== 'undefined')`.
137
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
138
- - Date formatting in a user's locale which doesn't match the server.
139
- - External changing data without sending a snapshot of it along with the HTML.
140
- - Invalid HTML tag nesting.
141
-
142
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
143
-
144
- https://react.dev/link/hydration-mismatch
145
-
146
- ...
147
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
148
- <AppDevOverlayErrorBoundary globalError={[...]}>
149
- <ReplaySsrOnlyErrors>
150
- <DevRootHTTPAccessFallbackBoundary>
151
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
152
- <HTTPAccessFallbackErrorBoundary pathname="/login/" notFound={<NotAllowedRootHTTPFallbackError>} ...>
153
- <RedirectBoundary>
154
- <RedirectErrorBoundary router={{...}}>
155
- <Head>
156
- <__next_root_layout_boundary__>
157
- <SegmentViewNode type="layout" pagePath="layout.tsx">
158
- <SegmentTrieNode>
159
- <link>
160
- <RootLayout>
161
- <html lang="zh-CN" suppressHydrationWarning={true}>
162
- <body
163
- className="antialiased"
164
- style={{fontFamily:"\"SF Pro D..."}}
165
- - mpa-version="12.3.4"
166
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
167
- >
168
- ...
169
-
170
- Error: Failed to find Server Action. This request might be from an older or newer deployment.
171
- Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
172
- at ignore-listed frames
173
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
174
 
175
- - A server/client branch `if (typeof window !== 'undefined')`.
176
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
177
- - Date formatting in a user's locale which doesn't match the server.
178
- - External changing data without sending a snapshot of it along with the HTML.
179
- - Invalid HTML tag nesting.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- https://react.dev/link/hydration-mismatch
 
184
 
185
- ...
186
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
187
- <AppDevOverlayErrorBoundary globalError={[...]}>
188
- <ReplaySsrOnlyErrors>
189
- <DevRootHTTPAccessFallbackBoundary>
190
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
191
- <HTTPAccessFallbackErrorBoundary pathname="/static/ca..." notFound={<NotAllowedRootHTTPFallbackError>} ...>
192
- <RedirectBoundary>
193
- <RedirectErrorBoundary router={{...}}>
194
- <Head>
195
- <__next_root_layout_boundary__>
196
- <SegmentViewNode type="layout" pagePath="layout.tsx">
197
- <SegmentTrieNode>
198
- <link>
199
- <RootLayout>
200
- <html lang="zh-CN" suppressHydrationWarning={true}>
201
- <body
202
- className="antialiased"
203
- style={{fontFamily:"\"SF Pro D..."}}
204
- - mpa-version="12.3.4"
205
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
206
- >
207
- ...
208
-
209
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
210
 
211
- - A server/client branch `if (typeof window !== 'undefined')`.
212
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
213
- - Date formatting in a user's locale which doesn't match the server.
214
- - External changing data without sending a snapshot of it along with the HTML.
215
- - Invalid HTML tag nesting.
216
 
217
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
- https://react.dev/link/hydration-mismatch
 
220
 
221
- ...
222
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
223
- <AppDevOverlayErrorBoundary globalError={[...]}>
224
- <ReplaySsrOnlyErrors>
225
- <DevRootHTTPAccessFallbackBoundary>
226
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
227
- <HTTPAccessFallbackErrorBoundary pathname="/" notFound={<NotAllowedRootHTTPFallbackError>} ...>
228
- <RedirectBoundary>
229
- <RedirectErrorBoundary router={{...}}>
230
- <Head>
231
- <__next_root_layout_boundary__>
232
- <SegmentViewNode type="layout" pagePath="layout.tsx">
233
- <SegmentTrieNode>
234
- <link>
235
- <RootLayout>
236
- <html lang="zh-CN" suppressHydrationWarning={true}>
237
- <body
238
- className="antialiased"
239
- style={{fontFamily:"\"SF Pro D..."}}
240
- - mpa-version="12.3.4"
241
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
242
- >
243
- ...
244
-
245
- [browser] A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
246
 
247
- - A server/client branch `if (typeof window !== 'undefined')`.
248
- - Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
249
- - Date formatting in a user's locale which doesn't match the server.
250
- - External changing data without sending a snapshot of it along with the HTML.
251
- - Invalid HTML tag nesting.
252
 
253
- It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- https://react.dev/link/hydration-mismatch
 
256
 
257
- ...
258
- <HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
259
- <AppDevOverlayErrorBoundary globalError={[...]}>
260
- <ReplaySsrOnlyErrors>
261
- <DevRootHTTPAccessFallbackBoundary>
262
- <HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
263
- <HTTPAccessFallbackErrorBoundary pathname="/" notFound={<NotAllowedRootHTTPFallbackError>} ...>
264
- <RedirectBoundary>
265
- <RedirectErrorBoundary router={{...}}>
266
- <Head>
267
- <__next_root_layout_boundary__>
268
- <SegmentViewNode type="layout" pagePath="layout.tsx">
269
- <SegmentTrieNode>
270
- <link>
271
- <RootLayout>
272
- <html lang="zh-CN" suppressHydrationWarning={true}>
273
- <body
274
- className="antialiased"
275
- style={{fontFamily:"\"SF Pro D..."}}
276
- - mpa-version="12.3.4"
277
- - mpa-extension-id="aidjohbjielfdhcaookdaolppglahebo"
278
- >
279
- ...
280
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
2
  at Array.some (<anonymous>)
3
  [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
4
  at Array.some (<anonymous>)
5
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
6
+ at Array.some (<anonymous>)
7
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
8
+ at Array.some (<anonymous>)
9
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
10
+ at Array.some (<anonymous>)
11
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'length')
12
+ at Array.some (<anonymous>)
13
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'slice')
14
+ at ImagePageContent (src\app\image\page.tsx:1615:43)
15
+ at ImagePage (webpack-internal:///(app-pages-browser)/./src/app/image/page.tsx:2526:87)
16
+ 1613 |
17
+ 1614 | const activePackageGrants = creditSummary?.package_grants ?? [];
18
+ > 1615 | const recentRedeemCodes = myRedeemCodes.slice(0, 12);
19
+ | ^
20
+ 1616 | const creditTotal = toQuotaCount(creditSummary?.credits) ?? 0;
21
+ 1617 | const dailyRemaining = creditSummary?.daily_credits_remaining ?? 0;
22
+ 1618 | const dailyTotal = creditSummary?.daily_credits ?? 0;
23
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'slice')
24
+ at ImagePageContent (src\app\image\page.tsx:1615:43)
25
+ at ImagePage (src\app\image\page.tsx:1988:10)
26
+ 1613 |
27
+ 1614 | const activePackageGrants = creditSummary?.package_grants ?? [];
28
+ > 1615 | const recentRedeemCodes = myRedeemCodes.slice(0, 12);
29
+ | ^
30
+ 1616 | const creditTotal = toQuotaCount(creditSummary?.credits) ?? 0;
31
+ 1617 | const dailyRemaining = creditSummary?.daily_credits_remaining ?? 0;
32
+ 1618 | const dailyTotal = creditSummary?.daily_credits ?? 0;
33
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'slice')
34
+ at ImagePageContent (src\app\image\page.tsx:1615:43)
35
+ at ImagePage (src\app\image\page.tsx:1988:10)
36
+ 1613 |
37
+ 1614 | const activePackageGrants = creditSummary?.package_grants ?? [];
38
+ > 1615 | const recentRedeemCodes = myRedeemCodes.slice(0, 12);
39
+ | ^
40
+ 1616 | const creditTotal = toQuotaCount(creditSummary?.credits) ?? 0;
41
+ 1617 | const dailyRemaining = creditSummary?.daily_credits_remaining ?? 0;
42
+ 1618 | const dailyTotal = creditSummary?.daily_credits ?? 0;
43
+ ⚠ Fast Refresh had to perform a full reload due to a runtime error.
44
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'slice')
45
+ at ImagePageContent (src\app\image\page.tsx:1615:43)
46
+ at ImagePage (src\app\image\page.tsx:2104:10)
47
+ 1613 |
48
+ 1614 | const activePackageGrants = creditSummary?.package_grants ?? [];
49
+ > 1615 | const recentRedeemCodes = myRedeemCodes.slice(0, 12);
50
+ | ^
51
+ 1616 | const creditTotal = toQuotaCount(creditSummary?.credits) ?? 0;
52
+ 1617 | const dailyRemaining = creditSummary?.daily_credits_remaining ?? 0;
53
+ 1618 | const dailyTotal = creditSummary?.daily_credits ?? 0;
54
+ [browser] Uncaught TypeError: Cannot read properties of undefined (reading 'slice')
55
+ at ImagePageContent (src\app\image\page.tsx:1615:43)
56
+ at ImagePage (src\app\image\page.tsx:2104:10)
57
+ 1613 |
58
+ 1614 | const activePackageGrants = creditSummary?.package_grants ?? [];
59
+ > 1615 | const recentRedeemCodes = myRedeemCodes.slice(0, 12);
60
+ | ^
61
+ 1616 | const creditTotal = toQuotaCount(creditSummary?.credits) ?? 0;
62
+ 1617 | const dailyRemaining = creditSummary?.daily_credits_remaining ?? 0;
63
+ 1618 | const dailyTotal = creditSummary?.daily_credits ?? 0;
64
+ [browser] The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
+ Previous: [false, false, async ()=>{
67
+ if (!isNormalUser) {
68
+ return;
69
+ }
70
+ setIsLoadingMyRedeemCodes(true);
71
+ try {
72
+ const data = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchMyRedeemCodes)();
73
+ setMyRedeemCodes(data.items);
74
+ } catch {
75
+ setMyRedeemCodes([]);
76
+ } finally{
77
+ setIsLoadingMyRedeemCodes(false);
78
+ }
79
+ }]
80
+ Incoming: [false, false, async ()=>{
81
+ if (!isNormalUser) {
82
+ return;
83
+ }
84
+ setIsLoadingMyRedeemCodes(true);
85
+ try {
86
+ const data = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchMyRedeemCodes)();
87
+ setMyRedeemCodes(data.items);
88
+ } catch {
89
+ setMyRedeemCodes([]);
90
+ } finally{
91
+ setIsLoadingMyRedeemCodes(false);
92
+ }
93
+ }, async ()=>{
94
+ try {
95
+ if (isNormalUser) {
96
+ const creditsData = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchMyCredits)();
97
+ setCreditSummary(creditsData.credits);
98
+ const siteCredits = toQuotaCount(creditsData.credits.credits) ?? 0;
99
+ try {
100
+ const poolData = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchQuotaSummary)(selectedAccountPoolStrategy, !isMemoryEnabled);
101
+ const poolQuota = poolData.unknown ? null : toQuotaCount(poolData.quota);
102
+ setAccountPoolQuotaCount(poolQuota);
103
+ setAvailableQuota(String(siteCredits));
104
+ setAvailableQuotaCount(siteCredits);
105
+ } catch {
106
+ setAccountPoolQuotaCount(null);
107
+ setAvailableQuota(String(siteCredits));
108
+ setAvailableQuotaCount(siteCredits);
109
+ }
110
+ return;
111
+ }
112
+ const data = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchQuotaSummary)(selectedAccountPoolStrategy, !isMemoryEnabled);
113
+ const quotaCount = data.unknown ? null : toQuotaCount(data.quota);
114
+ setAccountPoolQuotaCount(quotaCount);
115
+ setAvailableQuota(String(data.label ?? data.quota ?? 0));
116
+ setAvailableQuotaCount(quotaCount);
117
+ } catch {
118
+ setAccountPoolQuotaCount(null);
119
+ setAvailableQuota({
120
+ "ImagePageContent.useCallback[loadQuota]": (prev)=>prev === "加载中..." ? "--" : prev
121
+ }["ImagePageContent.useCallback[loadQuota]"]);
122
+ setAvailableQuotaCount(null);
123
+ }
124
+ }]
125
+ at ImagePageContent (src\app\image\page.tsx:669:12)
126
+ 667 | }, [isNormalUser]);
127
+ 668 |
128
+ > 669 | useEffect(() => {
129
+ | ^
130
+ 670 | if (!isBenefitsOpen || !isNormalUser) {
131
+ 671 | return;
132
+ 672 | } (src\app\image\page.tsx:669:12)
133
+ [browser] The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant.
134
 
135
+ Previous: [true, true, async ()=>{
136
+ if (!isNormalUser) {
137
+ return;
138
+ }
139
+ setIsLoadingMyRedeemCodes(true);
140
+ try {
141
+ const data = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchMyRedeemCodes)();
142
+ setMyRedeemCodes(data.items);
143
+ } catch {
144
+ setMyRedeemCodes([]);
145
+ } finally{
146
+ setIsLoadingMyRedeemCodes(false);
147
+ }
148
+ }]
149
+ Incoming: [false, true, async ()=>{
150
+ if (!isNormalUser) {
151
+ return;
152
+ }
153
+ setIsLoadingMyRedeemCodes(true);
154
+ try {
155
+ const data = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchMyRedeemCodes)();
156
+ setMyRedeemCodes(data.items);
157
+ } catch {
158
+ setMyRedeemCodes([]);
159
+ } finally{
160
+ setIsLoadingMyRedeemCodes(false);
161
+ }
162
+ }, async ()=>{
163
+ try {
164
+ if (isNormalUser) {
165
+ const creditsData = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchMyCredits)();
166
+ setCreditSummary(creditsData.credits);
167
+ const siteCredits = toQuotaCount(creditsData.credits.credits) ?? 0;
168
+ try {
169
+ const poolData = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchQuotaSummary)(selectedAccountPoolStrategy, !isMemoryEnabled);
170
+ const poolQuota = poolData.unknown ? null : toQuotaCount(poolData.quota);
171
+ setAccountPoolQuotaCount(poolQuota);
172
+ setAvailableQuota(String(siteCredits));
173
+ setAvailableQuotaCount(siteCredits);
174
+ } catch {
175
+ setAccountPoolQuotaCount(null);
176
+ setAvailableQuota(String(siteCredits));
177
+ setAvailableQuotaCount(siteCredits);
178
+ }
179
+ return;
180
+ }
181
+ const data = await (0,_lib_api__WEBPACK_IMPORTED_MODULE_9__.fetchQuotaSummary)(selectedAccountPoolStrategy, !isMemoryEnabled);
182
+ const quotaCount = data.unknown ? null : toQuotaCount(data.quota);
183
+ setAccountPoolQuotaCount(quotaCount);
184
+ setAvailableQuota(String(data.label ?? data.quota ?? 0));
185
+ setAvailableQuotaCount(quotaCount);
186
+ } catch {
187
+ setAccountPoolQuotaCount(null);
188
+ setAvailableQuota({
189
+ "ImagePageContent.useCallback[loadQuota]": (prev)=>prev === "加载中..." ? "--" : prev
190
+ }["ImagePageContent.useCallback[loadQuota]"]);
191
+ setAvailableQuotaCount(null);
192
+ }
193
+ }]
194
+ at ImagePageContent (src\app\image\page.tsx:669:12)
195
+ 667 | }, [isNormalUser]);
196
+ 668 |
197
+ > 669 | useEffect(() => {
198
+ | ^
199
+ 670 | if (!isBenefitsOpen || !isNormalUser) {
200
+ 671 | return;
201
+ 672 | } (src\app\image\page.tsx:669:12)
202
+ [browser] ./src/app/image/page.tsx
203
+ Error: x Expected '</', got 'ident'
204
+ ,-[F:\code\CHATSAM-Public\web\src\app\image\page.tsx:2069:1]
205
+ 2066 | )}
206
+ 2067 | </div>
207
+ 2068 | </div>
208
+ 2069 | <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
209
+ : ^^^^^^^^^
210
+ 2070 | <div className="font-medium">次数说明</div>
211
+ 2071 | <p className="mt-1 text-xs text-amber-800/90">
212
+ 2072 | 生成图片会优先扣今日赠送次数,再扣套餐额度,然后扣快过期的限时次数,最后扣永久购买次数。
213
+ `----
214
 
215
+ Caused by:
216
+ Syntax Error
217
 
218
+ Import trace for requested module:
219
+ ./src/app/image/page.tsx
220
+ [browser] ./src/app/image/page.tsx
221
+ Error: x Expected '</', got 'ident'
222
+ ,-[F:\code\CHATSAM-Public\web\src\app\image\page.tsx:2069:1]
223
+ 2066 | )}
224
+ 2067 | </div>
225
+ 2068 | </div>
226
+ 2069 | <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
227
+ : ^^^^^^^^^
228
+ 2070 | <div className="font-medium">次数说明</div>
229
+ 2071 | <p className="mt-1 text-xs text-amber-800/90">
230
+ 2072 | 生成图片会优先扣今日赠送次数,再扣套餐额度,然后扣快过期的限时次数,最后扣永久购买次数。
231
+ `----
 
 
 
 
 
 
 
 
 
 
 
232
 
233
+ Caused by:
234
+ Syntax Error
 
 
 
235
 
236
+ Import trace for requested module:
237
+ ./src/app/image/page.tsx
238
+ [browser] ./src/app/image/page.tsx
239
+ Error: x Expected '</', got 'ident'
240
+ ,-[F:\code\CHATSAM-Public\web\src\app\image\page.tsx:2069:1]
241
+ 2066 | )}
242
+ 2067 | </div>
243
+ 2068 | </div>
244
+ 2069 | <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
245
+ : ^^^^^^^^^
246
+ 2070 | <div className="font-medium">次数说明</div>
247
+ 2071 | <p className="mt-1 text-xs text-amber-800/90">
248
+ 2072 | 生成图片会优先扣今日赠送次数,再扣套餐额度,然后扣快过期的限时次数,最后扣永久购买次数。
249
+ `----
250
 
251
+ Caused by:
252
+ Syntax Error
253
 
254
+ Import trace for requested module:
255
+ ./src/app/image/page.tsx
256
+ [browser] ./src/app/image/page.tsx
257
+ Error: x Expected '</', got 'ident'
258
+ ,-[F:\code\CHATSAM-Public\web\src\app\image\page.tsx:2069:1]
259
+ 2066 | )}
260
+ 2067 | </div>
261
+ 2068 | </div>
262
+ 2069 | <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
263
+ : ^^^^^^^^^
264
+ 2070 | <div className="font-medium">次数说明</div>
265
+ 2071 | <p className="mt-1 text-xs text-amber-800/90">
266
+ 2072 | 生成图片会优先扣今日赠送次数,再扣套餐额度,然后扣快过期的限时次数,最后扣永久购买次数。
267
+ `----
 
 
 
 
 
 
 
 
 
 
 
268
 
269
+ Caused by:
270
+ Syntax Error
 
 
 
271
 
272
+ Import trace for requested module:
273
+ ./src/app/image/page.tsx
274
+ ⨯ ./src/app/image/page.tsx
275
+ Error: x Expected '</', got 'ident'
276
+ ,-[F:\code\CHATSAM-Public\web\src\app\image\page.tsx:2069:1]
277
+ 2066 | )}
278
+ 2067 | </div>
279
+ 2068 | </div>
280
+ 2069 | <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
281
+ : ^^^^^^^^^
282
+ 2070 | <div className="font-medium">次数说明</div>
283
+ 2071 | <p className="mt-1 text-xs text-amber-800/90">
284
+ 2072 | 生成图片会优先扣今日赠送次数,再扣套餐额度,然后扣快过期的限时次数,最后扣永久购买次数。
285
+ `----
286
 
287
+ Caused by:
288
+ Syntax Error
289
 
290
+ Import trace for requested module:
291
+ ./src/app/image/page.tsx
292
+ <w> [webpack.cache.PackFileCacheStrategy] Caching failed for pack: Error: ENOENT: no such file or directory, rename 'F:\code\CHATSAM-Public\web\.next\dev\cache\webpack\client-development-fallback\0.pack.gz_' -> 'F:\code\CHATSAM-Public\web\.next\dev\cache\webpack\client-development-fallback\0.pack.gz'
293
+ <w> [webpack.cache.PackFileCacheStrategy] Caching failed for pack: Error: ENOENT: no such file or directory, rename 'F:\code\CHATSAM-Public\web\.next\dev\cache\webpack\client-development-fallback\0.pack.gz_' -> 'F:\code\CHATSAM-Public\web\.next\dev\cache\webpack\client-development-fallback\0.pack.gz'
294
+ ⚠ Fast Refresh had to perform a full reload due to a runtime error.
295
+ ⚠ Fast Refresh had to perform a full reload due to a runtime error.
296
+ [browser] Uncaught ReferenceError: webConfig is not defined
297
+ at getExternalApiUrl (src\app\image\page.tsx:452:28)
298
+ at ImagePageContent (src\app\image\page.tsx:512:26)
299
+ at ImagePage (webpack-internal:///(app-pages-browser)/./src/app/image/page.tsx:3152:87)
300
+ 450 |
301
+ 451 | function getExternalApiUrl() {
302
+ > 452 | const configuredApiUrl = webConfig.apiUrl.replace(/\/$/, "");
303
+ | ^
304
+ 453 | if (configuredApiUrl) {
305
+ 454 | return configuredApiUrl;
306
+ 455 | }
307
+ [browser] Uncaught ReferenceError: webConfig is not defined
308
+ at getExternalApiUrl (src\app\image\page.tsx:452:28)
309
+ at ImagePageContent (src\app\image\page.tsx:512:26)
310
+ at ImagePage (webpack-internal:///(app-pages-browser)/./src/app/image/page.tsx:3152:87)
311
+ 450 |
312
+ 451 | function getExternalApiUrl() {
313
+ > 452 | const configuredApiUrl = webConfig.apiUrl.replace(/\/$/, "");
314
+ | ^
315
+ 453 | if (configuredApiUrl) {
316
+ 454 | return configuredApiUrl;
317
+ 455 | }
318
+ [browser] Uncaught ReferenceError: webConfig is not defined
319
+ at getExternalApiUrl (src\app\image\page.tsx:452:28)
320
+ at ImagePageContent (src\app\image\page.tsx:512:26)
321
+ at ImagePage (src\app\image\page.tsx:2248:10)
322
+ 450 |
323
+ 451 | function getExternalApiUrl() {
324
+ > 452 | const configuredApiUrl = webConfig.apiUrl.replace(/\/$/, "");
325
+ | ^
326
+ 453 | if (configuredApiUrl) {
327
+ 454 | return configuredApiUrl;
328
+ 455 | }
329
+ [browser] Uncaught ReferenceError: webConfig is not defined
330
+ at getExternalApiUrl (src\app\image\page.tsx:452:28)
331
+ at ImagePageContent (src\app\image\page.tsx:512:26)
332
+ at ImagePage (src\app\image\page.tsx:2248:10)
333
+ 450 |
334
+ 451 | function getExternalApiUrl() {
335
+ > 452 | const configuredApiUrl = webConfig.apiUrl.replace(/\/$/, "");
336
+ | ^
337
+ 453 | if (configuredApiUrl) {
338
+ 454 | return configuredApiUrl;
339
+ 455 | }
340
+ ⚠ Fast Refresh had to perform a full reload due to a runtime error.
341
+ ⚠ Fast Refresh had to perform a full reload due to a runtime error.
342
+ [browser] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead.
web/.next-dev-3000.out.log CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/app/image/components/image-composer.tsx CHANGED
@@ -28,8 +28,8 @@ type ImageComposerProps = {
28
  onMemoryToggle: () => void;
29
  onAccountPoolStrategyChange: (value: string) => void;
30
  onSubmit: () => void | Promise<void>;
 
31
  onRedeemClick?: () => void;
32
- onApiKeyClick?: () => void;
33
  onPickReferenceImage: () => void;
34
  onReferenceImageChange: (files: File[]) => void | Promise<void>;
35
  onRemoveReferenceImage: (index: number) => void;
@@ -74,8 +74,8 @@ export function ImageComposer({
74
  onMemoryToggle,
75
  onAccountPoolStrategyChange,
76
  onSubmit,
 
77
  onRedeemClick,
78
- onApiKeyClick,
79
  onPickReferenceImage,
80
  onReferenceImageChange,
81
  onRemoveReferenceImage,
@@ -351,11 +351,22 @@ export function ImageComposer({
351
  <span className="hidden sm:inline">{referenceImages.length > 0 ? "添加参考图" : "上传"}</span>
352
  </Button>
353
  {availableQuota !== "--" && (
354
- <div
355
- className="shrink-0 rounded-full bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-600 sm:px-3 sm:py-2 sm:text-xs"
356
- >
357
- <span className="hidden sm:inline">{quotaLabel} </span>{availableQuota}
358
- </div>
 
 
 
 
 
 
 
 
 
 
 
359
  )}
360
  {onRedeemClick ? (
361
  <Button
@@ -367,16 +378,6 @@ export function ImageComposer({
367
  充值
368
  </Button>
369
  ) : null}
370
- {onApiKeyClick ? (
371
- <Button
372
- type="button"
373
- variant="outline"
374
- className="h-9 shrink-0 rounded-full border-sky-200 bg-sky-50 px-3 text-xs font-medium text-sky-700 shadow-none hover:bg-sky-100 sm:h-10 sm:px-4 sm:text-sm"
375
- onClick={onApiKeyClick}
376
- >
377
- 密钥
378
- </Button>
379
- ) : null}
380
  <Button
381
  type="button"
382
  variant="outline"
 
28
  onMemoryToggle: () => void;
29
  onAccountPoolStrategyChange: (value: string) => void;
30
  onSubmit: () => void | Promise<void>;
31
+ onBenefitsClick?: () => void;
32
  onRedeemClick?: () => void;
 
33
  onPickReferenceImage: () => void;
34
  onReferenceImageChange: (files: File[]) => void | Promise<void>;
35
  onRemoveReferenceImage: (index: number) => void;
 
74
  onMemoryToggle,
75
  onAccountPoolStrategyChange,
76
  onSubmit,
77
+ onBenefitsClick,
78
  onRedeemClick,
 
79
  onPickReferenceImage,
80
  onReferenceImageChange,
81
  onRemoveReferenceImage,
 
351
  <span className="hidden sm:inline">{referenceImages.length > 0 ? "添加参考图" : "上传"}</span>
352
  </Button>
353
  {availableQuota !== "--" && (
354
+ onBenefitsClick ? (
355
+ <Button
356
+ type="button"
357
+ variant="outline"
358
+ className="h-9 shrink-0 rounded-full border-stone-200 bg-stone-100 px-2 text-[10px] font-medium text-stone-700 shadow-none hover:bg-stone-200 sm:h-10 sm:px-3 sm:text-xs"
359
+ onClick={onBenefitsClick}
360
+ >
361
+ <span className="hidden sm:inline">{quotaLabel} </span>{availableQuota}
362
+ </Button>
363
+ ) : (
364
+ <div
365
+ className="shrink-0 rounded-full bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-600 sm:px-3 sm:py-2 sm:text-xs"
366
+ >
367
+ <span className="hidden sm:inline">{quotaLabel} </span>{availableQuota}
368
+ </div>
369
+ )
370
  )}
371
  {onRedeemClick ? (
372
  <Button
 
378
  充值
379
  </Button>
380
  ) : null}
 
 
 
 
 
 
 
 
 
 
381
  <Button
382
  type="button"
383
  variant="outline"
web/src/app/image/page.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { Copy, History, KeyRound, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { ImageComposer } from "@/app/image/components/image-composer";
@@ -21,9 +21,9 @@ import {
21
  createImageEditTask,
22
  createImageGenerationTask,
23
  fetchImageTasks,
24
- fetchMyApiKey,
25
  fetchMyCredits,
26
  fetchMyProfile,
 
27
  fetchQuotaSummary,
28
  fetchShopPublicConfig,
29
  previewRedeemCode,
@@ -37,7 +37,6 @@ import {
37
  import { fetchImageBlob } from "@/lib/image-url";
38
  import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
39
  import { useAuthGuard } from "@/lib/use-auth-guard";
40
- import webConfig from "@/constants/common-env";
41
  import { Input } from "@/components/ui/input";
42
  import { cn } from "@/lib/utils";
43
  import { setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
@@ -105,6 +104,39 @@ function formatConversationTime(value: string) {
105
  return formatChinaShortDateTime(value);
106
  }
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  function redeemCodeValidityLabel(item: RedeemCode) {
109
  let redeemText = "卡密长期可兑换";
110
  if (item.expires_at) {
@@ -119,12 +151,24 @@ function redeemCodeValidityLabel(item: RedeemCode) {
119
  redeemText = days <= 1 ? "卡密还有不到1天过期,请尽快兑换" : `卡密还有${days}天过期,请尽快兑换`;
120
  }
121
  }
 
 
 
 
 
 
122
  const creditText = item.credit_expires_days ? `兑换后${item.credit_expires_days}天内需用完` : "兑换后永久有效";
123
  return `${redeemText},${creditText}`;
124
  }
125
 
126
  function redeemCodeStatusLabel(item: RedeemCode, redeemable: boolean, message: string) {
127
  if (redeemable) {
 
 
 
 
 
 
128
  return `可充值 ${Number(item.credits || 0)} 次`;
129
  }
130
  return message || "这张卡密暂时不能兑换";
@@ -404,17 +448,6 @@ function isTemporaryWechatName(profile: MyProfile | null) {
404
  );
405
  }
406
 
407
- function getExternalApiUrl() {
408
- const configuredApiUrl = webConfig.apiUrl.replace(/\/$/, "");
409
- if (configuredApiUrl) {
410
- return configuredApiUrl;
411
- }
412
- if (typeof window !== "undefined") {
413
- return window.location.origin;
414
- }
415
- return "";
416
- }
417
-
418
  function ImagePageContent({ session }: { session: StoredAuthSession }) {
419
  const role = session.role;
420
  const isAdmin = role === "admin";
@@ -443,15 +476,15 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
443
  const [availableQuotaCount, setAvailableQuotaCount] = useState<number | null>(null);
444
  const [accountPoolQuotaCount, setAccountPoolQuotaCount] = useState<number | null>(null);
445
  const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null);
 
446
  const [isRedeemOpen, setIsRedeemOpen] = useState(false);
447
  const [redeemValue, setRedeemValue] = useState("");
448
  const [isRedeeming, setIsRedeeming] = useState(false);
449
  const [redeemPreview, setRedeemPreview] = useState<{ item: RedeemCode; redeemable: boolean; message: string } | null>(null);
450
  const [isLoadingRedeemPreview, setIsLoadingRedeemPreview] = useState(false);
451
  const [redeemPreviewError, setRedeemPreviewError] = useState("");
452
- const [isApiKeyOpen, setIsApiKeyOpen] = useState(false);
453
- const [apiKeyValue, setApiKeyValue] = useState("");
454
- const [isLoadingApiKey, setIsLoadingApiKey] = useState(false);
455
  const [profile, setProfile] = useState<MyProfile | null>(null);
456
  const [isProfileOpen, setIsProfileOpen] = useState(false);
457
  const [nicknameValue, setNicknameValue] = useState("");
@@ -462,7 +495,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
462
  const [lightboxIndex, setLightboxIndex] = useState(0);
463
  const personalAccountPoolEnabled = profile ? profile.account_pool_enabled === true : session.accountPoolEnabled === true;
464
  const hasPersonalAccountPool = isAdmin || personalAccountPoolEnabled;
465
- const externalApiUrl = getExternalApiUrl();
466
  const [deleteConfirm, setDeleteConfirm] = useState<
467
  | { type: "one"; id: string }
468
  | { type: "prompt"; conversationId: string; turnId: string }
@@ -602,6 +634,29 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
602
  };
603
  }, [loadQuota]);
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  useEffect(() => {
606
  if (!isNormalUser) {
607
  return;
@@ -1498,6 +1553,7 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1498
  setRedeemPreviewError("");
1499
  setIsRedeemOpen(false);
1500
  await loadQuota();
 
1501
  toast.success("充值成功");
1502
  } catch (error) {
1503
  toast.error(error instanceof Error ? error.message : "充值失败");
@@ -1506,26 +1562,12 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1506
  }
1507
  };
1508
 
1509
- const handleOpenApiKey = async () => {
1510
- setIsApiKeyOpen(true);
1511
- setIsLoadingApiKey(true);
1512
- try {
1513
- const data = await fetchMyApiKey();
1514
- setApiKeyValue(data.api_key);
1515
- } catch (error) {
1516
- setApiKeyValue("");
1517
- toast.error(error instanceof Error ? error.message : "加载密钥失败");
1518
- } finally {
1519
- setIsLoadingApiKey(false);
1520
- }
1521
- };
1522
-
1523
- const handleCopyApiKey = async () => {
1524
- if (!apiKeyValue) {
1525
  return;
1526
  }
1527
- await navigator.clipboard.writeText(apiKeyValue);
1528
- toast.success("密钥已复制");
1529
  };
1530
 
1531
  const openProfileDialog = () => {
@@ -1533,6 +1575,133 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1533
  setIsProfileOpen(true);
1534
  };
1535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1536
  const handleSaveNickname = async () => {
1537
  const normalizedName = nicknameValue.trim();
1538
  if (!normalizedName) {
@@ -1681,8 +1850,8 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1681
  onMemoryToggle={() => setIsMemoryEnabled((value) => !value)}
1682
  onAccountPoolStrategyChange={setAccountPoolStrategy}
1683
  onSubmit={handleSubmit}
 
1684
  onRedeemClick={isNormalUser ? () => setIsRedeemOpen(true) : undefined}
1685
- onApiKeyClick={isNormalUser ? () => void handleOpenApiKey() : undefined}
1686
  onPickReferenceImage={() => fileInputRef.current?.click()}
1687
  onReferenceImageChange={handleReferenceImageChange}
1688
  onRemoveReferenceImage={handleRemoveReferenceImage}
@@ -1700,6 +1869,29 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1700
  onIndexChange={setLightboxIndex}
1701
  />
1702
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1703
  <Dialog
1704
  open={isRedeemOpen}
1705
  onOpenChange={(open) => {
@@ -1714,14 +1906,132 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1714
  <DialogContent showCloseButton={false} className="rounded-2xl p-6">
1715
  <DialogHeader className="gap-2">
1716
  <DialogTitle>充值次数</DialogTitle>
1717
- <DialogDescription className="text-sm leading-6">
1718
- 输入卡密充值次数;部分福利卡密可能有到账后的使用期限。
1719
- </DialogDescription>
1720
- </DialogHeader>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1721
  <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
1722
  <div className="font-medium">次数说明</div>
1723
  <p className="mt-1 text-xs text-amber-800/90">
1724
- 生成图片会优先扣今日赠送次数,再扣快过期的限时次数,最后扣永久购买次数。
1725
  </p>
1726
  </div>
1727
  <div className="space-y-2">
@@ -1783,46 +2093,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
1783
  </DialogContent>
1784
  </Dialog>
1785
 
1786
- <Dialog open={isApiKeyOpen} onOpenChange={setIsApiKeyOpen}>
1787
- <DialogContent showCloseButton={false} className="rounded-2xl p-6">
1788
- <DialogHeader className="gap-2">
1789
- <DialogTitle className="flex items-center gap-2">
1790
- <KeyRound className="size-5" />
1791
- 密钥
1792
- </DialogTitle>
1793
- <DialogDescription className="text-sm leading-6">
1794
- 这个 Key 可以用于网站登录,也可以填到支持 OpenAI 图片接口的第三方工具里调用当前网站。
1795
- </DialogDescription>
1796
- </DialogHeader>
1797
- <div className="space-y-3">
1798
- <div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm leading-6 text-stone-700">
1799
- <div className="text-xs font-medium uppercase tracking-wide text-stone-400">URL</div>
1800
- <div className="break-all font-mono text-xs text-stone-800">
1801
- {externalApiUrl || "部署后显示当前网站地址"}
1802
- </div>
1803
- </div>
1804
- <div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm leading-6 text-stone-700">
1805
- <div className="text-xs font-medium uppercase tracking-wide text-stone-400">Key</div>
1806
- <div className="break-all font-mono text-xs text-stone-800">
1807
- {isLoadingApiKey ? "加载中..." : apiKeyValue || "当前密钥无法回显,请联系管理员重置。"}
1808
- </div>
1809
- </div>
1810
- </div>
1811
- <DialogFooter>
1812
- <Button variant="outline" onClick={openProfileDialog}>
1813
- 修改昵称
1814
- </Button>
1815
- <Button variant="outline" onClick={() => setIsApiKeyOpen(false)}>
1816
- 关闭
1817
- </Button>
1818
- <Button className="bg-stone-950 text-white hover:bg-stone-800" onClick={() => void handleCopyApiKey()} disabled={!apiKeyValue || isLoadingApiKey}>
1819
- <Copy className="size-4" />
1820
- 复制密钥
1821
- </Button>
1822
- </DialogFooter>
1823
- </DialogContent>
1824
- </Dialog>
1825
-
1826
  <Dialog open={isProfileOpen} onOpenChange={setIsProfileOpen}>
1827
  <DialogContent showCloseButton={false} className="rounded-2xl p-6">
1828
  <DialogHeader className="gap-2">
 
1
  "use client";
2
 
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Copy, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { ImageComposer } from "@/app/image/components/image-composer";
 
21
  createImageEditTask,
22
  createImageGenerationTask,
23
  fetchImageTasks,
 
24
  fetchMyCredits,
25
  fetchMyProfile,
26
+ fetchMyRedeemCodes,
27
  fetchQuotaSummary,
28
  fetchShopPublicConfig,
29
  previewRedeemCode,
 
37
  import { fetchImageBlob } from "@/lib/image-url";
38
  import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
39
  import { useAuthGuard } from "@/lib/use-auth-guard";
 
40
  import { Input } from "@/components/ui/input";
41
  import { cn } from "@/lib/utils";
42
  import { setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
 
104
  return formatChinaShortDateTime(value);
105
  }
106
 
107
+ function packageLimitText(plan?: RedeemCode["package_plan"] | null) {
108
+ if (!plan) return "未设置套餐";
109
+ const parts = [
110
+ plan.daily_limit ? `日 ${plan.daily_limit}` : "",
111
+ plan.weekly_limit ? `周 ${plan.weekly_limit}` : "",
112
+ plan.monthly_limit ? `月 ${plan.monthly_limit}` : "",
113
+ plan.total_limit ? `总 ${plan.total_limit}` : "",
114
+ ].filter(Boolean);
115
+ return parts.length > 0 ? parts.join(" / ") : "未设置限额";
116
+ }
117
+
118
+ function packageSummary(item: RedeemCode) {
119
+ const plan = item.package_plan;
120
+ if (!plan) return "套餐配置缺失";
121
+ return `${plan.name || "套餐"} · ${plan.duration_days || 1} 天 · ${packageLimitText(plan)}`;
122
+ }
123
+
124
+ function redeemCodeTypeLabel(item: RedeemCode) {
125
+ if (item.code_type === "daily_gift") return "每日赠送";
126
+ if (item.code_type === "package") return "套餐";
127
+ return "普通卡密";
128
+ }
129
+
130
+ function redeemedCodeText(item: RedeemCode) {
131
+ if (item.code) return item.code;
132
+ return `${item.prefix || "CS"}-****-${String(item.id || "").slice(0, 4).toUpperCase()}`;
133
+ }
134
+
135
+ function packageUsagePercent(used: number, limit: number) {
136
+ if (limit <= 0) return 0;
137
+ return Math.min(100, Math.round((used / limit) * 100));
138
+ }
139
+
140
  function redeemCodeValidityLabel(item: RedeemCode) {
141
  let redeemText = "卡密长期可兑换";
142
  if (item.expires_at) {
 
151
  redeemText = days <= 1 ? "卡密还有不到1天过期,请尽快兑换" : `卡密还有${days}天过期,请尽快兑换`;
152
  }
153
  }
154
+ if (item.code_type === "daily_gift") {
155
+ return `${redeemText},兑换后计入今日赠送次数`;
156
+ }
157
+ if (item.code_type === "package") {
158
+ return `${redeemText},兑换后获得 ${packageSummary(item)}`;
159
+ }
160
  const creditText = item.credit_expires_days ? `兑换后${item.credit_expires_days}天内需用完` : "兑换后永久有效";
161
  return `${redeemText},${creditText}`;
162
  }
163
 
164
  function redeemCodeStatusLabel(item: RedeemCode, redeemable: boolean, message: string) {
165
  if (redeemable) {
166
+ if (item.code_type === "daily_gift") {
167
+ return `可增加今日赠送 ${Number(item.credits || 0)} 次`;
168
+ }
169
+ if (item.code_type === "package") {
170
+ return `可兑换套餐:${packageSummary(item)}`;
171
+ }
172
  return `可充值 ${Number(item.credits || 0)} 次`;
173
  }
174
  return message || "这张卡密暂时不能兑换";
 
448
  );
449
  }
450
 
 
 
 
 
 
 
 
 
 
 
 
451
  function ImagePageContent({ session }: { session: StoredAuthSession }) {
452
  const role = session.role;
453
  const isAdmin = role === "admin";
 
476
  const [availableQuotaCount, setAvailableQuotaCount] = useState<number | null>(null);
477
  const [accountPoolQuotaCount, setAccountPoolQuotaCount] = useState<number | null>(null);
478
  const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null);
479
+ const [isBenefitsOpen, setIsBenefitsOpen] = useState(false);
480
  const [isRedeemOpen, setIsRedeemOpen] = useState(false);
481
  const [redeemValue, setRedeemValue] = useState("");
482
  const [isRedeeming, setIsRedeeming] = useState(false);
483
  const [redeemPreview, setRedeemPreview] = useState<{ item: RedeemCode; redeemable: boolean; message: string } | null>(null);
484
  const [isLoadingRedeemPreview, setIsLoadingRedeemPreview] = useState(false);
485
  const [redeemPreviewError, setRedeemPreviewError] = useState("");
486
+ const [myRedeemCodes, setMyRedeemCodes] = useState<RedeemCode[]>([]);
487
+ const [isLoadingMyRedeemCodes, setIsLoadingMyRedeemCodes] = useState(false);
 
488
  const [profile, setProfile] = useState<MyProfile | null>(null);
489
  const [isProfileOpen, setIsProfileOpen] = useState(false);
490
  const [nicknameValue, setNicknameValue] = useState("");
 
495
  const [lightboxIndex, setLightboxIndex] = useState(0);
496
  const personalAccountPoolEnabled = profile ? profile.account_pool_enabled === true : session.accountPoolEnabled === true;
497
  const hasPersonalAccountPool = isAdmin || personalAccountPoolEnabled;
 
498
  const [deleteConfirm, setDeleteConfirm] = useState<
499
  | { type: "one"; id: string }
500
  | { type: "prompt"; conversationId: string; turnId: string }
 
634
  };
635
  }, [loadQuota]);
636
 
637
+ const loadMyRedeemCodes = useCallback(async () => {
638
+ if (!isNormalUser) {
639
+ return;
640
+ }
641
+ setIsLoadingMyRedeemCodes(true);
642
+ try {
643
+ const data = await fetchMyRedeemCodes();
644
+ setMyRedeemCodes(data.items);
645
+ } catch {
646
+ setMyRedeemCodes([]);
647
+ } finally {
648
+ setIsLoadingMyRedeemCodes(false);
649
+ }
650
+ }, [isNormalUser]);
651
+
652
+ useEffect(() => {
653
+ if (!isBenefitsOpen || !isNormalUser) {
654
+ return;
655
+ }
656
+ void loadQuota();
657
+ void loadMyRedeemCodes();
658
+ }, [isBenefitsOpen, isNormalUser, loadMyRedeemCodes, loadQuota]);
659
+
660
  useEffect(() => {
661
  if (!isNormalUser) {
662
  return;
 
1553
  setRedeemPreviewError("");
1554
  setIsRedeemOpen(false);
1555
  await loadQuota();
1556
+ void loadMyRedeemCodes();
1557
  toast.success("充值成功");
1558
  } catch (error) {
1559
  toast.error(error instanceof Error ? error.message : "充值失败");
 
1562
  }
1563
  };
1564
 
1565
+ const copyText = async (value: string, message = "已复制") => {
1566
+ if (!value) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1567
  return;
1568
  }
1569
+ await navigator.clipboard.writeText(value);
1570
+ toast.success(message);
1571
  };
1572
 
1573
  const openProfileDialog = () => {
 
1575
  setIsProfileOpen(true);
1576
  };
1577
 
1578
+ const activePackageGrants = creditSummary?.package_grants ?? [];
1579
+ const recentRedeemCodes = myRedeemCodes.slice(0, 12);
1580
+ const creditTotal = toQuotaCount(creditSummary?.credits) ?? 0;
1581
+ const dailyRemaining = creditSummary?.daily_credits_remaining ?? 0;
1582
+ const dailyTotal = creditSummary?.daily_credits ?? 0;
1583
+ const packageCredits = creditSummary?.package_credits ?? 0;
1584
+ const expiringCredits = creditSummary?.expiring_credits ?? 0;
1585
+ const permanentCredits = creditSummary?.permanent_credits ?? 0;
1586
+ const benefitsPanel = (
1587
+ <div className="space-y-4">
1588
+ <div className="rounded-2xl border border-stone-200 bg-stone-50/70 p-4">
1589
+ <div className="flex items-center justify-between gap-3">
1590
+ <div>
1591
+ <div className="text-sm font-semibold text-stone-900">我的额度</div>
1592
+ <div className="mt-1 text-xs text-stone-500">生成时按下面顺序扣除:每日赠送、套餐、限时、永久。</div>
1593
+ </div>
1594
+ <div className="rounded-xl bg-stone-950 px-3 py-2 text-right text-white">
1595
+ <div className="text-[11px] text-stone-300">总可用</div>
1596
+ <div className="text-lg font-semibold leading-none">{creditTotal}</div>
1597
+ </div>
1598
+ </div>
1599
+ <div className="mt-4 grid gap-2 sm:grid-cols-4">
1600
+ <div className="rounded-xl border border-stone-200 bg-white px-3 py-2">
1601
+ <div className="text-[11px] text-stone-500">今日赠送</div>
1602
+ <div className="mt-1 text-sm font-semibold text-stone-900">{dailyRemaining} / {dailyTotal}</div>
1603
+ </div>
1604
+ <div className="rounded-xl border border-sky-200 bg-white px-3 py-2">
1605
+ <div className="text-[11px] text-sky-600">套餐额度</div>
1606
+ <div className="mt-1 text-sm font-semibold text-sky-900">{packageCredits}</div>
1607
+ </div>
1608
+ <div className="rounded-xl border border-amber-200 bg-white px-3 py-2">
1609
+ <div className="text-[11px] text-amber-600">限时次数</div>
1610
+ <div className="mt-1 text-sm font-semibold text-amber-900">{expiringCredits}</div>
1611
+ </div>
1612
+ <div className="rounded-xl border border-stone-200 bg-white px-3 py-2">
1613
+ <div className="text-[11px] text-stone-500">永久次数</div>
1614
+ <div className="mt-1 text-sm font-semibold text-stone-900">{permanentCredits}</div>
1615
+ </div>
1616
+ </div>
1617
+ </div>
1618
+
1619
+ <div className="rounded-2xl border border-sky-200 bg-sky-50/60 p-4">
1620
+ <div className="flex items-center justify-between gap-3">
1621
+ <div>
1622
+ <div className="text-sm font-semibold text-sky-950">当前套餐</div>
1623
+ <div className="mt-1 text-xs text-sky-700">这里只显示仍有可用额度且未过期的套餐。</div>
1624
+ </div>
1625
+ <div className="text-xs font-medium text-sky-700">{activePackageGrants.length} 个</div>
1626
+ </div>
1627
+ {activePackageGrants.length > 0 ? (
1628
+ <div className="mt-3 space-y-2">
1629
+ {activePackageGrants.map((grant) => {
1630
+ const totalLimit = Number(grant.total_limit || 0);
1631
+ const usedTotal = Number(grant.used_total || 0);
1632
+ const remaining = Number(grant.remaining || 0);
1633
+ const progress = packageUsagePercent(usedTotal, totalLimit);
1634
+ return (
1635
+ <div key={grant.id || `${grant.name}-${grant.expires_at}`} className="rounded-xl border border-sky-100 bg-white px-3 py-3">
1636
+ <div className="flex flex-wrap items-center justify-between gap-2">
1637
+ <div className="font-medium text-stone-900">{grant.name || "套餐"}</div>
1638
+ <div className="text-xs text-sky-700">剩余 {remaining} 次</div>
1639
+ </div>
1640
+ <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-stone-500">
1641
+ <span>{packageLimitText(grant)}</span>
1642
+ {grant.expires_at ? <span>到期 {formatChinaDateTime(grant.expires_at)}</span> : null}
1643
+ {grant.code_id ? <span>来源卡密 {grant.code_id}</span> : null}
1644
+ </div>
1645
+ {totalLimit > 0 ? (
1646
+ <div className="mt-3">
1647
+ <div className="h-1.5 overflow-hidden rounded-full bg-sky-100">
1648
+ <div className="h-full rounded-full bg-sky-500" style={{ width: `${progress}%` }} />
1649
+ </div>
1650
+ <div className="mt-1 text-[11px] text-stone-500">总限额已用 {usedTotal} / {totalLimit}</div>
1651
+ </div>
1652
+ ) : null}
1653
+ </div>
1654
+ );
1655
+ })}
1656
+ </div>
1657
+ ) : (
1658
+ <div className="mt-3 rounded-xl border border-dashed border-sky-200 bg-white/70 px-3 py-4 text-center text-xs text-sky-700">
1659
+ 暂无生效套餐,兑换套餐卡密后会显示在这里。
1660
+ </div>
1661
+ )}
1662
+ </div>
1663
+
1664
+ <div className="rounded-2xl border border-stone-200 bg-white p-4">
1665
+ <div className="flex items-center justify-between gap-3">
1666
+ <div>
1667
+ <div className="text-sm font-semibold text-stone-900">已兑换卡密</div>
1668
+ <div className="mt-1 text-xs text-stone-500">最近 100 条兑换记录,方便核对套餐、赠送和普通充值来源。</div>
1669
+ </div>
1670
+ {isLoadingMyRedeemCodes ? <LoaderCircle className="size-4 animate-spin text-stone-400" /> : <div className="text-xs text-stone-500">{myRedeemCodes.length} 条</div>}
1671
+ </div>
1672
+ {recentRedeemCodes.length > 0 ? (
1673
+ <div className="mt-3 space-y-2">
1674
+ {recentRedeemCodes.map((item) => (
1675
+ <div key={item.id} className="rounded-xl border border-stone-100 bg-stone-50 px-3 py-3">
1676
+ <div className="flex flex-wrap items-center justify-between gap-2">
1677
+ <div className="break-all font-mono text-xs font-medium text-stone-800">{redeemedCodeText(item)}</div>
1678
+ {item.code ? (
1679
+ <Button type="button" variant="outline" className="h-7 rounded-lg border-stone-200 bg-white px-2 text-xs" onClick={() => void copyText(item.code || "", "卡密已复制")}>
1680
+ <Copy className="size-3" />
1681
+ 复制
1682
+ </Button>
1683
+ ) : null}
1684
+ </div>
1685
+ <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-stone-500">
1686
+ <span>{redeemCodeTypeLabel(item)}</span>
1687
+ <span>{item.code_type === "package" ? packageSummary(item) : `${Number(item.credits || 0)} 次`}</span>
1688
+ {item.redeemed_at ? <span>兑换 {formatChinaDateTime(item.redeemed_at)}</span> : null}
1689
+ </div>
1690
+ </div>
1691
+ ))}
1692
+ {myRedeemCodes.length > recentRedeemCodes.length ? (
1693
+ <div className="text-center text-xs text-stone-400">还有 {myRedeemCodes.length - recentRedeemCodes.length} 条较早记录未展开显示</div>
1694
+ ) : null}
1695
+ </div>
1696
+ ) : (
1697
+ <div className="mt-3 rounded-xl border border-dashed border-stone-200 bg-stone-50 px-3 py-4 text-center text-xs text-stone-500">
1698
+ 暂无兑换记录。
1699
+ </div>
1700
+ )}
1701
+ </div>
1702
+ </div>
1703
+ );
1704
+
1705
  const handleSaveNickname = async () => {
1706
  const normalizedName = nicknameValue.trim();
1707
  if (!normalizedName) {
 
1850
  onMemoryToggle={() => setIsMemoryEnabled((value) => !value)}
1851
  onAccountPoolStrategyChange={setAccountPoolStrategy}
1852
  onSubmit={handleSubmit}
1853
+ onBenefitsClick={isNormalUser ? () => setIsBenefitsOpen(true) : undefined}
1854
  onRedeemClick={isNormalUser ? () => setIsRedeemOpen(true) : undefined}
 
1855
  onPickReferenceImage={() => fileInputRef.current?.click()}
1856
  onReferenceImageChange={handleReferenceImageChange}
1857
  onRemoveReferenceImage={handleRemoveReferenceImage}
 
1869
  onIndexChange={setLightboxIndex}
1870
  />
1871
 
1872
+ <Dialog open={isBenefitsOpen} onOpenChange={setIsBenefitsOpen}>
1873
+ <DialogContent showCloseButton={false} className="hide-scrollbar max-h-[min(88dvh,760px)] overflow-y-auto rounded-2xl p-6 sm:max-w-2xl">
1874
+ <DialogHeader className="gap-2">
1875
+ <DialogTitle>我的权益</DialogTitle>
1876
+ <DialogDescription className="text-sm leading-6">
1877
+ 查看当前套餐、卡密兑换记录和额度构成。
1878
+ </DialogDescription>
1879
+ </DialogHeader>
1880
+ {benefitsPanel}
1881
+ <DialogFooter>
1882
+ <Button variant="outline" onClick={() => setIsBenefitsOpen(false)}>
1883
+ 关闭
1884
+ </Button>
1885
+ <Button className="bg-stone-950 text-white hover:bg-stone-800" onClick={() => {
1886
+ setIsBenefitsOpen(false);
1887
+ setIsRedeemOpen(true);
1888
+ }}>
1889
+ 兑换卡密
1890
+ </Button>
1891
+ </DialogFooter>
1892
+ </DialogContent>
1893
+ </Dialog>
1894
+
1895
  <Dialog
1896
  open={isRedeemOpen}
1897
  onOpenChange={(open) => {
 
1906
  <DialogContent showCloseButton={false} className="rounded-2xl p-6">
1907
  <DialogHeader className="gap-2">
1908
  <DialogTitle>充值次数</DialogTitle>
1909
+ <DialogDescription className="text-sm leading-6">
1910
+ 输入卡密兑换次数;每日赠送卡密会加入今日赠送次数,套餐卡密会加入套餐额度,普通卡密会加入购买次数。
1911
+ </DialogDescription>
1912
+ </DialogHeader>
1913
+ {false ? (
1914
+ <div className="space-y-4">
1915
+ <div className="rounded-2xl border border-stone-200 bg-stone-50/70 p-4">
1916
+ <div className="flex items-center justify-between gap-3">
1917
+ <div>
1918
+ <div className="text-sm font-semibold text-stone-900">我的额度</div>
1919
+ <div className="mt-1 text-xs text-stone-500">生成时按下面顺序扣除:每日赠送、套餐、限时、永久。</div>
1920
+ </div>
1921
+ <div className="rounded-xl bg-stone-950 px-3 py-2 text-right text-white">
1922
+ <div className="text-[11px] text-stone-300">总可用</div>
1923
+ <div className="text-lg font-semibold leading-none">{creditTotal}</div>
1924
+ </div>
1925
+ </div>
1926
+ <div className="mt-4 grid gap-2 sm:grid-cols-4">
1927
+ <div className="rounded-xl border border-stone-200 bg-white px-3 py-2">
1928
+ <div className="text-[11px] text-stone-500">今日赠送</div>
1929
+ <div className="mt-1 text-sm font-semibold text-stone-900">{dailyRemaining} / {dailyTotal}</div>
1930
+ </div>
1931
+ <div className="rounded-xl border border-sky-200 bg-white px-3 py-2">
1932
+ <div className="text-[11px] text-sky-600">套餐额度</div>
1933
+ <div className="mt-1 text-sm font-semibold text-sky-900">{packageCredits}</div>
1934
+ </div>
1935
+ <div className="rounded-xl border border-amber-200 bg-white px-3 py-2">
1936
+ <div className="text-[11px] text-amber-600">限时次数</div>
1937
+ <div className="mt-1 text-sm font-semibold text-amber-900">{expiringCredits}</div>
1938
+ </div>
1939
+ <div className="rounded-xl border border-stone-200 bg-white px-3 py-2">
1940
+ <div className="text-[11px] text-stone-500">永久次数</div>
1941
+ <div className="mt-1 text-sm font-semibold text-stone-900">{permanentCredits}</div>
1942
+ </div>
1943
+ </div>
1944
+ </div>
1945
+
1946
+ <div className="rounded-2xl border border-sky-200 bg-sky-50/60 p-4">
1947
+ <div className="flex items-center justify-between gap-3">
1948
+ <div>
1949
+ <div className="text-sm font-semibold text-sky-950">当前套餐</div>
1950
+ <div className="mt-1 text-xs text-sky-700">这里只显示仍有可用额度且未过期的套餐。</div>
1951
+ </div>
1952
+ <div className="text-xs font-medium text-sky-700">{activePackageGrants.length} 个</div>
1953
+ </div>
1954
+ {activePackageGrants.length > 0 ? (
1955
+ <div className="mt-3 space-y-2">
1956
+ {activePackageGrants.map((grant) => {
1957
+ const totalLimit = Number(grant.total_limit || 0);
1958
+ const usedTotal = Number(grant.used_total || 0);
1959
+ const remaining = Number(grant.remaining || 0);
1960
+ const progress = packageUsagePercent(usedTotal, totalLimit);
1961
+ return (
1962
+ <div key={grant.id || `${grant.name}-${grant.expires_at}`} className="rounded-xl border border-sky-100 bg-white px-3 py-3">
1963
+ <div className="flex flex-wrap items-center justify-between gap-2">
1964
+ <div className="font-medium text-stone-900">{grant.name || "套餐"}</div>
1965
+ <div className="text-xs text-sky-700">剩余 {remaining} 次</div>
1966
+ </div>
1967
+ <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-stone-500">
1968
+ <span>{packageLimitText(grant)}</span>
1969
+ {grant.expires_at ? <span>到期 {formatChinaDateTime(grant.expires_at)}</span> : null}
1970
+ {grant.code_id ? <span>来源卡密 {grant.code_id}</span> : null}
1971
+ </div>
1972
+ {totalLimit > 0 ? (
1973
+ <div className="mt-3">
1974
+ <div className="h-1.5 overflow-hidden rounded-full bg-sky-100">
1975
+ <div className="h-full rounded-full bg-sky-500" style={{ width: `${progress}%` }} />
1976
+ </div>
1977
+ <div className="mt-1 text-[11px] text-stone-500">总限额已用 {usedTotal} / {totalLimit}</div>
1978
+ </div>
1979
+ ) : null}
1980
+ </div>
1981
+ );
1982
+ })}
1983
+ </div>
1984
+ ) : (
1985
+ <div className="mt-3 rounded-xl border border-dashed border-sky-200 bg-white/70 px-3 py-4 text-center text-xs text-sky-700">
1986
+ 暂无生效套餐,兑换套餐卡密后会显示在这里。
1987
+ </div>
1988
+ )}
1989
+ </div>
1990
+
1991
+ <div className="rounded-2xl border border-stone-200 bg-white p-4">
1992
+ <div className="flex items-center justify-between gap-3">
1993
+ <div>
1994
+ <div className="text-sm font-semibold text-stone-900">已兑换卡密</div>
1995
+ <div className="mt-1 text-xs text-stone-500">最近 100 条兑换记录,方便核对套餐、赠送和普通充值来源。</div>
1996
+ </div>
1997
+ {isLoadingMyRedeemCodes ? <LoaderCircle className="size-4 animate-spin text-stone-400" /> : <div className="text-xs text-stone-500">{myRedeemCodes.length} 条</div>}
1998
+ </div>
1999
+ {recentRedeemCodes.length > 0 ? (
2000
+ <div className="mt-3 space-y-2">
2001
+ {recentRedeemCodes.map((item) => (
2002
+ <div key={item.id} className="rounded-xl border border-stone-100 bg-stone-50 px-3 py-3">
2003
+ <div className="flex flex-wrap items-center justify-between gap-2">
2004
+ <div className="break-all font-mono text-xs font-medium text-stone-800">{redeemedCodeText(item)}</div>
2005
+ {item.code ? (
2006
+ <Button type="button" variant="outline" className="h-7 rounded-lg border-stone-200 bg-white px-2 text-xs" onClick={() => void copyText(item.code || "", "卡密已复制")}>
2007
+ <Copy className="size-3" />
2008
+ 复制
2009
+ </Button>
2010
+ ) : null}
2011
+ </div>
2012
+ <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-stone-500">
2013
+ <span>{redeemCodeTypeLabel(item)}</span>
2014
+ <span>{item.code_type === "package" ? packageSummary(item) : `${Number(item.credits || 0)} 次`}</span>
2015
+ {item.redeemed_at ? <span>兑换 {formatChinaDateTime(item.redeemed_at)}</span> : null}
2016
+ </div>
2017
+ </div>
2018
+ ))}
2019
+ {myRedeemCodes.length > recentRedeemCodes.length ? (
2020
+ <div className="text-center text-xs text-stone-400">还有 {myRedeemCodes.length - recentRedeemCodes.length} 条较早记录未展开显示</div>
2021
+ ) : null}
2022
+ </div>
2023
+ ) : (
2024
+ <div className="mt-3 rounded-xl border border-dashed border-stone-200 bg-stone-50 px-3 py-4 text-center text-xs text-stone-500">
2025
+ 暂无兑换记录。
2026
+ </div>
2027
+ )}
2028
+ </div>
2029
+ </div>
2030
+ ) : null}
2031
  <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
2032
  <div className="font-medium">次数说明</div>
2033
  <p className="mt-1 text-xs text-amber-800/90">
2034
+ 生成图片会优先扣今日赠送次数,再扣套餐额度,然后扣快过期的限时次数,最后扣永久购买次数。
2035
  </p>
2036
  </div>
2037
  <div className="space-y-2">
 
2093
  </DialogContent>
2094
  </Dialog>
2095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2096
  <Dialog open={isProfileOpen} onOpenChange={setIsProfileOpen}>
2097
  <DialogContent showCloseButton={false} className="rounded-2xl p-6">
2098
  <DialogHeader className="gap-2">
web/src/app/settings/components/shop-codes-card.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
- import { Ban, CheckCircle2, Copy, LoaderCircle, Search, Ticket, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Badge } from "@/components/ui/badge";
@@ -12,11 +12,34 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
12
  import { Input } from "@/components/ui/input";
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
14
  import { Textarea } from "@/components/ui/textarea";
15
- import { createRedeemCodes, deleteRedeemCode, disableRedeemCode, enableRedeemCode, fetchShopCodes, type RedeemCode } from "@/lib/api";
 
 
 
 
 
 
 
 
 
16
  import { formatChinaDateTime } from "@/lib/time";
17
 
 
 
18
  type CodeStatusFilter = "all" | "active" | "redeemed" | "disabled" | "expired";
19
  type ValidityMode = "permanent" | "custom";
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  function formatDateTime(value?: string | null) {
22
  return formatChinaDateTime(value);
@@ -34,19 +57,77 @@ function statusLabel(status: string, item?: RedeemCode) {
34
  return status;
35
  }
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  function redeemedUserLabel(item: RedeemCode) {
38
  const user = item.redeemed_user;
39
  return user?.name || user?.wechat_nickname || item.redeemed_by || "";
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  function matchesKeyword(item: RedeemCode, keyword: string) {
43
  const normalized = keyword.trim().toLowerCase();
44
  if (!normalized) return true;
45
  return [
46
  item.id,
 
47
  item.prefix,
48
  item.batch,
49
  item.batch_id,
 
 
 
 
50
  item.note,
51
  item.redeemed_by,
52
  item.redeemed_user?.name,
@@ -57,11 +138,23 @@ function matchesKeyword(item: RedeemCode, keyword: string) {
57
 
58
  export function ShopCodesCard() {
59
  const didLoadRef = useRef(false);
 
 
 
 
60
  const [items, setItems] = useState<RedeemCode[]>([]);
61
  const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
62
  const [count, setCount] = useState("10");
63
  const [credits, setCredits] = useState("20");
64
  const [prefix, setPrefix] = useState("CS");
 
 
 
 
 
 
 
 
65
  const [batch, setBatch] = useState("");
66
  const [note, setNote] = useState("");
67
  const [validityMode, setValidityMode] = useState<ValidityMode>("permanent");
@@ -76,6 +169,13 @@ export function ShopCodesCard() {
76
  const [keyword, setKeyword] = useState("");
77
  const [deletingItem, setDeletingItem] = useState<RedeemCode | null>(null);
78
  const [isListExpanded, setIsListExpanded] = useState(false);
 
 
 
 
 
 
 
79
 
80
  const load = async () => {
81
  setIsLoading(true);
@@ -95,6 +195,12 @@ export function ShopCodesCard() {
95
  void load();
96
  }, []);
97
 
 
 
 
 
 
 
98
  const copyText = async (value: string) => {
99
  try {
100
  await navigator.clipboard.writeText(value);
@@ -109,17 +215,37 @@ export function ShopCodesCard() {
109
  const normalizedCredits = Math.max(1, Math.trunc(Number(credits) || 1));
110
  const normalizedValidityDays = Math.max(1, Math.min(3650, Math.trunc(Number(validityDays) || 1)));
111
  const normalizedCreditValidityDays = Math.max(1, Math.min(3650, Math.trunc(Number(creditValidityDays) || 1)));
 
 
 
 
 
 
 
 
 
 
 
 
112
  setIsCreating(true);
113
  try {
114
  const data = await createRedeemCodes({
115
  count: normalizedCount,
116
- credits: normalizedCredits,
117
  prefix,
118
  batch,
119
  note,
120
- validity_days: validityMode === "custom" ? normalizedValidityDays : null,
121
- credit_expires_days: creditValidityMode === "custom" ? normalizedCreditValidityDays : null,
122
- batch_single_use: batchSingleUse,
 
 
 
 
 
 
 
 
123
  });
124
  setGeneratedCodes(data.codes);
125
  setItems(data.all_items);
@@ -185,12 +311,49 @@ export function ShopCodesCard() {
185
  }
186
  };
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  const stats = {
189
  total: items.length,
190
  active: items.filter((item) => item.status === "active" && !isCodeExpired(item)).length,
191
  redeemed: items.filter((item) => item.status === "redeemed").length,
192
  disabled: items.filter((item) => item.status === "disabled").length,
193
  expired: items.filter((item) => isCodeExpired(item)).length,
 
 
194
  };
195
  const filteredItems = items.filter((item) => {
196
  const matchesStatus = statusFilter === "all" || (statusFilter === "expired" ? isCodeExpired(item) : item.status === statusFilter && !isCodeExpired(item));
@@ -210,69 +373,157 @@ export function ShopCodesCard() {
210
  </div>
211
  <div>
212
  <h2 className="text-lg font-semibold tracking-tight">卡密管理</h2>
213
- <p className="text-sm text-stone-500">生成普通用户充值卡密;明文只在生成后显示一次,可复制到第三方发卡网售卖。</p>
214
  </div>
215
  </div>
216
  </div>
217
 
218
- <div className="grid gap-3 md:grid-cols-[120px_120px_120px_1fr]">
219
  <Input value={count} onChange={(event) => setCount(event.target.value)} type="number" min="1" max="1000" placeholder="数量" className="h-11 rounded-xl border-stone-200 bg-white" />
220
- <Input value={credits} onChange={(event) => setCredits(event.target.value)} type="number" min="1" placeholder="每张次数" className="h-11 rounded-xl border-stone-200 bg-white" />
 
 
221
  <Input value={prefix} onChange={(event) => setPrefix(event.target.value)} placeholder="前缀" className="h-11 rounded-xl border-stone-200 bg-white" />
 
 
 
 
 
 
 
 
 
 
222
  <Input value={batch} onChange={(event) => setBatch(event.target.value)} placeholder="批次备注,例如 2026-06 首批" className="h-11 rounded-xl border-stone-200 bg-white" />
223
  </div>
224
- <div className="grid gap-3 lg:grid-cols-[150px_130px_150px_170px_minmax(240px,1fr)]">
225
- <div>
226
- <Select value={validityMode} onValueChange={(value) => setValidityMode(value as ValidityMode)}>
227
- <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white shadow-none">
228
- <SelectValue />
229
- </SelectTrigger>
230
- <SelectContent>
231
- <SelectItem value="permanent">兑换永久</SelectItem>
232
- <SelectItem value="custom">兑换限期</SelectItem>
233
- </SelectContent>
234
- </Select>
235
- </div>
236
- <div>
237
  <Input
238
- value={validityDays}
239
- onChange={(event) => setValidityDays(event.target.value)}
240
- type="number"
241
- min="1"
242
- max="3650"
243
- disabled={validityMode === "permanent"}
244
- placeholder="几天内可兑换"
245
- className="h-11 rounded-xl border-stone-200 bg-white disabled:bg-stone-100"
246
  />
 
 
 
247
  </div>
248
- <div>
249
- <Select value={creditValidityMode} onValueChange={(value) => setCreditValidityMode(value as ValidityMode)}>
250
- <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white shadow-none">
251
- <SelectValue />
252
- </SelectTrigger>
253
- <SelectContent>
254
- <SelectItem value="permanent">次数永久</SelectItem>
255
- <SelectItem value="custom">次数限期</SelectItem>
256
- </SelectContent>
257
- </Select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  </div>
259
- <div>
260
- <Input
261
- value={creditValidityDays}
262
- onChange={(event) => setCreditValidityDays(event.target.value)}
263
- type="number"
264
- min="1"
265
- max="3650"
266
- disabled={creditValidityMode === "permanent"}
267
- placeholder="到账后几天内用完"
268
- className="h-11 rounded-xl border-stone-200 bg-white disabled:bg-stone-100"
269
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  </div>
271
- <label className="flex min-h-11 items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 text-sm text-stone-700">
272
- <Checkbox checked={batchSingleUse} onCheckedChange={(checked) => setBatchSingleUse(Boolean(checked))} />
273
- <span>同批每人限兑一次</span>
274
- </label>
275
- </div>
276
  <div className="flex flex-col gap-3 md:flex-row">
277
  <Input value={note} onChange={(event) => setNote(event.target.value)} placeholder="备注(可选)" className="h-11 rounded-xl border-stone-200 bg-white" />
278
  <Button className="h-11 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800" onClick={() => void handleCreate()} disabled={isCreating}>
@@ -286,7 +537,7 @@ export function ShopCodesCard() {
286
  <div className="flex items-center justify-between gap-3">
287
  <div>
288
  <div className="text-sm font-medium text-emerald-900">本次生成的卡密明文</div>
289
- <p className="text-xs text-emerald-700">请现在复制保存,后台之后只保存哈希,不会再显示明文。</p>
290
  </div>
291
  <Button variant="outline" className="rounded-xl border-emerald-200 bg-white text-emerald-700" onClick={() => void copyText(generatedCodes.join("\n"))}>
292
  <Copy className="size-4" />
@@ -297,6 +548,174 @@ export function ShopCodesCard() {
297
  </div>
298
  ) : null}
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  {isLoading ? (
301
  <div className="flex items-center justify-center py-10">
302
  <LoaderCircle className="size-5 animate-spin text-stone-400" />
@@ -323,7 +742,7 @@ export function ShopCodesCard() {
323
  <Input
324
  value={keyword}
325
  onChange={(event) => setKeyword(event.target.value)}
326
- placeholder="搜索批次、备注、用户、openid 或卡密ID"
327
  className="h-10 rounded-xl border-stone-200 bg-white pl-9 shadow-none"
328
  />
329
  </div>
@@ -333,6 +752,8 @@ export function ShopCodesCard() {
333
  <span>过期 {stats.expired}</span>
334
  <span>已兑 {stats.redeemed}</span>
335
  <span>禁用 {stats.disabled}</span>
 
 
336
  </div>
337
  </div>
338
  {filteredItems.length === 0 ? (
@@ -353,21 +774,31 @@ export function ShopCodesCard() {
353
  ) : null}
354
  {visibleItems.map((item) => {
355
  const isPending = pendingIds.has(item.id);
 
356
  return (
357
  <div key={item.id} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-4 md:flex-row md:items-center md:justify-between">
358
  <div className="min-w-0 space-y-2">
359
  <div className="flex flex-wrap items-center gap-2">
360
- <div className="font-mono text-sm font-medium text-stone-800">{item.prefix}-****-{item.id.slice(0, 4).toUpperCase()}</div>
 
 
 
 
 
 
361
  <Badge variant={item.status === "active" && !isCodeExpired(item) ? "success" : "secondary"} className="rounded-md">
362
  {statusLabel(item.status, item)}
363
  </Badge>
364
- <span className="text-xs text-stone-500">{item.credits} 次</span>
 
 
 
365
  {item.batch_single_use ? <Badge variant="outline" className="rounded-md">同批限兑</Badge> : null}
366
  </div>
367
  <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
368
  <span>创建 {formatDateTime(item.created_at)}</span>
369
  <span>兑换 {item.expires_at ? `至 ${formatDateTime(item.expires_at)}` : "永久"}</span>
370
- <span>次数 {item.credit_expires_days ? `到账后 ${item.credit_expires_days} 天内有效` : "永久"}</span>
371
  {item.batch ? <span>批次 {item.batch}</span> : null}
372
  {item.redeemed_at ? <span>兑换 {formatDateTime(item.redeemed_at)}</span> : null}
373
  {item.redeemed_by ? <span>兑换人 {redeemedUserLabel(item)}</span> : null}
@@ -412,10 +843,11 @@ export function ShopCodesCard() {
412
  </DialogHeader>
413
  {deletingItem ? (
414
  <div className="rounded-xl bg-stone-50 p-4 text-sm text-stone-600">
415
- <div>卡密:{deletingItem.prefix}-****-{deletingItem.id.slice(0, 4).toUpperCase()}</div>
416
- <div>状态:{statusLabel(deletingItem.status, deletingItem)},次数:{deletingItem.credits}</div>
 
417
  <div>兑换期限:{deletingItem.expires_at ? `至 ${formatDateTime(deletingItem.expires_at)}` : "永久"}</div>
418
- <div>次数期限:{deletingItem.credit_expires_days ? `到账后 ${deletingItem.credit_expires_days} 天内有效` : "永久"}</div>
419
  {deletingItem.batch ? <div>批次:{deletingItem.batch}</div> : null}
420
  </div>
421
  ) : null}
 
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
+ import { Archive, Ban, CheckCircle2, Copy, Eraser, LoaderCircle, Search, Ticket, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Badge } from "@/components/ui/badge";
 
12
  import { Input } from "@/components/ui/input";
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
14
  import { Textarea } from "@/components/ui/textarea";
15
+ import {
16
+ cleanupRedeemCodes,
17
+ createRedeemCodes,
18
+ deleteRedeemCode,
19
+ disableRedeemCode,
20
+ enableRedeemCode,
21
+ fetchShopCodes,
22
+ type RedeemCode,
23
+ type RedeemCodeCleanupResponse,
24
+ } from "@/lib/api";
25
  import { formatChinaDateTime } from "@/lib/time";
26
 
27
+ import { useSettingsStore } from "../store";
28
+
29
  type CodeStatusFilter = "all" | "active" | "redeemed" | "disabled" | "expired";
30
  type ValidityMode = "permanent" | "custom";
31
+ type CodeType = "normal" | "daily_gift" | "package";
32
+
33
+ function chinaDateInputValue(value = new Date()) {
34
+ const parts = new Intl.DateTimeFormat("en-CA", {
35
+ timeZone: "Asia/Shanghai",
36
+ year: "numeric",
37
+ month: "2-digit",
38
+ day: "2-digit",
39
+ }).formatToParts(value);
40
+ const data = Object.fromEntries(parts.map((part) => [part.type, part.value]));
41
+ return `${data.year}-${data.month}-${data.day}`;
42
+ }
43
 
44
  function formatDateTime(value?: string | null) {
45
  return formatChinaDateTime(value);
 
57
  return status;
58
  }
59
 
60
+ function normalizeCodeType(value?: string | null): CodeType {
61
+ if (value === "daily_gift") return "daily_gift";
62
+ if (value === "package") return "package";
63
+ return "normal";
64
+ }
65
+
66
+ function codeTypeLabel(value?: string | null) {
67
+ const type = normalizeCodeType(value);
68
+ if (type === "package") return "套餐卡密";
69
+ return type === "daily_gift" ? "每日赠送卡密" : "普通卡密";
70
+ }
71
+
72
+ function codeTypeBadgeClass(value?: string | null) {
73
+ const type = normalizeCodeType(value);
74
+ if (type === "daily_gift") return "rounded-md border-amber-200 bg-amber-50 text-amber-700";
75
+ if (type === "package") return "rounded-md border-sky-200 bg-sky-50 text-sky-700";
76
+ return "rounded-md border-stone-200 bg-stone-50 text-stone-600";
77
+ }
78
+
79
+ function optionalLimit(value: string) {
80
+ const normalized = Math.trunc(Number(value) || 0);
81
+ return normalized > 0 ? Math.min(1000000, normalized) : null;
82
+ }
83
+
84
+ function packageLimitText(plan?: RedeemCode["package_plan"] | null) {
85
+ if (!plan) return "未设置套餐";
86
+ const parts = [
87
+ plan.daily_limit ? `日 ${plan.daily_limit}` : "",
88
+ plan.weekly_limit ? `周 ${plan.weekly_limit}` : "",
89
+ plan.monthly_limit ? `月 ${plan.monthly_limit}` : "",
90
+ plan.total_limit ? `总 ${plan.total_limit}` : "",
91
+ ].filter(Boolean);
92
+ return parts.length > 0 ? parts.join(" / ") : "未设置限额";
93
+ }
94
+
95
+ function packageSummary(item: RedeemCode) {
96
+ const plan = item.package_plan;
97
+ if (!plan) return "套餐配置缺失";
98
+ return `${plan.name || "套餐"} · ${plan.duration_days || 1} 天 · ${packageLimitText(plan)}`;
99
+ }
100
+
101
  function redeemedUserLabel(item: RedeemCode) {
102
  const user = item.redeemed_user;
103
  return user?.name || user?.wechat_nickname || item.redeemed_by || "";
104
  }
105
 
106
+ function codeDisplay(item: RedeemCode) {
107
+ if (item.code) return item.code;
108
+ return `旧卡密无明文 · ${item.prefix}-****-${item.id.slice(0, 4).toUpperCase()}`;
109
+ }
110
+
111
+ function cleanupReasonLabel(value?: string) {
112
+ if (value === "expired") return "过期未兑换";
113
+ if (value === "disabled") return "已禁用";
114
+ if (value === "redeemed") return "已兑换归档";
115
+ return value || "待清理";
116
+ }
117
+
118
  function matchesKeyword(item: RedeemCode, keyword: string) {
119
  const normalized = keyword.trim().toLowerCase();
120
  if (!normalized) return true;
121
  return [
122
  item.id,
123
+ item.code,
124
  item.prefix,
125
  item.batch,
126
  item.batch_id,
127
+ item.code_type,
128
+ codeTypeLabel(item.code_type),
129
+ item.package_plan?.name,
130
+ packageLimitText(item.package_plan),
131
  item.note,
132
  item.redeemed_by,
133
  item.redeemed_user?.name,
 
138
 
139
  export function ShopCodesCard() {
140
  const didLoadRef = useRef(false);
141
+ const config = useSettingsStore((state) => state.config);
142
+ const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
143
+ const saveConfig = useSettingsStore((state) => state.saveConfig);
144
+ const setRedeemCodeCleanupField = useSettingsStore((state) => state.setRedeemCodeCleanupField);
145
  const [items, setItems] = useState<RedeemCode[]>([]);
146
  const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
147
  const [count, setCount] = useState("10");
148
  const [credits, setCredits] = useState("20");
149
  const [prefix, setPrefix] = useState("CS");
150
+ const [codeType, setCodeType] = useState<CodeType>("normal");
151
+ const [dailyGiftDate, setDailyGiftDate] = useState(() => chinaDateInputValue());
152
+ const [packageName, setPackageName] = useState("普通日卡");
153
+ const [packageDurationDays, setPackageDurationDays] = useState("1");
154
+ const [packageDailyLimit, setPackageDailyLimit] = useState("20");
155
+ const [packageWeeklyLimit, setPackageWeeklyLimit] = useState("");
156
+ const [packageMonthlyLimit, setPackageMonthlyLimit] = useState("");
157
+ const [packageTotalLimit, setPackageTotalLimit] = useState("");
158
  const [batch, setBatch] = useState("");
159
  const [note, setNote] = useState("");
160
  const [validityMode, setValidityMode] = useState<ValidityMode>("permanent");
 
169
  const [keyword, setKeyword] = useState("");
170
  const [deletingItem, setDeletingItem] = useState<RedeemCode | null>(null);
171
  const [isListExpanded, setIsListExpanded] = useState(false);
172
+ const [cleanupDeleteExpired, setCleanupDeleteExpired] = useState(true);
173
+ const [cleanupDeleteDisabled, setCleanupDeleteDisabled] = useState(false);
174
+ const [cleanupArchiveRedeemed, setCleanupArchiveRedeemed] = useState(false);
175
+ const [cleanupRedeemedDays, setCleanupRedeemedDays] = useState("90");
176
+ const [cleanupLimit, setCleanupLimit] = useState("1000");
177
+ const [isCleaning, setIsCleaning] = useState(false);
178
+ const [cleanupResult, setCleanupResult] = useState<RedeemCodeCleanupResponse | null>(null);
179
 
180
  const load = async () => {
181
  setIsLoading(true);
 
195
  void load();
196
  }, []);
197
 
198
+ useEffect(() => {
199
+ if (codeType === "daily_gift" || codeType === "package") {
200
+ setCreditValidityMode("permanent");
201
+ }
202
+ }, [codeType]);
203
+
204
  const copyText = async (value: string) => {
205
  try {
206
  await navigator.clipboard.writeText(value);
 
215
  const normalizedCredits = Math.max(1, Math.trunc(Number(credits) || 1));
216
  const normalizedValidityDays = Math.max(1, Math.min(3650, Math.trunc(Number(validityDays) || 1)));
217
  const normalizedCreditValidityDays = Math.max(1, Math.min(3650, Math.trunc(Number(creditValidityDays) || 1)));
218
+ const normalizedPackageDurationDays = Math.max(1, Math.min(3650, Math.trunc(Number(packageDurationDays) || 1)));
219
+ const packageLimits = {
220
+ daily: optionalLimit(packageDailyLimit),
221
+ weekly: optionalLimit(packageWeeklyLimit),
222
+ monthly: optionalLimit(packageMonthlyLimit),
223
+ total: optionalLimit(packageTotalLimit),
224
+ };
225
+ if (codeType === "package" && !packageLimits.daily && !packageLimits.weekly && !packageLimits.monthly && !packageLimits.total) {
226
+ toast.error("套餐卡密至少填写一个日/周/月/总限额");
227
+ return;
228
+ }
229
+ const packageDisplayCredits = packageLimits.total || packageLimits.monthly || packageLimits.weekly || packageLimits.daily || normalizedCredits;
230
  setIsCreating(true);
231
  try {
232
  const data = await createRedeemCodes({
233
  count: normalizedCount,
234
+ credits: codeType === "package" ? packageDisplayCredits : normalizedCredits,
235
  prefix,
236
  batch,
237
  note,
238
+ validity_days: codeType === "normal" ? validityMode === "custom" ? normalizedValidityDays : null : null,
239
+ credit_expires_days: codeType === "normal" ? creditValidityMode === "custom" ? normalizedCreditValidityDays : null : null,
240
+ batch_single_use: codeType === "daily_gift" ? true : codeType === "normal" ? batchSingleUse : false,
241
+ code_type: codeType,
242
+ daily_gift_date: codeType === "daily_gift" ? dailyGiftDate : "",
243
+ package_name: codeType === "package" ? packageName : "",
244
+ package_duration_days: codeType === "package" ? normalizedPackageDurationDays : 1,
245
+ package_daily_limit: codeType === "package" ? packageLimits.daily : null,
246
+ package_weekly_limit: codeType === "package" ? packageLimits.weekly : null,
247
+ package_monthly_limit: codeType === "package" ? packageLimits.monthly : null,
248
+ package_total_limit: codeType === "package" ? packageLimits.total : null,
249
  });
250
  setGeneratedCodes(data.codes);
251
  setItems(data.all_items);
 
311
  }
312
  };
313
 
314
+ const cleanupPayload = (dryRun: boolean) => ({
315
+ delete_expired: cleanupDeleteExpired,
316
+ delete_disabled: cleanupDeleteDisabled,
317
+ archive_redeemed: cleanupArchiveRedeemed,
318
+ redeemed_older_than_days: Math.max(1, Math.min(3650, Math.trunc(Number(cleanupRedeemedDays) || 90))),
319
+ limit: Math.max(1, Math.min(20000, Math.trunc(Number(cleanupLimit) || 1000))),
320
+ dry_run: dryRun,
321
+ });
322
+
323
+ const handleCleanup = async (dryRun: boolean) => {
324
+ if (!cleanupDeleteExpired && !cleanupDeleteDisabled && !cleanupArchiveRedeemed) {
325
+ toast.error("请至少选择一种清理类型");
326
+ return;
327
+ }
328
+ const payload = cleanupPayload(dryRun);
329
+ if (!dryRun) {
330
+ const confirmed = window.confirm("确认执行批量清理?匹配的卡密会先写入 JSONL 归档,再从数据库移除。");
331
+ if (!confirmed) return;
332
+ }
333
+ setIsCleaning(true);
334
+ try {
335
+ const data = await cleanupRedeemCodes(payload);
336
+ setCleanupResult(data);
337
+ if (!dryRun && data.all_items) {
338
+ setItems(data.all_items);
339
+ }
340
+ const summary = data.summary;
341
+ toast.success(dryRun ? `预览到 ${summary.matched} 张可处理卡密` : `已清理 ${summary.deleted} 张卡密`);
342
+ } catch (error) {
343
+ toast.error(error instanceof Error ? error.message : "批量处理卡密失败");
344
+ } finally {
345
+ setIsCleaning(false);
346
+ }
347
+ };
348
+
349
  const stats = {
350
  total: items.length,
351
  active: items.filter((item) => item.status === "active" && !isCodeExpired(item)).length,
352
  redeemed: items.filter((item) => item.status === "redeemed").length,
353
  disabled: items.filter((item) => item.status === "disabled").length,
354
  expired: items.filter((item) => isCodeExpired(item)).length,
355
+ dailyGift: items.filter((item) => normalizeCodeType(item.code_type) === "daily_gift").length,
356
+ package: items.filter((item) => normalizeCodeType(item.code_type) === "package").length,
357
  };
358
  const filteredItems = items.filter((item) => {
359
  const matchesStatus = statusFilter === "all" || (statusFilter === "expired" ? isCodeExpired(item) : item.status === statusFilter && !isCodeExpired(item));
 
373
  </div>
374
  <div>
375
  <h2 className="text-lg font-semibold tracking-tight">卡密管理</h2>
376
+ <p className="text-sm text-stone-500">生成购买次数或每日赠送卡密;新生成的卡密会在后台保留明文,方便查询、复制和批量维护。</p>
377
  </div>
378
  </div>
379
  </div>
380
 
381
+ <div className={codeType === "package" ? "grid gap-3 md:grid-cols-[120px_120px_190px_1fr]" : "grid gap-3 md:grid-cols-[120px_120px_120px_190px_1fr]"}>
382
  <Input value={count} onChange={(event) => setCount(event.target.value)} type="number" min="1" max="1000" placeholder="数量" className="h-11 rounded-xl border-stone-200 bg-white" />
383
+ {codeType !== "package" ? (
384
+ <Input value={credits} onChange={(event) => setCredits(event.target.value)} type="number" min="1" placeholder="每张次数" className="h-11 rounded-xl border-stone-200 bg-white" />
385
+ ) : null}
386
  <Input value={prefix} onChange={(event) => setPrefix(event.target.value)} placeholder="前缀" className="h-11 rounded-xl border-stone-200 bg-white" />
387
+ <Select value={codeType} onValueChange={(value) => setCodeType(value as CodeType)}>
388
+ <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white shadow-none">
389
+ <SelectValue />
390
+ </SelectTrigger>
391
+ <SelectContent>
392
+ <SelectItem value="normal">普通卡密</SelectItem>
393
+ <SelectItem value="daily_gift">每日赠送卡密</SelectItem>
394
+ <SelectItem value="package">套餐卡密</SelectItem>
395
+ </SelectContent>
396
+ </Select>
397
  <Input value={batch} onChange={(event) => setBatch(event.target.value)} placeholder="批次备注,例如 2026-06 首批" className="h-11 rounded-xl border-stone-200 bg-white" />
398
  </div>
399
+ {codeType === "daily_gift" ? (
400
+ <div className="grid gap-3 md:grid-cols-[170px_1fr]">
 
 
 
 
 
 
 
 
 
 
 
401
  <Input
402
+ value={dailyGiftDate}
403
+ onChange={(event) => setDailyGiftDate(event.target.value || chinaDateInputValue())}
404
+ type="date"
405
+ min={chinaDateInputValue()}
406
+ className="h-11 rounded-xl border-stone-200 bg-white"
 
 
 
407
  />
408
+ <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs leading-5 text-amber-800">
409
+ 每日赠送卡密只在所选活动日期当天可兑换、当天可用,并且同一批次每人只能兑换一张;不会增加购买剩余和累计充值。
410
+ </div>
411
  </div>
412
+ ) : null}
413
+ {codeType === "package" ? (
414
+ <div className="space-y-3 rounded-xl border border-sky-200 bg-sky-50/70 p-4">
415
+ <div className="grid gap-3 md:grid-cols-[minmax(180px,1fr)_140px_repeat(4,minmax(120px,1fr))]">
416
+ <Input
417
+ value={packageName}
418
+ onChange={(event) => setPackageName(event.target.value)}
419
+ placeholder="套餐名,例如 高级周卡"
420
+ className="h-11 rounded-xl border-sky-200 bg-white"
421
+ />
422
+ <Input
423
+ value={packageDurationDays}
424
+ onChange={(event) => setPackageDurationDays(event.target.value)}
425
+ type="number"
426
+ min="1"
427
+ max="3650"
428
+ placeholder="套餐天数"
429
+ className="h-11 rounded-xl border-sky-200 bg-white"
430
+ />
431
+ <Input
432
+ value={packageDailyLimit}
433
+ onChange={(event) => setPackageDailyLimit(event.target.value)}
434
+ type="number"
435
+ min="0"
436
+ max="1000000"
437
+ placeholder="日限额"
438
+ className="h-11 rounded-xl border-sky-200 bg-white"
439
+ />
440
+ <Input
441
+ value={packageWeeklyLimit}
442
+ onChange={(event) => setPackageWeeklyLimit(event.target.value)}
443
+ type="number"
444
+ min="0"
445
+ max="1000000"
446
+ placeholder="周限额"
447
+ className="h-11 rounded-xl border-sky-200 bg-white"
448
+ />
449
+ <Input
450
+ value={packageMonthlyLimit}
451
+ onChange={(event) => setPackageMonthlyLimit(event.target.value)}
452
+ type="number"
453
+ min="0"
454
+ max="1000000"
455
+ placeholder="月限额"
456
+ className="h-11 rounded-xl border-sky-200 bg-white"
457
+ />
458
+ <Input
459
+ value={packageTotalLimit}
460
+ onChange={(event) => setPackageTotalLimit(event.target.value)}
461
+ type="number"
462
+ min="0"
463
+ max="1000000"
464
+ placeholder="总限额"
465
+ className="h-11 rounded-xl border-sky-200 bg-white"
466
+ />
467
+ </div>
468
+ <div className="text-xs leading-5 text-sky-800">
469
+ 套餐有效期从用户兑换时开始计算;日/周/月/总限额可按需组合,至少填写一个限额。套餐额度会优先于普通购买次数消耗,不计入累计充值。
470
+ </div>
471
  </div>
472
+ ) : null}
473
+ {codeType === "normal" ? (
474
+ <div className="grid gap-3 lg:grid-cols-[150px_130px_150px_170px_minmax(240px,1fr)]">
475
+ <div>
476
+ <Select value={validityMode} onValueChange={(value) => setValidityMode(value as ValidityMode)}>
477
+ <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white shadow-none">
478
+ <SelectValue />
479
+ </SelectTrigger>
480
+ <SelectContent>
481
+ <SelectItem value="permanent">兑换永久</SelectItem>
482
+ <SelectItem value="custom">兑换限期</SelectItem>
483
+ </SelectContent>
484
+ </Select>
485
+ </div>
486
+ <div>
487
+ <Input
488
+ value={validityDays}
489
+ onChange={(event) => setValidityDays(event.target.value)}
490
+ type="number"
491
+ min="1"
492
+ max="3650"
493
+ disabled={validityMode === "permanent"}
494
+ placeholder="几天内可兑换"
495
+ className="h-11 rounded-xl border-stone-200 bg-white disabled:bg-stone-100"
496
+ />
497
+ </div>
498
+ <div>
499
+ <Select value={creditValidityMode} onValueChange={(value) => setCreditValidityMode(value as ValidityMode)}>
500
+ <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white shadow-none">
501
+ <SelectValue />
502
+ </SelectTrigger>
503
+ <SelectContent>
504
+ <SelectItem value="permanent">次数永久</SelectItem>
505
+ <SelectItem value="custom">次数限期</SelectItem>
506
+ </SelectContent>
507
+ </Select>
508
+ </div>
509
+ <div>
510
+ <Input
511
+ value={creditValidityDays}
512
+ onChange={(event) => setCreditValidityDays(event.target.value)}
513
+ type="number"
514
+ min="1"
515
+ max="3650"
516
+ disabled={creditValidityMode === "permanent"}
517
+ placeholder="到账后几天内用完"
518
+ className="h-11 rounded-xl border-stone-200 bg-white disabled:bg-stone-100"
519
+ />
520
+ </div>
521
+ <label className="flex min-h-11 items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 text-sm text-stone-700">
522
+ <Checkbox checked={batchSingleUse} onCheckedChange={(checked) => setBatchSingleUse(Boolean(checked))} />
523
+ <span>同批每人限兑一次</span>
524
+ </label>
525
  </div>
526
+ ) : null}
 
 
 
 
527
  <div className="flex flex-col gap-3 md:flex-row">
528
  <Input value={note} onChange={(event) => setNote(event.target.value)} placeholder="备注(可选)" className="h-11 rounded-xl border-stone-200 bg-white" />
529
  <Button className="h-11 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800" onClick={() => void handleCreate()} disabled={isCreating}>
 
537
  <div className="flex items-center justify-between gap-3">
538
  <div>
539
  <div className="text-sm font-medium text-emerald-900">本次生成的卡密明文</div>
540
+ <p className="text-xs text-emerald-700">建议同步保存到发卡平台;后台列表也会继续显示这些新卡密。</p>
541
  </div>
542
  <Button variant="outline" className="rounded-xl border-emerald-200 bg-white text-emerald-700" onClick={() => void copyText(generatedCodes.join("\n"))}>
543
  <Copy className="size-4" />
 
548
  </div>
549
  ) : null}
550
 
551
+ <div className="space-y-4 rounded-xl border border-stone-200 bg-stone-50/80 p-4">
552
+ <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
553
+ <div>
554
+ <div className="flex items-center gap-2 text-sm font-medium text-stone-900">
555
+ <Eraser className="size-4 text-stone-500" />
556
+ 批量处理卡密
557
+ </div>
558
+ <p className="mt-1 text-xs leading-5 text-stone-500">
559
+ 清理前可以先预览;执行时会先写入 JSONL 归档,再从数据库移除,适合控制 HF SQLite 体积。
560
+ </p>
561
+ </div>
562
+ <div className="flex shrink-0 gap-2">
563
+ <Button
564
+ type="button"
565
+ variant="outline"
566
+ className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
567
+ onClick={() => void handleCleanup(true)}
568
+ disabled={isCleaning}
569
+ >
570
+ {isCleaning ? <LoaderCircle className="size-4 animate-spin" /> : <Search className="size-4" />}
571
+ 预览
572
+ </Button>
573
+ <Button
574
+ type="button"
575
+ className="h-9 rounded-xl bg-stone-950 px-4 text-white hover:bg-stone-800"
576
+ onClick={() => void handleCleanup(false)}
577
+ disabled={isCleaning}
578
+ >
579
+ {isCleaning ? <LoaderCircle className="size-4 animate-spin" /> : <Archive className="size-4" />}
580
+ 执行清理
581
+ </Button>
582
+ </div>
583
+ </div>
584
+ <div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(180px,1fr)_minmax(220px,1fr)_120px_120px]">
585
+ <label className="flex min-h-11 items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 text-sm text-stone-700">
586
+ <Checkbox checked={cleanupDeleteExpired} onCheckedChange={(checked) => setCleanupDeleteExpired(Boolean(checked))} />
587
+ <span>删除过期未兑换卡密</span>
588
+ </label>
589
+ <label className="flex min-h-11 items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 text-sm text-stone-700">
590
+ <Checkbox checked={cleanupDeleteDisabled} onCheckedChange={(checked) => setCleanupDeleteDisabled(Boolean(checked))} />
591
+ <span>删除已禁用卡密</span>
592
+ </label>
593
+ <label className="flex min-h-11 items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 text-sm text-stone-700">
594
+ <Checkbox checked={cleanupArchiveRedeemed} onCheckedChange={(checked) => setCleanupArchiveRedeemed(Boolean(checked))} />
595
+ <span>归档已兑换旧卡密</span>
596
+ </label>
597
+ <Input
598
+ type="number"
599
+ min="1"
600
+ max="3650"
601
+ value={cleanupRedeemedDays}
602
+ onChange={(event) => setCleanupRedeemedDays(event.target.value)}
603
+ className="h-11 rounded-xl border-stone-200 bg-white"
604
+ placeholder="兑换超过天数"
605
+ disabled={!cleanupArchiveRedeemed}
606
+ />
607
+ <Input
608
+ type="number"
609
+ min="1"
610
+ max="20000"
611
+ value={cleanupLimit}
612
+ onChange={(event) => setCleanupLimit(event.target.value)}
613
+ className="h-11 rounded-xl border-stone-200 bg-white"
614
+ placeholder="单次上限"
615
+ />
616
+ </div>
617
+ {cleanupResult ? (
618
+ <div className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-xs leading-6 text-stone-600">
619
+ <div className="font-medium text-stone-900">
620
+ {cleanupResult.dry_run ? "预览结果" : "清理结果"}:匹配 {cleanupResult.summary.matched} 张,删除 {cleanupResult.summary.deleted} 张,归档 {cleanupResult.summary.archived} 张
621
+ </div>
622
+ <div>
623
+ 过期 {cleanupResult.summary.expired},禁用 {cleanupResult.summary.disabled},已兑换归档 {cleanupResult.summary.redeemed}
624
+ {cleanupResult.summary.archive_path ? `;归档文件:${cleanupResult.summary.archive_path}` : ""}
625
+ </div>
626
+ {cleanupResult.items.length > 0 ? (
627
+ <div className="mt-2 flex flex-wrap gap-2">
628
+ {cleanupResult.items.slice(0, 8).map((item) => (
629
+ <span key={`${item.id}-${item.cleanup_reason || ""}`} className="rounded-md bg-stone-100 px-2 py-1 font-mono text-[11px] text-stone-600">
630
+ {cleanupReasonLabel(item.cleanup_reason)} · {codeDisplay(item)}
631
+ </span>
632
+ ))}
633
+ {cleanupResult.items.length > 8 ? <span className="rounded-md bg-stone-100 px-2 py-1 text-[11px] text-stone-500">还有 {cleanupResult.items.length - 8} 张</span> : null}
634
+ </div>
635
+ ) : null}
636
+ </div>
637
+ ) : null}
638
+ <div className="rounded-xl border border-stone-200 bg-white px-4 py-3">
639
+ <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
640
+ <label className="flex items-center gap-3 text-sm font-medium text-stone-800">
641
+ <Checkbox
642
+ checked={Boolean(config?.redeem_code_cleanup?.enabled)}
643
+ onCheckedChange={(checked) => setRedeemCodeCleanupField("enabled", Boolean(checked))}
644
+ />
645
+ 自动清理过期卡密
646
+ </label>
647
+ <Button
648
+ type="button"
649
+ variant="outline"
650
+ className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
651
+ onClick={() => void saveConfig()}
652
+ disabled={isSavingConfig}
653
+ >
654
+ {isSavingConfig ? <LoaderCircle className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
655
+ 保存自动清理
656
+ </Button>
657
+ </div>
658
+ <div className="mt-3 grid gap-3 lg:grid-cols-6">
659
+ <Input
660
+ type="number"
661
+ min="1"
662
+ value={String(config?.redeem_code_cleanup?.interval_minutes || 1440)}
663
+ onChange={(event) => setRedeemCodeCleanupField("interval_minutes", event.target.value)}
664
+ className="h-10 rounded-xl border-stone-200 bg-white"
665
+ disabled={!config?.redeem_code_cleanup?.enabled}
666
+ placeholder="间隔分钟"
667
+ />
668
+ <Input
669
+ type="number"
670
+ min="1"
671
+ max="20000"
672
+ value={String(config?.redeem_code_cleanup?.limit || 1000)}
673
+ onChange={(event) => setRedeemCodeCleanupField("limit", event.target.value)}
674
+ className="h-10 rounded-xl border-stone-200 bg-white"
675
+ disabled={!config?.redeem_code_cleanup?.enabled}
676
+ placeholder="单次上限"
677
+ />
678
+ <Input
679
+ type="number"
680
+ min="1"
681
+ max="3650"
682
+ value={String(config?.redeem_code_cleanup?.redeemed_older_than_days || 90)}
683
+ onChange={(event) => setRedeemCodeCleanupField("redeemed_older_than_days", event.target.value)}
684
+ className="h-10 rounded-xl border-stone-200 bg-white"
685
+ disabled={!config?.redeem_code_cleanup?.enabled || !config?.redeem_code_cleanup?.archive_redeemed}
686
+ placeholder="已兑换天数"
687
+ />
688
+ <label className="flex items-center gap-2 text-sm text-stone-700">
689
+ <Checkbox
690
+ checked={config?.redeem_code_cleanup?.delete_expired !== false}
691
+ onCheckedChange={(checked) => setRedeemCodeCleanupField("delete_expired", Boolean(checked))}
692
+ disabled={!config?.redeem_code_cleanup?.enabled}
693
+ />
694
+ 删除过期未兑换
695
+ </label>
696
+ <label className="flex items-center gap-2 text-sm text-stone-700">
697
+ <Checkbox
698
+ checked={Boolean(config?.redeem_code_cleanup?.delete_disabled)}
699
+ onCheckedChange={(checked) => setRedeemCodeCleanupField("delete_disabled", Boolean(checked))}
700
+ disabled={!config?.redeem_code_cleanup?.enabled}
701
+ />
702
+ 删除禁用卡密
703
+ </label>
704
+ <label className="flex items-center gap-2 text-sm text-stone-700">
705
+ <Checkbox
706
+ checked={Boolean(config?.redeem_code_cleanup?.archive_redeemed)}
707
+ onCheckedChange={(checked) => setRedeemCodeCleanupField("archive_redeemed", Boolean(checked))}
708
+ disabled={!config?.redeem_code_cleanup?.enabled}
709
+ />
710
+ 归档已兑换旧卡密
711
+ </label>
712
+ </div>
713
+ <p className="mt-2 text-xs leading-5 text-stone-500">
714
+ 自动任务默认只删过期未兑换卡密;如开启已兑换归档,会处理超过 {String(config?.redeem_code_cleanup?.redeemed_older_than_days || 90)} 天的记录。
715
+ </p>
716
+ </div>
717
+ </div>
718
+
719
  {isLoading ? (
720
  <div className="flex items-center justify-center py-10">
721
  <LoaderCircle className="size-5 animate-spin text-stone-400" />
 
742
  <Input
743
  value={keyword}
744
  onChange={(event) => setKeyword(event.target.value)}
745
+ placeholder="搜索卡密、批次、备注、用户或 openid"
746
  className="h-10 rounded-xl border-stone-200 bg-white pl-9 shadow-none"
747
  />
748
  </div>
 
752
  <span>过期 {stats.expired}</span>
753
  <span>已兑 {stats.redeemed}</span>
754
  <span>禁用 {stats.disabled}</span>
755
+ <span>赠送 {stats.dailyGift}</span>
756
+ <span>套餐 {stats.package}</span>
757
  </div>
758
  </div>
759
  {filteredItems.length === 0 ? (
 
774
  ) : null}
775
  {visibleItems.map((item) => {
776
  const isPending = pendingIds.has(item.id);
777
+ const displayCode = codeDisplay(item);
778
  return (
779
  <div key={item.id} className="flex flex-col gap-3 rounded-xl border border-stone-200 bg-white px-4 py-4 md:flex-row md:items-center md:justify-between">
780
  <div className="min-w-0 space-y-2">
781
  <div className="flex flex-wrap items-center gap-2">
782
+ <div className={`break-all font-mono text-sm font-medium ${item.code ? "text-stone-800" : "text-stone-500"}`}>{displayCode}</div>
783
+ {item.code ? (
784
+ <Button variant="outline" className="h-7 rounded-lg border-stone-200 bg-white px-2 text-xs text-stone-600" onClick={() => void copyText(item.code || "")}>
785
+ <Copy className="size-3" />
786
+ 复制
787
+ </Button>
788
+ ) : null}
789
  <Badge variant={item.status === "active" && !isCodeExpired(item) ? "success" : "secondary"} className="rounded-md">
790
  {statusLabel(item.status, item)}
791
  </Badge>
792
+ <Badge variant="outline" className={codeTypeBadgeClass(item.code_type)}>
793
+ {codeTypeLabel(item.code_type)}
794
+ </Badge>
795
+ <span className="text-xs text-stone-500">{normalizeCodeType(item.code_type) === "package" ? packageSummary(item) : `${item.credits} 次`}</span>
796
  {item.batch_single_use ? <Badge variant="outline" className="rounded-md">同批限兑</Badge> : null}
797
  </div>
798
  <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500">
799
  <span>创建 {formatDateTime(item.created_at)}</span>
800
  <span>兑换 {item.expires_at ? `至 ${formatDateTime(item.expires_at)}` : "永久"}</span>
801
+ <span>{normalizeCodeType(item.code_type) === "package" ? `套餐 ${packageSummary(item)}` : normalizeCodeType(item.code_type) === "daily_gift" ? "计入今日赠送" : `次数 ${item.credit_expires_days ? `到账后 ${item.credit_expires_days} 天内有效` : "永久"}`}</span>
802
  {item.batch ? <span>批次 {item.batch}</span> : null}
803
  {item.redeemed_at ? <span>兑换 {formatDateTime(item.redeemed_at)}</span> : null}
804
  {item.redeemed_by ? <span>兑换人 {redeemedUserLabel(item)}</span> : null}
 
843
  </DialogHeader>
844
  {deletingItem ? (
845
  <div className="rounded-xl bg-stone-50 p-4 text-sm text-stone-600">
846
+ <div>卡密:{codeDisplay(deletingItem)}</div>
847
+ <div>状态:{statusLabel(deletingItem.status, deletingItem)},{normalizeCodeType(deletingItem.code_type) === "package" ? `套餐:${packageSummary(deletingItem)}` : `次数:${deletingItem.credits}`}</div>
848
+ <div>类型:{codeTypeLabel(deletingItem.code_type)}</div>
849
  <div>兑换期限:{deletingItem.expires_at ? `至 ${formatDateTime(deletingItem.expires_at)}` : "永久"}</div>
850
+ <div>{normalizeCodeType(deletingItem.code_type) === "package" ? `到账规则:${packageSummary(deletingItem)}` : normalizeCodeType(deletingItem.code_type) === "daily_gift" ? "到账规则:计入今日赠送" : `次数期限:${deletingItem.credit_expires_days ? `到账后 ${deletingItem.credit_expires_days} 天内有效` : "永久"}`}</div>
851
  {deletingItem.batch ? <div>批次:{deletingItem.batch}</div> : null}
852
  </div>
853
  ) : null}
web/src/app/settings/components/user-keys-card.tsx CHANGED
@@ -345,9 +345,11 @@ export function UserKeysCard({ kind = "internal" }: UserKeysCardProps) {
345
  <>
346
  <span>总可用 {item.credits ?? 0} 次</span>
347
  <span>购买剩余 {item.paid_credits ?? item.credits ?? 0} 次</span>
 
348
  <span>限时剩余 {item.expiring_credits ?? 0} 次</span>
349
  <span>永久剩余 {item.permanent_credits ?? 0} 次</span>
350
  <span>今日赠送剩余 {item.daily_credits_remaining ?? 0} / {item.daily_credits ?? 0} 次</span>
 
351
  <span>已用 {item.used_credits ?? 0} 次</span>
352
  <span>累计充值 {item.total_recharged ?? 0} 次</span>
353
  </>
 
345
  <>
346
  <span>总可用 {item.credits ?? 0} 次</span>
347
  <span>购买剩余 {item.paid_credits ?? item.credits ?? 0} 次</span>
348
+ <span>套餐剩余 {item.package_credits ?? 0} 次</span>
349
  <span>限时剩余 {item.expiring_credits ?? 0} 次</span>
350
  <span>永久剩余 {item.permanent_credits ?? 0} 次</span>
351
  <span>今日赠送剩余 {item.daily_credits_remaining ?? 0} / {item.daily_credits ?? 0} 次</span>
352
+ {(item.daily_bonus_credits ?? 0) > 0 ? <span>卡密赠送 +{item.daily_bonus_credits ?? 0} 次</span> : null}
353
  <span>已用 {item.used_credits ?? 0} 次</span>
354
  <span>累计充值 {item.total_recharged ?? 0} 次</span>
355
  </>
web/src/app/settings/store.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
  type FreeAccountCleanupSettings,
33
  type ImageStorageMode,
34
  type ImageStorageSettings,
 
35
  type RegisterConfig,
36
  type SettingsConfig,
37
  } from "@/lib/api";
@@ -48,6 +49,16 @@ const DEFAULT_FREE_ACCOUNT_CLEANUP: FreeAccountCleanupSettings = {
48
  action: "mark_abnormal",
49
  };
50
 
 
 
 
 
 
 
 
 
 
 
51
  function normalizeFreeAccountCleanup(value: unknown): FreeAccountCleanupSettings {
52
  const source = typeof value === "object" && value !== null ? value as Partial<FreeAccountCleanupSettings> : {};
53
  const action = source.action === "delete" ? "delete" : "mark_abnormal";
@@ -60,6 +71,19 @@ function normalizeFreeAccountCleanup(value: unknown): FreeAccountCleanupSettings
60
  };
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  function normalizeConfig(config: SettingsConfig): SettingsConfig {
64
  const imageStorage = typeof config.image_storage === "object" && config.image_storage
65
  ? config.image_storage as ImageStorageSettings
@@ -127,6 +151,7 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
127
  auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
128
  auto_remove_rate_limited_accounts: Boolean(config.auto_remove_rate_limited_accounts),
129
  free_account_cleanup: normalizeFreeAccountCleanup(config.free_account_cleanup),
 
130
  cleanup_shared_conversations: Boolean(config.cleanup_shared_conversations),
131
  log_levels: Array.isArray(config.log_levels) ? config.log_levels : [],
132
  proxy: typeof config.proxy === "string" ? config.proxy : "",
@@ -284,6 +309,7 @@ type SettingsStore = {
284
  setAutoRemoveInvalidAccounts: (value: boolean) => void;
285
  setAutoRemoveRateLimitedAccounts: (value: boolean) => void;
286
  setFreeAccountCleanupField: <K extends keyof FreeAccountCleanupSettings>(key: K, value: FreeAccountCleanupSettings[K]) => void;
 
287
  setCleanupSharedConversations: (value: boolean) => void;
288
  setLogLevel: (level: string, enabled: boolean) => void;
289
  setProxy: (value: string) => void;
@@ -446,6 +472,12 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
446
  interval_minutes: Math.max(1, Number(config.free_account_cleanup?.interval_minutes) || 10),
447
  failure_threshold: Math.max(1, Number(config.free_account_cleanup?.failure_threshold) || 2),
448
  },
 
 
 
 
 
 
449
  cleanup_shared_conversations: Boolean(config.cleanup_shared_conversations),
450
  proxy: config.proxy.trim(),
451
  base_url: String(config.base_url || "").trim(),
@@ -605,6 +637,24 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
605
  });
606
  },
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  setCleanupSharedConversations: (value) => {
609
  set((state) => state.config ? { config: { ...state.config, cleanup_shared_conversations: value } } : {});
610
  },
 
32
  type FreeAccountCleanupSettings,
33
  type ImageStorageMode,
34
  type ImageStorageSettings,
35
+ type RedeemCodeCleanupSettings,
36
  type RegisterConfig,
37
  type SettingsConfig,
38
  } from "@/lib/api";
 
49
  action: "mark_abnormal",
50
  };
51
 
52
+ const DEFAULT_REDEEM_CODE_CLEANUP: RedeemCodeCleanupSettings = {
53
+ enabled: false,
54
+ interval_minutes: 1440,
55
+ delete_expired: true,
56
+ delete_disabled: false,
57
+ archive_redeemed: false,
58
+ redeemed_older_than_days: 90,
59
+ limit: 1000,
60
+ };
61
+
62
  function normalizeFreeAccountCleanup(value: unknown): FreeAccountCleanupSettings {
63
  const source = typeof value === "object" && value !== null ? value as Partial<FreeAccountCleanupSettings> : {};
64
  const action = source.action === "delete" ? "delete" : "mark_abnormal";
 
71
  };
72
  }
73
 
74
+ function normalizeRedeemCodeCleanup(value: unknown): RedeemCodeCleanupSettings {
75
+ const source = typeof value === "object" && value !== null ? value as Partial<RedeemCodeCleanupSettings> : {};
76
+ return {
77
+ enabled: Boolean(source.enabled),
78
+ interval_minutes: Math.max(1, Number(source.interval_minutes) || Number(DEFAULT_REDEEM_CODE_CLEANUP.interval_minutes)),
79
+ delete_expired: source.delete_expired !== false,
80
+ delete_disabled: Boolean(source.delete_disabled),
81
+ archive_redeemed: Boolean(source.archive_redeemed),
82
+ redeemed_older_than_days: Math.max(1, Number(source.redeemed_older_than_days) || Number(DEFAULT_REDEEM_CODE_CLEANUP.redeemed_older_than_days)),
83
+ limit: Math.max(1, Math.min(20000, Number(source.limit) || Number(DEFAULT_REDEEM_CODE_CLEANUP.limit))),
84
+ };
85
+ }
86
+
87
  function normalizeConfig(config: SettingsConfig): SettingsConfig {
88
  const imageStorage = typeof config.image_storage === "object" && config.image_storage
89
  ? config.image_storage as ImageStorageSettings
 
151
  auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
152
  auto_remove_rate_limited_accounts: Boolean(config.auto_remove_rate_limited_accounts),
153
  free_account_cleanup: normalizeFreeAccountCleanup(config.free_account_cleanup),
154
+ redeem_code_cleanup: normalizeRedeemCodeCleanup(config.redeem_code_cleanup),
155
  cleanup_shared_conversations: Boolean(config.cleanup_shared_conversations),
156
  log_levels: Array.isArray(config.log_levels) ? config.log_levels : [],
157
  proxy: typeof config.proxy === "string" ? config.proxy : "",
 
309
  setAutoRemoveInvalidAccounts: (value: boolean) => void;
310
  setAutoRemoveRateLimitedAccounts: (value: boolean) => void;
311
  setFreeAccountCleanupField: <K extends keyof FreeAccountCleanupSettings>(key: K, value: FreeAccountCleanupSettings[K]) => void;
312
+ setRedeemCodeCleanupField: <K extends keyof RedeemCodeCleanupSettings>(key: K, value: RedeemCodeCleanupSettings[K]) => void;
313
  setCleanupSharedConversations: (value: boolean) => void;
314
  setLogLevel: (level: string, enabled: boolean) => void;
315
  setProxy: (value: string) => void;
 
472
  interval_minutes: Math.max(1, Number(config.free_account_cleanup?.interval_minutes) || 10),
473
  failure_threshold: Math.max(1, Number(config.free_account_cleanup?.failure_threshold) || 2),
474
  },
475
+ redeem_code_cleanup: {
476
+ ...normalizeRedeemCodeCleanup(config.redeem_code_cleanup),
477
+ interval_minutes: Math.max(1, Number(config.redeem_code_cleanup?.interval_minutes) || 1440),
478
+ redeemed_older_than_days: Math.max(1, Number(config.redeem_code_cleanup?.redeemed_older_than_days) || 90),
479
+ limit: Math.max(1, Math.min(20000, Number(config.redeem_code_cleanup?.limit) || 1000)),
480
+ },
481
  cleanup_shared_conversations: Boolean(config.cleanup_shared_conversations),
482
  proxy: config.proxy.trim(),
483
  base_url: String(config.base_url || "").trim(),
 
637
  });
638
  },
639
 
640
+ setRedeemCodeCleanupField: (key, value) => {
641
+ set((state) => {
642
+ if (!state.config) {
643
+ return {};
644
+ }
645
+ const current = normalizeRedeemCodeCleanup(state.config.redeem_code_cleanup);
646
+ return {
647
+ config: {
648
+ ...state.config,
649
+ redeem_code_cleanup: normalizeRedeemCodeCleanup({
650
+ ...current,
651
+ [key]: value,
652
+ }),
653
+ },
654
+ };
655
+ });
656
+ },
657
+
658
  setCleanupSharedConversations: (value) => {
659
  set((state) => state.config ? { config: { ...state.config, cleanup_shared_conversations: value } } : {});
660
  },
web/src/components/top-nav.tsx CHANGED
@@ -2,15 +2,29 @@
2
 
3
  import Link from "next/link";
4
  import { useEffect, useState } from "react";
5
- import { ExternalLink } from "lucide-react";
6
  import { usePathname } from "next/navigation";
 
7
 
8
  import webConfig from "@/constants/common-env";
9
  import { AnnouncementsPopover } from "@/components/announcements-popover";
 
 
 
 
 
 
 
 
 
 
 
 
10
  import { getValidatedAuthSession } from "@/lib/auth-session";
 
11
  import { DEFAULT_BRANDING, type normalizeBranding } from "@/lib/branding";
12
  import { cn } from "@/lib/utils";
13
- import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
14
 
15
  const adminNavItems = [
16
  { href: "/image", label: "画图" },
@@ -31,11 +45,36 @@ function getRoleLabel(role: StoredAuthSession["role"]) {
31
  return "管理员";
32
  }
33
  if (role === "normal") {
34
- return "普通用户";
35
  }
36
  return "内部用户";
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  function sanitizeSvgMarkup(markup: string) {
40
  const trimmed = markup.trim();
41
  if (!/^\s*<svg[\s>][\s\S]*<\/svg>\s*$/i.test(trimmed)) {
@@ -90,6 +129,16 @@ type Branding = ReturnType<typeof normalizeBranding>;
90
  export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding }) {
91
  const pathname = usePathname();
92
  const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
 
 
 
 
 
 
 
 
 
 
93
 
94
  useEffect(() => {
95
  let active = true;
@@ -123,6 +172,8 @@ export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding })
123
  }, [pathname]);
124
 
125
  const handleLogout = () => {
 
 
126
  const clearPromise = clearStoredAuthSession();
127
  setSession(null);
128
  window.location.replace("/login");
@@ -131,6 +182,92 @@ export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding })
131
  });
132
  };
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  if (pathname === "/login" || session === undefined || !session) {
135
  return null;
136
  }
@@ -150,84 +287,276 @@ export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding })
150
  const externalLinkIconUrl = branding.site_external_link_icon_url.trim();
151
  const externalLinkIconSvg = branding.site_external_link_icon_svg.trim();
152
  const ExternalLinkIcon = ExternalLink;
 
 
 
153
 
154
  return (
155
- <header className="border-b border-stone-100/50">
156
- <div className="relative flex min-h-12 flex-col gap-1 px-3 py-2 sm:h-12 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-6 sm:py-0">
157
- <div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
158
- <Link
159
- href="/image"
160
- className="inline-flex shrink-0 items-center gap-1.5 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
161
- >
162
- {logoUrl ? <img src={logoUrl} alt="" className="size-5 rounded-md object-cover" /> : null}
163
- <span>{headerTitle}</span>
164
- </Link>
165
- {externalLinkUrl && externalLinkLabel ? (
166
- <a
167
- href={externalLinkUrl}
168
- target="_blank"
169
- rel="noreferrer"
170
- className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700"
171
- aria-label={externalLinkLabel}
172
  >
173
- {externalLinkIconSvg ? (
174
- <InlineSvgIcon svg={externalLinkIconSvg} />
175
- ) : externalLinkIconUrl ? (
176
- <img src={externalLinkIconUrl} alt="" className="size-4 rounded-sm object-cover" />
177
- ) : (
178
- <ExternalLinkIcon className="size-4" />
179
- )}
180
- <span className="hidden md:inline">{externalLinkLabel}</span>
181
- </a>
182
- ) : null}
183
- <button
184
- type="button"
185
- className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
186
- onClick={handleLogout}
187
- >
188
- 退出
189
- </button>
190
- </div>
191
- <nav className="hide-scrollbar -mx-1 flex min-w-0 flex-1 gap-1 overflow-x-auto px-1 sm:mx-0 sm:justify-center sm:gap-8 sm:overflow-visible sm:px-0">
192
- {navItems.map((item) => {
193
- const active = pathname === item.href;
194
- return (
195
- <Link
196
- key={item.href}
197
- href={item.href}
198
- className={cn(
199
- "relative shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-[13px] font-medium transition sm:rounded-none sm:px-0 sm:text-[15px]",
200
- active
201
- ? "bg-stone-950 text-white sm:bg-transparent sm:font-semibold sm:text-stone-950"
202
- : "text-stone-500 hover:text-stone-900",
203
- )}
204
  >
205
- {item.label}
206
- {active ? <span className="absolute inset-x-0 -bottom-[1px] hidden h-0.5 bg-stone-950 sm:block" /> : null}
207
- </Link>
208
- );
209
- })}
210
- </nav>
211
- <div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
212
- <AnnouncementsPopover session={session} />
213
- <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
214
- {roleLabel} · {displayName}
215
- </span>
216
- <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
217
- v{webConfig.appVersion}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  </span>
219
- <button
220
- type="button"
221
- className="py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:text-sm"
222
- onClick={handleLogout}
223
- >
224
- 退出
225
- </button>
 
 
 
 
226
  </div>
227
- <div className="absolute top-2 right-12 sm:hidden scale-90">
228
- <AnnouncementsPopover session={session} />
 
229
  </div>
230
- </div>
231
- </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  );
233
  }
 
2
 
3
  import Link from "next/link";
4
  import { useEffect, useState } from "react";
5
+ import { ChevronDown, Copy, ExternalLink, KeyRound, LoaderCircle, LogOut, UserRound } from "lucide-react";
6
  import { usePathname } from "next/navigation";
7
+ import { toast } from "sonner";
8
 
9
  import webConfig from "@/constants/common-env";
10
  import { AnnouncementsPopover } from "@/components/announcements-popover";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "@/components/ui/dialog";
22
+ import { fetchMyApiKey, fetchMyProfile, updateMyProfile, type MyProfile } from "@/lib/api";
23
  import { getValidatedAuthSession } from "@/lib/auth-session";
24
+ import { formatChinaDateTime } from "@/lib/time";
25
  import { DEFAULT_BRANDING, type normalizeBranding } from "@/lib/branding";
26
  import { cn } from "@/lib/utils";
27
+ import { clearStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
28
 
29
  const adminNavItems = [
30
  { href: "/image", label: "画图" },
 
45
  return "管理员";
46
  }
47
  if (role === "normal") {
48
+ return "微信用户";
49
  }
50
  return "内部用户";
51
  }
52
 
53
+ function getExternalApiUrl() {
54
+ const configuredApiUrl = webConfig.apiUrl.replace(/\/$/, "");
55
+ if (configuredApiUrl) {
56
+ return configuredApiUrl;
57
+ }
58
+ if (typeof window !== "undefined") {
59
+ return window.location.origin;
60
+ }
61
+ return "";
62
+ }
63
+
64
+ function isTemporaryWechatName(profile: MyProfile | null) {
65
+ const name = String(profile?.name || "").trim();
66
+ return Boolean(profile?.wechat_openid) && (
67
+ !name ||
68
+ name === "微信用户" ||
69
+ /^微信用户\s+[A-Za-z0-9_-]{4,}$/.test(name) ||
70
+ /^Wechat user(?:\s+\d+)?$/i.test(name)
71
+ );
72
+ }
73
+
74
+ function canViewApiKey(role: StoredAuthSession["role"]) {
75
+ return role === "normal" || role === "user";
76
+ }
77
+
78
  function sanitizeSvgMarkup(markup: string) {
79
  const trimmed = markup.trim();
80
  if (!/^\s*<svg[\s>][\s\S]*<\/svg>\s*$/i.test(trimmed)) {
 
129
  export function TopNav({ branding = DEFAULT_BRANDING }: { branding?: Branding }) {
130
  const pathname = usePathname();
131
  const [session, setSession] = useState<StoredAuthSession | null | undefined>(undefined);
132
+ const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false);
133
+ const [isMobileAccountMenuOpen, setIsMobileAccountMenuOpen] = useState(false);
134
+ const [isApiKeyOpen, setIsApiKeyOpen] = useState(false);
135
+ const [apiKeyValue, setApiKeyValue] = useState("");
136
+ const [isLoadingApiKey, setIsLoadingApiKey] = useState(false);
137
+ const [profile, setProfile] = useState<MyProfile | null>(null);
138
+ const [isProfileOpen, setIsProfileOpen] = useState(false);
139
+ const [nicknameValue, setNicknameValue] = useState("");
140
+ const [isLoadingProfile, setIsLoadingProfile] = useState(false);
141
+ const [isSavingNickname, setIsSavingNickname] = useState(false);
142
 
143
  useEffect(() => {
144
  let active = true;
 
172
  }, [pathname]);
173
 
174
  const handleLogout = () => {
175
+ setIsAccountMenuOpen(false);
176
+ setIsMobileAccountMenuOpen(false);
177
  const clearPromise = clearStoredAuthSession();
178
  setSession(null);
179
  window.location.replace("/login");
 
182
  });
183
  };
184
 
185
+ const handleOpenApiKey = async () => {
186
+ if (!session || !canViewApiKey(session.role)) {
187
+ return;
188
+ }
189
+ setIsAccountMenuOpen(false);
190
+ setIsMobileAccountMenuOpen(false);
191
+ setIsApiKeyOpen(true);
192
+ setIsLoadingApiKey(true);
193
+ if (session.role === "user") {
194
+ setApiKeyValue(session.key);
195
+ setIsLoadingApiKey(false);
196
+ return;
197
+ }
198
+ try {
199
+ const data = await fetchMyApiKey();
200
+ setApiKeyValue(data.api_key);
201
+ } catch (error) {
202
+ setApiKeyValue("");
203
+ toast.error(error instanceof Error ? error.message : "加载密钥失败");
204
+ } finally {
205
+ setIsLoadingApiKey(false);
206
+ }
207
+ };
208
+
209
+ const handleOpenProfile = async () => {
210
+ setIsAccountMenuOpen(false);
211
+ setIsMobileAccountMenuOpen(false);
212
+ setNicknameValue(session?.name || "");
213
+ setIsProfileOpen(true);
214
+ setIsLoadingProfile(true);
215
+ try {
216
+ const data = await fetchMyProfile();
217
+ setProfile(data.profile);
218
+ setNicknameValue(data.profile.name || session?.name || "");
219
+ } catch (error) {
220
+ setProfile(null);
221
+ toast.error(error instanceof Error ? error.message : "加载个人资料失败");
222
+ } finally {
223
+ setIsLoadingProfile(false);
224
+ }
225
+ };
226
+
227
+ const handleSaveNickname = async () => {
228
+ if (!session || session.role !== "normal") {
229
+ setIsProfileOpen(false);
230
+ return;
231
+ }
232
+ const normalizedName = nicknameValue.trim();
233
+ if (!normalizedName) {
234
+ toast.error("请输入昵称");
235
+ return;
236
+ }
237
+ setIsSavingNickname(true);
238
+ try {
239
+ const data = await updateMyProfile(normalizedName);
240
+ const nextProfile = data.profile;
241
+ const nextSession = {
242
+ ...session,
243
+ name: nextProfile.name || normalizedName,
244
+ wechatOpenid: nextProfile.wechat_openid || session.wechatOpenid,
245
+ };
246
+ setProfile(nextProfile);
247
+ setNicknameValue(nextProfile.name || "");
248
+ setSession(nextSession);
249
+ await setStoredAuthSession(nextSession);
250
+ setIsProfileOpen(false);
251
+ toast.success("昵称已保存");
252
+ } catch (error) {
253
+ toast.error(error instanceof Error ? error.message : "保存昵称失败");
254
+ } finally {
255
+ setIsSavingNickname(false);
256
+ }
257
+ };
258
+
259
+ const handleCopyApiKey = async () => {
260
+ if (!apiKeyValue) {
261
+ return;
262
+ }
263
+ try {
264
+ await navigator.clipboard.writeText(apiKeyValue);
265
+ toast.success("密钥已复制");
266
+ } catch {
267
+ toast.error("复制失败,请手动选择密钥复制");
268
+ }
269
+ };
270
+
271
  if (pathname === "/login" || session === undefined || !session) {
272
  return null;
273
  }
 
287
  const externalLinkIconUrl = branding.site_external_link_icon_url.trim();
288
  const externalLinkIconSvg = branding.site_external_link_icon_svg.trim();
289
  const ExternalLinkIcon = ExternalLink;
290
+ const externalApiUrl = getExternalApiUrl();
291
+ const profileName = profile?.name?.trim() || displayName;
292
+ const showApiKey = canViewApiKey(session.role);
293
 
294
  return (
295
+ <>
296
+ <header className="border-b border-stone-100/50">
297
+ <div className="relative flex min-h-12 flex-col gap-1 px-3 py-2 sm:h-12 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-6 sm:py-0">
298
+ <div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
299
+ <Link
300
+ href="/image"
301
+ className="inline-flex shrink-0 items-center gap-1.5 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
 
 
 
 
 
 
 
 
 
 
302
  >
303
+ {logoUrl ? <img src={logoUrl} alt="" className="size-5 rounded-md object-cover" /> : null}
304
+ <span>{headerTitle}</span>
305
+ <span className="rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-stone-500">
306
+ v{webConfig.appVersion}
307
+ </span>
308
+ </Link>
309
+ {externalLinkUrl && externalLinkLabel ? (
310
+ <a
311
+ href={externalLinkUrl}
312
+ target="_blank"
313
+ rel="noreferrer"
314
+ className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700"
315
+ aria-label={externalLinkLabel}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  >
317
+ {externalLinkIconSvg ? (
318
+ <InlineSvgIcon svg={externalLinkIconSvg} />
319
+ ) : externalLinkIconUrl ? (
320
+ <img src={externalLinkIconUrl} alt="" className="size-4 rounded-sm object-cover" />
321
+ ) : (
322
+ <ExternalLinkIcon className="size-4" />
323
+ )}
324
+ <span className="hidden md:inline">{externalLinkLabel}</span>
325
+ </a>
326
+ ) : null}
327
+ <div className="ml-auto sm:hidden">
328
+ <AccountPopover
329
+ displayName={displayName}
330
+ roleLabel={roleLabel}
331
+ session={session}
332
+ open={isMobileAccountMenuOpen}
333
+ onOpenChange={setIsMobileAccountMenuOpen}
334
+ onProfile={() => void handleOpenProfile()}
335
+ onApiKey={() => void handleOpenApiKey()}
336
+ onLogout={handleLogout}
337
+ />
338
+ </div>
339
+ </div>
340
+ <nav className="hide-scrollbar -mx-1 flex min-w-0 flex-1 gap-1 overflow-x-auto px-1 sm:mx-0 sm:justify-center sm:gap-8 sm:overflow-visible sm:px-0">
341
+ {navItems.map((item) => {
342
+ const active = pathname === item.href;
343
+ return (
344
+ <Link
345
+ key={item.href}
346
+ href={item.href}
347
+ className={cn(
348
+ "relative shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-[13px] font-medium transition sm:rounded-none sm:px-0 sm:text-[15px]",
349
+ active
350
+ ? "bg-stone-950 text-white sm:bg-transparent sm:font-semibold sm:text-stone-950"
351
+ : "text-stone-500 hover:text-stone-900",
352
+ )}
353
+ >
354
+ {item.label}
355
+ {active ? <span className="absolute inset-x-0 -bottom-[1px] hidden h-0.5 bg-stone-950 sm:block" /> : null}
356
+ </Link>
357
+ );
358
+ })}
359
+ </nav>
360
+ <div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
361
+ <AnnouncementsPopover session={session} />
362
+ <AccountPopover
363
+ displayName={displayName}
364
+ roleLabel={roleLabel}
365
+ session={session}
366
+ open={isAccountMenuOpen}
367
+ onOpenChange={setIsAccountMenuOpen}
368
+ onProfile={() => void handleOpenProfile()}
369
+ onApiKey={() => void handleOpenApiKey()}
370
+ onLogout={handleLogout}
371
+ />
372
+ </div>
373
+ <div className="absolute top-2 right-16 sm:hidden scale-90">
374
+ <AnnouncementsPopover session={session} />
375
+ </div>
376
+ </div>
377
+ </header>
378
+ {showApiKey ? (
379
+ <Dialog
380
+ open={isApiKeyOpen}
381
+ onOpenChange={(open) => {
382
+ setIsApiKeyOpen(open);
383
+ if (!open) {
384
+ setIsLoadingApiKey(false);
385
+ }
386
+ }}
387
+ >
388
+ <DialogContent showCloseButton={false} className="rounded-2xl p-6">
389
+ <DialogHeader className="gap-2">
390
+ <DialogTitle className="flex items-center gap-2">
391
+ <KeyRound className="size-5" />
392
+ 密钥
393
+ </DialogTitle>
394
+ <DialogDescription className="text-sm leading-6">
395
+ 这个 Key 可以用于网站登录,也可以填到支持 OpenAI 图片接口的第三方工具里调用当前网站。
396
+ </DialogDescription>
397
+ </DialogHeader>
398
+ <div className="space-y-3">
399
+ <div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm leading-6 text-stone-700">
400
+ <div className="text-xs font-medium uppercase tracking-wide text-stone-400">URL</div>
401
+ <div className="break-all font-mono text-xs text-stone-800">
402
+ {externalApiUrl || "部署后显示当前网站地址"}
403
+ </div>
404
+ </div>
405
+ <div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm leading-6 text-stone-700">
406
+ <div className="text-xs font-medium uppercase tracking-wide text-stone-400">Key</div>
407
+ <div className="break-all font-mono text-xs text-stone-800">
408
+ {isLoadingApiKey ? "加载中..." : apiKeyValue || "当前密钥无法回显,请联系管理员重置。"}
409
+ </div>
410
+ </div>
411
+ </div>
412
+ <DialogFooter>
413
+ <Button variant="outline" onClick={() => setIsApiKeyOpen(false)}>
414
+ 关闭
415
+ </Button>
416
+ <Button className="bg-stone-950 text-white hover:bg-stone-800" onClick={() => void handleCopyApiKey()} disabled={!apiKeyValue || isLoadingApiKey}>
417
+ {isLoadingApiKey ? <LoaderCircle className="size-4 animate-spin" /> : <Copy className="size-4" />}
418
+ 复制密钥
419
+ </Button>
420
+ </DialogFooter>
421
+ </DialogContent>
422
+ </Dialog>
423
+ ) : null}
424
+ <Dialog open={isProfileOpen} onOpenChange={setIsProfileOpen}>
425
+ <DialogContent showCloseButton={false} className="rounded-2xl p-6">
426
+ <DialogHeader className="gap-2">
427
+ <DialogTitle>个人资料</DialogTitle>
428
+ <DialogDescription className="text-sm leading-6">
429
+ 查看当前登录账号信息,微信用户可以在这里修改昵称。
430
+ </DialogDescription>
431
+ </DialogHeader>
432
+ <div className="space-y-3">
433
+ <div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm leading-6 text-stone-700">
434
+ <div className="text-xs font-medium uppercase tracking-wide text-stone-400">账号</div>
435
+ <div className="break-all text-sm font-medium text-stone-900">{profileName}</div>
436
+ <div className="text-xs text-stone-500">{roleLabel}</div>
437
+ <div className="mt-1 text-xs text-stone-500">注册时间:{formatChinaDateTime(profile?.created_at)}</div>
438
+ </div>
439
+ {profile?.wechat_openid ? (
440
+ <div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-xs leading-5 text-stone-500 break-all">
441
+ 微信 openid:{profile.wechat_openid}
442
+ </div>
443
+ ) : null}
444
+ {session.role === "normal" ? (
445
+ <div className="space-y-2">
446
+ <label className="text-sm font-medium text-stone-700">昵称</label>
447
+ <Input
448
+ value={nicknameValue}
449
+ onChange={(event) => setNicknameValue(event.target.value)}
450
+ placeholder={isLoadingProfile ? "加载中..." : "请输入一个方便识别的昵称"}
451
+ maxLength={32}
452
+ className="h-11 rounded-xl border-stone-200 bg-white"
453
+ disabled={isLoadingProfile || isSavingNickname}
454
+ onKeyDown={(event) => {
455
+ if (event.key === "Enter") {
456
+ void handleSaveNickname();
457
+ }
458
+ }}
459
+ />
460
+ <p className="text-xs text-stone-400">最多 32 个字符,保存后别人不能再使用同一个昵称。</p>
461
+ </div>
462
+ ) : null}
463
+ </div>
464
+ <DialogFooter>
465
+ {session.role !== "normal" || !isTemporaryWechatName(profile) ? (
466
+ <Button variant="outline" onClick={() => setIsProfileOpen(false)} disabled={isSavingNickname}>
467
+ 关闭
468
+ </Button>
469
+ ) : null}
470
+ {session.role === "normal" ? (
471
+ <Button className="bg-stone-950 text-white hover:bg-stone-800" onClick={() => void handleSaveNickname()} disabled={isLoadingProfile || isSavingNickname}>
472
+ {isSavingNickname ? <LoaderCircle className="size-4 animate-spin" /> : null}
473
+ 保存昵称
474
+ </Button>
475
+ ) : null}
476
+ </DialogFooter>
477
+ </DialogContent>
478
+ </Dialog>
479
+ </>
480
+ );
481
+ }
482
+
483
+ function AccountPopover({
484
+ displayName,
485
+ roleLabel,
486
+ session,
487
+ open,
488
+ onOpenChange,
489
+ onProfile,
490
+ onApiKey,
491
+ onLogout,
492
+ }: {
493
+ displayName: string;
494
+ roleLabel: string;
495
+ session: StoredAuthSession;
496
+ open: boolean;
497
+ onOpenChange: (open: boolean) => void;
498
+ onProfile: () => void;
499
+ onApiKey: () => void;
500
+ onLogout: () => void;
501
+ }) {
502
+ return (
503
+ <Popover open={open} onOpenChange={onOpenChange}>
504
+ <PopoverTrigger asChild>
505
+ <button
506
+ type="button"
507
+ className="inline-flex h-10 shrink-0 items-center gap-1 px-1 text-left text-stone-700 transition hover:text-stone-950"
508
+ aria-label="账号菜单"
509
+ >
510
+ <span className="flex min-w-0 max-w-[120px] flex-col leading-tight">
511
+ <span className="truncate text-xs font-semibold text-stone-900">{displayName}</span>
512
+ <span className="truncate text-[11px] font-medium text-stone-400">{roleLabel}</span>
513
  </span>
514
+ <ChevronDown className={cn("size-3.5 text-stone-400 transition", open && "rotate-180")} />
515
+ </button>
516
+ </PopoverTrigger>
517
+ <PopoverContent
518
+ align="end"
519
+ sideOffset={10}
520
+ className="w-[224px] overflow-hidden rounded-2xl border border-stone-200 bg-white p-0 text-stone-900 shadow-[0_24px_70px_-32px_rgba(15,23,42,0.32)]"
521
+ >
522
+ <div className="px-4 py-3">
523
+ <div className="text-sm font-semibold text-stone-950">{displayName}</div>
524
+ <div className="mt-0.5 truncate text-xs text-stone-500">{roleLabel}</div>
525
  </div>
526
+ <div className="border-t border-stone-100 py-1.5">
527
+ <AccountMenuButton icon={UserRound} label="个人资料" onClick={onProfile} />
528
+ {canViewApiKey(session.role) ? <AccountMenuButton icon={KeyRound} label="API 密钥" onClick={onApiKey} /> : null}
529
  </div>
530
+ <div className="border-t border-stone-100 py-1.5">
531
+ <AccountMenuButton icon={LogOut} label="退出登录" tone="danger" onClick={onLogout} />
532
+ </div>
533
+ </PopoverContent>
534
+ </Popover>
535
+ );
536
+ }
537
+
538
+ function AccountMenuButton({
539
+ icon: Icon,
540
+ label,
541
+ tone = "default",
542
+ onClick,
543
+ }: {
544
+ icon: React.ComponentType<{ className?: string }>;
545
+ label: string;
546
+ tone?: "default" | "danger";
547
+ onClick: () => void;
548
+ }) {
549
+ return (
550
+ <button
551
+ type="button"
552
+ className={cn(
553
+ "flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition",
554
+ tone === "danger" ? "text-rose-600 hover:bg-rose-50 hover:text-rose-700" : "text-stone-700 hover:bg-stone-50 hover:text-stone-950",
555
+ )}
556
+ onClick={onClick}
557
+ >
558
+ <Icon className={cn("size-4", tone === "danger" ? "text-rose-500" : "text-stone-400")} />
559
+ <span>{label}</span>
560
+ </button>
561
  );
562
  }
web/src/lib/api.ts CHANGED
@@ -96,6 +96,16 @@ export type FreeAccountCleanupSettings = {
96
  action: FreeAccountCleanupAction;
97
  };
98
 
 
 
 
 
 
 
 
 
 
 
99
  export type SettingsConfig = {
100
  proxy: string;
101
  base_url?: string;
@@ -131,6 +141,7 @@ export type SettingsConfig = {
131
  auto_remove_invalid_accounts?: boolean;
132
  auto_remove_rate_limited_accounts?: boolean;
133
  free_account_cleanup?: FreeAccountCleanupSettings;
 
134
  cleanup_shared_conversations?: boolean;
135
  log_levels?: string[];
136
  backup?: BackupSettings;
@@ -318,6 +329,8 @@ export type UserKey = {
318
  permanent_credits?: number;
319
  expiring_credits?: number;
320
  expiring_reserved_credits?: number;
 
 
321
  credit_grants?: Array<{
322
  id?: string;
323
  remaining?: number;
@@ -328,6 +341,7 @@ export type UserKey = {
328
  daily_credits?: number;
329
  daily_credits_remaining?: number;
330
  daily_credits_used?: number;
 
331
  daily_reserved_credits?: number;
332
  daily_credits_date?: string;
333
  has_api_key_display?: boolean;
@@ -341,6 +355,7 @@ export type MyProfile = {
341
  id: string;
342
  name: string;
343
  role: AuthRole | string;
 
344
  account_pool_enabled?: boolean;
345
  wechat_openid?: string;
346
  wechat_bound_at?: string | null;
@@ -364,26 +379,62 @@ export type CreditSummary = {
364
  expires_at?: string;
365
  source?: string;
366
  }>;
 
 
367
  daily_credits: number;
368
  daily_credits_remaining: number;
369
  daily_credits_used: number | null;
 
370
  daily_reserved_credits: number;
371
  daily_credits_date: string;
372
  };
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  export type RedeemCode = {
375
  id: string;
 
376
  prefix: string;
377
  credits: number;
378
  status: "active" | "redeemed" | "disabled" | string;
 
379
  batch?: string | null;
380
  batch_id?: string | null;
381
  batch_single_use?: boolean;
382
  note?: string | null;
383
  created_at?: string | null;
 
384
  expires_at?: string | null;
 
 
385
  credit_expires_days?: number | null;
386
  credit_expires_at?: string | null;
 
387
  is_expired?: boolean;
388
  redeemed_by?: string | null;
389
  redeemed_at?: string | null;
@@ -393,14 +444,39 @@ export type RedeemCode = {
393
  wechat_openid?: string | null;
394
  wechat_nickname?: string | null;
395
  } | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  };
397
 
398
  export type ShopLedgerEntry = {
399
  id: string;
400
  time: string;
401
  type: string;
 
402
  user_id?: string;
403
  code_id?: string;
 
404
  delta?: number;
405
  credits?: number;
406
  batch?: string;
@@ -883,6 +959,10 @@ export async function fetchMyCredits() {
883
  return httpRequest<{ credits: CreditSummary }>("/api/me/credits");
884
  }
885
 
 
 
 
 
886
  export async function fetchMyProfile() {
887
  return httpRequest<{ profile: MyProfile }>("/api/me/profile");
888
  }
@@ -929,6 +1009,14 @@ export async function createRedeemCodes(body: {
929
  validity_days?: number | null;
930
  credit_expires_days?: number | null;
931
  batch_single_use?: boolean;
 
 
 
 
 
 
 
 
932
  }) {
933
  return httpRequest<{ codes: string[]; items: RedeemCode[]; all_items: RedeemCode[] }>("/api/shop/codes", {
934
  method: "POST",
@@ -957,6 +1045,20 @@ export async function deleteRedeemCode(codeId: string) {
957
  });
958
  }
959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960
  export async function fetchShopLedger() {
961
  return httpRequest<{ items: ShopLedgerEntry[] }>("/api/shop/ledger");
962
  }
 
96
  action: FreeAccountCleanupAction;
97
  };
98
 
99
+ export type RedeemCodeCleanupSettings = {
100
+ enabled: boolean;
101
+ interval_minutes: number | string;
102
+ delete_expired: boolean;
103
+ delete_disabled: boolean;
104
+ archive_redeemed: boolean;
105
+ redeemed_older_than_days: number | string;
106
+ limit: number | string;
107
+ };
108
+
109
  export type SettingsConfig = {
110
  proxy: string;
111
  base_url?: string;
 
141
  auto_remove_invalid_accounts?: boolean;
142
  auto_remove_rate_limited_accounts?: boolean;
143
  free_account_cleanup?: FreeAccountCleanupSettings;
144
+ redeem_code_cleanup?: RedeemCodeCleanupSettings;
145
  cleanup_shared_conversations?: boolean;
146
  log_levels?: string[];
147
  backup?: BackupSettings;
 
329
  permanent_credits?: number;
330
  expiring_credits?: number;
331
  expiring_reserved_credits?: number;
332
+ package_credits?: number;
333
+ package_grants?: PackageGrant[];
334
  credit_grants?: Array<{
335
  id?: string;
336
  remaining?: number;
 
341
  daily_credits?: number;
342
  daily_credits_remaining?: number;
343
  daily_credits_used?: number;
344
+ daily_bonus_credits?: number;
345
  daily_reserved_credits?: number;
346
  daily_credits_date?: string;
347
  has_api_key_display?: boolean;
 
355
  id: string;
356
  name: string;
357
  role: AuthRole | string;
358
+ created_at?: string | null;
359
  account_pool_enabled?: boolean;
360
  wechat_openid?: string;
361
  wechat_bound_at?: string | null;
 
379
  expires_at?: string;
380
  source?: string;
381
  }>;
382
+ package_credits: number;
383
+ package_grants: PackageGrant[];
384
  daily_credits: number;
385
  daily_credits_remaining: number;
386
  daily_credits_used: number | null;
387
+ daily_bonus_credits: number;
388
  daily_reserved_credits: number;
389
  daily_credits_date: string;
390
  };
391
 
392
+ export type PackagePlan = {
393
+ name?: string | null;
394
+ duration_days?: number | null;
395
+ daily_limit?: number | null;
396
+ weekly_limit?: number | null;
397
+ monthly_limit?: number | null;
398
+ total_limit?: number | null;
399
+ };
400
+
401
+ export type PackageGrant = PackagePlan & {
402
+ id?: string | null;
403
+ remaining?: number | null;
404
+ created_at?: string | null;
405
+ starts_at?: string | null;
406
+ expires_at?: string | null;
407
+ source?: string | null;
408
+ code_id?: string | null;
409
+ used_total?: number | null;
410
+ reserved_total?: number | null;
411
+ used_daily?: number | null;
412
+ reserved_daily?: number | null;
413
+ used_weekly?: number | null;
414
+ reserved_weekly?: number | null;
415
+ used_monthly?: number | null;
416
+ reserved_monthly?: number | null;
417
+ };
418
+
419
  export type RedeemCode = {
420
  id: string;
421
+ code?: string | null;
422
  prefix: string;
423
  credits: number;
424
  status: "active" | "redeemed" | "disabled" | string;
425
+ code_type?: "normal" | "daily_gift" | "package" | string | null;
426
  batch?: string | null;
427
  batch_id?: string | null;
428
  batch_single_use?: boolean;
429
  note?: string | null;
430
  created_at?: string | null;
431
+ starts_at?: string | null;
432
  expires_at?: string | null;
433
+ daily_gift_date?: string | null;
434
+ package_plan?: PackagePlan | null;
435
  credit_expires_days?: number | null;
436
  credit_expires_at?: string | null;
437
+ is_not_started?: boolean;
438
  is_expired?: boolean;
439
  redeemed_by?: string | null;
440
  redeemed_at?: string | null;
 
444
  wechat_openid?: string | null;
445
  wechat_nickname?: string | null;
446
  } | null;
447
+ cleanup_reason?: string;
448
+ };
449
+
450
+ export type RedeemCodeCleanupSummary = {
451
+ matched: number;
452
+ deleted: number;
453
+ archived: number;
454
+ archive_path?: string | null;
455
+ expired: number;
456
+ disabled: number;
457
+ redeemed: number;
458
+ limit: number;
459
+ };
460
+
461
+ export type RedeemCodeCleanupResponse = {
462
+ dry_run: boolean;
463
+ summary: RedeemCodeCleanupSummary;
464
+ archive?: {
465
+ archived: number;
466
+ path?: string | null;
467
+ };
468
+ items: RedeemCode[];
469
+ all_items?: RedeemCode[];
470
  };
471
 
472
  export type ShopLedgerEntry = {
473
  id: string;
474
  time: string;
475
  type: string;
476
+ source?: string;
477
  user_id?: string;
478
  code_id?: string;
479
+ code_type?: string;
480
  delta?: number;
481
  credits?: number;
482
  batch?: string;
 
959
  return httpRequest<{ credits: CreditSummary }>("/api/me/credits");
960
  }
961
 
962
+ export async function fetchMyRedeemCodes() {
963
+ return httpRequest<{ items: RedeemCode[] }>("/api/me/redeem-codes");
964
+ }
965
+
966
  export async function fetchMyProfile() {
967
  return httpRequest<{ profile: MyProfile }>("/api/me/profile");
968
  }
 
1009
  validity_days?: number | null;
1010
  credit_expires_days?: number | null;
1011
  batch_single_use?: boolean;
1012
+ code_type?: string;
1013
+ daily_gift_date?: string;
1014
+ package_name?: string;
1015
+ package_duration_days?: number;
1016
+ package_daily_limit?: number | null;
1017
+ package_weekly_limit?: number | null;
1018
+ package_monthly_limit?: number | null;
1019
+ package_total_limit?: number | null;
1020
  }) {
1021
  return httpRequest<{ codes: string[]; items: RedeemCode[]; all_items: RedeemCode[] }>("/api/shop/codes", {
1022
  method: "POST",
 
1045
  });
1046
  }
1047
 
1048
+ export async function cleanupRedeemCodes(body: {
1049
+ delete_expired: boolean;
1050
+ delete_disabled: boolean;
1051
+ archive_redeemed: boolean;
1052
+ redeemed_older_than_days: number;
1053
+ limit: number;
1054
+ dry_run: boolean;
1055
+ }) {
1056
+ return httpRequest<RedeemCodeCleanupResponse>("/api/shop/codes/cleanup", {
1057
+ method: "POST",
1058
+ body,
1059
+ });
1060
+ }
1061
+
1062
  export async function fetchShopLedger() {
1063
  return httpRequest<{ items: ShopLedgerEntry[] }>("/api/shop/ledger");
1064
  }