feat: implement SQLite database management and Supabase integration for user authentication and points synchronization
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const res = await fetch(`${API_BASE}/api/auth/telegram/bot-bind-link`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create Telegram bot bind link",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -727,19 +727,23 @@ export function AccountCenter() {
|
||||
restricted: isEn ? "Restricted" : "受限",
|
||||
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
|
||||
telegramHint: isEn
|
||||
? "Send the command below to the polyweather bot to sync notifications and access. Telegram group access is reviewed for Pro users."
|
||||
: "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。Telegram 群组需提交申请并完成 Pro 审核。",
|
||||
? "Use one-click Bot binding first to sync notifications and access. Telegram group access is reviewed automatically for Pro users."
|
||||
: "优先使用「一键绑定机器人」同步通知与权限。Telegram 群组会根据 Pro 状态自动审核入群申请。",
|
||||
telegramFallbackHint: isEn
|
||||
? "Fallback: if one-click binding does not open Telegram correctly, copy the command below and send it to @WeatherQuant_bot."
|
||||
: "备用复制方式:如果一键绑定无法正常打开 Telegram,请复制下方命令并发送给 @WeatherQuant_bot。",
|
||||
paymentManualSupport: isEn
|
||||
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
|
||||
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
||||
telegramBotLink: isEn
|
||||
? "Open Bot (@WeatherQuant_bot)"
|
||||
: "打开机器人 (@WeatherQuant_bot)",
|
||||
telegramBotBindLink: isEn ? "One-click Bot Binding" : "一键绑定机器人",
|
||||
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
|
||||
telegramTopicsGroupLink: isEn
|
||||
? "Real-time Weather Updates"
|
||||
: "城市实测温度群",
|
||||
copyCommand: isEn ? "Copy command" : "复制命令",
|
||||
copyCommand: isEn ? "Copy fallback command" : "复制备用命令",
|
||||
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||
@@ -869,6 +873,7 @@ export function AccountCenter() {
|
||||
const [paymentError, setPaymentError] = useState("");
|
||||
const [lastIntentId, setLastIntentId] = useState("");
|
||||
const [lastTxHash, setLastTxHash] = useState("");
|
||||
const [telegramBindOpening, setTelegramBindOpening] = useState(false);
|
||||
const [manualPayment, setManualPayment] = useState<
|
||||
CreatedIntent["direct_payment"] | null
|
||||
>(null);
|
||||
@@ -1791,6 +1796,31 @@ export function AccountCenter() {
|
||||
});
|
||||
};
|
||||
|
||||
const openTelegramBotBindLink = async () => {
|
||||
setTelegramBindOpening(true);
|
||||
setPaymentError("");
|
||||
try {
|
||||
const authHeaders = await buildAuthedHeaders(true, false);
|
||||
const res = await fetch("/api/auth/telegram/bot-bind-link", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = (await res.text()).slice(0, 300);
|
||||
throw new Error(raw || "failed to create telegram bind link");
|
||||
}
|
||||
const data = (await res.json()) as { bot_url?: string };
|
||||
const botUrl = String(data.bot_url || "").trim();
|
||||
if (!botUrl) throw new Error("telegram bind link missing");
|
||||
window.open(botUrl, "_blank", "noopener,noreferrer");
|
||||
setPaymentInfo("已打开 Telegram Bot,请在 Bot 内点击 Start 完成绑定。");
|
||||
} catch (error) {
|
||||
setPaymentError(normalizePaymentError(error).message);
|
||||
} finally {
|
||||
setTelegramBindOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Payment Logic (preserved) ---
|
||||
|
||||
const waitForReceipt = async (
|
||||
@@ -3009,7 +3039,8 @@ export function AccountCenter() {
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
) : null}
|
||||
{TELEGRAM_TOPICS_GROUP_URL ? (
|
||||
{TELEGRAM_TOPICS_GROUP_URL &&
|
||||
TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL ? (
|
||||
<Link
|
||||
href={TELEGRAM_TOPICS_GROUP_URL}
|
||||
target="_blank"
|
||||
@@ -3036,6 +3067,15 @@ export function AccountCenter() {
|
||||
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{bindCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => void openTelegramBotBindLink()}
|
||||
disabled={telegramBindOpening || !isAuthenticated}
|
||||
className="px-4 py-3 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg text-white text-xs font-bold"
|
||||
title={copy.telegramBotBindLink}
|
||||
aria-label={copy.telegramBotBindLink}
|
||||
>
|
||||
{telegramBindOpening ? "..." : copy.telegramBotBindLink}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopy(bindCommand)}
|
||||
className="p-4 bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg text-white"
|
||||
@@ -3045,6 +3085,9 @@ export function AccountCenter() {
|
||||
{copied ? <CheckCircle2 size={20} /> : <Copy size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-5 text-slate-400">
|
||||
{copy.telegramFallbackHint}
|
||||
</p>
|
||||
<div className="mt-5 rounded-2xl border border-amber-400/25 bg-amber-500/8 px-4 py-3 text-xs leading-6 text-amber-100/90">
|
||||
{copy.paymentManualSupport}
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB |
@@ -114,11 +114,57 @@ class BasicCommandHandler:
|
||||
def handle_start_help(self, message: Any) -> None:
|
||||
trace = CommandTrace("/start", message)
|
||||
try:
|
||||
parts = (getattr(message, "text", None) or "").split(maxsplit=1)
|
||||
payload = str(parts[1] if len(parts) > 1 else "").strip()
|
||||
if payload.startswith("bind_"):
|
||||
token = payload[len("bind_") :].strip()
|
||||
result = self._bind_from_web_token(message, token)
|
||||
trace.set_status("ok" if result == "bound" else "error", result)
|
||||
return
|
||||
self.bot.reply_to(message, self.io_layer.build_welcome_text(), parse_mode="HTML")
|
||||
trace.set_status("ok")
|
||||
finally:
|
||||
trace.emit()
|
||||
|
||||
def _bind_from_web_token(self, message: Any, token: str) -> str:
|
||||
if not token:
|
||||
self.bot.reply_to(message, "❌ 绑定链接无效,请回到网页重新点击一键绑定。")
|
||||
return "invalid_token"
|
||||
user = message.from_user
|
||||
try:
|
||||
payload = self.io_layer.db.consume_web_bind_token(token)
|
||||
except Exception as exc:
|
||||
logger.warning("web bind token consume failed user_id={}: {}", getattr(user, "id", ""), exc)
|
||||
payload = None
|
||||
if not isinstance(payload, dict):
|
||||
self.bot.reply_to(message, "❌ 绑定链接已过期或无效,请回到网页重新点击一键绑定。")
|
||||
return "invalid_or_expired_token"
|
||||
|
||||
supabase_user_id = str(payload.get("supabase_user_id") or "").strip()
|
||||
supabase_email = str(payload.get("supabase_email") or "").strip()
|
||||
if not supabase_user_id:
|
||||
self.bot.reply_to(message, "❌ 绑定链接缺少网页账号信息,请重新登录后再试。")
|
||||
return "missing_supabase_user_id"
|
||||
|
||||
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user))
|
||||
bind_result = self.io_layer.db.bind_supabase_identity(
|
||||
telegram_id=user.id,
|
||||
supabase_user_id=supabase_user_id,
|
||||
supabase_email=supabase_email,
|
||||
)
|
||||
if not bool(bind_result.get("ok")):
|
||||
reason = str(bind_result.get("reason") or "bind_failed")
|
||||
self.bot.reply_to(message, f"❌ 绑定失败:{reason}")
|
||||
return reason
|
||||
self.bot.reply_to(
|
||||
message,
|
||||
(
|
||||
"✅ 账号绑定完成。\n"
|
||||
"现在可以回到网页点击“加入 Telegram 群组”,入群申请会自动审核。"
|
||||
),
|
||||
)
|
||||
return "bound"
|
||||
|
||||
def handle_id(self, message: Any) -> None:
|
||||
trace = CommandTrace("/id", message)
|
||||
try:
|
||||
|
||||
@@ -366,6 +366,18 @@ class DBManager:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_telegram_bind_tokens_expires ON telegram_bind_tokens(expires_at)"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS web_telegram_bind_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
supabase_user_id TEXT NOT NULL,
|
||||
supabase_email TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_web_telegram_bind_tokens_expires ON web_telegram_bind_tokens(expires_at)"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS airport_obs_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1452,6 +1464,69 @@ class DBManager:
|
||||
conn.commit()
|
||||
return int(row["telegram_id"])
|
||||
|
||||
def create_web_bind_token(
|
||||
self,
|
||||
supabase_user_id: str,
|
||||
supabase_email: str = "",
|
||||
ttl_minutes: int = 10,
|
||||
) -> str:
|
||||
normalized_uid = str(supabase_user_id or "").strip().lower()
|
||||
if not normalized_uid:
|
||||
raise ValueError("supabase_user_id is required")
|
||||
token = secrets.token_urlsafe(16)
|
||||
now = datetime.now()
|
||||
expires_at = now + timedelta(minutes=max(1, int(ttl_minutes)))
|
||||
with self._get_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM web_telegram_bind_tokens
|
||||
WHERE supabase_user_id = ? OR expires_at < ?
|
||||
""",
|
||||
(normalized_uid, now.isoformat()),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO web_telegram_bind_tokens (
|
||||
token, supabase_user_id, supabase_email, expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(token, normalized_uid, str(supabase_email or "").strip(), expires_at.isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
return token
|
||||
|
||||
def consume_web_bind_token(self, token: str) -> Optional[Dict[str, str]]:
|
||||
token = str(token or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
now = datetime.now()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT supabase_user_id, supabase_email, expires_at
|
||||
FROM web_telegram_bind_tokens
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(row["expires_at"])
|
||||
except Exception:
|
||||
expires_at = now
|
||||
conn.execute("DELETE FROM web_telegram_bind_tokens WHERE token = ?", (token,))
|
||||
conn.commit()
|
||||
if now > expires_at:
|
||||
return None
|
||||
return {
|
||||
"supabase_user_id": str(row["supabase_user_id"] or "").strip().lower(),
|
||||
"supabase_email": str(row["supabase_email"] or "").strip(),
|
||||
}
|
||||
|
||||
def add_message_activity(
|
||||
self,
|
||||
telegram_id: int,
|
||||
|
||||
@@ -91,6 +91,42 @@ def test_basic_handler_diag_returns_html():
|
||||
assert "Bot 启动诊断" in bot.replies[0]["text"]
|
||||
|
||||
|
||||
def test_start_bind_token_binds_telegram_to_web_account():
|
||||
bot = DummyBot()
|
||||
db = SimpleNamespace(
|
||||
consume_web_bind_token=lambda token: {
|
||||
"supabase_user_id": "user-1",
|
||||
"supabase_email": "u@example.com",
|
||||
}
|
||||
if token == "abc123"
|
||||
else None,
|
||||
upsert_user=lambda *_args, **_kwargs: None,
|
||||
bind_supabase_identity=lambda **_kwargs: {"ok": True, "reason": "bound"},
|
||||
)
|
||||
io_layer = SimpleNamespace(
|
||||
build_welcome_text=lambda: "WELCOME",
|
||||
build_points_rank_text=lambda _user: "TOP",
|
||||
display_name=lambda user: user.username,
|
||||
db=db,
|
||||
)
|
||||
handler = BasicCommandHandler(
|
||||
bot=bot,
|
||||
io_layer=io_layer,
|
||||
runtime_status_provider=lambda: RuntimeStatus(
|
||||
started_at="2026-03-12 00:00:00 UTC",
|
||||
loops=[],
|
||||
command_access_mode="group_member",
|
||||
protected_commands=["/city", "/deb"],
|
||||
required_group_chat_id="-1001234567890",
|
||||
),
|
||||
)
|
||||
|
||||
handler.handle_start_help(_message("/start bind_abc123"))
|
||||
|
||||
assert len(bot.replies) == 1
|
||||
assert "账号绑定完成" in bot.replies[0]["text"]
|
||||
|
||||
|
||||
def test_basic_handler_markets_returns_summary():
|
||||
bot = DummyBot()
|
||||
io_layer = SimpleNamespace(
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
|
||||
from web.core import TelegramBindTokenRequest, TelegramLoginRequest
|
||||
from web.services.auth_api import (
|
||||
bind_telegram_by_token,
|
||||
create_telegram_bot_bind_link,
|
||||
get_auth_me_payload,
|
||||
login_with_telegram,
|
||||
)
|
||||
@@ -25,3 +26,8 @@ async def auth_telegram_login(request: Request, body: TelegramLoginRequest):
|
||||
@router.post("/api/auth/telegram/bind-by-token")
|
||||
async def auth_telegram_bind_by_token(request: Request, body: TelegramBindTokenRequest):
|
||||
return bind_telegram_by_token(request, body)
|
||||
|
||||
|
||||
@router.post("/api/auth/telegram/bot-bind-link")
|
||||
async def auth_telegram_bot_bind_link(request: Request):
|
||||
return create_telegram_bot_bind_link(request)
|
||||
|
||||
@@ -197,3 +197,30 @@ def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
|
||||
"binding": bind_result,
|
||||
"telegram_pricing": price,
|
||||
}
|
||||
|
||||
|
||||
def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]:
|
||||
"""Create a one-time web-to-bot bind deep link for the authenticated account."""
|
||||
legacy_routes._assert_entitlement(request)
|
||||
identity = legacy_routes._require_supabase_identity(request)
|
||||
|
||||
db = DBManager()
|
||||
token = db.create_web_bind_token(
|
||||
supabase_user_id=identity["user_id"],
|
||||
supabase_email=identity.get("email") or "",
|
||||
ttl_minutes=10,
|
||||
)
|
||||
start_param = f"bind_{token}"
|
||||
bot_username = str(
|
||||
legacy_routes.os.getenv("TELEGRAM_BOT_USERNAME")
|
||||
or legacy_routes.os.getenv("NEXT_PUBLIC_TELEGRAM_BOT_USERNAME")
|
||||
or "WeatherQuant_bot"
|
||||
).strip().lstrip("@")
|
||||
bot_url = f"https://t.me/{bot_username}?start={start_param}"
|
||||
return {
|
||||
"ok": True,
|
||||
"token": token,
|
||||
"start_param": start_param,
|
||||
"bot_url": bot_url,
|
||||
"expires_in_seconds": 600,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user