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:
140
app/Jobs/FetchAndParseGmailInvoices.php
Normal file
140
app/Jobs/FetchAndParseGmailInvoices.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?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 App\Models\User;
|
||||
use App\Models\Expense;
|
||||
use App\Models\CategoryRule; // 💡 假設這是你存自動分類規則的 Model
|
||||
use App\Services\GmailParserService;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\Gmail;
|
||||
use Google\Service\Gmail\ModifyMessageRequest;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FetchAndParseGmailInvoices implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $user;
|
||||
|
||||
/**
|
||||
* 這裡我們可以在初始化時傳入指定 User,或者在 handle 裡跑全部 User
|
||||
*/
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function handle(GmailParserService $parserService)
|
||||
{
|
||||
// 1. 檢查使用者憑證
|
||||
if (!$this->user->google_refresh_token) {
|
||||
Log::warning("User ID {$this->user->id} 試圖執行抓信任務,但缺乏 refresh_token。");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 初始化 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);
|
||||
|
||||
// 3. 搜尋帶有專屬標籤且「未讀」的信件
|
||||
$searchQuery = 'label:FinBuddy-Card is:unread';
|
||||
$response = $gmailService->users_messages->listUsersMessages('me', [
|
||||
'q' => $searchQuery,
|
||||
'maxResults' => 20 // 每次最多處理 20 筆,防爆
|
||||
]);
|
||||
|
||||
$messages = $response->getMessages();
|
||||
|
||||
if (empty($messages)) {
|
||||
Log::info("User ID {$this->user->id}: 沒發現符合條件的未讀刷卡信件。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 逐封拆解信件
|
||||
foreach ($messages as $msgItem) {
|
||||
$msgId = $msgItem->getId();
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
// 遞迴拿到內文
|
||||
$body = $this->getDecodedBody($payload);
|
||||
|
||||
// 5. 丟給 Parser 進行 Regex 解析
|
||||
$parsedData = $parserService->parse($subject, $body);
|
||||
|
||||
if ($parsedData) {
|
||||
// 🟢 智慧分類:根據商家名稱反查資料庫裡的自動分類規則
|
||||
// 假設你的規則表有關鍵字比對 (例如: keyword = 'PCHOME', category_id = 3)
|
||||
$matchedRule = CategoryRule::where('user_id', $this->user->id)
|
||||
->get()
|
||||
->first(function ($rule) use ($parsedData) {
|
||||
return Str::contains(strtoupper($parsedData['seller_name']), strtoupper($rule->keyword));
|
||||
});
|
||||
|
||||
$categoryId = $matchedRule ? $matchedRule->category_id : null;
|
||||
|
||||
// 6. 寫入消費紀錄(我們已經補上了 $fillable,絕對暢行無阻!)
|
||||
Expense::create([
|
||||
'user_id' => $this->user->id,
|
||||
'amount' => $parsedData['amount'],
|
||||
'item_name' => $parsedData['item_name'],
|
||||
'seller_name' => $parsedData['seller_name'],
|
||||
'date' => $parsedData['date'],
|
||||
'category_id' => $categoryId,
|
||||
'note' => 'Gmail背景自動記帳',
|
||||
]);
|
||||
|
||||
Log::info("User ID {$this->user->id}: 成功自動記帳一筆金額 {$parsedData['amount']},商家: {$parsedData['seller_name']}");
|
||||
}
|
||||
|
||||
// 7. 🟢 防重複防線:記帳成功後,立刻把信件在 Gmail 標記為「已讀」
|
||||
$mods = new ModifyMessageRequest();
|
||||
$mods->setRemoveLabelIds(['UNREAD']); // 移除未讀標籤 = 標記已讀
|
||||
$gmailService->users_messages->modify('me', $msgId, $mods);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("User ID {$this->user->id} Gmail 自動抓帳 Job 發生崩潰: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遞迴解析內文的輔助方法
|
||||
*/
|
||||
private function getDecodedBody($part)
|
||||
{
|
||||
$body = $part->getBody();
|
||||
if ($body && $body->getData()) {
|
||||
$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 '';
|
||||
}
|
||||
}
|
||||
148
app/Jobs/UpdateAssetPriceJob.php
Normal file
148
app/Jobs/UpdateAssetPriceJob.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Security;
|
||||
use App\Models\SecurityPriceHistories;
|
||||
use Carbon\Carbon;
|
||||
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\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdateAssetPriceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $symbol;
|
||||
protected $type;
|
||||
protected $defaultName;
|
||||
|
||||
public function __construct(string $symbol, string $type, string $defaultName)
|
||||
{
|
||||
$this->symbol = $symbol;
|
||||
$this->type = $type;
|
||||
$this->defaultName = $defaultName;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
Log::info("🔄 開始從持倉清單發動爬蟲: [{$this->symbol}] {$this->defaultName}");
|
||||
|
||||
$isRealPrice = true;
|
||||
$latestPrice = 0;
|
||||
$latestPriceDate = null;
|
||||
$name = $this->defaultName;
|
||||
$changeAmount = 0;
|
||||
$changeRate = 0;
|
||||
$error = null;
|
||||
|
||||
// 股票去爬證交所
|
||||
if ($this->type === 'stock') {
|
||||
Log::info("開始抓取{$this->type}:{$this->symbol}價格");
|
||||
$response = Http::timeout(10)->get("https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=tse_{$this->symbol}.tw");
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$info = $json['msgArray'][0] ?? null;
|
||||
if (isset($info['^']) && !Carbon::parse($info['^'])->isToday()) {
|
||||
$isRealPrice = false;
|
||||
}
|
||||
|
||||
if ($info) {
|
||||
$latestPrice = floatval($info['z'] ?? $info['o'] ?? 0);
|
||||
$latestPriceDate = isset($info['d']) ? Carbon::parse($info['d'])->format('Y-m-d') : now()->format('Y-m-d');
|
||||
$name = $info['n'] ?? $this->defaultName;
|
||||
|
||||
if (isset($info['y']) && $latestPrice > 0) {
|
||||
$changeAmount = floatval($latestPrice - $info['y']);
|
||||
$changeRate = ($changeAmount / $info['y']) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($this->type === 'fund') {
|
||||
Log::info("開始抓取{$this->type}:{$this->symbol}價格");
|
||||
// 去鉅亨網抓
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders([
|
||||
'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',
|
||||
'Referer' => 'https://www.anuefund.com/',
|
||||
'Accept' => 'application/json, text/plain, */*',
|
||||
])
|
||||
->get("https://www.anuefund.com/anuefundApi/FundDetail/FundInfo", [
|
||||
'fundDetailEnum' => 'FundINFO',
|
||||
'FundID' => $this->symbol
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$headerInfo = $json['data']['hearder'] ?? null;
|
||||
|
||||
if (isset($headerInfo['navDate']) && !Carbon::parse($headerInfo['navDate'])->isToday()) {
|
||||
//不是當天資料不更新
|
||||
$isRealPrice = false;
|
||||
}
|
||||
|
||||
if ($headerInfo) {
|
||||
$latestPriceDate = isset($headerInfo['navDate']) ? substr($headerInfo['navDate'], 0, 10) : now()->format('Y-m-d');
|
||||
$latestPrice = floatval($headerInfo['nav'] ?? 0);
|
||||
|
||||
if ($latestPriceDate !== now()->format('Y-m-d')) {
|
||||
//不是當天資料不更新
|
||||
$isRealPrice = false;
|
||||
$latestPrice = 0;
|
||||
}
|
||||
|
||||
$name = $headerInfo['fundName'] ?? $this->defaultName;
|
||||
$changeAmount = floatval($headerInfo['upDown'] ?? 0);
|
||||
$changeRate = floatval($headerInfo['upDownRate'] ?? 0);
|
||||
}
|
||||
}
|
||||
Log::info("抓取{$this->type}:{$this->symbol}資料結束");
|
||||
}
|
||||
|
||||
// 爬蟲成功拿到有效股價,幫新表補資料
|
||||
if ($latestPrice > 0 && $isRealPrice) {
|
||||
Log::info("開始寫入Security和SecurityPriceHistories表,標的: [{$this->symbol}],價格: {$latestPrice}---");
|
||||
$security = Security::updateOrCreate(
|
||||
['code' => $this->symbol],
|
||||
[
|
||||
'type' => $this->type,
|
||||
'name' => $name,
|
||||
'latest_price' => $latestPrice,
|
||||
'latest_price_at' => $latestPriceDate,
|
||||
'change_amount' => $changeAmount,
|
||||
'change_rate' => $changeRate,
|
||||
'source' => 'twse',
|
||||
]
|
||||
);
|
||||
|
||||
SecurityPriceHistories::updateOrCreate(
|
||||
[
|
||||
'security_id' => $security->id,
|
||||
'price_date' => now()->format('Y-m-d'), // 記錄今天的日期
|
||||
],
|
||||
[
|
||||
'price' => $latestPrice,
|
||||
'change_amount' => $changeAmount,
|
||||
'change_rate' => $changeRate,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
Log::info("✅ Security & SecurityPriceHistories 標的 [{$this->symbol}] 補齊並更新成功: {$latestPrice}");
|
||||
} elseif (!$isRealPrice) {
|
||||
Log::info("ℹ️ 標的 [{$this->symbol}] 抓到的價格是非即時的,不更新價格資訊。");
|
||||
} else {
|
||||
Log::warning("⚠️ 標的 [{$this->symbol}] 未能從證交所抓到有效價格,{$error}");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("❌ 更新標的 [{$this->symbol}] 價格時發生異常: " . $e->getMessage());
|
||||
$this->release(60);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user