xiaolongmr commited on
Commit ·
eaf2aba
1
Parent(s): 41fee80
更新卡密
Browse files- .codex/config.toml +0 -1
- .gitignore +1 -0
- api-dev-8000.err.log +1 -13
- api/ai.py +6 -0
- api/shop.py +49 -2
- api/support.py +31 -0
- services/auth_service.py +389 -8
- services/config.py +51 -0
- services/image_task_service.py +13 -2
- services/shop_service.py +407 -37
- test/test_account_image_capabilities.py +418 -0
- web/.next-dev-3000.err.log +326 -264
- web/.next-dev-3000.out.log +0 -0
- web/src/app/image/components/image-composer.tsx +18 -17
- web/src/app/image/page.tsx +352 -82
- web/src/app/settings/components/shop-codes-card.tsx +496 -64
- web/src/app/settings/components/user-keys-card.tsx +2 -0
- web/src/app/settings/store.ts +50 -0
- web/src/components/top-nav.tsx +404 -75
- web/src/lib/api.ts +102 -0
.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 [
|
| 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()
|
| 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,
|
| 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":
|
| 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
|
| 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")
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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["
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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":
|
|
|
|
| 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 |
-
|
| 465 |
-
auth_items,
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 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 |
-
|
| 514 |
-
auth_items,
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 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=
|
| 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]
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
.
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
.
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 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 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
|
|
|
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 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 |
-
|
| 212 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
-
|
|
|
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 248 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
-
|
| 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 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 [
|
| 453 |
-
const [
|
| 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
|
| 1510 |
-
|
| 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(
|
| 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 |
-
|
| 1718 |
-
|
| 1719 |
-
|
| 1720 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">生成
|
| 214 |
</div>
|
| 215 |
</div>
|
| 216 |
</div>
|
| 217 |
|
| 218 |
-
<div className="grid gap-3 md:grid-cols-[
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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={
|
| 239 |
-
onChange={(event) =>
|
| 240 |
-
type="
|
| 241 |
-
min=
|
| 242 |
-
|
| 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 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
<
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
</div>
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
</div>
|
| 271 |
-
|
| 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">
|
| 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="搜索批次、备注、用户
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
<Badge variant={item.status === "active" && !isCodeExpired(item) ? "success" : "secondary"} className="rounded-md">
|
| 362 |
{statusLabel(item.status, item)}
|
| 363 |
</Badge>
|
| 364 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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>卡密:{
|
| 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 |
-
<
|
| 156 |
-
<
|
| 157 |
-
<div className="flex
|
| 158 |
-
<
|
| 159 |
-
|
| 160 |
-
|
| 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 |
-
{
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
<
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 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 |
-
{
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
</span>
|
| 219 |
-
<
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
</div>
|
| 227 |
-
<div className="
|
| 228 |
-
<
|
|
|
|
| 229 |
</div>
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|