diff --git a/.env.example b/.env.example index 7b3d4a6..3df8d85 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,38 @@ AUTH_SESSION_TTL_SEC=86400 AUTH_REMEMBER_SESSION_TTL_SEC=604800 # 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026) AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026 + +# --- VIP 平台模型配置(用户开启 VIP 后优先使用)--- +# 平台文本模型 +PLATFORM_OPENAI_API_KEY= +# PLATFORM_OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +PLATFORM_OPENAI_MODEL=qwen-plus +# 平台生图模型 +PLATFORM_OPENAI_IMAGE_MODEL=wanx2.0-t2i-turbo +PLATFORM_OPENAI_TIMEOUT=120 +PLATFORM_OPENAI_MAX_OUTPUT_TOKENS=8192 +PLATFORM_OPENAI_MAX_RETRIES=0 +# 新用户免费试用 Credits +VIP_TRIAL_TOKENS=500 +# 标准坐席每月额度 +CREDITS_SEAT_MONTHLY_QUOTA=1500 +# 文本计费:100万 token 的人民币价格 +CREDITS_TOKEN_PRICE_PER_MILLION_CNY=7.9 +# 图片计费:160 张图 = 0.75 元 +CREDITS_IMAGE_PRICE_PACKAGE_CNY=0.75 +CREDITS_IMAGE_PRICE_PACKAGE_IMAGES=160 +# 兼容字段(旧版):单张图人民币价格 +CREDITS_IMAGE_PRICE_PER_IMAGE_CNY=0.04 +# 兼容字段(旧版):可保留默认,不再作为首选换算规则 +CREDITS_PER_MILLION_TOKENS=1500 +CREDITS_PER_120_IMAGES=1500 +# 标准加油包:19.9 元 = 1500 Credits +CREDITS_RECHARGE_PACKAGE_AMOUNT=19.9 +CREDITS_RECHARGE_PACKAGE_CREDITS=1500 + +# 购物系统打通(可选) +# SHOP_BACKEND_CREATE_ORDER_URL=https://shop.example.com/api/order/create +# 微信支付回调路径(本项目已提供): +# 下单入口:POST https://你的域名/api/pay/wechat/ +# 回调入口:POST https://你的域名/api/pay/wechat/backcall +SHOP_BACKEND_CALLBACK_TOKEN= diff --git a/app/config.py b/app/config.py index fe7b690..61ae5fe 100644 --- a/app/config.py +++ b/app/config.py @@ -75,27 +75,87 @@ class Settings(BaseSettings): auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY") vip_trial_tokens: int = Field( - default=20000, + default=500, alias="VIP_TRIAL_TOKENS", - description="新用户试用赠送 token", + description="新用户试用赠送 Credits", + ) + credits_seat_monthly_quota: int = Field( + default=1500, + alias="CREDITS_SEAT_MONTHLY_QUOTA", + description="标准坐席每月 Credits 额度", + ) + credits_standard_seat_price_cny: float = Field( + default=198.0, + alias="CREDITS_STANDARD_SEAT_PRICE_CNY", + description="标准坐席月费(人民币)", ) vip_rewrite_token_per_1k_chars: int = Field( - default=1200, + default=1500, alias="VIP_REWRITE_TOKEN_PER_1K_CHARS", - description="改写按千字计费 token 单价", + description="兼容字段:改写计费参数(建议使用 Credits 规则字段)", ) vip_image_token_per_image: int = Field( - default=1800, + default=1500, alias="VIP_IMAGE_TOKEN_PER_IMAGE", - description="文生图单张扣减 token", + description="兼容字段:生图计费参数(建议使用 Credits 规则字段)", + ) + credits_per_million_tokens: int = Field( + default=1500, + alias="CREDITS_PER_MILLION_TOKENS", + description="兼容字段:100万 token 对应的 Credits 抵扣(建议使用人民币折算字段)", + ) + credits_per_120_images: int = Field( + default=1500, + alias="CREDITS_PER_120_IMAGES", + description="兼容字段:120 张图片对应的 Credits 抵扣(建议使用人民币折算字段)", + ) + credits_token_price_per_million_cny: float = Field( + default=7.9, + alias="CREDITS_TOKEN_PRICE_PER_MILLION_CNY", + description="文本计费:100万 token 的人民币价格", + ) + credits_image_price_per_image_cny: float = Field( + default=0.04, + alias="CREDITS_IMAGE_PRICE_PER_IMAGE_CNY", + description="兼容字段:生图计费单张价格(建议使用整包折算字段)", + ) + credits_image_price_package_cny: float = Field( + default=0.75, + alias="CREDITS_IMAGE_PRICE_PACKAGE_CNY", + description="生图计费:图片整包人民币价格", + ) + credits_image_price_package_images: int = Field( + default=160, + alias="CREDITS_IMAGE_PRICE_PACKAGE_IMAGES", + description="生图计费:整包包含图片张数", + ) + credits_recharge_package_amount: float = Field( + default=19.9, + alias="CREDITS_RECHARGE_PACKAGE_AMOUNT", + description="标准加油包价格(元)", + ) + credits_recharge_package_credits: int = Field( + default=1500, + alias="CREDITS_RECHARGE_PACKAGE_CREDITS", + description="标准加油包 Credits 数量", ) platform_openai_api_key: str | None = Field(default=None, alias="PLATFORM_OPENAI_API_KEY") platform_openai_base_url: str | None = Field(default=None, alias="PLATFORM_OPENAI_BASE_URL") - platform_openai_model: str = Field(default="gpt-4.1-mini", alias="PLATFORM_OPENAI_MODEL") - platform_openai_image_model: str = Field(default="gpt-image-1", alias="PLATFORM_OPENAI_IMAGE_MODEL") + platform_openai_model: str = Field(default="qwen-plus", alias="PLATFORM_OPENAI_MODEL") + platform_openai_image_model: str = Field(default="wanx2.0-t2i-turbo", alias="PLATFORM_OPENAI_IMAGE_MODEL") + platform_openai_text_model_options: str = Field( + default="gpt-4.1-mini,gpt-4.1,gpt-4o-mini,qwen-plus,qwen-max", + alias="PLATFORM_OPENAI_TEXT_MODEL_OPTIONS", + ) + platform_openai_image_model_options: str = Field( + default="wanx2.0-t2i-turbo,wanx2.1-t2i-plus,wanx2.1-t2i-turbo,gpt-image-1,dall-e-3", + alias="PLATFORM_OPENAI_IMAGE_MODEL_OPTIONS", + ) platform_openai_timeout: float = Field(default=120.0, alias="PLATFORM_OPENAI_TIMEOUT") platform_openai_max_output_tokens: int = Field(default=8192, alias="PLATFORM_OPENAI_MAX_OUTPUT_TOKENS") platform_openai_max_retries: int = Field(default=0, alias="PLATFORM_OPENAI_MAX_RETRIES") + shop_backend_create_order_url: str | None = Field(default=None, alias="SHOP_BACKEND_CREATE_ORDER_URL") + shop_backend_callback_token: str = Field(default="", alias="SHOP_BACKEND_CALLBACK_TOKEN") settings = Settings() diff --git a/app/main.py b/app/main.py index 6445f1f..4eb9f0e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,11 @@ from __future__ import annotations import logging +import math +import re +import secrets +import time +import uuid from pathlib import Path from urllib.parse import urlparse @@ -16,8 +21,12 @@ from app.middleware import RequestContextMiddleware from app.schemas import ( AIModelCreateRequest, AIModelDeleteRequest, + AIImageModelUpdateRequest, AIModelSwitchRequest, AuthCredentialRequest, + BillingRechargeCreateRequest, + BillingPayNowRequest, + BillingRechargeNotifyRequest, ChangePasswordRequest, DeleteAccountRequest, ForgotPasswordResetRequest, @@ -30,6 +39,7 @@ from app.schemas import ( WechatBindingRequest, WechatPublishRequest, WechatSwitchRequest, + UserProfileUpdateRequest, VipRechargeRequest, VipToggleRequest, ) @@ -64,6 +74,11 @@ wechat = WechatPublisher() poster_material = PosterMaterialService(wechat) im = IMPublisher() users = UserStore(settings.auth_db_path) +_register_rate: dict[str, list[float]] = {} +_login_rate: dict[str, list[float]] = {} +_challenge_pool: dict[str, dict] = {} +USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{4,24}$") +PASSWORD_STRONG_RE = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{10,64}$") def _session_ttl(remember_me: bool) -> int: @@ -84,6 +99,67 @@ def _require_user(request: Request) -> dict | None: return u +def _client_ip(request: Request) -> str: + xff = (request.headers.get("x-forwarded-for") or "").strip() + if xff: + return xff.split(",")[0].strip() + return (request.client.host if request.client else "") or "unknown" + + +def _hit_limit(bucket: dict[str, list[float]], key: str, limit: int, window_sec: int) -> bool: + now = time.time() + points = bucket.get(key, []) + points = [p for p in points if now - p <= window_sec] + points.append(now) + bucket[key] = points + return len(points) > limit + + +def _create_challenge() -> dict: + a = secrets.randbelow(8) + 2 + b = secrets.randbelow(9) + 1 + op = "+" if secrets.randbelow(2) == 0 else "-" + if op == "-": + a, b = max(a, b) + 5, min(a, b) + answer = str(a + b) if op == "+" else str(a - b) + cid = secrets.token_urlsafe(16) + _challenge_pool[cid] = {"answer": answer, "created_at": time.time(), "used": False} + return {"challenge_id": cid, "question": f"{a} {op} {b} = ?"} + + +def _verify_challenge(req: AuthCredentialRequest) -> tuple[bool, str]: + cid = (req.challenge_id or "").strip() + ans = (req.challenge_answer or "").strip() + if req.honeypot: + return False, "请求被拒绝" + if not cid or not ans: + return False, "请先完成人机校验" + item = _challenge_pool.get(cid) + if not item: + return False, "校验已失效,请刷新后重试" + if item.get("used"): + return False, "校验已使用,请刷新后重试" + age = time.time() - float(item.get("created_at") or 0) + if age < 2: + return False, "提交过快,请稍后重试" + if age > 300: + return False, "校验已过期,请刷新后重试" + if ans != str(item.get("answer") or ""): + return False, "人机校验答案错误" + item["used"] = True + return True, "" + + +def _validate_username_password(username: str, password: str) -> tuple[bool, str]: + if not USERNAME_RE.match(username): + return False, "用户名需为 4-24 位,仅支持字母/数字/下划线" + if not PASSWORD_STRONG_RE.match(password): + return False, "密码需 10-64 位,且包含大小写字母、数字和特殊字符" + if username.lower() in password.lower(): + return False, "密码不能包含用户名" + return True, "" + + def _platform_model_cfg() -> dict: return { "api_key": settings.platform_openai_api_key or "", @@ -99,7 +175,9 @@ def _platform_model_cfg() -> dict: def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]: vip = users.get_vip_status(user_id) - if prefer_vip and vip.get("vip_enabled") and int(vip.get("token_balance") or 0) > 0: + if prefer_vip and vip.get("vip_enabled"): + if int(vip.get("total_available_credits") or 0) <= 0: + return None, "vip_empty" cfg = _platform_model_cfg() if cfg.get("api_key"): return cfg, "vip" @@ -107,12 +185,114 @@ def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | Non return cfg, "user" +def _quota_detail() -> str: + return "Credits 额度已用完(席位额度+共享加油包),请充值或等待下个计费周期" + + +def _credits_from_cny(amount_cny: float) -> int: + package_credits = max(1, int(settings.credits_recharge_package_credits)) + package_amount = max(0.01, float(settings.credits_recharge_package_amount)) + cny = max(0.0, float(amount_cny)) + if cny <= 0: + return 0 + return max(1, int(math.ceil((cny * package_credits) / package_amount))) + + def _estimate_rewrite_cost(req: RewriteRequest, result) -> int: + trace = getattr(result, "trace", None) or {} + usage = trace.get("usage") if isinstance(trace, dict) else {} + usage_total_tokens = int((usage or {}).get("total_tokens") or 0) + if usage_total_tokens > 0: + token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny)) + return _credits_from_cny((usage_total_tokens / 1_000_000.0) * token_price_cny) src_chars = len((req.source_text or "").strip()) out_chars = len((result.body_markdown or "").strip()) + len((result.title or "").strip()) + len((result.summary or "").strip()) total_chars = max(1, src_chars + out_chars) - blocks = (total_chars + 999) // 1000 - return int(blocks * max(1, int(settings.vip_rewrite_token_per_1k_chars))) + estimated_tokens = int(total_chars * 1.8) + token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny)) + return _credits_from_cny((max(1, estimated_tokens) / 1_000_000.0) * token_price_cny) + + +def _estimate_image_cost(image_count: int) -> int: + cnt = max(0, int(image_count)) + if cnt <= 0: + return 0 + pkg_images = max(1, int(settings.credits_image_price_package_images or 160)) + pkg_cny = max(0.0, float(settings.credits_image_price_package_cny or 0.75)) + return _credits_from_cny((cnt / pkg_images) * pkg_cny) + + +def _new_order_no() -> str: + return f"RC{time.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}" + + +async def _create_recharge_order_with_pay_url( + user_id: int, + username: str, + credits: int, + callback_url: str, + order_meta: dict | None = None, + channel: str = "wechat", +) -> tuple[dict, str]: + package_credits = max(1, int(settings.credits_recharge_package_credits)) + package_amount = float(settings.credits_recharge_package_amount) + amount_cny = round((int(credits) / package_credits) * package_amount, 2) + order_no = _new_order_no() + order = users.create_recharge_order( + user_id=user_id, + order_no=order_no, + channel=(channel or "wechat").strip() or "wechat", + token_amount=int(credits), + amount_cny=amount_cny, + meta={"username": username, **(order_meta or {})}, + ) + pay_payload = { + "order_no": order_no, + "user_id": int(user_id), + "username": username, + "callback_url": callback_url, + **order, + } + pay_url = "" + if settings.shop_backend_create_order_url: + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post( + settings.shop_backend_create_order_url, + json=pay_payload, + headers={"X-Shop-Token": settings.shop_backend_callback_token or ""}, + ) + body = r.json() if r.content else {} + if r.status_code < 400 and isinstance(body, dict): + pay_url = str(body.get("pay_url") or "") + except Exception as exc: + logger.warning("shop_create_order_failed order=%s err=%s", order_no, exc) + return order, pay_url + + +async def _fetch_pay_url_for_order(order: dict, user_id: int, username: str, callback_url: str) -> str: + pay_payload = { + "order_no": order.get("order_no", ""), + "user_id": int(user_id), + "username": username, + "callback_url": callback_url, + **order, + } + pay_url = "" + if settings.shop_backend_create_order_url: + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post( + settings.shop_backend_create_order_url, + json=pay_payload, + headers={"X-Shop-Token": settings.shop_backend_callback_token or ""}, + ) + body = r.json() if r.content else {} + if r.status_code < 400 and isinstance(body, dict): + pay_url = str(body.get("pay_url") or "") + except Exception as exc: + logger.warning("shop_repay_order_failed order=%s err=%s", order.get("order_no", ""), exc) + return pay_url @app.get("/", response_class=HTMLResponse) @@ -140,6 +320,48 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name}) +@app.get("/billing", response_class=HTMLResponse) +async def billing_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/billing", status_code=302) + return templates.TemplateResponse( + "billing.html", + { + "request": request, + "app_name": settings.app_name, + "package_amount": settings.credits_recharge_package_amount, + "package_credits": settings.credits_recharge_package_credits, + }, + ) + + +@app.get("/upgrade", response_class=HTMLResponse) +async def upgrade_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/upgrade", status_code=302) + return templates.TemplateResponse( + "upgrade.html", + { + "request": request, + "app_name": settings.app_name, + "trial_tokens": settings.vip_trial_tokens, + "rewrite_cost": settings.credits_per_million_tokens, + "image_cost": settings.credits_per_120_images, + "seat_price": settings.credits_standard_seat_price_cny, + "seat_quota": settings.credits_seat_monthly_quota, + "package_amount": settings.credits_recharge_package_amount, + "package_credits": settings.credits_recharge_package_credits, + }, + ) + + +@app.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/profile", status_code=302) + return templates.TemplateResponse("profile.html", {"request": request, "app_name": settings.app_name}) + + @app.get("/guide", response_class=HTMLResponse) async def guide_page(request: Request): if not _current_user(request): @@ -197,14 +419,48 @@ async def auth_me(request: Request): } +@app.get("/api/profile") +async def api_profile(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + return {"ok": True, "profile": users.get_user_profile(user["id"])} + + +@app.post("/api/profile") +async def api_profile_update(req: UserProfileUpdateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + profile = users.save_user_profile( + user["id"], + subscriber_name=(req.subscriber_name or "").strip(), + subscriber_phone=(req.subscriber_phone or "").strip(), + shipping_address=(req.shipping_address or "").strip(), + ) + return {"ok": True, "detail": "个人信息已保存", "profile": profile} + + +@app.get("/api/auth/challenge") +async def auth_challenge(): + return {"ok": True, **_create_challenge()} + + @app.post("/api/auth/register") -async def auth_register(req: AuthCredentialRequest, response: Response): +async def auth_register(req: AuthCredentialRequest, request: Request, response: Response): + ip = _client_ip(request) + if _hit_limit(_register_rate, f"ip:{ip}", limit=8, window_sec=600): + return {"ok": False, "detail": "请求过于频繁,请稍后再试"} + if _hit_limit(_register_rate, f"user:{(req.username or '').strip().lower()}", limit=6, window_sec=600): + return {"ok": False, "detail": "该用户名操作过于频繁,请稍后再试"} username = (req.username or "").strip() password = req.password or "" - if len(username) < 2: - return {"ok": False, "detail": "用户名至少 2 个字符"} - if len(password) < 6: - return {"ok": False, "detail": "密码至少 6 个字符"} + ok, msg = _validate_username_password(username, password) + if not ok: + return {"ok": False, "detail": msg} + ok_challenge, challenge_msg = _verify_challenge(req) + if not ok_challenge: + return {"ok": False, "detail": challenge_msg} try: user = users.create_user(username, password) except Exception as exc: @@ -225,14 +481,19 @@ async def auth_register(req: AuthCredentialRequest, response: Response): ) return { "ok": True, - "detail": "注册并登录成功,已赠送试用 token,请保存重置码", + "detail": "注册并登录成功,已赠送试用 Credits,请保存重置码", "user": {"id": user["id"], "username": user["username"]}, "reset_code": user.get("reset_code", ""), } @app.post("/api/auth/login") -async def auth_login(req: AuthCredentialRequest, response: Response): +async def auth_login(req: AuthCredentialRequest, request: Request, response: Response): + ip = _client_ip(request) + if _hit_limit(_login_rate, f"ip:{ip}", limit=20, window_sec=600): + return {"ok": False, "detail": "登录过于频繁,请稍后再试"} + if _hit_limit(_login_rate, f"user:{(req.username or '').strip().lower()}", limit=12, window_sec=600): + return {"ok": False, "detail": "该账户登录尝试过多,请稍后再试"} try: user = users.verify_user((req.username or "").strip(), req.password or "") except Exception as exc: @@ -335,6 +596,217 @@ async def auth_delete_account(req: DeleteAccountRequest, request: Request, respo return {"ok": True, "detail": "账号已注销,关联数据已清空"} +@app.get("/api/auth/vip/status") +async def auth_vip_status(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + return {"ok": True, "vip": users.get_vip_status(user["id"])} + + +@app.post("/api/auth/vip/toggle") +async def auth_vip_toggle(req: VipToggleRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + vip = users.set_vip_enabled(user["id"], bool(req.enabled)) + return {"ok": True, "detail": "VIP 模式已更新", "vip": vip} + + +@app.post("/api/auth/vip/recharge") +async def auth_vip_recharge(req: VipRechargeRequest, request: Request): + token = (request.headers.get("X-Shop-Token") or "").strip() + if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token: + raise HTTPException(status_code=403, detail="直充接口已禁用,请走支付订单接口") + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + vip = users.recharge_tokens( + user["id"], + int(req.tokens), + kind="admin_direct_recharge", + ref_type="admin", + ref_id="", + detail={"source": "legacy_api"}, + ) + return {"ok": True, "detail": "充值成功", "vip": vip} + + +@app.get("/api/billing/overview") +async def billing_overview(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + return { + "ok": True, + "vip": users.get_vip_status(user["id"]), + "recharge_records": users.list_recharge_orders(user["id"], limit=30), + "consume_records": users.list_token_ledger(user["id"], limit=100), + } + + +@app.post("/api/billing/recharge/create") +async def billing_recharge_create(req: BillingRechargeCreateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + order, pay_url = await _create_recharge_order_with_pay_url( + user_id=user["id"], + username=user["username"], + credits=int(req.tokens), + callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall", + order_meta={ + "subscriber_name": (req.subscriber_name or "").strip(), + "subscriber_phone": (req.subscriber_phone or "").strip(), + "shipping_address": (req.shipping_address or "").strip(), + }, + channel=(req.channel or "wechat").strip() or "wechat", + ) + return {"ok": True, "detail": "充值订单已创建", "order": order, "pay_url": pay_url} + + +@app.post("/api/billing/recharge/pay-now") +async def billing_recharge_pay_now(req: BillingPayNowRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + order_no = (req.order_no or "").strip() + if not order_no: + return {"ok": False, "detail": "订单号不能为空"} + order = users.get_recharge_order(user["id"], order_no) + if not order: + return {"ok": False, "detail": "订单不存在"} + status = (order.get("status") or "").lower() + if status in {"paid", "success"}: + return {"ok": False, "detail": "订单已支付,无需重复支付", "order": order} + if status in {"cancelled", "closed"}: + return {"ok": False, "detail": "订单已取消,请重新创建订单", "order": order} + pay_url = await _fetch_pay_url_for_order( + order=order, + user_id=user["id"], + username=user["username"], + callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall", + ) + return {"ok": True, "detail": "支付链接已生成", "order": order, "pay_url": pay_url} + + +@app.get("/api/pay/wechat/") +async def pay_wechat_ready(): + return {"ok": True, "detail": "wechat pay api ready"} + + +@app.post("/api/pay/wechat/") +async def pay_wechat_create(req: BillingRechargeCreateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + order, pay_url = await _create_recharge_order_with_pay_url( + user_id=user["id"], + username=user["username"], + credits=int(req.tokens), + callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall", + order_meta={ + "subscriber_name": (req.subscriber_name or "").strip(), + "subscriber_phone": (req.subscriber_phone or "").strip(), + "shipping_address": (req.shipping_address or "").strip(), + }, + channel=(req.channel or "wechat").strip() or "wechat", + ) + return {"ok": True, "detail": "支付订单已创建", "order": order, "pay_url": pay_url} + + +@app.get("/api/pay/address/suggest") +async def pay_address_suggest(request: Request): + ip = _client_ip(request) + if not ip or ip in {"unknown", "127.0.0.1", "::1"}: + return {"ok": True, "detail": "本地环境,建议手动填写地址", "ip": ip or "unknown", "address": ""} + url = f"https://ip-api.com/json/{ip}?lang=zh-CN" + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(url) + body = r.json() if r.content else {} + if r.status_code >= 400 or not isinstance(body, dict): + return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} + if body.get("status") != "success": + return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} + country = str(body.get("country") or "").strip() + region = str(body.get("regionName") or "").strip() + city = str(body.get("city") or "").strip() + isp = str(body.get("isp") or "").strip() + addr = "".join([x for x in [country, region, city] if x]) + if isp: + addr = f"{addr}({isp})" if addr else isp + if not addr: + addr = f"IP:{ip}" + return {"ok": True, "detail": "已根据IP自动识别地址并填充,可手动修改", "ip": ip, "address": addr} + except Exception: + return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} + + +@app.post("/api/billing/recharge/notify") +async def billing_recharge_notify(req: BillingRechargeNotifyRequest, request: Request): + token = (request.headers.get("X-Shop-Token") or "").strip() + if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token: + raise HTTPException(status_code=403, detail="forbidden") + user = _require_user(request) + uid = user["id"] if user else 0 + # 回调优先根据登录态用户确认;若无登录态,可通过订单所属用户在 DB 内判断 + if uid <= 0: + # 无用户态时,先查订单归属(通过 mark 接口会二次校验) + uid = -1 + if (req.status or "").lower() not in {"paid", "success"}: + return {"ok": True, "detail": "ignored"} + if uid > 0: + ok, msg = users.mark_recharge_order_paid( + uid, + req.order_no, + paid_amount_cny=float(req.paid_amount_cny or 0.0), + external_txn_id=(req.external_txn_id or "").strip(), + meta={"status": req.status}, + ) + if not ok: + return {"ok": False, "detail": msg} + return {"ok": True, "detail": msg} + # no session callback path: iterate known users is unavailable; use direct sql in store by probing order owner + # fallback: let store resolve user by order internally via new helper-like behavior + owner_id = users.get_recharge_order_user_id(req.order_no) + if not owner_id: + return {"ok": False, "detail": "订单不存在"} + ok, msg = users.mark_recharge_order_paid( + int(owner_id), + req.order_no, + paid_amount_cny=float(req.paid_amount_cny or 0.0), + external_txn_id=(req.external_txn_id or "").strip(), + meta={"status": req.status}, + ) + if not ok: + return {"ok": False, "detail": msg} + return {"ok": True, "detail": msg} + + +@app.post("/api/pay/wechat/backcall") +async def pay_wechat_backcall(req: BillingRechargeNotifyRequest, request: Request): + configured = (settings.shop_backend_callback_token or "").strip() + provided = (request.headers.get("X-Shop-Token") or request.query_params.get("token") or "").strip() + if configured and provided != configured: + raise HTTPException(status_code=403, detail="forbidden") + if (req.status or "").lower() not in {"paid", "success"}: + return {"ok": True, "detail": "ignored"} + owner_id = users.get_recharge_order_user_id(req.order_no) + if not owner_id: + return {"ok": False, "detail": "订单不存在"} + ok, msg = users.mark_recharge_order_paid( + int(owner_id), + req.order_no, + paid_amount_cny=float(req.paid_amount_cny or 0.0), + external_txn_id=(req.external_txn_id or "").strip(), + meta={"status": req.status, "source": "wechat_backcall"}, + ) + if not ok: + return {"ok": False, "detail": msg} + return {"ok": True, "detail": msg} + + @app.post("/api/auth/wechat/bind") async def auth_wechat_bind(req: WechatBindingRequest, request: Request): user = _require_user(request) @@ -425,6 +897,20 @@ async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request): return {"ok": True, "detail": "模型配置已删除"} +@app.post("/api/auth/ai-models/image-model/update") +async def auth_ai_image_model_update(req: AIImageModelUpdateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + image_model = (req.image_model or "").strip() + if not image_model: + return {"ok": False, "detail": "文生图模型不能为空"} + ok = users.update_active_ai_image_model(user["id"], image_model) + if not ok: + return {"ok": False, "detail": "保存失败:请先配置文本模型"} + return {"ok": True, "detail": "文生图模型已更新"} + + @app.post("/api/rewrite") async def rewrite(req: RewriteRequest, request: Request): rid = getattr(request.state, "request_id", "") @@ -444,7 +930,9 @@ async def rewrite(req: RewriteRequest, request: Request): user = _require_user(request) if not user: raise HTTPException(status_code=401, detail="请先登录") - model_cfg = users.get_active_ai_model(user["id"]) + model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True) + if model_source == "vip_empty": + raise HTTPException(status_code=402, detail=_quota_detail()) if not model_cfg: raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型") backup = { @@ -472,6 +960,50 @@ async def rewrite(req: RewriteRequest, request: Request): settings.openai_max_output_tokens = backup["openai_max_output_tokens"] settings.openai_max_retries = backup["openai_max_retries"] settings.openai_image_model = backup["openai_image_model"] + usage = ((result.trace or {}).get("usage") or {}) if isinstance(result.trace, dict) else {} + prompt_tokens = int(usage.get("prompt_tokens") or 0) + completion_tokens = int(usage.get("completion_tokens") or 0) + total_tokens = int(usage.get("total_tokens") or 0) + billed_basis = "usage_tokens" if total_tokens > 0 else "char_estimate" + token_cost = _estimate_rewrite_cost(req, result) + vip_status = users.get_vip_status(user["id"]) + should_consume = bool(vip_status.get("vip_enabled")) + if should_consume: + ok_cost, balance = users.consume_tokens( + user["id"], + token_cost, + kind="rewrite", + ref_type="request", + ref_id=rid, + detail={ + "source_chars": len((req.source_text or "").strip()), + "target_body_chars": int(req.target_body_chars or 0), + "title_chars": len((result.title or "").strip()), + "summary_chars": len((result.summary or "").strip()), + "body_chars": len((result.body_markdown or "").strip()), + "model": model_cfg.get("model") if model_cfg else "", + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "credits_rule": ( + f"1000000 tokens={float(settings.credits_token_price_per_million_cny):.2f}元," + f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元," + f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元" + ), + "billed_basis": billed_basis, + }, + ) + if not ok_cost: + raise HTTPException(status_code=402, detail=_quota_detail()) + if result.trace is None: + result.trace = {} + result.trace["vip"] = { + "model_source": "platform" if model_source == "vip" else "user", + "credits_cost": token_cost, + "credits_balance": balance, + "billed_basis": billed_basis, + "total_tokens": total_tokens, + } tr = result.trace or {} logger.info( "api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s", @@ -599,7 +1131,10 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques len(req.summary or ""), req.upload_to_wechat, ) - model_cfg = users.get_active_ai_model(user["id"]) + model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True) + if model_source == "vip_empty": + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + image_model_override = (req.image_model or "").strip() backup = { "openai_api_key": settings.openai_api_key, "openai_base_url": settings.openai_base_url, @@ -626,6 +1161,8 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques settings.openai_max_output_tokens = 8192 settings.openai_max_retries = 0 settings.openai_image_model = backup["openai_image_model"] + if image_model_override: + settings.openai_image_model = image_model_override out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding) finally: settings.openai_api_key = backup["openai_api_key"] @@ -643,6 +1180,31 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques out.note, len(out.warnings), ) + if out.ok and model_source == "vip": + token_cost = _estimate_image_cost(1) + ok_cost, balance = users.consume_tokens( + user["id"], + token_cost, + kind="cover_generate", + ref_type="request", + ref_id=rid, + detail={ + "title": (req.title or "")[:120], + "style_hint": (req.style_hint or "")[:120], + "image_count": 1, + "image_model": image_model_override or settings.openai_image_model, + "image_price_package_cny": float(settings.credits_image_price_package_cny), + "image_price_package_images": int(settings.credits_image_price_package_images), + "credits_rule": ( + f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元," + f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元" + ), + }, + ) + if not ok_cost: + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + out.warnings = list(out.warnings or []) + out.warnings.append(f"已扣减 {token_cost} Credits,可用余额 {balance}") return out @@ -684,7 +1246,10 @@ async def generate_posters(req: PosterGenerateRequest, request: Request): req.upload_to_wechat, int(req.max_images or 0), ) - model_cfg = users.get_active_ai_model(user["id"]) + model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True) + if model_source == "vip_empty": + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + image_model_override = (req.image_model or "").strip() backup = { "openai_api_key": settings.openai_api_key, "openai_base_url": settings.openai_base_url, @@ -711,6 +1276,8 @@ async def generate_posters(req: PosterGenerateRequest, request: Request): settings.openai_max_output_tokens = 8192 settings.openai_max_retries = 0 settings.openai_image_model = backup["openai_image_model"] + if image_model_override: + settings.openai_image_model = image_model_override out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding) finally: settings.openai_api_key = backup["openai_api_key"] @@ -727,6 +1294,32 @@ async def generate_posters(req: PosterGenerateRequest, request: Request): len(out.posters), len(out.warnings), ) + if out.ok and model_source == "vip": + image_count = max(0, len(out.posters or [])) + token_cost = _estimate_image_cost(image_count) + ok_cost, balance = users.consume_tokens( + user["id"], + token_cost, + kind="poster_generate", + ref_type="request", + ref_id=rid, + detail={ + "image_count": image_count, + "body_chars": len((req.body_markdown or "").strip()), + "max_images": int(req.max_images or 0), + "image_model": image_model_override or settings.openai_image_model, + "image_price_package_cny": float(settings.credits_image_price_package_cny), + "image_price_package_images": int(settings.credits_image_price_package_images), + "credits_rule": ( + f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元," + f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元" + ), + }, + ) + if not ok_cost: + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + out.warnings = list(out.warnings or []) + out.warnings.append(f"已扣减 {token_cost} Credits,可用余额 {balance}") return out diff --git a/app/schemas.py b/app/schemas.py index 2ca6ba4..68382c9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -54,6 +54,9 @@ class AuthCredentialRequest(BaseModel): username: str password: str remember_me: bool = False + challenge_id: str = "" + challenge_answer: str = "" + honeypot: str = "" class ChangePasswordRequest(BaseModel): @@ -97,6 +100,7 @@ class WechatCoverGenerateRequest(BaseModel): title: str = "" summary: str = "" style_hint: str = "" + image_model: str = "" upload_to_wechat: bool = True @@ -119,12 +123,42 @@ class AIModelDeleteRequest(BaseModel): model_id: int +class AIImageModelUpdateRequest(BaseModel): + image_model: str + + class VipToggleRequest(BaseModel): enabled: bool = True class VipRechargeRequest(BaseModel): - tokens: int = Field(default=10000, ge=1, le=10_000_000) + tokens: int = Field(default=1500, ge=1, le=10_000_000) + + +class BillingRechargeCreateRequest(BaseModel): + tokens: int = Field(default=1500, ge=1, le=10_000_000) + amount_cny: float = Field(default=19.9, ge=0.01, le=999999) + channel: str = "wechat" + subscriber_name: str = "" + subscriber_phone: str = "" + shipping_address: str = "" + + +class BillingRechargeNotifyRequest(BaseModel): + order_no: str + paid_amount_cny: float = Field(default=0.0, ge=0.0) + external_txn_id: str = "" + status: str = "paid" + + +class BillingPayNowRequest(BaseModel): + order_no: str + + +class UserProfileUpdateRequest(BaseModel): + subscriber_name: str = "" + subscriber_phone: str = "" + shipping_address: str = "" class PosterGenerateRequest(BaseModel): @@ -132,6 +166,7 @@ class PosterGenerateRequest(BaseModel): summary: str = "" body_markdown: str = Field(..., min_length=20) style_hint: str = "" + image_model: str = "" upload_to_wechat: bool = True max_images: int = Field(default=6, ge=1, le=12) diff --git a/app/services/ai_rewriter.py b/app/services/ai_rewriter.py index 18dd5f8..8ddb12f 100644 --- a/app/services/ai_rewriter.py +++ b/app/services/ai_rewriter.py @@ -108,6 +108,10 @@ class AIRewriter: def __init__(self) -> None: self._client = None self._prefer_chat_first = False + self._usage_prompt_tokens = 0 + self._usage_completion_tokens = 0 + self._usage_total_tokens = 0 + self._usage_calls = 0 if settings.openai_api_key: base_url = settings.openai_base_url or "" self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url @@ -128,6 +132,22 @@ class AIRewriter: else: logger.warning("AIRewriter_init openai_key_missing=1 rewrite_will_use_fallback_only=1") + def _accumulate_usage(self, usage: Any) -> None: + if usage is None: + return + data = usage.model_dump() if hasattr(usage, "model_dump") else usage + if not isinstance(data, dict): + return + prompt = int(data.get("prompt_tokens") or data.get("input_tokens") or 0) + completion = int(data.get("completion_tokens") or data.get("output_tokens") or 0) + total = int(data.get("total_tokens") or 0) + if total <= 0: + total = max(0, prompt + completion) + self._usage_prompt_tokens += max(0, prompt) + self._usage_completion_tokens += max(0, completion) + self._usage_total_tokens += max(0, total) + self._usage_calls += 1 + def rewrite(self, req: RewriteRequest, request_id: str = "") -> RewriteResponse: cleaned_source = self._clean_source(req.source_text) started = time.monotonic() @@ -256,6 +276,12 @@ class AIRewriter: ) trace["quality_issues_final"] = final_issues if not final_issues: + trace["usage"] = { + "prompt_tokens": int(self._usage_prompt_tokens), + "completion_tokens": int(self._usage_completion_tokens), + "total_tokens": int(self._usage_total_tokens), + "model_calls": int(self._usage_calls), + } trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1) trace["mode"] = "ai" logger.info( @@ -266,6 +292,12 @@ class AIRewriter: return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace) # 模型已返回有效 JSON:默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes,避免误用模板稿 if settings.ai_soft_accept and self._model_output_usable(normalized): + trace["usage"] = { + "prompt_tokens": int(self._usage_prompt_tokens), + "completion_tokens": int(self._usage_completion_tokens), + "total_tokens": int(self._usage_total_tokens), + "model_calls": int(self._usage_calls), + } trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1) trace["mode"] = "ai" trace["quality_soft_accept"] = True @@ -669,6 +701,7 @@ class AIRewriter: msg = (choice.message.content if choice else "") or "" fr = getattr(choice, "finish_reason", None) if choice else None usage = getattr(completion, "usage", None) + self._accumulate_usage(usage) udump = ( usage.model_dump() if usage is not None and hasattr(usage, "model_dump") @@ -755,6 +788,7 @@ class AIRewriter: text={"format": {"type": "json_object"}}, timeout=timeout_sec, ) + self._accumulate_usage(getattr(completion, "usage", None)) output_text = completion.output_text or "" ms = (time.monotonic() - t0) * 1000 logger.info( diff --git a/app/services/user_store.py b/app/services/user_store.py index 3fe6551..589fba7 100644 --- a/app/services/user_store.py +++ b/app/services/user_store.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import hmac +import json import secrets import sqlite3 import time @@ -69,6 +70,12 @@ class UserStore: pref_cols = self._table_columns(c, "user_prefs") if "active_ai_model_id" not in pref_cols: c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER") + if "subscriber_name" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_name TEXT NOT NULL DEFAULT ''") + if "subscriber_phone" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_phone TEXT NOT NULL DEFAULT ''") + if "shipping_address" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN shipping_address TEXT NOT NULL DEFAULT ''") c.execute( """ CREATE TABLE IF NOT EXISTS ai_models ( @@ -95,11 +102,62 @@ class UserStore: vip_enabled INTEGER NOT NULL DEFAULT 0, token_balance INTEGER NOT NULL DEFAULT 0, total_consumed_tokens INTEGER NOT NULL DEFAULT 0, + seat_quota_credits INTEGER NOT NULL DEFAULT 1500, + seat_used_credits INTEGER NOT NULL DEFAULT 0, + seat_cycle TEXT NOT NULL DEFAULT '', + cycle_started_at INTEGER NOT NULL DEFAULT 0, + cycle_expires_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ) """ ) + wallet_cols = self._table_columns(c, "user_wallets") + if "seat_quota_credits" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN seat_quota_credits INTEGER NOT NULL DEFAULT 25000") + if "seat_used_credits" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN seat_used_credits INTEGER NOT NULL DEFAULT 0") + if "seat_cycle" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN seat_cycle TEXT NOT NULL DEFAULT ''") + if "cycle_started_at" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_started_at INTEGER NOT NULL DEFAULT 0") + if "cycle_expires_at" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_expires_at INTEGER NOT NULL DEFAULT 0") + c.execute( + """ + CREATE TABLE IF NOT EXISTS recharge_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + order_no TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL DEFAULT '', + token_amount INTEGER NOT NULL DEFAULT 0, + amount_cny REAL NOT NULL DEFAULT 0.0, + status TEXT NOT NULL DEFAULT 'pending', + external_txn_id TEXT NOT NULL DEFAULT '', + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + paid_at INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) + c.execute( + """ + CREATE TABLE IF NOT EXISTS token_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + direction TEXT NOT NULL, + token_change INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT '', + ref_type TEXT NOT NULL DEFAULT '', + ref_id TEXT NOT NULL DEFAULT '', + detail_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) ai_cols = self._table_columns(c, "ai_models") if "image_model" not in ai_cols: c.execute("ALTER TABLE ai_models ADD COLUMN image_model TEXT NOT NULL DEFAULT ''") @@ -438,6 +496,57 @@ class UserStore: return None return {"id": int(row["id"]), "username": row["username"]} + def get_user_profile(self, user_id: int) -> dict: + with self._conn() as c: + row = c.execute( + """ + SELECT subscriber_name, subscriber_phone, shipping_address + FROM user_prefs + WHERE user_id=? + LIMIT 1 + """, + (user_id,), + ).fetchone() + if not row: + return {"subscriber_name": "", "subscriber_phone": "", "shipping_address": ""} + return { + "subscriber_name": (row["subscriber_name"] or "").strip(), + "subscriber_phone": (row["subscriber_phone"] or "").strip(), + "shipping_address": (row["shipping_address"] or "").strip(), + } + + def save_user_profile( + self, + user_id: int, + *, + subscriber_name: str, + subscriber_phone: str, + shipping_address: str, + ) -> dict: + now = int(time.time()) + with self._conn() as c: + c.execute( + """ + INSERT INTO user_prefs( + user_id, active_wechat_account_id, active_ai_model_id, + subscriber_name, subscriber_phone, shipping_address, updated_at + ) VALUES (?, NULL, NULL, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + subscriber_name=excluded.subscriber_name, + subscriber_phone=excluded.subscriber_phone, + shipping_address=excluded.shipping_address, + updated_at=excluded.updated_at + """, + ( + user_id, + (subscriber_name or "").strip(), + (subscriber_phone or "").strip(), + (shipping_address or "").strip(), + now, + ), + ) + return self.get_user_profile(user_id) + def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool: now = int(time.time()) with self._conn() as c: @@ -464,6 +573,8 @@ class UserStore: c.execute("DELETE FROM user_prefs WHERE user_id=?", (user_id,)) c.execute("DELETE FROM wechat_bindings WHERE user_id=?", (user_id,)) c.execute("DELETE FROM user_wallets WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM recharge_orders WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM token_ledger WHERE user_id=?", (user_id,)) c.execute( "UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?", (now, str(now), user_id), @@ -472,14 +583,81 @@ class UserStore: def _ensure_wallet_row(self, c: sqlite3.Connection, user_id: int) -> None: now = int(time.time()) + cycle = time.strftime("%Y-%m", time.localtime(now)) c.execute( """ - INSERT OR IGNORE INTO user_wallets(user_id, vip_enabled, token_balance, total_consumed_tokens, updated_at) - VALUES (?, 0, 0, 0, ?) + INSERT OR IGNORE INTO user_wallets( + user_id, vip_enabled, token_balance, total_consumed_tokens, + seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at + ) + VALUES (?, 0, 0, 0, 1500, 0, ?, 0, 0, ?) """, - (user_id, now), + (user_id, cycle, now), ) + def _refresh_billing_cycle(self, c: sqlite3.Connection, user_id: int) -> None: + now = int(time.time()) + row = c.execute( + "SELECT seat_cycle, cycle_expires_at, token_balance, seat_used_credits FROM user_wallets WHERE user_id=?", + (user_id,), + ).fetchone() + current_cycle = (row["seat_cycle"] or "") if row else "" + expires_at = int(row["cycle_expires_at"] or 0) if row else 0 + if expires_at > 0 and now >= expires_at: + c.execute( + """ + UPDATE user_wallets + SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=? + WHERE user_id=? + """, + (now, user_id), + ) + return + # 兼容历史按自然月 seat_cycle 的老数据:若没有新周期字段,保留原行为 + if expires_at <= 0: + paid = c.execute( + """ + SELECT paid_at + FROM recharge_orders + WHERE user_id=? AND status='paid' AND paid_at IS NOT NULL + ORDER BY paid_at DESC, id DESC + LIMIT 1 + """, + (user_id,), + ).fetchone() + if paid and int(paid["paid_at"] or 0) > 0: + start_at = int(paid["paid_at"]) + new_expires = start_at + 30 * 24 * 3600 + if now >= new_expires: + c.execute( + """ + UPDATE user_wallets + SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=? + WHERE user_id=? + """, + (now, user_id), + ) + else: + c.execute( + """ + UPDATE user_wallets + SET seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=? + WHERE user_id=? + """, + (time.strftime("%Y-%m", time.localtime(start_at)), start_at, new_expires, now, user_id), + ) + return + cycle = time.strftime("%Y-%m", time.localtime(now)) + if current_cycle != cycle: + c.execute( + """ + UPDATE user_wallets + SET seat_used_credits=0, seat_cycle=?, updated_at=? + WHERE user_id=? + """, + (cycle, now, user_id), + ) + def ensure_trial_tokens(self, user_id: int, trial_tokens: int) -> dict: amount = max(0, int(trial_tokens)) now = int(time.time()) @@ -491,31 +669,63 @@ class UserStore: ).fetchone() current = int(row["token_balance"] or 0) if row else 0 if current <= 0 and amount > 0: + new_balance = amount c.execute( """ UPDATE user_wallets SET vip_enabled=1, token_balance=?, updated_at=? WHERE user_id=? """, - (amount, now, user_id), + (new_balance, now, user_id), + ) + c.execute( + """ + INSERT INTO token_ledger( + user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + ) VALUES (?, 'in', ?, ?, 'trial_grant', 'system', '', '{}', ?) + """, + (user_id, amount, new_balance, now), ) return self.get_vip_status(user_id) def get_vip_status(self, user_id: int) -> dict: with self._conn() as c: self._ensure_wallet_row(c, user_id) + self._refresh_billing_cycle(c, user_id) row = c.execute( """ - SELECT vip_enabled, token_balance, total_consumed_tokens, updated_at + SELECT + vip_enabled, token_balance, total_consumed_tokens, + seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at FROM user_wallets WHERE user_id=? """, (user_id,), ).fetchone() + seat_quota = int(row["seat_quota_credits"] or 0) if row else 0 + seat_used = int(row["seat_used_credits"] or 0) if row else 0 + seat_remaining = max(0, seat_quota - seat_used) + shared_credits = int(row["token_balance"] or 0) if row else 0 + cycle_started_at = int(row["cycle_started_at"] or 0) if row else 0 + cycle_expires_at = int(row["cycle_expires_at"] or 0) if row else 0 + now = int(time.time()) + cycle_active = cycle_expires_at > now if cycle_expires_at > 0 else True + if not cycle_active: + seat_remaining = 0 + shared_credits = 0 return { "vip_enabled": bool(int(row["vip_enabled"] or 0)) if row else False, - "token_balance": int(row["token_balance"] or 0) if row else 0, + "token_balance": shared_credits, "total_consumed_tokens": int(row["total_consumed_tokens"] or 0) if row else 0, + "seat_quota_credits": seat_quota, + "seat_used_credits": seat_used, + "seat_remaining_credits": seat_remaining, + "shared_credits": shared_credits, + "total_available_credits": seat_remaining + shared_credits, + "seat_cycle": (row["seat_cycle"] or "") if row else "", + "cycle_started_at": cycle_started_at, + "cycle_expires_at": cycle_expires_at, + "cycle_active": cycle_active, "updated_at": int(row["updated_at"] or 0) if row else 0, } @@ -529,45 +739,328 @@ class UserStore: ) return self.get_vip_status(user_id) - def recharge_tokens(self, user_id: int, tokens: int) -> dict: + def recharge_tokens( + self, + user_id: int, + tokens: int, + *, + kind: str = "manual_recharge", + ref_type: str = "", + ref_id: str = "", + detail: dict | None = None, + cycle_start_at: int | None = None, + cycle_days: int = 30, + ) -> dict: add = max(0, int(tokens)) now = int(time.time()) with self._conn() as c: self._ensure_wallet_row(c, user_id) + self._refresh_billing_cycle(c, user_id) + row = c.execute("SELECT token_balance FROM user_wallets WHERE user_id=?", (user_id,)).fetchone() + prev = int(row["token_balance"] or 0) if row else 0 + new_balance = prev + add + start_at = int(cycle_start_at or 0) + if start_at > 0: + expires_at = start_at + max(1, int(cycle_days)) * 24 * 3600 + c.execute( + """ + UPDATE user_wallets + SET token_balance=token_balance + ?, vip_enabled=1, seat_used_credits=0, seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=? + WHERE user_id=? + """, + (add, time.strftime("%Y-%m", time.localtime(start_at)), start_at, expires_at, now, user_id), + ) + else: + c.execute( + """ + UPDATE user_wallets + SET token_balance=token_balance + ?, vip_enabled=1, updated_at=? + WHERE user_id=? + """, + (add, now, user_id), + ) c.execute( """ - UPDATE user_wallets - SET token_balance=token_balance + ?, vip_enabled=1, updated_at=? - WHERE user_id=? + INSERT INTO token_ledger( + user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + ) VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?) """, - (add, now, user_id), + ( + user_id, + add, + new_balance, + kind, + ref_type, + ref_id or "", + json.dumps(detail or {}, ensure_ascii=True), + now, + ), ) return self.get_vip_status(user_id) - def consume_tokens(self, user_id: int, tokens: int) -> tuple[bool, int]: + def consume_tokens( + self, + user_id: int, + tokens: int, + *, + kind: str = "usage", + ref_type: str = "", + ref_id: str = "", + detail: dict | None = None, + ) -> tuple[bool, int]: cost = max(0, int(tokens)) now = int(time.time()) with self._conn() as c: self._ensure_wallet_row(c, user_id) + self._refresh_billing_cycle(c, user_id) row = c.execute( - "SELECT token_balance FROM user_wallets WHERE user_id=?", + "SELECT token_balance, seat_quota_credits, seat_used_credits FROM user_wallets WHERE user_id=?", (user_id,), ).fetchone() - balance = int(row["token_balance"] or 0) if row else 0 + shared_balance = int(row["token_balance"] or 0) if row else 0 + seat_quota = int(row["seat_quota_credits"] or 0) if row else 0 + seat_used = int(row["seat_used_credits"] or 0) if row else 0 + seat_remaining = max(0, seat_quota - seat_used) if cost <= 0: - return True, balance - if balance < cost: - return False, balance - new_balance = balance - cost + return True, seat_remaining + shared_balance + use_from_seat = min(seat_remaining, cost) + need_shared = cost - use_from_seat + if shared_balance < need_shared: + return False, seat_remaining + shared_balance + new_shared = shared_balance - need_shared + new_seat_used = seat_used + use_from_seat c.execute( """ UPDATE user_wallets - SET token_balance=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=? + SET token_balance=?, seat_used_credits=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=? WHERE user_id=? """, - (new_balance, cost, now, user_id), + (new_shared, new_seat_used, cost, now, user_id), ) - return True, new_balance + c.execute( + """ + INSERT INTO token_ledger( + user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + ) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + cost, + max(0, seat_quota - new_seat_used) + new_shared, + kind, + ref_type, + ref_id or "", + json.dumps( + { + **(detail or {}), + "credit_source": {"seat": use_from_seat, "shared": need_shared}, + }, + ensure_ascii=True, + ), + now, + ), + ) + return True, max(0, seat_quota - new_seat_used) + new_shared + + def create_recharge_order( + self, + user_id: int, + order_no: str, + channel: str, + token_amount: int, + amount_cny: float, + meta: dict | None = None, + ) -> dict: + now = int(time.time()) + with self._conn() as c: + c.execute( + """ + INSERT INTO recharge_orders( + user_id, order_no, channel, token_amount, amount_cny, status, external_txn_id, meta_json, created_at, paid_at + ) VALUES (?, ?, ?, ?, ?, 'pending', '', ?, ?, NULL) + """, + ( + user_id, + order_no, + channel or "", + int(token_amount), + float(amount_cny), + json.dumps(meta or {}, ensure_ascii=True), + now, + ), + ) + return { + "order_no": order_no, + "channel": channel, + "token_amount": int(token_amount), + "amount_cny": float(amount_cny), + "status": "pending", + "created_at": now, + } + + def mark_recharge_order_paid( + self, + user_id: int, + order_no: str, + paid_amount_cny: float, + external_txn_id: str = "", + meta: dict | None = None, + ) -> tuple[bool, str]: + now = int(time.time()) + with self._conn() as c: + row = c.execute( + """ + SELECT user_id, token_amount, amount_cny, status + FROM recharge_orders + WHERE order_no=? + """, + (order_no,), + ).fetchone() + if not row: + return False, "订单不存在" + if int(row["user_id"]) != int(user_id): + return False, "订单无权限" + if (row["status"] or "") == "paid": + return True, "already_paid" + if float(paid_amount_cny or 0.0) + 1e-9 < float(row["amount_cny"] or 0.0): + return False, "支付金额不足" + c.execute( + """ + UPDATE recharge_orders + SET status='paid', external_txn_id=?, paid_at=?, meta_json=? + WHERE order_no=? + """, + ( + external_txn_id or "", + now, + json.dumps(meta or {}, ensure_ascii=True), + order_no, + ), + ) + self.recharge_tokens( + user_id, + int(row["token_amount"] or 0), + kind="paid_recharge", + ref_type="order", + ref_id=order_no, + detail={"paid_amount_cny": float(paid_amount_cny or 0.0), "external_txn_id": external_txn_id or ""}, + cycle_start_at=now, + cycle_days=30, + ) + return True, "ok" + + def list_recharge_orders(self, user_id: int, limit: int = 50) -> list[dict]: + with self._conn() as c: + now = int(time.time()) + expire_before = now - 15 * 60 + c.execute( + """ + UPDATE recharge_orders + SET status='cancelled' + WHERE user_id=? AND status='pending' AND created_at<=? + """, + (user_id, expire_before), + ) + rows = c.execute( + """ + SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json + FROM recharge_orders + WHERE user_id=? + ORDER BY id DESC + LIMIT ? + """, + (user_id, max(1, min(int(limit), 200))), + ).fetchall() + return [ + { + "order_no": r["order_no"] or "", + "channel": r["channel"] or "", + "token_amount": int(r["token_amount"] or 0), + "amount_cny": float(r["amount_cny"] or 0.0), + "status": r["status"] or "", + "external_txn_id": r["external_txn_id"] or "", + "created_at": int(r["created_at"] or 0), + "paid_at": int(r["paid_at"] or 0) if r["paid_at"] else None, + "meta": json.loads(r["meta_json"] or "{}"), + } + for r in rows + ] + + def get_recharge_order(self, user_id: int, order_no: str) -> dict | None: + now = int(time.time()) + with self._conn() as c: + expire_before = now - 15 * 60 + c.execute( + """ + UPDATE recharge_orders + SET status='cancelled' + WHERE user_id=? AND status='pending' AND created_at<=? + """, + (user_id, expire_before), + ) + row = c.execute( + """ + SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json + FROM recharge_orders + WHERE user_id=? AND order_no=? + LIMIT 1 + """, + (user_id, order_no), + ).fetchone() + if not row: + return None + try: + meta = json.loads(row["meta_json"] or "{}") + except Exception: + meta = {} + return { + "order_no": row["order_no"] or "", + "channel": row["channel"] or "", + "token_amount": int(row["token_amount"] or 0), + "amount_cny": float(row["amount_cny"] or 0.0), + "status": row["status"] or "", + "external_txn_id": row["external_txn_id"] or "", + "created_at": int(row["created_at"] or 0), + "paid_at": int(row["paid_at"] or 0) if row["paid_at"] else None, + "meta": meta, + } + + def get_recharge_order_user_id(self, order_no: str) -> int | None: + with self._conn() as c: + row = c.execute("SELECT user_id FROM recharge_orders WHERE order_no=?", (order_no,)).fetchone() + return int(row["user_id"]) if row and row["user_id"] else None + + def list_token_ledger(self, user_id: int, limit: int = 100) -> list[dict]: + with self._conn() as c: + rows = c.execute( + """ + SELECT direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + FROM token_ledger + WHERE user_id=? + ORDER BY id DESC + LIMIT ? + """, + (user_id, max(1, min(int(limit), 500))), + ).fetchall() + out: list[dict] = [] + for r in rows: + try: + detail = json.loads(r["detail_json"] or "{}") + except Exception: + detail = {} + out.append( + { + "direction": r["direction"] or "", + "token_change": int(r["token_change"] or 0), + "balance_after": int(r["balance_after"] or 0), + "kind": r["kind"] or "", + "ref_type": r["ref_type"] or "", + "ref_id": r["ref_id"] or "", + "detail": detail, + "created_at": int(r["created_at"] or 0), + } + ) + return out def save_wechat_binding( self, @@ -887,6 +1380,41 @@ class UserStore: ) return True + def update_active_ai_image_model(self, user_id: int, image_model: str) -> bool: + now = int(time.time()) + name = (image_model or "").strip() + with self._conn() as c: + pref = c.execute( + "SELECT active_ai_model_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + aid = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None + if not aid: + row = c.execute( + "SELECT id FROM ai_models WHERE user_id=? ORDER BY updated_at DESC, id DESC LIMIT 1", + (user_id,), + ).fetchone() + aid = int(row["id"]) if row else None + if not aid: + return False + c.execute( + "UPDATE ai_models SET image_model=?, updated_at=? WHERE id=? AND user_id=?", + (name, now, aid, user_id), + ) + if c.total_changes <= 0: + return False + c.execute( + """ + INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_ai_model_id=excluded.active_ai_model_id, + updated_at=excluded.updated_at + """, + (user_id, aid, now), + ) + return True + def get_active_ai_model(self, user_id: int) -> dict | None: with self._conn() as c: pref = c.execute( diff --git a/app/static/app.js b/app/static/app.js index 9906565..c0a8376 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -18,6 +18,8 @@ const imBtn = $("imBtn"); const coverUploadBtn = $("coverUploadBtn"); const coverUrlUploadBtn = $("coverUrlUploadBtn"); const coverGenerateBtn = $("coverGenerateBtn"); +const saveCoverImageModelBtn = $("saveCoverImageModelBtn"); +const coverImageModelInput = $("coverImageModel"); const coverModeManualBtn = $("coverModeManualBtn"); const coverModeAiBtn = $("coverModeAiBtn"); const coverManualSection = $("coverManualSection"); @@ -26,11 +28,13 @@ const coverAutoAfterRewrite = $("coverAutoAfterRewrite"); const coverPreview = $("coverPreview"); const coverPreviewWrap = $("coverPreviewWrap"); const logoutBtn = $("logoutBtn"); +const clearDraftBtn = $("clearDraftBtn"); const targetBodyCharsInput = $("targetBodyChars"); const posterGenerateBtn = $("posterGenerateBtn"); const posterPreviewList = $("posterPreviewList"); const posterHint = $("posterHint"); const posterAutoInclude = $("posterAutoInclude"); +const DRAFT_STORAGE_KEY = "aifagao:index:draft:v1"; let posterState = { signature: "", @@ -125,6 +129,123 @@ function setStatus(msg, danger = false) { statusEl.textContent = msg; } +function saveDraftState() { + try { + const data = { + sourceText: ($("sourceText") && $("sourceText").value) || "", + titleHint: ($("titleHint") && $("titleHint").value) || "", + audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "", + toneExtra: ($("toneExtra") && $("toneExtra").value) || "", + avoidWords: ($("avoidWords") && $("avoidWords").value) || "", + keepPoints: ($("keepPoints") && $("keepPoints").value) || "", + targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500", + title: ($("title") && $("title").value) || "", + summary: ($("summary") && $("summary").value) || "", + body: ($("body") && $("body").value) || "", + thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "", + coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "", + coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "", + coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked), + posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked), + audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value), + toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value), + }; + window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data)); + } catch { + // ignore + } +} + +function restoreDraftState() { + try { + const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + if (!data || typeof data !== "object") return; + const setVal = (id, val) => { + const el = $(id); + if (!el || typeof val !== "string") return; + el.value = val; + }; + setVal("sourceText", data.sourceText || ""); + setVal("titleHint", data.titleHint || ""); + setVal("audienceExtra", data.audienceExtra || ""); + setVal("toneExtra", data.toneExtra || ""); + setVal("avoidWords", data.avoidWords || ""); + setVal("keepPoints", data.keepPoints || ""); + setVal("targetBodyChars", String(data.targetBodyChars || "500")); + setVal("title", data.title || ""); + setVal("summary", data.summary || ""); + setVal("body", data.body || ""); + setVal("thumbMediaId", data.thumbMediaId || ""); + setVal("coverStyleHint", data.coverStyleHint || ""); + if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) { + coverImageModelInput.value = data.coverImageModel; + } + if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite); + if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude); + const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []); + document.querySelectorAll('input[name="audienceChip"]').forEach((el) => { + el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked; + }); + const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []); + document.querySelectorAll('input[name="toneChip"]').forEach((el) => { + el.checked = toneSet.size ? toneSet.has(el.value) : el.checked; + }); + } catch { + // ignore + } +} + +function clearDraftState() { + try { + window.localStorage.removeItem(DRAFT_STORAGE_KEY); + } catch { + // ignore + } + const clearIds = [ + "sourceText", + "titleHint", + "audienceExtra", + "toneExtra", + "avoidWords", + "keepPoints", + "title", + "summary", + "body", + "thumbMediaId", + "coverStyleHint", + "coverUrl", + ]; + clearIds.forEach((id) => { + const el = $(id); + if (!el) return; + el.value = ""; + }); + if ($("targetBodyChars")) $("targetBodyChars").value = "500"; + if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false; + if (posterAutoInclude) posterAutoInclude.checked = false; + if (coverImageModelInput) coverImageModelInput.value = ""; + document.querySelectorAll('input[name="audienceChip"]').forEach((el) => { + el.checked = false; + }); + document.querySelectorAll('input[name="toneChip"]').forEach((el) => { + el.checked = false; + }); + if (coverPreviewWrap) coverPreviewWrap.hidden = true; + if (coverPreview) coverPreview.src = ""; + if ($("coverFile")) $("coverFile").value = ""; + + posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] }; + renderPosterPreview([]); + updateCounters(); + syncTargetCharChips(); + initMultiDropdowns(); + initImageModelStatus(); + if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。"; + setStatus("草稿已清除。"); +} + function buildPosterSignature() { const title = ($("title") && $("title").value.trim()) || ""; const summary = ($("summary") && $("summary").value.trim()) || ""; @@ -183,7 +304,7 @@ function renderPosterPreview(posters) { function markPosterStaleIfNeeded() { if (!posterState.signature || !posterHint) return; if (posterState.signature !== buildPosterSignature()) { - posterHint.textContent = "正文已修改,发布前会自动重建段落海报。"; + posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报”。"; } } @@ -194,11 +315,26 @@ async function postJSON(url, body) { body: JSON.stringify(body), }); const data = await res.json(); - if (!res.ok) throw new Error(data.detail || "请求失败"); + if (!res.ok) { + const err = new Error(data.detail || "请求失败"); + err.payload = data; + throw err; + } data._requestId = res.headers.get("X-Request-ID") || ""; return data; } +function handleUpgradeRequired(err) { + const data = (err && err.payload) || {}; + const msg = (err && err.message) || data.detail || ""; + if (!data.upgrade_required && !msg.includes("免费额度已用完") && !msg.includes("余额不足")) return false; + setStatus("免费额度已用完,请前往升级页充值或升级 VIP 用户。", true); + window.setTimeout(() => { + window.location.href = "/upgrade"; + }, 800); + return true; +} + async function generatePosterMaterials({ silent = false } = {}) { const bodyMarkdown = (($("body") && $("body").value) || "").trim(); if (bodyMarkdown.length < 20) { @@ -212,9 +348,14 @@ async function generatePosterMaterials({ silent = false } = {}) { title: $("title").value, summary: $("summary").value, body_markdown: $("body").value, + image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "", upload_to_wechat: true, }); - if (!data.ok) throw new Error(data.detail || "海报生成失败"); + if (!data.ok) { + const err = new Error(data.detail || "海报生成失败"); + err.payload = data; + throw err; + } posterState = { signature: buildPosterSignature(), @@ -252,9 +393,14 @@ async function generateWechatCover({ silent = false } = {}) { title, summary: (($("summary") && $("summary").value) || "").trim(), style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(), + image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "", upload_to_wechat: true, }); - if (!data.ok) throw new Error(data.detail || "封面生成失败"); + if (!data.ok) { + const err = new Error(data.detail || "封面生成失败"); + err.payload = data; + throw err; + } const mid = data.thumb_media_id || ""; if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid; if (data.preview_data_url && coverPreview && coverPreviewWrap) { @@ -353,6 +499,20 @@ async function initWechatAccountSwitch() { if (me) renderWechatAccountSelect(me); } +async function initImageModelStatus() { + try { + const me = await fetch("/api/auth/me").then((r) => r.json()); + const active = me && me.active_ai_model ? me.active_ai_model : null; + const imageModel = active && active.image_model ? String(active.image_model).trim() : ""; + if (coverImageModelInput) { + const current = (coverImageModelInput.value || "").trim(); + if (!current) coverImageModelInput.value = imageModel || "wanx2.0-t2i-turbo"; + } + } catch { + if (coverImageModelInput && !(coverImageModelInput.value || "").trim()) coverImageModelInput.value = "wanx2.0-t2i-turbo"; + } +} + async function logoutAndGoAuth() { try { await postJSON("/api/auth/logout", {}); @@ -369,6 +529,12 @@ if (logoutBtn) { }); } +if (clearDraftBtn) { + clearDraftBtn.addEventListener("click", () => { + clearDraftState(); + }); +} + if (coverModeManualBtn) { coverModeManualBtn.addEventListener("click", () => setCoverMode("manual")); } @@ -419,6 +585,7 @@ $("rewriteBtn").addEventListener("click", async () => { $("summary").value = data.summary || ""; $("body").value = data.body_markdown || ""; updateCounters(); + saveDraftState(); const tr = data.trace || {}; if (data.mode === "fallback") { const note = (data.quality_notes || [])[0] || "当前为保底改写稿"; @@ -429,23 +596,19 @@ $("rewriteBtn").addEventListener("click", async () => { } else { setStatus("改写完成。"); } - try { - setStatus("改写完成,正在生成段落海报..."); - await generatePosterMaterials({ silent: true }); - setStatus("改写与段落海报生成完成。"); - } catch (posterErr) { - setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true); - } + if (posterHint) posterHint.textContent = "改写完成。默认不自动生成海报,可手动点击“生成段落海报”。"; if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) { try { setStatus("改写完成,正在按输出标题生成封面..."); await generateWechatCover({ silent: true }); setStatus("改写、封面与段落海报生成完成。"); } catch (coverErr) { + if (handleUpgradeRequired(coverErr)) return; setStatus(`改写完成,封面未生成:${coverErr.message}`, true); } } } catch (e) { + if (handleUpgradeRequired(e)) return; setStatus(`改写失败: ${e.message}`, true); } finally { setLoading(rewriteBtn, false, "改写并排版", "改写中..."); @@ -460,15 +623,10 @@ $("wechatBtn").addEventListener("click", async () => { const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked); if (autoInclude) { const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters; - if (stale) { - try { - await generatePosterMaterials({ silent: true }); - } catch (posterErr) { - setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true); - } - } - if (posterState.bodyMarkdownWithPosters) { + if (!stale && posterState.bodyMarkdownWithPosters) { bodyForPublish = posterState.bodyMarkdownWithPosters; + } else { + setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true); } } const data = await postJSON("/api/publish/wechat", { @@ -495,7 +653,9 @@ if (coverGenerateBtn) { coverGenerateBtn.addEventListener("click", async () => { try { await generateWechatCover({ silent: false }); + saveDraftState(); } catch (e) { + if (handleUpgradeRequired(e)) return; const hint = $("coverHint"); if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。"; setStatus(`封面生成失败: ${e.message}`, true); @@ -508,6 +668,30 @@ if (coverGenerateBtn) { }); } +if (saveCoverImageModelBtn) { + saveCoverImageModelBtn.addEventListener("click", async () => { + const value = (coverImageModelInput && coverImageModelInput.value.trim()) || ""; + if (!value) { + setStatus("请先填写文生图模型", true); + return; + } + setLoading(saveCoverImageModelBtn, true, "保存模型", "保存中..."); + try { + const out = await postJSON("/api/auth/ai-models/image-model/update", { image_model: value }); + if (!out.ok) { + setStatus(out.detail || "保存失败", true); + return; + } + setStatus("文生图模型已保存。"); + saveDraftState(); + } catch (e) { + setStatus(e.message || "保存失败", true); + } finally { + setLoading(saveCoverImageModelBtn, false, "保存模型", "保存中..."); + } + }); +} + if (coverUploadBtn) { coverUploadBtn.addEventListener("click", async () => { const fileInput = $("coverFile"); @@ -529,6 +713,7 @@ if (coverUploadBtn) { if ($("thumbMediaId")) $("thumbMediaId").value = mid; if (hint) hint.textContent = `封面上传成功,已绑定 media_id:${mid}`; setStatus("封面上传成功,发布时将优先使用该封面。"); + saveDraftState(); } catch (e) { if (hint) hint.textContent = "封面上传失败,请看状态提示。"; setStatus(`封面上传失败: ${e.message}`, true); @@ -561,6 +746,7 @@ if (coverUrlUploadBtn) { if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id:${mid}`; setStatus("URL 封面上传成功,发布时将优先使用该封面。"); if ($("coverUrl")) $("coverUrl").value = ""; + saveDraftState(); } catch (e) { if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。"; setStatus(`URL 封面上传失败: ${e.message}`, true); @@ -579,7 +765,9 @@ if (posterGenerateBtn) { posterGenerateBtn.addEventListener("click", async () => { try { await generatePosterMaterials({ silent: false }); + saveDraftState(); } catch (e) { + if (handleUpgradeRequired(e)) return; setStatus(`海报生成失败: ${e.message}`, true); if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。"; if ((e.message || "").includes("请先登录")) { @@ -610,13 +798,30 @@ $("imBtn").addEventListener("click", async () => { ["sourceText", "title", "summary", "body"].forEach((id) => { $(id).addEventListener("input", updateCounters); + $(id).addEventListener("input", saveDraftState); if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded); }); +["titleHint", "audienceExtra", "toneExtra", "avoidWords", "keepPoints", "targetBodyChars", "thumbMediaId", "coverStyleHint"].forEach( + (id) => { + const el = $(id); + if (el) el.addEventListener("input", saveDraftState); + }, +); +document.querySelectorAll('input[name="audienceChip"],input[name="toneChip"]').forEach((el) => { + el.addEventListener("change", saveDraftState); +}); +if (coverAutoAfterRewrite) coverAutoAfterRewrite.addEventListener("change", saveDraftState); +if (posterAutoInclude) posterAutoInclude.addEventListener("change", saveDraftState); +if (coverImageModelInput) coverImageModelInput.addEventListener("input", saveDraftState); + +restoreDraftState(); updateCounters(); initMultiDropdowns(); initWechatAccountSwitch(); syncTargetCharChips(); renderPosterPreview([]); setCoverMode("manual"); +initImageModelStatus(); +window.addEventListener("beforeunload", saveDraftState); window.addEventListener("load", () => setCoverMode("manual")); diff --git a/app/static/auth.js b/app/static/auth.js index 65ce867..c5573ec 100644 --- a/app/static/auth.js +++ b/app/static/auth.js @@ -1,4 +1,5 @@ const $ = (id) => document.getElementById(id); +let challengeId = ""; function setStatus(msg, danger = false) { const el = $("status"); @@ -24,6 +25,21 @@ async function postJSON(url, body) { return data; } +async function refreshChallenge() { + try { + const res = await fetch("/api/auth/challenge"); + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.detail || "校验题加载失败"); + challengeId = (data.challenge_id || "").trim(); + const label = $("challengeLabel"); + if (label) label.textContent = `人机校验:${data.question || ""}`; + const ans = $("challengeAnswer"); + if (ans) ans.value = ""; + } catch { + setStatus("校验题加载失败,请刷新页面重试", true); + } +} + function nextPath() { const nxt = (window.__NEXT_PATH__ || "/").trim(); if (!nxt.startsWith("/")) return "/"; @@ -35,6 +51,9 @@ function fields() { username: ($("username") && $("username").value.trim()) || "", password: ($("password") && $("password").value) || "", remember_me: Boolean($("rememberMe") && $("rememberMe").checked), + challenge_id: challengeId, + challenge_answer: ($("challengeAnswer") && $("challengeAnswer").value.trim()) || "", + honeypot: ($("botTrap") && $("botTrap").value) || "", }; } @@ -57,6 +76,7 @@ async function authAction(url, button, idleText, loadingText, okMessage) { const loginBtn = $("loginBtn"); const registerBtn = $("registerBtn"); +const refreshChallengeBtn = $("refreshChallengeBtn"); if (loginBtn) { loginBtn.addEventListener("click", async () => { @@ -78,7 +98,7 @@ if (registerBtn) { const msg = `注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` + "请立即复制并妥善保管,点击“确定”后继续进入系统。"; - window.alert(msg); + await window.uiAlert(msg, "注册成功"); try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(code); @@ -91,8 +111,19 @@ if (registerBtn) { window.location.href = nextPath(); } catch (e) { setStatus(e.message || "请求异常", true); + await refreshChallenge(); } finally { setLoading(registerBtn, false, "注册", "注册中..."); } }); } + +if (refreshChallengeBtn) { + refreshChallengeBtn.addEventListener("click", async () => { + setLoading(refreshChallengeBtn, true, "刷新题目", "刷新中..."); + await refreshChallenge(); + setLoading(refreshChallengeBtn, false, "刷新题目", "刷新中..."); + }); +} + +refreshChallenge(); diff --git a/app/static/billing.js b/app/static/billing.js new file mode 100644 index 0000000..85e2504 --- /dev/null +++ b/app/static/billing.js @@ -0,0 +1,340 @@ +const $ = (id) => document.getElementById(id); + +function setStatus(msg, danger = false) { + const el = $("status"); + if (!el) return; + el.style.color = danger ? "#b42318" : "#0f5f3d"; + el.textContent = msg; +} + +function setLoading(button, loading, idleText, loadingText) { + if (!button) return; + button.disabled = loading; + button.textContent = loading ? loadingText : idleText; +} + +function importantNotice(message, title = "提示") { + if (typeof window.uiAlert === "function") { + void window.uiAlert(message, title); + return; + } + setStatus(message, true); +} + +function openPayLink(url) { + const payUrl = String(url || "").trim(); + if (!payUrl) return false; + const tab = window.open(payUrl, "_blank", "noopener"); + if (tab) return true; + window.location.href = payUrl; + return true; +} + +async function postJSON(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "请求失败"); + return data; +} + +async function authMe() { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!data.logged_in) { + window.location.href = "/auth?next=/billing"; + return null; + } + return data; +} + +function fmtTime(ts) { + const n = Number(ts || 0); + if (!n) return "-"; + return new Date(n * 1000).toLocaleString(); +} + +function mapOrderStatus(status) { + const s = String(status || "").toLowerCase(); + if (s === "paid" || s === "success") return "已支付"; + if (s === "pending") return "待支付"; + if (s === "failed") return "支付失败"; + if (s === "cancelled") return "已取消"; + if (s === "closed") return "已关闭"; + return status || "-"; +} + +function mapChannel(channel) { + const c = String(channel || "").toLowerCase(); + if (c === "wechat") return "微信支付"; + if (c === "alipay") return "支付宝"; + if (c === "stripe") return "Stripe"; + return channel || "-"; +} + +function formatDetail(detail) { + if (!detail || typeof detail !== "object") return "-"; + const rows = []; + if (detail.source_chars) rows.push(`原文${detail.source_chars}字`); + if (detail.body_chars) rows.push(`正文${detail.body_chars}字`); + if (detail.title_chars) rows.push(`标题${detail.title_chars}字`); + if (detail.summary_chars) rows.push(`摘要${detail.summary_chars}字`); + if (detail.image_count) rows.push(`图片${detail.image_count}张`); + if (detail.prompt_tokens) rows.push(`输入Token:${detail.prompt_tokens}`); + if (detail.completion_tokens) rows.push(`输出Token:${detail.completion_tokens}`); + if (detail.total_tokens) rows.push(`总Token:${detail.total_tokens}`); + if (detail.model) rows.push(`模型:${detail.model}`); + if (detail.image_model) rows.push(`生图模型:${detail.image_model}`); + if (detail.image_price_package_images && detail.image_price_package_cny) { + rows.push(`图片计价:${detail.image_price_package_images}张=${Number(detail.image_price_package_cny).toFixed(2)}元`); + } + if (detail.credits_rule) rows.push(`规则:${detail.credits_rule}`); + if (detail.billed_basis === "usage_tokens") rows.push("按真实Token计费"); + if (detail.billed_basis === "char_estimate") rows.push("按字符估算计费"); + if (detail.paid_amount_cny) rows.push(`实付¥${Number(detail.paid_amount_cny).toFixed(2)}`); + if (detail.external_txn_id) rows.push(`交易号:${detail.external_txn_id}`); + if (detail.credit_source && typeof detail.credit_source === "object") { + const seat = Number(detail.credit_source.seat || 0); + const shared = Number(detail.credit_source.shared || 0); + rows.push(`抵扣来源:席位${seat}/共享${shared}`); + } + return rows.length ? rows.join(" | ") : "-"; +} + +function renderRechargeRecords(records) { + const el = $("rechargeRecords"); + if (!el) return; + const list = Array.isArray(records) ? records : []; + if (!list.length) { + el.innerHTML = '
暂无充值记录
'; + return; + } + const rows = list + .map((r) => { + const statusText = mapOrderStatus(r.status); + const statusClass = + statusText === "已支付" + ? "paid" + : statusText === "待支付" + ? "pending" + : statusText === "支付失败" + ? "failed" + : "closed"; + return `| 订单号 | +状态 | +渠道 | +Credits | +金额 | +创建时间 | +支付时间 | +操作 | +
|---|
暂无消费记录
'; + return; + } + const kindTextMap = { + trial_grant: "试用赠送", + paid_recharge: "充值到账", + manual_recharge: "手动充值", + rewrite: "AI改写", + cover_generate: "封面生成", + poster_generate: "段落海报", + usage: "模型调用", + }; + const rows = list + .map((r) => { + const kindText = kindTextMap[r.kind] || r.kind || "-"; + const delta = `${r.direction === "out" ? "-" : "+"}${Number(r.token_change || 0)}`; + const ref = `${r.ref_type || "-"} ${r.ref_id || ""}`.trim(); + const detail = formatDetail(r.detail); + return `| 类型 | +Credits变动 | +余额 | +关联 | +明细 | +时间 | +
|---|