mirror of
https://github.com/henry4682/finance_app.git
synced 2026-07-16 00:10:00 +00:00
feat: 前端分離+功能api化
1. 新增資產管理CRUD、googleOAuth登入 2. 更改原記帳controller
This commit is contained in:
23
app/Console/Commands/DebugGmailJob.php
Normal file
23
app/Console/Commands/DebugGmailJob.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Google\Client as GoogleClient;
|
||||
|
||||
use App\Jobs\FetchAndParseGmailInvoices;
|
||||
|
||||
class DebugGmailJob extends Command
|
||||
{
|
||||
protected $signature = 'gmail:debug-job';
|
||||
protected $description = '繞過Job快取,直接在終端機打印最新未讀信件';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->info("🔄 正在讀取 User ID 1 的 Google 憑證...");
|
||||
$user = User::find(1);
|
||||
|
||||
app(FetchAndParseGmailInvoices::class, ['user' => $user])->handle(new \App\Services\GmailParserService());
|
||||
}
|
||||
}
|
||||
162
app/Console/Commands/FetchGmailInvoices.php
Normal file
162
app/Console/Commands/FetchGmailInvoices.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
use App\Models\Expense;
|
||||
use App\Services\GmailParserService;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\Gmail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class FetchAndParseGmailInvoices implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $user;
|
||||
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function handle(GmailParserService $parserService)
|
||||
{
|
||||
Log::info("====== [DEBUG START] 開始執行 Gmail 抓信任務 ======");
|
||||
|
||||
if (!$this->user->google_refresh_token) {
|
||||
Log::error("❌ 失敗:User ID {$this->user->id} 缺乏 google_refresh_token");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Log::info("🔄 正在初始化 Google Client & 刷新 Token...");
|
||||
$client = new GoogleClient();
|
||||
$client->setClientId(config('services.google.client_id'));
|
||||
$client->setClientSecret(config('services.google.client_secret'));
|
||||
$client->refreshToken($this->user->google_refresh_token);
|
||||
$gmailService = new Gmail($client);
|
||||
|
||||
$afterDate = Carbon::now()->subDays(3)->format('Y/m/d');
|
||||
$searchQuery = "from:cathaybk.com.tw subject:消費彙整通知 after:{$afterDate}";
|
||||
Log::info("🔍 正在向 Google API 發送搜尋 Query: [{$searchQuery}]");
|
||||
|
||||
$response = $gmailService->users_messages->listUsersMessages('me', [
|
||||
'q' => $searchQuery,
|
||||
'maxResults' => 10
|
||||
]);
|
||||
|
||||
$messages = $response->getMessages();
|
||||
|
||||
if (empty($messages)) {
|
||||
Log::warning("⚠️ 警告:Google API 回傳空陣列!");
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info("🎯 成功拿到信件清單!總共發現 " . count($messages) . " 封潛在信件,開始逐封處理...");
|
||||
|
||||
foreach ($messages as $index => $msgItem) {
|
||||
$msgId = $msgItem->getId();
|
||||
Log::info("--- [處理第 " . ($index + 1) . " 封信] ID: {$msgId} ---");
|
||||
|
||||
$message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']);
|
||||
$payload = $message->getPayload();
|
||||
|
||||
$subject = '';
|
||||
foreach ($payload->getHeaders() as $header) {
|
||||
if ($header->getName() === 'Subject') {
|
||||
$subject = $header->getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Log::info("📬 信件主旨: [{$subject}]");
|
||||
|
||||
$body = $this->getDecodedBody($payload);
|
||||
Log::info("📄 內文解碼完成,準備丟入 Parser...");
|
||||
|
||||
// 1. 執行解析(現在國泰會回報陣列,其餘單筆通知我們手動把它包成陣列)
|
||||
$parsedResult = $parserService->parse($subject, $body);
|
||||
|
||||
if (!$parsedResult) {
|
||||
Log::error("❌ 失敗:GmailParserService 無法解析此信件內容");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 💡 智慧防呆:如果回傳的是單筆消費(一維陣列),我們把它包成二維陣列,這樣下面統一用 foreach 處理
|
||||
$expensesList = isset($parsedResult['amount']) ? [$parsedResult] : $parsedResult;
|
||||
|
||||
Log::info("✨ Parser 解析成功!共發現 " . count($expensesList) . " 筆刷卡消費項目,開始遍歷寫入...");
|
||||
|
||||
// 🔄 遍歷這封信裡面的每一筆刷卡紀錄
|
||||
foreach ($expensesList as $parsedData) {
|
||||
|
||||
// 🟢 2. 智慧資產帳戶對照
|
||||
$matchedAccountRule = \App\Models\AccountRule::where('user_id', $this->user->id)
|
||||
->get()
|
||||
->first(function ($rule) use ($subject) {
|
||||
return Str::contains($subject, $rule->keyword);
|
||||
});
|
||||
$accountId = $matchedAccountRule ? $matchedAccountRule->account_id : null;
|
||||
|
||||
// 🟢 3. 智慧消費分類對照
|
||||
$matchedCategoryRule = \App\Models\CategoryRule::where('user_id', $this->user->id)
|
||||
->get()
|
||||
->first(function ($rule) use ($parsedData) {
|
||||
return Str::contains(strtoupper($parsedData['seller_name']), strtoupper($rule->keyword));
|
||||
});
|
||||
$categoryId = $matchedCategoryRule ? $matchedCategoryRule->category_id : null;
|
||||
|
||||
// 4. 💾 嘗試寫入資料庫
|
||||
$expense = Expense::firstOrCreate(
|
||||
[
|
||||
'user_id' => $this->user->id,
|
||||
'amount' => $parsedData['amount'],
|
||||
'seller_name' => $parsedData['seller_name'],
|
||||
'date' => $parsedData['date'],
|
||||
],
|
||||
[
|
||||
'item_name' => $parsedData['item_name'],
|
||||
'account_id' => $accountId,
|
||||
'category_id' => $categoryId,
|
||||
'note' => 'Gmail自動同步(多筆完全體)',
|
||||
]
|
||||
);
|
||||
|
||||
if ($expense->wasRecentlyCreated) {
|
||||
Log::info("✅ 成功!建立全新消費: [{$parsedData['seller_name']}] 額度: [{$parsedData['amount']}], ID: {$expense->id}");
|
||||
} else {
|
||||
Log::info("ℹ️ 提示:[{$parsedData['seller_name']}] 額度: [{$parsedData['amount']}] 已存在,跳過。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("====== [DEBUG END] Gmail 抓信任務順利結束 ======");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("💥 崩潰!handle 內發生致命錯誤: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getDecodedBody($part)
|
||||
{
|
||||
$body = $part->getBody();
|
||||
if ($body && $body->getData()) {
|
||||
return base64_decode(str_replace(['-', '_'], ['+', '/'], $body->getData()));
|
||||
}
|
||||
$parts = $part->getParts();
|
||||
if ($parts) {
|
||||
foreach ($parts as $subPart) {
|
||||
$result = $this->getDecodedBody($subPart);
|
||||
if ($result) return $result;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/RefreshAllAssetPrices.php
Normal file
40
app/Console/Commands/RefreshAllAssetPrices.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\UpdateAssetPriceJob;
|
||||
use App\Models\InvestmentHolding;
|
||||
use App\Models\Security;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshAllAssetPrices extends Command
|
||||
{
|
||||
// 執行此指令的名稱:php artisan asset:refresh-prices
|
||||
protected $signature = 'asset:refresh-prices';
|
||||
protected $description = '將全系統所有股票與基金的價格更新任務推入 Queue 佇列';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// 🟢 1. 從持倉表中撈出所有使用者「目前有在使用」且「不重複」的股票與基金代號
|
||||
// 這樣能完美確保 2330 只會被爬一次,不會對證交所造成重複負擔
|
||||
$uniqueHoldings = Security::select('code', 'type', 'name')
|
||||
->groupBy('code', 'type', 'name') // 去除重複
|
||||
->get();
|
||||
|
||||
$this->info("全系統共發現 " . $uniqueHoldings->count() . " 檔不重複的現役標的,開始指派更新任務...");
|
||||
|
||||
$delaySeconds = 0;
|
||||
|
||||
foreach ($uniqueHoldings as $holding) {
|
||||
UpdateAssetPriceJob::dispatch(
|
||||
$holding->code,
|
||||
$holding->type,
|
||||
$holding->name
|
||||
)->delay(now()->addSeconds($delaySeconds));
|
||||
|
||||
$delaySeconds += 2;
|
||||
}
|
||||
|
||||
$this->info("所有任務皆已成功排入背景 Queue,預計花費 {$delaySeconds} 秒消化完畢。");
|
||||
}
|
||||
}
|
||||
115
app/Console/Commands/TestGmailFetch.php
Normal file
115
app/Console/Commands/TestGmailFetch.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\User;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\Gmail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TestGmailFetch extends Command
|
||||
{
|
||||
protected $signature = 'gmail:test-fetch';
|
||||
protected $description = '測試抓取最新的一筆發票或刷卡信件,並輸出至 Log 中';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
// 1. 抓取你剛才綁定成功的使用者(假設 ID 為 1)
|
||||
$user = User::find(1);
|
||||
|
||||
if (!$user || !$user->google_refresh_token) {
|
||||
$this->error('找不到使用者,或該使用者尚未擁有 google_refresh_token!');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("🔄 正在初始化 Google Client 并刷新 Token...");
|
||||
|
||||
// 2. 初始化 Google Client
|
||||
$client = new GoogleClient();
|
||||
$client->setClientId(config('services.google.client_id'));
|
||||
$client->setClientSecret(config('services.google.client_secret'));
|
||||
|
||||
// 用 refresh_token 去跟 Google 交換新的短期 access_token
|
||||
$client->refreshToken($user->google_refresh_token);
|
||||
$accessToken = $client->getAccessToken();
|
||||
|
||||
// 順手把新的 token 蓋回資料庫(防呆)
|
||||
$user->update([
|
||||
'google_access_token' => $accessToken['access_token'],
|
||||
'google_token_expires_at' => now()->addSeconds($accessToken['expires_in']),
|
||||
]);
|
||||
|
||||
// 3. 呼叫 Gmail 服務
|
||||
$gmailService = new Gmail($client);
|
||||
|
||||
$this->info("🔍 正在透過標籤精準搜尋信用卡消費通知...");
|
||||
|
||||
$searchQuery = 'label:信用卡 -is:spam -is:trash';
|
||||
|
||||
$response = $gmailService->users_messages->listUsersMessages('me', [
|
||||
'maxResults' => 1, // 先抓一筆來看內文
|
||||
'q' => $searchQuery
|
||||
]);
|
||||
|
||||
$messages = $response->getMessages();
|
||||
|
||||
if (empty($messages)) {
|
||||
$this->warn('❌ 找不到任何符合關鍵字的信件!請確認你的 Gmail 裡有這種類型的信。');
|
||||
return;
|
||||
}
|
||||
|
||||
$msgId = $messages[0]->getId();
|
||||
$this->info("🎯 成功找到信件 ID: {$msgId},正在下載詳細內容...");
|
||||
|
||||
// 4. 抓取信件完整細節
|
||||
$message = $gmailService->users_messages->get('me', $msgId, ['format' => 'full']);
|
||||
$payload = $message->getPayload();
|
||||
$headers = $payload->getHeaders();
|
||||
|
||||
// 解析主旨
|
||||
$subject = '';
|
||||
foreach ($headers as $header) {
|
||||
if ($header->getName() === 'Subject') {
|
||||
$subject = $header->getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析內文 (處理可能存在的多重 Multipart 結構)
|
||||
$body = $this->getDecodedBody($payload);
|
||||
|
||||
// 5. 🟢 核心現形:把內容原封不動地噴進 Log
|
||||
Log::info("=== 【GMAIL 測試抓取成功】 ===");
|
||||
Log::info("主旨: " . $subject);
|
||||
Log::info("信件 ID: " . $msgId);
|
||||
Log::info("=== 原始內文開始 ===");
|
||||
Log::info($body);
|
||||
Log::info("=== 原始內文結束 ===");
|
||||
|
||||
$this->info("✅ 抓取完畢!真實內容已成功寫入 storage/logs/laravel.log,快去打開瞧瞧吧!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 輔助方法:遞迴解析 Gmail 的 Body 內容
|
||||
*/
|
||||
private function getDecodedBody($part)
|
||||
{
|
||||
$body = $part->getBody();
|
||||
if ($body && $body->getData()) {
|
||||
// Gmail 的 base64 比較特殊,需要把 - 和 _ 換回來
|
||||
$sanitizedData = str_replace(['-', '_'], ['+', '/'], $body->getData());
|
||||
return base64_decode($sanitizedData);
|
||||
}
|
||||
|
||||
$parts = $part->getParts();
|
||||
if ($parts) {
|
||||
foreach ($parts as $subPart) {
|
||||
$result = $this->getDecodedBody($subPart);
|
||||
if ($result) return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user