feat(accounts): 添加账号cookies存储及支付链接国家选择功能

- 在账号详情页添加cookies编辑与保存功能,用于支付请求
- 支付页面新增国家选择下拉框,支持多国货币计费
- 优化无痕打开浏览器功能,支持注入账号cookies
- 更新数据库模型、API路由及前端界面
This commit is contained in:
cnlimiter
2026-03-17 13:59:00 +08:00
parent d1e37aff56
commit 036a66d72b
10 changed files with 274 additions and 79 deletions

View File

@@ -21,6 +21,9 @@ dev = [
"pytest>=7.0.0", "pytest>=7.0.0",
"httpx>=0.24.0", "httpx>=0.24.0",
] ]
payment = [
"playwright>=1.40.0",
]
[project.scripts] [project.scripts]
codex-webui = "webui:main" codex-webui = "webui:main"

Binary file not shown.

View File

@@ -23,62 +23,101 @@ def _build_proxies(proxy: Optional[str]) -> Optional[dict]:
return None return None
def generate_plus_link(account: Account, proxy: Optional[str] = None) -> str: _COUNTRY_CURRENCY_MAP = {
"""生成 Plus 支付链接""" "SG": "SGD",
if not account.access_token: "US": "USD",
raise ValueError("账号缺少 access_token") "TR": "TRY",
"JP": "JPY",
headers = { "HK": "HKD",
"Authorization": f"Bearer {account.access_token}", "GB": "GBP",
"Content-Type": "application/json", "EU": "EUR",
} "AU": "AUD",
payload = { "CA": "CAD",
"plan_type": "plus", "IN": "INR",
"checkout_ui_mode": "hosted", "BR": "BRL",
"cancel_url": "https://chatgpt.com/", "MX": "MXN",
"success_url": "https://chatgpt.com/", }
}
resp = cffi_requests.post(
PAYMENT_CHECKOUT_URL,
headers=headers,
json=payload,
proxies=_build_proxies(proxy),
timeout=30,
impersonate="chrome110",
)
resp.raise_for_status()
data = resp.json()
if "url" in data:
return data["url"]
raise ValueError(data.get("detail", "API 未返回支付链接"))
def generate_team_link( def _extract_oai_did(cookies_str: str) -> Optional[str]:
"""从 cookie 字符串中提取 oai-device-id"""
for part in cookies_str.split(";"):
part = part.strip()
if part.startswith("oai-did="):
return part[len("oai-did="):].strip()
return None
def _parse_cookie_str(cookies_str: str, domain: str) -> list:
"""'key=val; key2=val2' 格式解析为 Playwright cookie 列表"""
cookies = []
for part in cookies_str.split(";"):
part = part.strip()
if "=" not in part:
continue
name, _, value = part.partition("=")
cookies.append({
"name": name.strip(),
"value": value.strip(),
"domain": domain,
"path": "/",
})
return cookies
def _open_url_system_browser(url: str) -> bool:
"""回退方案:调用系统浏览器以无痕模式打开"""
platform = sys.platform
try:
if platform == "win32":
for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]:
try:
subprocess.Popen(f'start {browser} {flag} "{url}"', shell=True)
return True
except Exception:
continue
elif platform == "darwin":
subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url])
return True
else:
for binary in ["google-chrome", "chromium-browser", "chromium"]:
try:
subprocess.Popen([binary, "--incognito", url])
return True
except FileNotFoundError:
continue
except Exception as e:
logger.warning(f"系统浏览器无痕打开失败: {e}")
return False
def generate_plus_link(
account: Account, account: Account,
workspace_name: str = "MyTeam",
price_interval: str = "month",
seat_quantity: int = 5,
proxy: Optional[str] = None, proxy: Optional[str] = None,
country: str = "SG",
) -> str: ) -> str:
"""生成 Team 支付链接""" """生成 Plus 支付链接(后端携带账号 cookie 发请求)"""
if not account.access_token: if not account.access_token:
raise ValueError("账号缺少 access_token") raise ValueError("账号缺少 access_token")
currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
headers = { headers = {
"Authorization": f"Bearer {account.access_token}", "Authorization": f"Bearer {account.access_token}",
"Content-Type": "application/json", "Content-Type": "application/json",
"oai-language": "zh-CN",
} }
if account.cookies:
headers["cookie"] = account.cookies
oai_did = _extract_oai_did(account.cookies)
if oai_did:
headers["oai-device-id"] = oai_did
payload = { payload = {
"plan_name": "chatgptteamplan", "plan_name": "chatgptplusplan",
"team_plan_data": { "billing_details": {"country": country, "currency": currency},
"workspace_name": workspace_name,
"price_interval": price_interval,
"seat_quantity": seat_quantity,
},
"promo_campaign": { "promo_campaign": {
"promo_campaign_id": "team-1-month-free", "promo_campaign_id": "plus-1-month-free",
"is_coupon_from_query_param": True, "is_coupon_from_query_param": False,
}, },
"checkout_ui_mode": "custom", "checkout_ui_mode": "custom",
} }
@@ -98,36 +137,86 @@ def generate_team_link(
raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
def open_url_incognito(url: str) -> bool: def generate_team_link(
"""调用本机浏览器以无痕模式打开 URL""" account: Account,
platform = sys.platform workspace_name: str = "MyTeam",
try: price_interval: str = "month",
if platform == "win32": seat_quantity: int = 5,
# 依次尝试 Chrome、Edge proxy: Optional[str] = None,
for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]: country: str = "SG",
try: ) -> str:
subprocess.Popen( """生成 Team 支付链接(后端携带账号 cookie 发请求)"""
f'start {browser} {flag} "{url}"', if not account.access_token:
shell=True, raise ValueError("账号缺少 access_token")
currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
headers = {
"Authorization": f"Bearer {account.access_token}",
"Content-Type": "application/json",
"oai-language": "zh-CN",
}
if account.cookies:
headers["cookie"] = account.cookies
oai_did = _extract_oai_did(account.cookies)
if oai_did:
headers["oai-device-id"] = oai_did
payload = {
"plan_name": "chatgptteamplan",
"team_plan_data": {
"workspace_name": workspace_name,
"price_interval": price_interval,
"seat_quantity": seat_quantity,
},
"billing_details": {"country": country, "currency": currency},
"promo_campaign": {
"promo_campaign_id": "team-1-month-free",
"is_coupon_from_query_param": True,
},
"cancel_url": "https://chatgpt.com/#pricing",
"checkout_ui_mode": "custom",
}
resp = cffi_requests.post(
PAYMENT_CHECKOUT_URL,
headers=headers,
json=payload,
proxies=_build_proxies(proxy),
timeout=30,
impersonate="chrome110",
) )
return True resp.raise_for_status()
except Exception: data = resp.json()
continue if "checkout_session_id" in data:
elif platform == "darwin": return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
subprocess.Popen( raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
["open", "-a", "Google Chrome", "--args", "--incognito", url]
)
return True def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool:
else: """用 Playwright 以无痕模式打开 URL可注入 cookie"""
for binary in ["google-chrome", "chromium-browser", "chromium"]: import threading
try: try:
subprocess.Popen([binary, "--incognito", url]) from playwright.sync_api import sync_playwright
return True except ImportError:
except FileNotFoundError: logger.warning("playwright 未安装,回退到系统浏览器")
continue return _open_url_system_browser(url)
def _launch():
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=False, args=["--incognito"])
ctx = browser.new_context()
if cookies_str:
ctx.add_cookies(_parse_cookie_str(cookies_str, "chatgpt.com"))
page = ctx.new_page()
page.goto(url)
# 保持窗口打开直到用户关闭
page.wait_for_timeout(300_000) # 最多等待 5 分钟
except Exception as e: except Exception as e:
logger.warning(f"无痕打开浏览器失败: {e}") logger.warning(f"Playwright 无痕打开失败: {e}")
return False
threading.Thread(target=_launch, daemon=True).start()
return True
def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str:

View File

@@ -55,6 +55,7 @@ class Account(Base):
source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源 source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源
subscription_type = Column(String(20)) # None / 'plus' / 'team' subscription_type = Column(String(20)) # None / 'plus' / 'team'
subscription_at = Column(DateTime) # 订阅开通时间 subscription_at = Column(DateTime) # 订阅开通时间
cookies = Column(Text) # 完整 cookie 字符串,用于支付请求
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -97,6 +97,7 @@ class DatabaseSessionManager:
("accounts", "source", "VARCHAR(20) DEFAULT 'register'"), ("accounts", "source", "VARCHAR(20) DEFAULT 'register'"),
("accounts", "subscription_type", "VARCHAR(20)"), ("accounts", "subscription_type", "VARCHAR(20)"),
("accounts", "subscription_at", "DATETIME"), ("accounts", "subscription_at", "DATETIME"),
("accounts", "cookies", "TEXT"),
] ]
with self.engine.connect() as conn: with self.engine.connect() as conn:

View File

@@ -39,6 +39,7 @@ class AccountResponse(BaseModel):
proxy_used: Optional[str] = None proxy_used: Optional[str] = None
cpa_uploaded: bool = False cpa_uploaded: bool = False
cpa_uploaded_at: Optional[str] = None cpa_uploaded_at: Optional[str] = None
cookies: Optional[str] = None
created_at: Optional[str] = None created_at: Optional[str] = None
updated_at: Optional[str] = None updated_at: Optional[str] = None
@@ -56,6 +57,7 @@ class AccountUpdateRequest(BaseModel):
"""账号更新请求""" """账号更新请求"""
status: Optional[str] = None status: Optional[str] = None
metadata: Optional[dict] = None metadata: Optional[dict] = None
cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求
class BatchDeleteRequest(BaseModel): class BatchDeleteRequest(BaseModel):
@@ -116,6 +118,7 @@ def account_to_response(account: Account) -> AccountResponse:
proxy_used=account.proxy_used, proxy_used=account.proxy_used,
cpa_uploaded=account.cpa_uploaded or False, cpa_uploaded=account.cpa_uploaded or False,
cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None,
cookies=account.cookies,
created_at=account.created_at.isoformat() if account.created_at else None, created_at=account.created_at.isoformat() if account.created_at else None,
updated_at=account.updated_at.isoformat() if account.updated_at else None, updated_at=account.updated_at.isoformat() if account.updated_at else None,
) )
@@ -216,10 +219,24 @@ async def update_account(account_id: int, request: AccountUpdateRequest):
current_metadata.update(request.metadata) current_metadata.update(request.metadata)
update_data["metadata"] = current_metadata update_data["metadata"] = current_metadata
if request.cookies is not None:
# 留空则清空,非空则更新
update_data["cookies"] = request.cookies or None
account = crud.update_account(db, account_id, **update_data) account = crud.update_account(db, account_id, **update_data)
return account_to_response(account) return account_to_response(account)
@router.get("/{account_id}/cookies")
async def get_account_cookies(account_id: int):
"""获取账号的 cookie 字符串(仅供支付使用)"""
with get_db() as db:
account = crud.get_account_by_id(db, account_id)
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
return {"account_id": account_id, "cookies": account.cookies or ""}
@router.delete("/{account_id}") @router.delete("/{account_id}")
async def delete_account(account_id: int): async def delete_account(account_id: int):
"""删除单个账号""" """删除单个账号"""

View File

@@ -38,10 +38,12 @@ class GenerateLinkRequest(BaseModel):
seat_quantity: int = 5 seat_quantity: int = 5
proxy: Optional[str] = None proxy: Optional[str] = None
auto_open: bool = False # 生成后是否自动无痕打开 auto_open: bool = False # 生成后是否自动无痕打开
country: str = "SG" # 计费国家,决定货币 # 生成后是否自动无痕打开
class OpenIncognitoRequest(BaseModel): class OpenIncognitoRequest(BaseModel):
url: str url: str
account_id: Optional[int] = None # 可选,用于注入账号 cookie
class MarkSubscriptionRequest(BaseModel): class MarkSubscriptionRequest(BaseModel):
@@ -83,7 +85,7 @@ def generate_payment_link(request: GenerateLinkRequest):
try: try:
if request.plan_type == "plus": if request.plan_type == "plus":
link = generate_plus_link(account, proxy) link = generate_plus_link(account, proxy, country=request.country)
elif request.plan_type == "team": elif request.plan_type == "team":
link = generate_team_link( link = generate_team_link(
account, account,
@@ -91,6 +93,7 @@ def generate_payment_link(request: GenerateLinkRequest):
price_interval=request.price_interval, price_interval=request.price_interval,
seat_quantity=request.seat_quantity, seat_quantity=request.seat_quantity,
proxy=proxy, proxy=proxy,
country=request.country,
) )
else: else:
raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team") raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team")
@@ -102,7 +105,8 @@ def generate_payment_link(request: GenerateLinkRequest):
opened = False opened = False
if request.auto_open and link: if request.auto_open and link:
opened = open_url_incognito(link) cookies_str = account.cookies if account else None
opened = open_url_incognito(link, cookies_str)
return { return {
"success": True, "success": True,
@@ -114,13 +118,21 @@ def generate_payment_link(request: GenerateLinkRequest):
@router.post("/open-incognito") @router.post("/open-incognito")
def open_browser_incognito(request: OpenIncognitoRequest): def open_browser_incognito(request: OpenIncognitoRequest):
"""后端命令行以无痕模式打开指定 URL""" """后端以无痕模式打开指定 URL,可注入账号 cookie"""
if not request.url: if not request.url:
raise HTTPException(status_code=400, detail="URL 不能为空") raise HTTPException(status_code=400, detail="URL 不能为空")
success = open_url_incognito(request.url)
cookies_str = None
if request.account_id:
with get_db() as db:
account = db.query(Account).filter(Account.id == request.account_id).first()
if account:
cookies_str = account.cookies
success = open_url_incognito(request.url, cookies_str)
if success: if success:
return {"success": True, "message": "已在无痕模式打开浏览器"} return {"success": True, "message": "已在无痕模式打开浏览器"}
return {"success": False, "message": "未找到可用的 Chrome/Edge,请手动复制链接"} return {"success": False, "message": "未找到可用的浏览器,请手动复制链接"}
# ============== 订阅状态 ============== # ============== 订阅状态 ==============

View File

@@ -613,6 +613,17 @@ async function viewAccount(id) {
${tokens.refresh_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.refresh_token)}')" style="margin-left: 8px;">📋</button>` : ''} ${tokens.refresh_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.refresh_token)}')" style="margin-left: 8px;">📋</button>` : ''}
</div> </div>
</div> </div>
<div class="info-item" style="grid-column: span 2;">
<span class="label">Cookies支付用</span>
<div class="value">
<textarea id="cookies-input-${id}" rows="3"
style="width:100%;font-size:0.7rem;font-family:var(--font-mono);background:var(--surface-hover);border:1px solid var(--border);border-radius:4px;padding:6px;color:var(--text-primary);resize:vertical;"
placeholder="粘贴完整 cookie 字符串,留空则清除">${escapeHtml(account.cookies || '')}</textarea>
<button class="btn btn-secondary btn-sm" style="margin-top:4px" onclick="saveCookies(${id})">
保存 Cookies
</button>
</div>
</div>
</div> </div>
<div style="margin-top: var(--spacing-lg); display: flex; gap: var(--spacing-sm);"> <div style="margin-top: var(--spacing-lg); display: flex; gap: var(--spacing-sm);">
<button class="btn btn-primary" onclick="refreshToken(${id}); elements.detailModal.classList.remove('active');"> <button class="btn btn-primary" onclick="refreshToken(${id}); elements.detailModal.classList.remove('active');">
@@ -861,3 +872,16 @@ async function handleBatchUploadTm() {
updateBatchButtons(); updateBatchButtons();
} }
} }
// 保存账号 Cookies
async function saveCookies(id) {
const textarea = document.getElementById(`cookies-input-${id}`);
if (!textarea) return;
const cookiesValue = textarea.value.trim();
try {
await api.patch(`/accounts/${id}`, { cookies: cookiesValue });
toast.success('Cookies 已保存');
} catch (e) {
toast.error('保存 Cookies 失败: ' + e.message);
}
}

View File

@@ -2,6 +2,12 @@
* 支付页面 JavaScript * 支付页面 JavaScript
*/ */
const COUNTRY_CURRENCY_MAP = {
SG: 'SGD', US: 'USD', TR: 'TRY', JP: 'JPY',
HK: 'HKD', GB: 'GBP', EU: 'EUR', AU: 'AUD',
CA: 'CAD', IN: 'INR', BR: 'BRL', MX: 'MXN',
};
let selectedPlan = 'plus'; let selectedPlan = 'plus';
let generatedLink = ''; let generatedLink = '';
@@ -28,6 +34,13 @@ async function loadAccounts() {
} }
} }
// 国家切换
function onCountryChange() {
const country = document.getElementById('country-select').value;
const currency = COUNTRY_CURRENCY_MAP[country] || 'USD';
document.getElementById('currency-display').value = currency;
}
// 选择套餐 // 选择套餐
function selectPlan(plan) { function selectPlan(plan) {
selectedPlan = plan; selectedPlan = plan;
@@ -47,9 +60,12 @@ async function generateLink() {
return; return;
} }
const country = document.getElementById('country-select').value || 'SG';
const body = { const body = {
account_id: parseInt(accountId), account_id: parseInt(accountId),
plan_type: selectedPlan, plan_type: selectedPlan,
country: country,
}; };
if (selectedPlan === 'team') { if (selectedPlan === 'team') {
@@ -58,6 +74,9 @@ async function generateLink() {
body.price_interval = document.getElementById('price-interval').value; body.price_interval = document.getElementById('price-interval').value;
} }
const btn = document.querySelector('.form-actions .btn-primary');
if (btn) { btn.disabled = true; btn.textContent = '生成中...'; }
try { try {
const resp = await fetch('/api/payment/generate-link', { const resp = await fetch('/api/payment/generate-link', {
method: 'POST', method: 'POST',
@@ -76,6 +95,8 @@ async function generateLink() {
} }
} catch (e) { } catch (e) {
ui.showToast('请求失败: ' + e.message, 'error'); ui.showToast('请求失败: ' + e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '生成支付链接'; }
} }
} }
@@ -85,7 +106,6 @@ function copyLink() {
navigator.clipboard.writeText(generatedLink).then(() => { navigator.clipboard.writeText(generatedLink).then(() => {
ui.showToast('已复制到剪贴板', 'success'); ui.showToast('已复制到剪贴板', 'success');
}).catch(() => { }).catch(() => {
// 降级方案
const ta = document.getElementById('link-text'); const ta = document.getElementById('link-text');
ta.select(); ta.select();
document.execCommand('copy'); document.execCommand('copy');
@@ -93,19 +113,23 @@ function copyLink() {
}); });
} }
// 无痕打开浏览器 // 无痕打开浏览器(携带账号 cookie
async function openIncognito() { async function openIncognito() {
if (!generatedLink) { if (!generatedLink) {
ui.showToast('请先生成链接', 'warning'); ui.showToast('请先生成链接', 'warning');
return; return;
} }
const accountId = document.getElementById('account-select').value;
const statusEl = document.getElementById('open-status'); const statusEl = document.getElementById('open-status');
statusEl.textContent = '正在打开...'; statusEl.textContent = '正在打开...';
try { try {
const body = { url: generatedLink };
if (accountId) body.account_id = parseInt(accountId);
const resp = await fetch('/api/payment/open-incognito', { const resp = await fetch('/api/payment/open-incognito', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: generatedLink }), body: JSON.stringify(body),
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {

View File

@@ -100,6 +100,30 @@
</select> </select>
</div> </div>
<!-- 国家选择 -->
<div class="form-row">
<div class="form-group">
<label for="country-select">计费国家</label>
<select id="country-select" onchange="onCountryChange()">
<option value="SG">新加坡 (SGD)</option>
<option value="US">美国 (USD)</option>
<option value="TR">土耳其 (TRY)</option>
<option value="JP">日本 (JPY)</option>
<option value="HK">香港 (HKD)</option>
<option value="GB">英国 (GBP)</option>
<option value="AU">澳大利亚 (AUD)</option>
<option value="CA">加拿大 (CAD)</option>
<option value="IN">印度 (INR)</option>
<option value="BR">巴西 (BRL)</option>
<option value="MX">墨西哥 (MXN)</option>
</select>
</div>
<div class="form-group">
<label>对应货币</label>
<input type="text" id="currency-display" value="SGD" readonly style="background:var(--surface-hover);cursor:default">
</div>
</div>
<!-- Team 额外参数 --> <!-- Team 额外参数 -->
<div class="team-options" id="team-options"> <div class="team-options" id="team-options">
<div class="form-row"> <div class="form-row">