From c0edc06ab164d0b9172f45ef21d696f1936f8863 Mon Sep 17 00:00:00 2001 From: henry4682 Date: Tue, 10 Mar 2026 10:15:22 +0800 Subject: [PATCH] feat: linbot restruct the project --- app/DB/Models.py | 59 +++++++++ app/DB/Session.py | 13 ++ app/Invoice/Router.py | 22 ++++ app/Line/Captcha_state.py | 4 + app/Line/Expense.py | 259 ++++++++++++++++++++++++++++++++++++++ app/Line/Handlers.py | 50 ++++++++ app/Line/Router.py | 59 +++++++++ app/Trading/Router.py | 9 ++ app/main.py | 248 ++---------------------------------- app/old_main.py | 247 ++++++++++++++++++++++++++++++++++++ structure.txt | 19 +++ 11 files changed, 748 insertions(+), 241 deletions(-) create mode 100644 app/DB/Models.py create mode 100644 app/DB/Session.py create mode 100644 app/Invoice/Router.py create mode 100644 app/Line/Captcha_state.py create mode 100644 app/Line/Expense.py create mode 100644 app/Line/Handlers.py create mode 100644 app/Line/Router.py create mode 100644 app/Trading/Router.py create mode 100644 app/old_main.py create mode 100644 structure.txt diff --git a/app/DB/Models.py b/app/DB/Models.py new file mode 100644 index 0000000..ee14490 --- /dev/null +++ b/app/DB/Models.py @@ -0,0 +1,59 @@ +from sqlalchemy import Column, String, Numeric, Date, DateTime, BigInteger +from sqlalchemy.orm import declarative_base +from datetime import datetime + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + id = Column(BigInteger, primary_key=True) + name = Column(String) + email = Column(String, unique=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now) + + +class LineUser(Base): + __tablename__ = "line_users" + id = Column(BigInteger, primary_key=True) + line_user_id = Column(String, unique=True) + user_id = Column(BigInteger) + created_at = Column(DateTime, default=datetime.now) + + +class Category(Base): + __tablename__ = "categories" + id = Column(BigInteger, primary_key=True) + user_id = Column(BigInteger) + parent_id = Column(BigInteger, nullable=True) + name = Column(String) + color = Column(String, default='#3B82F6') + icon = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now) + + +class CategoryRule(Base): + __tablename__ = "category_rules" + id = Column(BigInteger, primary_key=True) + user_id = Column(BigInteger) + category_id = Column(BigInteger) + keyword = Column(String) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now) + + +class Expense(Base): + __tablename__ = "expenses" + id = Column(BigInteger, primary_key=True) + user_id = Column(BigInteger) + category_id = Column(BigInteger, nullable=True) + amount = Column(Numeric(10, 2)) + note = Column(String, nullable=True) + invoice_number = Column(String, nullable=True) + seller_name = Column(String, nullable=True) + item_name = Column(String, nullable=True) + date = Column(Date) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now) \ No newline at end of file diff --git a/app/DB/Session.py b/app/DB/Session.py new file mode 100644 index 0000000..818a4fa --- /dev/null +++ b/app/DB/Session.py @@ -0,0 +1,13 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.config import DATABASE_URL + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(bind=engine) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/Invoice/Router.py b/app/Invoice/Router.py new file mode 100644 index 0000000..ca8bc13 --- /dev/null +++ b/app/Invoice/Router.py @@ -0,0 +1,22 @@ +import asyncio +import threading +from fastapi import APIRouter + +router = APIRouter(prefix="/Invoice") + + +def _run_fetch(): + from invoice_fetcher import main as fetch_main + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(fetch_main()) + finally: + loop.close() + + +@router.get("/fetch") +async def fetch_invoices(): + print("🚀 開始抓取發票...") + threading.Thread(target=_run_fetch).start() + return {"status": "started"} \ No newline at end of file diff --git a/app/Line/Captcha_state.py b/app/Line/Captcha_state.py new file mode 100644 index 0000000..c87db4e --- /dev/null +++ b/app/Line/Captcha_state.py @@ -0,0 +1,4 @@ +import threading + +captcha_answer: str | None = None +captcha_event: threading.Event | None = None \ No newline at end of file diff --git a/app/Line/Expense.py b/app/Line/Expense.py new file mode 100644 index 0000000..3ca7585 --- /dev/null +++ b/app/Line/Expense.py @@ -0,0 +1,259 @@ +import re +from datetime import datetime, date +from sqlalchemy.orm import Session +from sqlalchemy import text +from app.db.models import User, LineUser, Category, CategoryRule, Expense +from app.db.session import SessionLocal + +EXPENSE_TEMPLATE = ( + "請填寫以下記帳資料後傳回:\n\n" + "品項: \n" + "類別: \n" + "金額: \n" + "店家: \n" + "備註: " +) + +DEFAULT_CATEGORIES = [ + ("餐飲", "#F97316", "🍽"), + ("交通", "#3B82F6", "🚗"), + ("購物", "#8B5CF6", "🛍"), + ("娛樂", "#EC4899", "🎮"), + ("醫療", "#10B981", "🏥"), + ("其他", "#6B7280", "📦"), +] + +DEFAULT_RULES = { + "餐飲": ["早餐", "午餐", "晚餐", "飲料", "便當", "餐飲"], + "交通": ["捷運", "公車", "計程車", "油錢", "停車", "交通"], + "購物": ["衣服", "3C", "日用品", "購物"], + "娛樂": ["電影", "遊戲", "旅遊", "娛樂"], + "醫療": ["藥局", "診所", "醫院", "醫療"], +} + + +def get_or_create_user(db: Session, line_user_id: str) -> int: + line_user = db.query(LineUser).filter( + LineUser.line_user_id == line_user_id + ).first() + if line_user: + return line_user.user_id + + new_user = User(name="LINE用戶", email=f"{line_user_id}@line.user") + db.add(new_user) + db.flush() + + parent_ids = {} + for name, color, icon in DEFAULT_CATEGORIES: + cat = Category(user_id=new_user.id, name=name, color=color, icon=icon) + db.add(cat) + db.flush() + parent_ids[name] = cat.id + + for cat_name, keywords in DEFAULT_RULES.items(): + for kw in keywords: + db.add(CategoryRule( + user_id=new_user.id, + category_id=parent_ids[cat_name], + keyword=kw + )) + + db.add(LineUser(line_user_id=line_user_id, user_id=new_user.id)) + db.commit() + return new_user.id + + +def resolve_category(db: Session, user_id: int, category_input: str) -> tuple[int, int | None]: + """回傳 (category_id, subcategory_id)""" + # 查 rule + rule = db.query(CategoryRule).filter( + CategoryRule.user_id == user_id, + CategoryRule.keyword == category_input + ).first() + if rule: + return rule.category_id, None + + # 查是否是大類 + parent = db.query(Category).filter( + Category.user_id == user_id, + Category.name == category_input, + Category.parent_id == None + ).first() + if parent: + return parent.id, None + + # 查是否是已存在小類 + sub = db.query(Category).filter( + Category.user_id == user_id, + Category.name == category_input, + Category.parent_id != None + ).first() + if sub: + return sub.parent_id, sub.id + + # 找不到 → 新增 subcategory 到「其他」 + other = db.query(Category).filter( + Category.user_id == user_id, + Category.name == "其他", + Category.parent_id == None + ).first() + parent_id = other.id if other else None + + new_sub = Category(user_id=user_id, parent_id=parent_id, name=category_input, color="#6B7280") + db.add(new_sub) + db.flush() + + db.add(CategoryRule(user_id=user_id, category_id=new_sub.id, keyword=category_input)) + return parent_id, new_sub.id + + +def parse_multiline_expense(text: str) -> dict | None: + fields = {} + field_map = { + "品項": "item_name", + "類別": "category", + "金額": "amount", + "店家": "seller_name", + "備註": "note", + } + for line in text.strip().splitlines(): + for key, field in field_map.items(): + if line.startswith(f"{key}:") or line.startswith(f"{key}:"): + value = re.split(r"[::]", line, 1)[-1].strip() + if value: + fields[field] = value + break + + if not all(k in fields for k in ["item_name", "category", "amount"]): + return None + + try: + fields["amount"] = float(re.sub(r"[^\d.]", "", fields["amount"])) + except ValueError: + return None + + return fields + + +def save_expense(line_user_id: str, fields: dict) -> str: + db = SessionLocal() + try: + user_id = get_or_create_user(db, line_user_id) + category_id, subcategory_id = resolve_category(db, user_id, fields["category"]) + + db.add(Expense( + user_id=user_id, + category_id=subcategory_id or category_id, + amount=fields["amount"], + note=fields.get("note"), + seller_name=fields.get("seller_name"), + item_name=fields["item_name"], + date=date.today(), + )) + db.commit() + + reply = ( + f"✅ 已記錄!\n" + f"品項:{fields['item_name']}\n" + f"類別:{fields['category']}\n" + f"金額:${fields['amount']:.0f}\n" + ) + if fields.get("seller_name"): + reply += f"店家:{fields['seller_name']}\n" + if fields.get("note"): + reply += f"備註:{fields['note']}\n" + return reply.strip() + except Exception as e: + db.rollback() + print("❌ 儲存失敗:", e) + return "儲存失敗,請稍後再試" + finally: + db.close() + + +def delete_expense(line_user_id: str, target: str) -> str: + db = SessionLocal() + try: + user_id = get_or_create_user(db, line_user_id) + rows = db.query(Expense).filter( + Expense.user_id == user_id, + text("DATE(date) = :today") + ).params(today=date.today()).all() + + if not rows: + return "今天還沒有記錄 📭" + + if target.isdigit(): + idx = int(target) - 1 + if idx < 0 or idx >= len(rows): + return f"沒有第 {target} 筆記錄,請先輸入「查今天」確認編號" + row = rows[idx] + db.delete(row) + db.commit() + return f"✅ 已刪除:{row.item_name} ${float(row.amount):.0f}" + + matched = [r for r in rows if r.item_name == target] + if not matched: + return f"今天沒有「{target}」的記錄" + row = matched[-1] + db.delete(row) + db.commit() + return f"✅ 已刪除:{row.item_name} ${float(row.amount):.0f}" + except Exception as e: + db.rollback() + print("❌ 刪除失敗:", e) + return "刪除失敗,請稍後再試" + finally: + db.close() + + +def query_today(line_user_id: str) -> str: + db = SessionLocal() + try: + user_id = get_or_create_user(db, line_user_id) + rows = db.query(Expense).filter( + Expense.user_id == user_id, + text("DATE(date) = :today") + ).params(today=date.today()).all() + + if not rows: + return "今天還沒有記錄 📭" + + total = sum(float(r.amount) for r in rows) + lines = [ + f"{i+1}. {r.item_name} ${float(r.amount):.0f}" + (f"({r.note})" if r.note else "") + for i, r in enumerate(rows) + ] + return "📋 今日記錄:\n" + "\n".join(lines) + f"\n\n💰 合計:${total:.0f}\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1" + except Exception as e: + print("❌ 查詢失敗:", e) + return "查詢失敗,請稍後再試" + finally: + db.close() + + +def query_month(line_user_id: str) -> str: + db = SessionLocal() + try: + user_id = get_or_create_user(db, line_user_id) + now = datetime.now() + rows = db.query(Expense).filter( + Expense.user_id == user_id, + text("EXTRACT(YEAR FROM date) = :year AND EXTRACT(MONTH FROM date) = :month") + ).params(year=now.year, month=now.month).all() + + if not rows: + return "本月還沒有記錄 📭" + + total = sum(float(r.amount) for r in rows) + summary = {} + for r in rows: + key = r.item_name or "其他" + summary[key] = summary.get(key, 0) + float(r.amount) + lines = [f"{cat}:${amt:.0f}" for cat, amt in sorted(summary.items(), key=lambda x: -x[1])] + return f"📊 本月統計({now.month}月):\n" + "\n".join(lines) + f"\n\n💰 總計:${total:.0f}" + except Exception as e: + print("❌ 查詢失敗:", e) + return "查詢失敗,請稍後再試" + finally: + db.close() \ No newline at end of file diff --git a/app/Line/Handlers.py b/app/Line/Handlers.py new file mode 100644 index 0000000..75c33d8 --- /dev/null +++ b/app/Line/Handlers.py @@ -0,0 +1,50 @@ +from app.line import captcha_state +from app.line.expense import ( + EXPENSE_TEMPLATE, + save_expense, + delete_expense, + query_today, + query_month, + parse_multiline_expense, +) + + +def handle_text(line_user_id: str, text: str) -> str: + if text == "查今天": + return query_today(line_user_id) + if text == "查本月": + return query_month(line_user_id) + if text in ("記帳", "記帳說明"): + return EXPENSE_TEMPLATE + if text.startswith("刪除 "): + return delete_expense(line_user_id, text[3:].strip()) + + # 多行記帳 + if "\n" in text: + fields = parse_multiline_expense(text) + if fields: + return save_expense(line_user_id, fields) + return ( + "格式不正確 😅\n\n" + "必填欄位:品項、類別、金額\n" + "點下方「記帳」取得模板!" + ) + + return ( + "點下方「記帳」取得記帳模板\n" + "或輸入「查今天」/「查本月」查詢" + ) + + +def handle_captcha(text: str) -> bool: + """回傳 True 表示是驗證碼輸入""" + if ( + text.isdigit() + and len(text) == 5 + and captcha_state.captcha_event + and not captcha_state.captcha_event.is_set() + ): + captcha_state.captcha_answer = text + captcha_state.captcha_event.set() + return True + return False \ No newline at end of file diff --git a/app/Line/Router.py b/app/Line/Router.py new file mode 100644 index 0000000..0e00294 --- /dev/null +++ b/app/Line/Router.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Request, HTTPException +from linebot.v3 import WebhookHandler +from linebot.v3.messaging import ( + Configuration, + ApiClient, + MessagingApi, + ReplyMessageRequest, + TextMessage, +) +from linebot.v3.webhooks import MessageEvent, TextMessageContent, FollowEvent +from linebot.v3.exceptions import InvalidSignatureError +from app.config import LINE_CHANNEL_ACCESS_TOKEN, LINE_CHANNEL_SECRET +from app.line.handlers import handle_text, handle_captcha + +router = APIRouter() +configuration = Configuration(access_token=LINE_CHANNEL_ACCESS_TOKEN) +handler = WebhookHandler(LINE_CHANNEL_SECRET) + + +@router.post("/webhook") +async def webhook(request: Request): + signature = request.headers.get("X-Line-Signature", "") + body = await request.body() + try: + handler.handle(body.decode(), signature) + except InvalidSignatureError: + raise HTTPException(status_code=400, detail="Invalid signature") + return "OK" + + +def _reply(reply_token: str, text: str): + with ApiClient(configuration) as api_client: + MessagingApi(api_client).reply_message( + ReplyMessageRequest( + reply_token=reply_token, + messages=[TextMessage(text=text)] + ) + ) + + +@handler.add(MessageEvent, message=TextMessageContent) +def on_message(event): + line_user_id = event.source.user_id + msg = event.message.text.strip() + + if handle_captcha(msg): + _reply(event.reply_token, "✅ 驗證碼已送出!") + else: + _reply(event.reply_token, handle_text(line_user_id, msg)) + + +@handler.add(FollowEvent) +def on_follow(event): + _reply(event.reply_token, ( + "👋 歡迎使用 Myfinance 記帳 Bot!\n\n" + "📝 點下方「記帳」開始記帳\n" + "📊 點「查今天」或「查本月」查詢\n\n" + "開始記帳吧!💪" + )) \ No newline at end of file diff --git a/app/Trading/Router.py b/app/Trading/Router.py new file mode 100644 index 0000000..10733ca --- /dev/null +++ b/app/Trading/Router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/Trading") + + +@router.get("/check-signals") +async def check_signals(): + # TODO: 移植策略邏輯 + return {"status": "not_implemented"} \ No newline at end of file diff --git a/app/main.py b/app/main.py index 8bf341f..4f22528 100644 --- a/app/main.py +++ b/app/main.py @@ -1,247 +1,13 @@ -import os -import re -import asyncio -import threading import nest_asyncio nest_asyncio.apply() -import captcha_state -from invoice_fetcher import main -from dotenv import load_dotenv -from fastapi import FastAPI, Request, HTTPException -from fastapi.staticfiles import StaticFiles -from linebot.v3 import WebhookHandler -from linebot.v3.messaging import ( - Configuration, - ApiClient, - MessagingApi, - ReplyMessageRequest, - TextMessage, -) -from linebot.v3.webhooks import MessageEvent, TextMessageContent, FollowEvent -from linebot.v3.exceptions import InvalidSignatureError -from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, text -from sqlalchemy.orm import declarative_base, sessionmaker -from datetime import datetime - -load_dotenv() +from fastapi import FastAPI +from app.line.router import router as line_router +from app.invoice.router import router as invoice_router +from app.trading.router import router as trading_router app = FastAPI() -# LINE 設定 -configuration = Configuration(access_token=os.getenv("LINE_CHANNEL_ACCESS_TOKEN")) -handler = WebhookHandler(os.getenv("LINE_CHANNEL_SECRET")) - -# DB 設定 -# DATABASE_URL = os.getenv("DATABASE_URL") -# engine = create_engine(DATABASE_URL) -# SessionLocal = sessionmaker(bind=engine) -# Base = declarative_base() - -# 資料表 -# class Transaction(Base): -# __tablename__ = "transactions" -# id = Column(Integer, primary_key=True, index=True) -# user_id = Column(String) -# category = Column(String) -# amount = Column(Float) -# note = Column(String, nullable=True) -# created_at = Column(DateTime, default=datetime.now) - -# Base.metadata.create_all(bind=engine) - -# Webhook endpoint -@app.post("/webhook") -async def webhook(request: Request): - signature = request.headers.get("X-Line-Signature", "") - body = await request.body() - try: - handler.handle(body.decode(), signature) - except InvalidSignatureError: - raise HTTPException(status_code=400, detail="Invalid signature") - return "OK" - -def run_fetch_in_thread(): - # 開一個全新的 event loop 跑 Playwright - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(main()) - finally: - loop.close() - -@app.get("/fetch") -async def fetch_invoices(): - print("🚀 開始抓取發票...") - thread = threading.Thread(target=run_fetch_in_thread) - thread.start() - return {"status": "started"} # 立刻回傳,不等爬蟲 - -@handler.add(MessageEvent, message=TextMessageContent) -def handle_message(event): - user_id = event.source.user_id - text = event.message.text.strip() - if text.isdigit() and captcha_state.captcha_event and not captcha_state.captcha_event.is_set(): - captcha_state.captcha_answer = text - captcha_state.captcha_event.set() # 通知爬蟲 - reply = "✅ 驗證碼已送出!" - else: - reply = parse_and_save(user_id, text) - - with ApiClient(configuration) as api_client: - line_bot_api = MessagingApi(api_client) - line_bot_api.reply_message( - ReplyMessageRequest( - reply_token=event.reply_token, - messages=[TextMessage(text=reply)] - ) - ) - -@handler.add(FollowEvent) -def handle_follow(event): - with ApiClient(configuration) as api_client: - line_bot_api = MessagingApi(api_client) - line_bot_api.reply_message( - ReplyMessageRequest( - reply_token=event.reply_token, - messages=[TextMessage(text=( - "👋 歡迎使用 Myfinance 記帳 Bot!\n\n" - "📝 記帳格式:\n" - "類別 金額\n" - "類別 金額 備註\n\n" - "範例:\n" - "早餐 80\n" - "午餐 120 便當\n" - "交通 50 捷運\n\n" - "📊 查詢指令:\n" - "查今天 → 今日明細\n" - "查本月 → 本月統計\n\n" - "開始記帳吧!💪" - ))] - ) - ) - -def parse_and_save(user_id: str, text: str) -> str: - # 查詢指令 - if text == "查今天": - return query_today(user_id) - if text == "查本月": - return query_month(user_id) - if text == "記帳說明": - return ( - "📝 記帳格式:\n" - "類別 金額\n" - "類別 金額 備註\n\n" - "範例:\n" - "早餐 80\n" - "午餐 120 便當\n" - "交通 50 捷運\n\n" - "輸入後 Bot 會自動記錄 ✅" - ) - - # 刪除指令:刪除 1 或 刪除 早餐 - if text.startswith("刪除 "): - target = text[3:].strip() - return delete_transaction(user_id, target) - - - if re.match(r"^\d{5}$", text): - return f"接收到驗證碼{text}" - - return "格式錯誤 😅\n記帳請輸入:類別 金額\n例如:早餐 80\n\n查詢請輸入:查今天 / 查本月" - -def save_transaction(user_id, category, amount, note): - print(f"記錄交易(模擬):{user_id} {category} {amount} {note}") - # db = SessionLocal() - # try: - # db.add(Transaction(user_id=user_id, category=category, amount=amount, note=note)) - # db.commit() - # finally: - # db.close() - -def delete_transaction(user_id: str, target: str) -> str: - # db = SessionLocal() - try: - today = datetime.now().date() - # rows = db.query(Transaction).filter( - # Transaction.user_id == user_id, - # text("DATE(created_at) = :today") - # ).params(today=today).all() - - # if not rows: - # return "今天還沒有記錄 📭" - - # 用編號刪除 - # if target.isdigit(): - # idx = int(target) - 1 - # if idx < 0 or idx >= len(rows): - # return f"沒有第 {target} 筆記錄,請先輸入「查今天」確認編號" - # row = rows[idx] - # db.delete(row) - # db.commit() - # return f"✅ 已刪除:{row.category} ${row.amount:.0f}" - - # 用類別刪除(刪最後一筆) - # matched = [r for r in rows if r.category == target] - # if not matched: - # return f"今天沒有「{target}」的記錄" - # row = matched[-1] - # db.delete(row) - # db.commit() - # return f"✅ 已刪除:{row.category} ${row.amount:.0f}" - return f"已刪除(模擬)" - - # finally: - # db.close() - except Exception as e: - print("❌ 刪除失敗:", e) - return "刪除失敗,請稍後再試" - -def query_today(user_id): - # db = SessionLocal() - try: - today = datetime.now().date() - # rows = db.query(Transaction).filter( - # Transaction.user_id == user_id, - # text("DATE(created_at) = :today") - # ).params(today=today).all() - # if not rows: - # return "今天還沒有記錄 📭" - # total = sum(r.amount for r in rows) - # lines = [ - # f"{i+1}. {r.category} ${r.amount:.0f}" + (f"({r.note})" if r.note else "") - # for i, r in enumerate(rows) - # ] - # return "📋 今日記錄:\n" + "\n".join(lines) + f"\n\n💰 合計:${total:.0f}\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1" - print(f"查詢今日記錄(模擬)") - return "📋 今日記錄:\n1. 早餐 $80\n2. 午餐 $120 便當\n3. 交通 $50 捷運\n\n💰 合計:$250\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1" - # finally: - # db.close() - except Exception as e: - print("❌ 查詢失敗:", e) - return "查詢失敗,請稍後再試" - -def query_month(user_id): - # db = SessionLocal() - try: - now = datetime.now() - # rows = db.query(Transaction).filter( - # Transaction.user_id == user_id, - # text("EXTRACT(YEAR FROM created_at) = :year AND EXTRACT(MONTH FROM created_at) = :month") - # ).params(year=now.year, month=now.month).all() - # if not rows: - # return "本月還沒有記錄 📭" - # total = sum(r.amount for r in rows) - # 依類別統計 - # summary = {} - # for r in rows: - # summary[r.category] = summary.get(r.category, 0) + r.amount - # lines = [f"{cat}:${amt:.0f}" for cat, amt in sorted(summary.items(), key=lambda x: -x[1])] - # return f"📊 本月統計({now.month}月):\n" + "\n".join(lines) + f"\n\n💰 總計:${total:.0f}" - print(f"查詢本月記錄(模擬)") - return f"📊 本月統計({now.month}月):\n早餐:$800\n午餐:$1200\n交通:$500\n\n💰 總計:$2500" - # finally: - # db.close() - except Exception as e: - print("❌ 查詢失敗:", e) - return "查詢失敗,請稍後再試" \ No newline at end of file +app.include_router(line_router) +app.include_router(invoice_router) +app.include_router(trading_router) \ No newline at end of file diff --git a/app/old_main.py b/app/old_main.py new file mode 100644 index 0000000..8bf341f --- /dev/null +++ b/app/old_main.py @@ -0,0 +1,247 @@ +import os +import re +import asyncio +import threading +import nest_asyncio +nest_asyncio.apply() +import captcha_state +from invoice_fetcher import main +from dotenv import load_dotenv +from fastapi import FastAPI, Request, HTTPException +from fastapi.staticfiles import StaticFiles +from linebot.v3 import WebhookHandler +from linebot.v3.messaging import ( + Configuration, + ApiClient, + MessagingApi, + ReplyMessageRequest, + TextMessage, +) +from linebot.v3.webhooks import MessageEvent, TextMessageContent, FollowEvent +from linebot.v3.exceptions import InvalidSignatureError +from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, text +from sqlalchemy.orm import declarative_base, sessionmaker +from datetime import datetime + + +load_dotenv() + +app = FastAPI() + +# LINE 設定 +configuration = Configuration(access_token=os.getenv("LINE_CHANNEL_ACCESS_TOKEN")) +handler = WebhookHandler(os.getenv("LINE_CHANNEL_SECRET")) + +# DB 設定 +# DATABASE_URL = os.getenv("DATABASE_URL") +# engine = create_engine(DATABASE_URL) +# SessionLocal = sessionmaker(bind=engine) +# Base = declarative_base() + +# 資料表 +# class Transaction(Base): +# __tablename__ = "transactions" +# id = Column(Integer, primary_key=True, index=True) +# user_id = Column(String) +# category = Column(String) +# amount = Column(Float) +# note = Column(String, nullable=True) +# created_at = Column(DateTime, default=datetime.now) + +# Base.metadata.create_all(bind=engine) + +# Webhook endpoint +@app.post("/webhook") +async def webhook(request: Request): + signature = request.headers.get("X-Line-Signature", "") + body = await request.body() + try: + handler.handle(body.decode(), signature) + except InvalidSignatureError: + raise HTTPException(status_code=400, detail="Invalid signature") + return "OK" + +def run_fetch_in_thread(): + # 開一個全新的 event loop 跑 Playwright + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(main()) + finally: + loop.close() + +@app.get("/fetch") +async def fetch_invoices(): + print("🚀 開始抓取發票...") + thread = threading.Thread(target=run_fetch_in_thread) + thread.start() + return {"status": "started"} # 立刻回傳,不等爬蟲 + +@handler.add(MessageEvent, message=TextMessageContent) +def handle_message(event): + user_id = event.source.user_id + text = event.message.text.strip() + if text.isdigit() and captcha_state.captcha_event and not captcha_state.captcha_event.is_set(): + captcha_state.captcha_answer = text + captcha_state.captcha_event.set() # 通知爬蟲 + reply = "✅ 驗證碼已送出!" + else: + reply = parse_and_save(user_id, text) + + with ApiClient(configuration) as api_client: + line_bot_api = MessagingApi(api_client) + line_bot_api.reply_message( + ReplyMessageRequest( + reply_token=event.reply_token, + messages=[TextMessage(text=reply)] + ) + ) + +@handler.add(FollowEvent) +def handle_follow(event): + with ApiClient(configuration) as api_client: + line_bot_api = MessagingApi(api_client) + line_bot_api.reply_message( + ReplyMessageRequest( + reply_token=event.reply_token, + messages=[TextMessage(text=( + "👋 歡迎使用 Myfinance 記帳 Bot!\n\n" + "📝 記帳格式:\n" + "類別 金額\n" + "類別 金額 備註\n\n" + "範例:\n" + "早餐 80\n" + "午餐 120 便當\n" + "交通 50 捷運\n\n" + "📊 查詢指令:\n" + "查今天 → 今日明細\n" + "查本月 → 本月統計\n\n" + "開始記帳吧!💪" + ))] + ) + ) + +def parse_and_save(user_id: str, text: str) -> str: + # 查詢指令 + if text == "查今天": + return query_today(user_id) + if text == "查本月": + return query_month(user_id) + if text == "記帳說明": + return ( + "📝 記帳格式:\n" + "類別 金額\n" + "類別 金額 備註\n\n" + "範例:\n" + "早餐 80\n" + "午餐 120 便當\n" + "交通 50 捷運\n\n" + "輸入後 Bot 會自動記錄 ✅" + ) + + # 刪除指令:刪除 1 或 刪除 早餐 + if text.startswith("刪除 "): + target = text[3:].strip() + return delete_transaction(user_id, target) + + + if re.match(r"^\d{5}$", text): + return f"接收到驗證碼{text}" + + return "格式錯誤 😅\n記帳請輸入:類別 金額\n例如:早餐 80\n\n查詢請輸入:查今天 / 查本月" + +def save_transaction(user_id, category, amount, note): + print(f"記錄交易(模擬):{user_id} {category} {amount} {note}") + # db = SessionLocal() + # try: + # db.add(Transaction(user_id=user_id, category=category, amount=amount, note=note)) + # db.commit() + # finally: + # db.close() + +def delete_transaction(user_id: str, target: str) -> str: + # db = SessionLocal() + try: + today = datetime.now().date() + # rows = db.query(Transaction).filter( + # Transaction.user_id == user_id, + # text("DATE(created_at) = :today") + # ).params(today=today).all() + + # if not rows: + # return "今天還沒有記錄 📭" + + # 用編號刪除 + # if target.isdigit(): + # idx = int(target) - 1 + # if idx < 0 or idx >= len(rows): + # return f"沒有第 {target} 筆記錄,請先輸入「查今天」確認編號" + # row = rows[idx] + # db.delete(row) + # db.commit() + # return f"✅ 已刪除:{row.category} ${row.amount:.0f}" + + # 用類別刪除(刪最後一筆) + # matched = [r for r in rows if r.category == target] + # if not matched: + # return f"今天沒有「{target}」的記錄" + # row = matched[-1] + # db.delete(row) + # db.commit() + # return f"✅ 已刪除:{row.category} ${row.amount:.0f}" + return f"已刪除(模擬)" + + # finally: + # db.close() + except Exception as e: + print("❌ 刪除失敗:", e) + return "刪除失敗,請稍後再試" + +def query_today(user_id): + # db = SessionLocal() + try: + today = datetime.now().date() + # rows = db.query(Transaction).filter( + # Transaction.user_id == user_id, + # text("DATE(created_at) = :today") + # ).params(today=today).all() + # if not rows: + # return "今天還沒有記錄 📭" + # total = sum(r.amount for r in rows) + # lines = [ + # f"{i+1}. {r.category} ${r.amount:.0f}" + (f"({r.note})" if r.note else "") + # for i, r in enumerate(rows) + # ] + # return "📋 今日記錄:\n" + "\n".join(lines) + f"\n\n💰 合計:${total:.0f}\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1" + print(f"查詢今日記錄(模擬)") + return "📋 今日記錄:\n1. 早餐 $80\n2. 午餐 $120 便當\n3. 交通 $50 捷運\n\n💰 合計:$250\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1" + # finally: + # db.close() + except Exception as e: + print("❌ 查詢失敗:", e) + return "查詢失敗,請稍後再試" + +def query_month(user_id): + # db = SessionLocal() + try: + now = datetime.now() + # rows = db.query(Transaction).filter( + # Transaction.user_id == user_id, + # text("EXTRACT(YEAR FROM created_at) = :year AND EXTRACT(MONTH FROM created_at) = :month") + # ).params(year=now.year, month=now.month).all() + # if not rows: + # return "本月還沒有記錄 📭" + # total = sum(r.amount for r in rows) + # 依類別統計 + # summary = {} + # for r in rows: + # summary[r.category] = summary.get(r.category, 0) + r.amount + # lines = [f"{cat}:${amt:.0f}" for cat, amt in sorted(summary.items(), key=lambda x: -x[1])] + # return f"📊 本月統計({now.month}月):\n" + "\n".join(lines) + f"\n\n💰 總計:${total:.0f}" + print(f"查詢本月記錄(模擬)") + return f"📊 本月統計({now.month}月):\n早餐:$800\n午餐:$1200\n交通:$500\n\n💰 總計:$2500" + # finally: + # db.close() + except Exception as e: + print("❌ 查詢失敗:", e) + return "查詢失敗,請稍後再試" \ No newline at end of file diff --git a/structure.txt b/structure.txt new file mode 100644 index 0000000..3406282 --- /dev/null +++ b/structure.txt @@ -0,0 +1,19 @@ +app/ +├── main.py # include 三個 router +├── config.py # 所有環境變數統一管理 +├── db/ +│ ├── session.py # engine + SessionLocal +│ └── models.py # 所有 SQLAlchemy model +├── line/ +│ ├── router.py # /webhook +│ ├── handlers.py # 訊息進來的入口 +│ ├── expense.py # 記帳所有邏輯(原本的 main.py) +│ └── captcha_state.py +├── invoice/ +│ └── router.py # /invoice/fetch +└── trading/ + ├── router.py # /trading/check-signals(骨架) + ├── data.py # TODO: 移植行情抓取 + ├── indicators.py # TODO: 移植指標計算 + ├── strategy.py # TODO: 移植策略邏輯 + └── broker.py # TODO: 永豐 Shioaji \ No newline at end of file