diff --git a/README.md b/README.md index 22b2584a..ff4f0dc7 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,36 @@ 7. 补充细节稳定性处理,尽量减少注册、订阅检测和账号管理过程中出现卡住或误判的情况。 +### v1.1.1 + +1. 新增 `CloudMail` 邮箱服务实现,并完成服务注册、配置接入、邮件轮询、验证码提取和基础收件处理能力。 + +2. 新增上传目标 `newApi` 支持,可根据配置选择不同导入目标类型。 + +3. 新增 `Codex` 账号导出格式,支持后续登录、迁移和导入使用。 + +4. 新增 `CPA` 认证文件 `proxy_url` 支持,现可在 CPA 服务配置中保存和使用代理地址。 + +5. 优化 OAuth token 刷新兼容逻辑,完善异常返回与一次性令牌场景处理,降低刷新报错概率。 + +6. 优化批量验证流程,改为受控并发执行,减少长时间阻塞和卡死问题。 + +7. 修复模板渲染兼容问题,提升不同 Starlette 版本下页面渲染稳定性。 + +8. 修复六位数字误判为 OTP 的问题,避免邮箱域名或无关文本中的六位数字被错误识别为验证码。 + +9. 新增 Outlook 账户“注册状态”识别与展示功能,可直接看到“已注册/未注册”,并支持显示关联账号编号(如“已注册 #1”)。 + +10. 修复 Outlook 邮箱匹配大小写问题,避免 Outlook.com 因大小写差异被误判为未注册。 + +11. 修复 Outlook 列表列错位、乱码和占位文案问题,恢复中文显示并优化列表信息布局。 + +12. 优化 WebUI 端口冲突处理,默认端口占用时自动切换可用端口。 + +13. 增加启动时轻量字段迁移逻辑,自动补齐新增字段,提升旧数据升级兼容性。 + +14. 批量注册上限由 `100` 提升至 `1000`(前后端同步)。 + ## 核心能力 - Web UI 管理注册任务和账号数据 diff --git a/pyproject.toml b/pyproject.toml index 9f02d207..bc382c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-console" -version = "1.1.0" +version = "1.1.1" description = "OpenAI account management console" requires-python = ">=3.10" dependencies = [ diff --git a/src/config/constants.py b/src/config/constants.py index 0949de3e..18e2964d 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -38,6 +38,7 @@ class EmailServiceType(str, Enum): DUCK_MAIL = "duck_mail" FREEMAIL = "freemail" IMAP_MAIL = "imap_mail" + CLOUDMAIL = "cloudmail" # ============================================================================ @@ -45,7 +46,7 @@ class EmailServiceType(str, Enum): # ============================================================================ APP_NAME = "OpenAI/Codex CLI 自动注册系统" -APP_VERSION = "1.1.0" +APP_VERSION = "1.1.1" APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统" # ============================================================================ diff --git a/src/config/project_notice.py b/src/config/project_notice.py index 5939ac17..8f8337d9 100644 --- a/src/config/project_notice.py +++ b/src/config/project_notice.py @@ -14,6 +14,8 @@ "qq_group_url": "https://qm.qq.com/q/4TETC3mWco", "telegram_name": "codex_console", "telegram_url": "https://t.me/codex_console", + "afdian_name": "dou-jiang", + "afdian_url": "https://afdian.com/a/dou-jiang", } diff --git a/src/config/settings.py b/src/config/settings.py index a929c0d8..20d5e956 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -48,7 +48,7 @@ class SettingDefinition: ), "app_version": SettingDefinition( db_key="app.version", - default_value="1.1.0", + default_value="1.1.1", category=SettingCategory.GENERAL, description="应用版本" ), @@ -592,7 +592,7 @@ class Settings(BaseModel): # 应用信息 app_name: str = "OpenAI/Codex CLI 自动注册系统" - app_version: str = "1.1.0" + app_version: str = "1.1.1" debug: bool = False # 数据库配置 diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py index a3f070f1..13e8ec8c 100644 --- a/src/core/openai/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -110,6 +110,31 @@ def _request_once(session: cffi_requests.Session): session = self._create_session() response = _request_once(session) + if response.status_code >= 400: + try: + error_payload = response.json() + except Exception: + error_payload = {} + + error_text = str(error_payload.get("error") or "").lower() + error_description = str(error_payload.get("error_description") or response.text or "") + if response.status_code == 400 and error_text in {"invalid_grant", "unsupported_grant_type", "invalid_request"}: + fallback_data = { + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + response = session.post( + self.TOKEN_URL, + data=fallback_data, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=30 + ) + # 代理通道触发地区/风控时,自动回退直连重试一次 if ( response.status_code in (401, 403) diff --git a/src/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py index 8df79609..11d0f497 100644 --- a/src/core/upload/sub2api_upload.py +++ b/src/core/upload/sub2api_upload.py @@ -22,6 +22,7 @@ def upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, + target_type: str = "sub2api", ) -> Tuple[bool, str]: """ 上传账号列表到 Sub2API 平台(不走代理) @@ -89,7 +90,7 @@ def upload_to_sub2api( payload = { "data": { - "type": "sub2api-data", + "type": "newapi-data" if str(target_type).lower() == "newapi" else "sub2api-data", "version": 1, "exported_at": exported_at, "proxies": [], @@ -138,6 +139,7 @@ def batch_upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, + target_type: str = "sub2api", ) -> dict: """ 批量上传指定 ID 的账号到 Sub2API 平台 @@ -169,7 +171,7 @@ def batch_upload_to_sub2api( if not accounts: return results - success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority) + success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority, target_type) if success: for acc in accounts: diff --git a/src/database/crud.py b/src/database/crud.py index 1e5609ba..3c6c8492 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -528,6 +528,7 @@ def create_cpa_service( name: str, api_url: str, api_token: str, + proxy_url: Optional[str] = None, enabled: bool = True, priority: int = 0 ) -> CpaService: @@ -536,6 +537,7 @@ def create_cpa_service( name=name, api_url=api_url, api_token=api_token, + proxy_url=proxy_url, enabled=enabled, priority=priority ) @@ -597,6 +599,7 @@ def create_sub2api_service( name: str, api_url: str, api_key: str, + target_type: str = 'sub2api', enabled: bool = True, priority: int = 0 ) -> Sub2ApiService: diff --git a/src/database/models.py b/src/database/models.py index 2dda5c8e..ff0e4443 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -197,6 +197,7 @@ class CpaService(Base): name = Column(String(100), nullable=False) # 服务名称 api_url = Column(String(500), nullable=False) # API URL api_token = Column(Text, nullable=False) # API Token + proxy_url = Column(String(1000)) # ?? URL enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) @@ -211,6 +212,7 @@ class Sub2ApiService(Base): name = Column(String(100), nullable=False) # 服务名称 api_url = Column(String(500), nullable=False) # API URL (host) api_key = Column(Text, nullable=False) # x-api-key + target_type = Column(String(50), nullable=False, default='sub2api') # sub2api/newapi enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) diff --git a/src/database/session.py b/src/database/session.py index 4d35759b..7aa8a490 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -110,6 +110,8 @@ def migrate_tables(self): ("accounts", "subscription_type", "VARCHAR(20)"), ("accounts", "subscription_at", "DATETIME"), ("accounts", "cookies", "TEXT"), + ("cpa_services", "proxy_url", "VARCHAR(1000)"), + ("sub2api_services", "target_type", "VARCHAR(50) DEFAULT 'sub2api'"), ("proxies", "is_default", "BOOLEAN DEFAULT 0"), ("bind_card_tasks", "checkout_session_id", "VARCHAR(120)"), ("bind_card_tasks", "publishable_key", "VARCHAR(255)"), diff --git a/src/services/__init__.py b/src/services/__init__.py index ad29d3e5..575e0050 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -17,6 +17,7 @@ from .duck_mail import DuckMailService from .freemail import FreemailService from .imap_mail import ImapMailService +from .cloudmail import CloudMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) @@ -26,6 +27,7 @@ EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService) EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService) EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService) +EmailServiceFactory.register(EmailServiceType.CLOUDMAIL, CloudMailService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( @@ -59,6 +61,7 @@ 'DuckMailService', 'FreemailService', 'ImapMailService', + 'CloudMailService', # Outlook 模块 'ProviderType', 'EmailMessage', diff --git a/src/services/cloudmail.py b/src/services/cloudmail.py new file mode 100644 index 00000000..86e93530 --- /dev/null +++ b/src/services/cloudmail.py @@ -0,0 +1,37 @@ +""" +CloudMail ?????? +""" + +from typing import Any, Dict, List, Optional + +from .temp_mail import TempMailService +from .base import EmailServiceType + + +class CloudMailService(TempMailService): + """?? CloudMail ????????""" + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + normalized = dict(config or {}) + normalized.setdefault("enable_prefix", True) + super().__init__(normalized, name) + self.service_type = EmailServiceType.CLOUDMAIL + + def list_emails(self, limit: int = 100, offset: int = 0, **kwargs) -> List[Dict[str, Any]]: + return super().list_emails(limit=limit, offset=offset, **kwargs) + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = r"(? Optional[str]: + return super().get_verification_code( + email=email, + email_id=email_id, + timeout=timeout, + pattern=pattern, + otp_sent_at=otp_sent_at, + ) diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index d2605292..62908a8c 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -21,6 +21,8 @@ from ..core.http_client import HTTPClient, RequestConfig from ..config.constants import OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_PATTERN +OTP_DOMAIN_PATTERN = re.compile(r"@[A-Za-z0-9.-]+\.\d{6}(?!\d)") + logger = logging.getLogger(__name__) @@ -747,6 +749,10 @@ def get_verification_code( reverse=True, )[0] code = str(best["code"]) + if OTP_DOMAIN_PATTERN.search(str(best.get("detail_content") or "")) and code in str(best.get("detail_content") or ""): + logger.debug("??????????????????? OTP") + time.sleep(3) + continue self._last_used_mail_ids[email] = str(best["mail_id"]) logger.info( "从 TempMail 邮箱 %s 找到验证码: %s(mail_id=%s ts=%s semantic=%s)", diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index e18b086d..631b063c 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -2,6 +2,7 @@ 账号管理 API 路由 """ import io +import asyncio import json import logging import re @@ -1636,6 +1637,42 @@ def make_account_entry(acc) -> dict: ) +@router.post("/export/codex") +async def export_accounts_codex(request: BatchExportRequest): + """????? Codex ???????""" + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + lines = [] + for acc in accounts: + lines.append(json.dumps({ + "email": acc.email, + "password": acc.password or "", + "client_id": acc.client_id or "", + "access_token": acc.access_token or "", + "refresh_token": acc.refresh_token or "", + "session_token": acc.session_token or "", + "account_id": acc.account_id or "", + "workspace_id": acc.workspace_id or "", + "cookies": acc.cookies or "", + "type": "codex", + "source": getattr(acc, "source", None) or "manual", + }, ensure_ascii=False)) + + content = "\n".join(lines) + filename = f"codex_accounts_{timestamp}.jsonl" + return StreamingResponse( + iter([content]), + media_type="application/x-ndjson", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + @router.post("/export/cpa") async def export_accounts_cpa(request: BatchExportRequest): """导出账号为 CPA Token JSON 格式(每个账号单独一个 JSON 文件,打包为 ZIP)""" @@ -2068,6 +2105,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, + target_type=locals().get("target_type", "sub2api"), ) return results @@ -2108,7 +2146,8 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp success, message = upload_to_sub2api( [account], api_url, api_key, - concurrency=concurrency, priority=priority + concurrency=concurrency, priority=priority, + target_type=locals().get("target_type", "sub2api") ) if success: return {"success": True, "message": message} @@ -2147,6 +2186,7 @@ async def batch_upload_accounts_to_tm(request: BatchUploadTMRequest): api_url = svc.api_url api_key = svc.api_key + target_type = getattr(svc, "target_type", "sub2api") ids = resolve_account_ids( db, request.ids, request.select_all, @@ -2175,6 +2215,7 @@ async def upload_account_to_tm(account_id: int, request: Optional[UploadTMReques api_url = svc.api_url api_key = svc.api_key + target_type = getattr(svc, "target_type", "sub2api") account = crud.get_account_by_id(db, account_id) if not account: diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 69317239..305efeff 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -7,10 +7,12 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel +from sqlalchemy import func from ...database import crud from ...database.session import get_db from ...database.models import EmailService as EmailServiceModel +from ...database.models import Account as AccountModel from ...services import EmailServiceFactory, EmailServiceType logger = logging.getLogger(__name__) @@ -37,13 +39,15 @@ class EmailServiceUpdate(BaseModel): class EmailServiceResponse(BaseModel): - """邮箱服务响应""" + """??????""" id: int service_type: str name: str enabled: bool priority: int - config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置 + config: Optional[Dict[str, Any]] = None # ?????????? + registration_status: Optional[str] = None + registered_account_id: Optional[int] = None last_used: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -115,7 +119,25 @@ def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: - """转换服务模型为响应""" + """?????????""" + registration_status = None + registered_account_id = None + if service.service_type == "outlook": + email = str((service.config or {}).get("email") or service.name or "").strip() + normalized_email = email.lower() + if email: + with get_db() as db: + account = ( + db.query(AccountModel) + .filter(func.lower(AccountModel.email) == normalized_email) + .first() + ) + if account: + registration_status = "registered" + registered_account_id = account.id + else: + registration_status = "unregistered" + return EmailServiceResponse( id=service.id, service_type=service.service_type, @@ -123,6 +145,8 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: enabled=service.enabled, priority=service.priority, config=filter_sensitive_config(service.config), + registration_status=registration_status, + registered_account_id=registered_account_id, last_used=service.last_used.isoformat() if service.last_used else None, created_at=service.created_at.isoformat() if service.created_at else None, updated_at=service.updated_at.isoformat() if service.updated_at else None, @@ -155,6 +179,7 @@ async def get_email_services_stats(): 'duck_mail_count': 0, 'freemail_count': 0, 'imap_mail_count': 0, + 'cloudmail_count': 0, 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count } @@ -172,6 +197,8 @@ async def get_email_services_stats(): stats['freemail_count'] = count elif service_type == 'imap_mail': stats['imap_mail_count'] = count + elif service_type == 'cloudmail': + stats['cloudmail_count'] = count return stats diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index b2cdcd5c..d428262e 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -862,15 +862,15 @@ async def start_batch_registration( """ 启动批量注册任务 - - count: 注册数量 (1-100) + - count: 注册数量 (1-1000) - email_service_type: 邮箱服务类型 - proxy: 代理地址 - interval_min: 最小间隔秒数 - interval_max: 最大间隔秒数 """ # 验证参数 - if request.count < 1 or request.count > 100: - raise HTTPException(status_code=400, detail="注册数量必须在 1-100 之间") + if request.count < 1 or request.count > 1000: + raise HTTPException(status_code=400, detail="注册数量必须在 1-1000 之间") try: EmailServiceType(request.email_service_type) diff --git a/src/web/routes/upload/cpa_services.py b/src/web/routes/upload/cpa_services.py index f98ec2f2..9c636cb7 100644 --- a/src/web/routes/upload/cpa_services.py +++ b/src/web/routes/upload/cpa_services.py @@ -19,6 +19,7 @@ class CpaServiceCreate(BaseModel): name: str api_url: str api_token: str + proxy_url: Optional[str] = None enabled: bool = True priority: int = 0 @@ -27,6 +28,7 @@ class CpaServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_token: Optional[str] = None + proxy_url: Optional[str] = None enabled: Optional[bool] = None priority: Optional[int] = None @@ -35,6 +37,7 @@ class CpaServiceResponse(BaseModel): id: int name: str api_url: str + proxy_url: Optional[str] = None has_token: bool enabled: bool priority: int @@ -55,6 +58,7 @@ def _to_response(svc) -> CpaServiceResponse: id=svc.id, name=svc.name, api_url=svc.api_url, + proxy_url=getattr(svc, "proxy_url", None), has_token=bool(svc.api_token), enabled=svc.enabled, priority=svc.priority, @@ -82,6 +86,7 @@ async def create_cpa_service(request: CpaServiceCreate): name=request.name, api_url=request.api_url, api_token=request.api_token, + proxy_url=request.proxy_url, enabled=request.enabled, priority=request.priority, ) @@ -110,6 +115,7 @@ async def get_cpa_service_full(service_id: int): "name": service.name, "api_url": service.api_url, "api_token": service.api_token, + "proxy_url": getattr(service, "proxy_url", None), "enabled": service.enabled, "priority": service.priority, } @@ -131,6 +137,8 @@ async def update_cpa_service(service_id: int, request: CpaServiceUpdate): # api_token 留空则保持原值 if request.api_token: update_data["api_token"] = request.api_token + if request.proxy_url is not None: + update_data["proxy_url"] = request.proxy_url if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index ddd77592..653f4b19 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -19,6 +19,7 @@ class Sub2ApiServiceCreate(BaseModel): name: str api_url: str api_key: str + target_type: str = "sub2api" enabled: bool = True priority: int = 0 @@ -27,6 +28,7 @@ class Sub2ApiServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_key: Optional[str] = None + target_type: Optional[str] = None enabled: Optional[bool] = None priority: Optional[int] = None @@ -89,6 +91,7 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate): name=request.name, api_url=request.api_url, api_key=request.api_key, + target_type=request.target_type, enabled=request.enabled, priority=request.priority, ) @@ -117,6 +120,7 @@ async def get_sub2api_service_full(service_id: int): "name": svc.name, "api_url": svc.api_url, "api_key": svc.api_key, + "target_type": getattr(svc, "target_type", "sub2api"), "enabled": svc.enabled, "priority": svc.priority, } @@ -138,6 +142,8 @@ async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate) # api_key 留空则保持原值 if request.api_key: update_data["api_key"] = request.api_key + if request.target_type is not None: + update_data["target_type"] = request.target_type if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: diff --git a/static/js/accounts.js b/static/js/accounts.js index 7038e041..bcce0243 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -804,7 +804,7 @@ async function exportAccounts(format) { // 从 Content-Disposition 获取文件名 const disposition = response.headers.get('Content-Disposition'); - let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : format}`; + let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : (format === 'codex' ? 'jsonl' : format)}`; if (disposition) { const match = disposition.match(/filename=(.+)/); if (match) { diff --git a/static/js/app.js b/static/js/app.js index 5a00e755..d96295b5 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1596,3 +1596,18 @@ async function restoreActiveTask() { } } } + + +async function refreshOutlookRegistrationStatus() { + try { + const ids = outlookAccounts.map(item => item.id).filter(Boolean); + const data = await api.post('/registration/outlook/check-accounts', { service_ids: ids }); + outlookAccounts = data.accounts || []; + renderOutlookAccounts(outlookAccounts); + addLog('info', `[??] Outlook ??????? (???: ${data.registered_count}, ???: ${data.unregistered_count})`); + toast.success('Outlook ???????'); + } catch (error) { + console.error('?? Outlook ??????:', error); + toast.error('?? Outlook ??????: ' + error.message); + } +} diff --git a/static/js/email_services.js b/static/js/email_services.js index 463b1a80..9583b2b5 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -238,9 +238,10 @@ async function loadOutlookServices() {