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:
67
routes/api.php
Normal file
67
routes/api.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\AccountController;
|
||||
use App\Http\Controllers\Api\CategoryController;
|
||||
use App\Http\Controllers\Api\CategoryRuleController;
|
||||
use App\Http\Controllers\Api\ExpenseController;
|
||||
use App\Http\Controllers\Api\InvestmentHoldingController;
|
||||
use App\Http\Controllers\Api\InvestmentTransactionController;
|
||||
use App\Http\Controllers\Api\InvoiceImportController;
|
||||
use App\Http\Controllers\Api\SecurityController;
|
||||
use App\Http\Controllers\Api\SecurityPriceController;
|
||||
use App\Http\Controllers\Api\DashboardController;
|
||||
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
})->middleware('auth:sanctum');
|
||||
|
||||
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
|
||||
// 儀表板統計數據 API
|
||||
Route::get('/dashboard/stats', [DashboardController::class, 'index']);
|
||||
|
||||
Route::apiResource('accounts', AccountController::class);
|
||||
|
||||
Route::apiResource('accounts.holdings', InvestmentHoldingController::class)
|
||||
->shallow();
|
||||
Route::delete('holdings/delete/{account}/{holding}', [InvestmentHoldingController::class, 'destroy']);
|
||||
|
||||
|
||||
Route::get('holdings/{holding}/transactions', [InvestmentTransactionController::class, 'index']);
|
||||
Route::post('holdings/{holding}/transactions', [InvestmentTransactionController::class, 'store']);
|
||||
Route::delete('holdings/{holding}/transactions/{transaction}', [InvestmentTransactionController::class, 'destroy']);
|
||||
|
||||
Route::get('/accounts/{accountId}/holdings-trend', [SecurityPriceController::class, 'accountTrend']);
|
||||
Route::get('holdings/{holding}/realtime', [SecurityPriceController::class, 'showRealtime']);
|
||||
Route::get('holdings/{holding}/history', [SecurityPriceController::class, 'history']);
|
||||
|
||||
Route::get('/securities/search', [SecurityController::class, 'index']);
|
||||
|
||||
// 取得所有支出明細列表
|
||||
Route::get('/expenses', [ExpenseController::class, 'index']);
|
||||
// 新增單筆支出
|
||||
Route::post('/expenses', [ExpenseController::class, 'store']);
|
||||
// 修改單筆支出
|
||||
Route::put('/expenses/{expense}', [ExpenseController::class, 'update']);
|
||||
// 刪除單筆支出
|
||||
Route::delete('/expenses/{expense}', [ExpenseController::class, 'destroy']);
|
||||
// 批量刪除支出
|
||||
Route::post('/expenses/destroy-batch', [ExpenseController::class, 'destroyBatch']);
|
||||
Route::get('/categories', [ExpenseController::class, 'categories']);
|
||||
Route::get('/category-rules', [ExpenseController::class, 'categoryRules']);
|
||||
|
||||
// 分類管理 API
|
||||
Route::post('/categories', [CategoryController::class, 'store']);
|
||||
Route::put('/categories/{category}', [CategoryController::class, 'update']);
|
||||
Route::delete('/categories/{category}', [CategoryController::class, 'destroy']);
|
||||
|
||||
// 自動分類規則 API
|
||||
Route::post('/category-rules', [CategoryRuleController::class, 'store']);
|
||||
Route::delete('/category-rules/{categoryRule}', [CategoryRuleController::class, 'destroy']);
|
||||
|
||||
// 發票 CSV 上傳匯入介面 API
|
||||
Route::post('/expenses/import', [InvoiceImportController::class, 'store']);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Http\Controllers\Auth\ConfirmablePasswordController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
|
||||
use App\Http\Controllers\Auth\EmailVerificationPromptController;
|
||||
use App\Http\Controllers\Auth\GoogleAuthController;
|
||||
use App\Http\Controllers\Auth\NewPasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordController;
|
||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||
@@ -33,6 +34,10 @@ Route::middleware('guest')->group(function () {
|
||||
|
||||
Route::post('reset-password', [NewPasswordController::class, 'store'])
|
||||
->name('password.store');
|
||||
|
||||
Route::get('auth/google', [GoogleAuthController::class, 'redirect'])
|
||||
->name('auth.google');
|
||||
Route::get('auth/google/callback', [GoogleAuthController::class, 'callback']);
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\RefreshAllAssetPrices;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
use App\Jobs\FetchAndParseGmailInvoices;
|
||||
use App\Models\User;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command(RefreshAllAssetPrices::class)
|
||||
->weekdays()
|
||||
->at('14:00')
|
||||
->at('22:00');
|
||||
|
||||
Schedule::call(function () {
|
||||
// 找出所有有綁定 Google Refresh Token 的使用者
|
||||
$users = User::whereNotNull('google_refresh_token')->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
// 分別派發非同步任務去排隊抓信,不塞車
|
||||
FetchAndParseGmailInvoices::dispatch($user);
|
||||
}
|
||||
})
|
||||
->weekdays()
|
||||
->at('14:00')
|
||||
->at('22:00');
|
||||
|
||||
129
routes/web.php
129
routes/web.php
@@ -6,7 +6,13 @@ use App\Http\Controllers\CategoryController;
|
||||
use App\Http\Controllers\CategoryRuleController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\InvoiceImportController;
|
||||
use App\Models\InvestmentHolding;
|
||||
use App\Models\Security;
|
||||
use App\Models\SecurityPriceHistories;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@@ -23,6 +29,129 @@ Route::get('/', function () {
|
||||
// return Inertia::render('Dashboard');
|
||||
// })->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
Route::get('/sync-all-history', function () {
|
||||
set_time_limit(600);
|
||||
|
||||
// 🟢 1. 纯粹捞出持仓,不用多余的 with 关联
|
||||
$holdings = InvestmentHolding::all();
|
||||
$count = 0;
|
||||
|
||||
foreach ($holdings as $holding) {
|
||||
|
||||
// 🟢 2. 直接用持仓的 symbol 去匹配字典表,拿到真正需要的自增主键 id
|
||||
$security = DB::table('securities')->where('code', $holding->symbol)->first();
|
||||
|
||||
if (!$security) {
|
||||
// 如果字典表没有,顺手现场帮它开荒一笔,拿到新 id
|
||||
$securityId = DB::table('securities')->insertGetId([
|
||||
'code' => $holding->symbol,
|
||||
'name' => $holding->name ?? $holding->symbol,
|
||||
'type' => $holding->type,
|
||||
'source' => $holding->type === 'stock' ? 'twse' : 'anue',
|
||||
'latest_price' => floatval($holding->avg_cost ?? 0),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$securityId = $security->id;
|
||||
}
|
||||
|
||||
// 找到最早的一笔买入交易作为历史起点
|
||||
$firstTx = $holding->transactions()->where('action', 'buy')->orderBy('traded_at', 'asc')->first();
|
||||
if (!$firstTx) continue;
|
||||
|
||||
$startDate = Carbon::parse($firstTx->traded_at);
|
||||
$endDate = Carbon::now();
|
||||
|
||||
dump("正在同步 [{$holding->type}] {$holding->symbol} - {$holding->name} 的历史资料自 {$startDate->toDateString()} 起...");
|
||||
|
||||
// ----------------------------------------
|
||||
// 股票 (Stock) 同步
|
||||
// ----------------------------------------
|
||||
if ($holding->type === 'stock') {
|
||||
$current = $startDate->copy()->startOfMonth();
|
||||
|
||||
while ($current->lte($endDate)) {
|
||||
$res = Http::get('https://www.twse.com.tw/rwd/zh/afterTrading/STOCK_DAY', [
|
||||
'stockNo' => $holding->symbol,
|
||||
'date' => $current->format('Ymd'),
|
||||
]);
|
||||
|
||||
$data = $res->json();
|
||||
|
||||
if (isset($data['stat']) && $data['stat'] === 'OK' && !empty($data['data'])) {
|
||||
foreach ($data['data'] as $row) {
|
||||
[$y, $m, $d] = explode('/', $row[0]);
|
||||
$dateStr = (1911 + (int)$y) . '-' . $m . '-' . $d;
|
||||
|
||||
if ($dateStr >= $startDate->toDateString()) {
|
||||
$closePrice = (float) str_replace(',', '', $row[6]);
|
||||
|
||||
// 🟢 成功通过 $securityId 灌入历史表
|
||||
DB::table('security_price_histories')->updateOrInsert(
|
||||
[
|
||||
'security_id' => $securityId,
|
||||
'price_date' => $dateStr,
|
||||
],
|
||||
[
|
||||
'price' => $closePrice,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$current->addMonth();
|
||||
usleep(300000); // 防黑名单
|
||||
}
|
||||
}
|
||||
// ----------------------------------------
|
||||
// 基金 (Fund) 同步
|
||||
// ----------------------------------------
|
||||
elseif ($holding->type === 'fund') {
|
||||
$res = Http::post("https://www.anuefund.com/anuefundApi/FundDetail/Price", [
|
||||
'priceENUM' => 'NAVHIS',
|
||||
'fundID' => $holding->symbol,
|
||||
'strDate' => $startDate->format('Y-m-d'),
|
||||
'endDate' => $endDate->format('Y-m-d'),
|
||||
'chartFomatUse' => true,
|
||||
'reload' => false
|
||||
]);
|
||||
|
||||
if ($res->ok() && !empty($res->json('data'))) {
|
||||
$pairs = json_decode($res->json('data'), true);
|
||||
|
||||
foreach ($pairs as $item) {
|
||||
if ($item[1] === 0) continue;
|
||||
|
||||
$dateStr = date('Y-m-d', intval($item[0]) / 1000);
|
||||
$navPrice = (float) $item[1];
|
||||
|
||||
// 🟢 成功通过 $securityId 灌入历史表
|
||||
DB::table('security_price_histories')->updateOrInsert(
|
||||
[
|
||||
'security_id' => $securityId,
|
||||
'price_date' => $dateStr,
|
||||
],
|
||||
[
|
||||
'price' => $navPrice,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
dump("▶ [{$holding->symbol}] 同步完成。");
|
||||
}
|
||||
|
||||
return "🎉 历史资料回归本地资料库成功!共写录 {$count} 笔历史价格点。";
|
||||
});
|
||||
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])
|
||||
->name('dashboard');
|
||||
|
||||
Reference in New Issue
Block a user