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:
269
app/Http/Controllers/Api/SecurityPriceController.php
Normal file
269
app/Http/Controllers/Api/SecurityPriceController.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InvestmentHolding;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SecurityPriceController extends Controller
|
||||
{
|
||||
/**
|
||||
* 最新價格/現值 API (自帶過期即時回補機制 ⚡)
|
||||
* GET /api/holdings/{holdingId}/realtime
|
||||
*/
|
||||
public function showRealtime(Request $request, string $holdingId)
|
||||
{
|
||||
// 1. 驗證持倉是否屬於該使用者
|
||||
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
|
||||
$q->where('user_id', $request->user()->id);
|
||||
})->findOrFail($holdingId);
|
||||
|
||||
// 2. 從本地字典表撈出紀錄
|
||||
$security = DB::table('securities')->where('code', $holding->symbol)->first();
|
||||
|
||||
// 💡 關鍵判定:如果字典表根本沒這檔標的,或者資料「過期了」,就即時去戳外網更新
|
||||
$needUpdate = false;
|
||||
|
||||
if (!$security) {
|
||||
$needUpdate = true;
|
||||
} else {
|
||||
$lastUpdate = Carbon::parse($security->updated_at);
|
||||
|
||||
// 情境 A:最新價格的日期不是今天
|
||||
if (!$lastUpdate->isToday()) {
|
||||
$needUpdate = true;
|
||||
}
|
||||
// 情境 B:如果是今天的台股交易時間 (09:00 - 14:00),且本地資料超過 15 分鐘沒更新,就幫他刷新
|
||||
elseif (now()->between('09:00', '14:00') && now()->diffInMinutes($lastUpdate) > 15) {
|
||||
$needUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($needUpdate) {
|
||||
// 當場發動外網即時救援
|
||||
$security = $this->fetchAndSaveRealtime($holding);
|
||||
}
|
||||
|
||||
if (!$security) {
|
||||
return response()->json(['error' => '無法取得該標的的最新價格資料'], 404);
|
||||
}
|
||||
|
||||
// 3. 統一格式回傳給前端
|
||||
return response()->json([
|
||||
'symbol' => $holding->symbol,
|
||||
'name' => $security->name,
|
||||
'type' => $holding->type,
|
||||
'latest_price' => (float) $security->latest_price,
|
||||
'change_amount' => (float) ($security->change_amount ?? 0),
|
||||
'change_rate' => (float) ($security->change_rate ?? 0),
|
||||
'updated_at' => $security->updated_at,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 內部方法:即時抓取外部市價並洗回資料庫字典表
|
||||
*/
|
||||
private function fetchAndSaveRealtime(InvestmentHolding $holding)
|
||||
{
|
||||
$priceData = [
|
||||
'name' => $holding->name ?? $holding->symbol,
|
||||
'latest_price' => 0.0,
|
||||
'change_amount' => 0.0,
|
||||
'change_rate' => 0.0,
|
||||
];
|
||||
|
||||
// ----------------------------------------
|
||||
// A. 股票實時抓取
|
||||
// ----------------------------------------
|
||||
if ($holding->type === 'stock') {
|
||||
try {
|
||||
$exchange = (str_starts_with($holding->symbol, '00') || strlen($holding->symbol) === 4) ? 'tse' : 'tse'; // 可依需求擴充 otc
|
||||
$res = Http::timeout(5)->get("https://mis.twse.com.tw/stock/api/getStockInfo.jsp", [
|
||||
'ex_ch' => "{$exchange}_{$holding->symbol}.tw",
|
||||
'json' => 1,
|
||||
'delay' => 0,
|
||||
]);
|
||||
|
||||
if ($res->ok() && !empty($res->json('msgArray'))) {
|
||||
$stock = $res->json('msgArray')[0];
|
||||
if (isset($stock['^']) && !Carbon::parse($stock['^'])->isToday()) {
|
||||
$existing = DB::table('securities')->where('code', $holding->symbol)->first();
|
||||
if ($existing) return $existing;
|
||||
}
|
||||
$priceData['name'] = $stock['n'] ?? $priceData['name'];
|
||||
|
||||
$currentPrice = $stock['z'] !== '-' ? (float) $stock['z'] : (float) $stock['y'];
|
||||
$prevPrice = (float) $stock['y'];
|
||||
|
||||
$priceData['latest_price'] = $currentPrice;
|
||||
$priceData['change_amount'] = $currentPrice - $prevPrice;
|
||||
$priceData['change_rate'] = $prevPrice > 0 ? (($currentPrice - $prevPrice) / $prevPrice) * 100 : 0;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 網路超時防呆,保留不崩潰
|
||||
}
|
||||
}
|
||||
// ----------------------------------------
|
||||
// B. 基金實時抓取
|
||||
// ----------------------------------------
|
||||
elseif ($holding->type === 'fund') {
|
||||
try {
|
||||
$res = Http::timeout(5)->get("https://www.anuefund.com/anuefundApi/FundDetail/FundInfo", [
|
||||
'fundDetailEnum' => 'FundINFO',
|
||||
'FundID' => $holding->symbol,
|
||||
]);
|
||||
|
||||
if ($res->ok() && !empty($res->json('data.hearder'))) {
|
||||
$header = $res->json('data.hearder');
|
||||
if (isset($header['navDate']) && !Carbon::parse($header['navDate'])->isToday()) {
|
||||
$existing = DB::table('securities')->where('code', $holding->symbol)->first();
|
||||
if ($existing) return $existing;
|
||||
}
|
||||
$priceData['name'] = $header['fundName'] ?? $priceData['name'];
|
||||
$priceData['latest_price'] = (float) $header['nav'];
|
||||
// 鉅亨網實時 API 沒有給當日漲跌幅的話,這裡預設留空或 0
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 防呆
|
||||
}
|
||||
}
|
||||
|
||||
// 如果連外部都抓不到且本地完全沒資料,就防呆返回 null
|
||||
if ($priceData['latest_price'] == 0) {
|
||||
$existing = DB::table('securities')->where('code', $holding->symbol)->first();
|
||||
if ($existing) return $existing;
|
||||
}
|
||||
|
||||
// 更新或新增進字典表
|
||||
DB::table('securities')->updateOrInsert(
|
||||
['code' => $holding->symbol],
|
||||
[
|
||||
'name' => $priceData['name'],
|
||||
'type' => $holding->type,
|
||||
'source' => $holding->type === 'stock' ? 'twse' : 'anue',
|
||||
'latest_price' => $priceData['latest_price'],
|
||||
'change_amount' => $priceData['change_amount'],
|
||||
'change_rate' => $priceData['change_rate'],
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(), // updateOrInsert 在更新時會自動忽略 created_at
|
||||
]
|
||||
);
|
||||
|
||||
return DB::table('securities')->where('code', $holding->symbol)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 整合後的歷史紀錄 API (股票、基金共用)
|
||||
* GET /api/holdings/{holdingId}/history
|
||||
*/
|
||||
public function history(Request $request, string $holdingId)
|
||||
{
|
||||
$holding = InvestmentHolding::whereHas('account', function ($q) use ($request) {
|
||||
$q->where('user_id', $request->user()->id);
|
||||
})->with(['transactions'])->findOrFail($holdingId);
|
||||
|
||||
// 找出最早的買入點作為時間起點
|
||||
$firstTx = $holding->transactions->where('action', 'buy')->sortBy('traded_at')->first();
|
||||
if (!$firstTx) return response()->json(['history' => [], 'buy_points' => []]);
|
||||
|
||||
$startDate = Carbon::parse($firstTx->traded_at)->toDateString();
|
||||
|
||||
// 拿持倉的 symbol 去字典表查出對應的歷史主鍵 id
|
||||
$securityId = DB::table('securities')->where('code', $holding->symbol)->value('id');
|
||||
if (!$securityId) {
|
||||
return response()->json(['history' => [], 'buy_points' => []]);
|
||||
}
|
||||
|
||||
// 從本地歷史資料庫撈取趨勢
|
||||
$history = DB::table('security_price_histories')
|
||||
->where('security_id', $securityId)
|
||||
->where('price_date', '>=', $startDate)
|
||||
->orderBy('price_date', 'asc')
|
||||
->get(['price_date as date', 'price as close'])
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'date' => $item->date,
|
||||
'close' => (float) $item->close
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
// 整合買賣點標記
|
||||
$buyPoints = $holding->transactions
|
||||
->where('action', 'buy')
|
||||
->map(fn($tx) => [
|
||||
'date' => Carbon::parse($tx->traded_at)->toDateString(),
|
||||
'price' => (float) ($holding->type === 'fund'
|
||||
? ($tx->nav ?? $tx->price ?? 0)
|
||||
: ($tx->price ?? $tx->nav ?? 0)),
|
||||
'note' => $tx->note ?? '買入',
|
||||
])->values();
|
||||
|
||||
return response()->json([
|
||||
'history' => $history,
|
||||
'buy_points' => $buyPoints,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accountTrend(Request $request, string $accountId)
|
||||
{
|
||||
$account = $request->user()->accounts()->findOrFail($accountId);
|
||||
|
||||
// 1. 撈出所有目前有庫存的持倉
|
||||
$holdings = $account->holdings()->where('shares', '>', 0)->get();
|
||||
|
||||
// 🟢 關鍵修正:找出所有有庫存標的裡,最古老的一筆買入交易日期
|
||||
$earliestTxDate = DB::table('investment_transactions')
|
||||
->whereIn('holding_id', $holdings->pluck('id'))
|
||||
->where('action', 'buy')
|
||||
->orderBy('traded_at', 'asc')
|
||||
->value('traded_at');
|
||||
|
||||
// 防呆:如果真的完全沒有交易紀錄,預設才走 30 天;有的話就以最古老日期為起點
|
||||
$startDate = $earliestTxDate
|
||||
? Carbon::parse($earliestTxDate)->toDateString()
|
||||
: now()->subDays(30)->format('Y-m-d');
|
||||
|
||||
$securityIds = DB::table('securities')->whereIn('code', $holdings->pluck('symbol'))->pluck('id', 'code');
|
||||
|
||||
// 2. 撈取歷史紀錄 (此時起點已經完美回溯到 4 月甚至更早了)
|
||||
$historyPrices = DB::table('security_price_histories')
|
||||
->whereIn('security_id', $securityIds->values())
|
||||
->where('price_date', '>=', $startDate)
|
||||
->orderBy('price_date', 'asc')
|
||||
->get()
|
||||
->groupBy('security_id');
|
||||
|
||||
$chartData = [];
|
||||
|
||||
foreach ($holdings as $holding) {
|
||||
$sid = $securityIds->get($holding->symbol);
|
||||
if (!$sid) continue;
|
||||
|
||||
$prices = $historyPrices->get($sid) ?? collect();
|
||||
$avgCost = floatval($holding->avg_cost);
|
||||
if ($avgCost <= 0) continue;
|
||||
|
||||
$seriesData = [];
|
||||
foreach ($prices as $hp) {
|
||||
$dailyRate = ((floatval($hp->price) - $avgCost) / $avgCost) * 100;
|
||||
$seriesData[] = [
|
||||
'date' => $hp->price_date,
|
||||
'rate' => round($dailyRate, 2)
|
||||
];
|
||||
}
|
||||
|
||||
$chartData[] = [
|
||||
'symbol' => $holding->symbol,
|
||||
'name' => $holding->name,
|
||||
'data' => $seriesData
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json($chartData);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user