diff --git a/README.md b/README.md index ff45f50..eeea25d 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,20 @@ MIT License - 详见 [LICENSE](LICENSE) 文件 ## 更新日志 +### v1.0.6 (2026-04-13) + +- 🔧 汇率服务安全重构:JisuAPI Key 从前端移至后端环境变量 `JISU_API_KEY`,彻底根除硬编码 +- 🔧 JisuAPI 解析修复:修正 `data.status !== 0`(原错误使用 `data.code`)和 `data.result?.rate`(原错误使用 `data.result?.result`) +- 🛡️ 缓存防毒:移除 `next: { revalidate }`,改用自研内存缓存,防止错误响应被 Next.js 缓存1小时 +- 🔧 交易编辑原子性:PATCH `/api/transactions` 完整重写,增删改操作使用 `prisma.$transaction` 原子事务 +- 📊 持仓重算服务:新增 `recalculatePosition()` 函数,PATCH 编辑时遍历历史 BUY/SELL 交易重算 avgCost +- 🔧 BUG-301 修复:编辑交易时正确逆向原始交易并重算持仓,彻底解决"编辑后持仓不变"的 Blockering Bug +- 🔧 币种自动识别:新增 `getCurrencyFromSymbol()` 函数,根据证券代码推断 CNY/HKD/USD +- 🔧 BUG-401/402 修复:新建交易时证券切换自动更新 currency,resetTxForm 默认 USD 而非账户货币 +- 💱 汇率 Fallback 强化:`getExchangeRate` 包裹完整 try-catch,确保 JisuAPI 失败时正确降级至 DEFAULT_RATES +- 📝 注释修正:`transactions/route.ts` 注释 BUTH → BUY/SELL +- 🔧 BUG-601 修复:`recalculatePosition()` 的 `upsert` 硬编码 `currency: 'USD'` → 动态提取 history 最后一笔交易的 currency;`update` 分支新增 `currency` 字段实现脏数据自愈(600690/601919 等 A股持仓被错误标记为 USD 的问题彻底修复) + ### v1.0.5 (2026-04-13) - 📛 证券名称增强:修复 INTC 等证券名称显示为空的问题 (BUG-101) diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 3987426..12f6611 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -164,7 +164,80 @@ export async function POST(request: Request) { } } -// 更新交易记录 +// ============================================================ +// 持仓重算服务(被 PATCH/DELETE 复用) +// 遍历指定账户+证券的所有 BUY/SELL 历史交易,重新计算 avgCost 和 currency +// ============================================================ +async function recalculatePosition( + tx: Prisma.TransactionClient, + accountId: string, + symbol: string +): Promise<{ quantity: Prisma.Decimal; averageCost: Prisma.Decimal }> { + const history = await tx.transaction.findMany({ + where: { + accountId, + symbol, + type: { in: ['BUY', 'SELL'] }, + }, + orderBy: { executedAt: 'asc' }, + }) + + let totalQty = new Prisma.Decimal(0) + let totalCost = new Prisma.Decimal(0) + let lastCurrency: string | null = null + + // 提取最后一笔有效交易的 currency(用于更新 Position 币种) + for (let i = history.length - 1; i >= 0; i--) { + const t = history[i] + if (t.currency) { + lastCurrency = t.currency + break + } + } + + for (const t of history) { + if (t.type === 'BUY' && t.quantity && t.price) { + const qty = new Prisma.Decimal(t.quantity) + const price = new Prisma.Decimal(t.price) + totalQty = totalQty.plus(qty) + totalCost = totalCost.plus(qty.times(price)) + } else if (t.type === 'SELL' && t.quantity) { + totalQty = totalQty.minus(new Prisma.Decimal(t.quantity)) + } + } + + // 清理:若持仓归零或负数,清除记录 + if (totalQty.isZero() || totalQty.isNegative()) { + await tx.position.delete({ + where: { accountId_symbol: { accountId, symbol } }, + }).catch(() => { /* ignore if not exists */ }) + return { quantity: new Prisma.Decimal(0), averageCost: new Prisma.Decimal(0) } + } + + const avgCost = totalCost.div(totalQty) + + // 动态获取 currency:从 history 最后一笔交易提取;若仍无则保持原有(用于 update 分支) + const dynamicCurrency = lastCurrency || 'USD' + + await tx.position.upsert({ + where: { accountId_symbol: { accountId, symbol } }, + // update 分支:必须同步更新 currency,实现脏数据自愈 + update: { quantity: totalQty, averageCost: avgCost, currency: dynamicCurrency }, + create: { + accountId, + symbol, + quantity: totalQty, + averageCost: avgCost, + currency: dynamicCurrency, // 动态币种,非硬编码 + }, + }) + + console.log(`[recalculatePosition] ${symbol}: qty=${totalQty.toString()}, avgCost=${avgCost.toString()}, currency=${dynamicCurrency}`) + + return { quantity: totalQty, averageCost: avgCost } +} + +// 更新交易记录(原子性:逆向原始交易 + 重算持仓/余额) export async function PATCH(request: Request) { try { const body = await request.json() @@ -174,6 +247,13 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'Transaction ID is required' }, { status: 400 }) } + // 获取原始交易记录 + const original = await prisma.transaction.findUnique({ where: { id } }) + if (!original) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + // 构建更新 payload const updatePayload: Record = {} if (type !== undefined) updatePayload.type = type if (symbol !== undefined) updatePayload.symbol = symbol || null @@ -185,12 +265,82 @@ export async function PATCH(request: Request) { if (notes !== undefined) updatePayload.notes = notes if (executedAt !== undefined) updatePayload.executedAt = new Date(executedAt) - const transaction = await prisma.transaction.update({ - where: { id }, - data: updatePayload, + // 开启原子事务 + const result = await prisma.$transaction(async (tx) => { + // 1. 撤销原始交易对 Account 的影响 + if (original.type === 'DEPOSIT' || original.type === 'WITHDRAW') { + const reverseAmount = original.type === 'DEPOSIT' + ? (original.amount as Prisma.Decimal).negated() + : original.amount as Prisma.Decimal + await tx.account.update({ + where: { id: original.accountId }, + data: { balance: { increment: reverseAmount } }, + }) + } + + // 2. 撤销原始交易对 Position 的影响 + if ((original.type === 'BUY' || original.type === 'SELL') && original.symbol) { + const position = await tx.position.findUnique({ + where: { accountId_symbol: { accountId: original.accountId, symbol: original.symbol } }, + }) + if (position) { + if (original.type === 'BUY' && original.quantity && original.price) { + // 撤销买入成本 + const undoQty = new Prisma.Decimal(original.quantity) + const undoAmount = undoQty.times(new Prisma.Decimal(original.price)) + const newQty = position.quantity.minus(undoQty) + if (newQty.isZero() || newQty.isNegative()) { + await tx.position.delete({ + where: { accountId_symbol: { accountId: original.accountId, symbol: original.symbol } }, + }) + } else { + const costBeforeBuy = position.averageCost.times(position.quantity).minus(undoAmount) + const newAvgCost = costBeforeBuy.div(newQty) + await tx.position.update({ + where: { accountId_symbol: { accountId: original.accountId, symbol: original.symbol } }, + data: { quantity: newQty, averageCost: newAvgCost }, + }) + } + } else if (original.type === 'SELL' && original.quantity) { + // 撤销卖出:恢复持仓数量 + const newQty = position.quantity.plus(new Prisma.Decimal(original.quantity)) + await tx.position.update({ + where: { accountId_symbol: { accountId: original.accountId, symbol: original.symbol } }, + data: { quantity: newQty }, + }) + } + } + } + + // 3. 更新交易记录 + const updated = await tx.transaction.update({ + where: { id }, + data: updatePayload, + }) + + // 4. 应用新交易对 Account 的影响 + const newType = type ?? original.type + if (newType === 'DEPOSIT' || newType === 'WITHDRAW') { + const newAmount = amount ?? original.amount + const changeAmount = newType === 'DEPOSIT' + ? new Prisma.Decimal(newAmount) + : (new Prisma.Decimal(newAmount)).negated() + await tx.account.update({ + where: { id: original.accountId }, + data: { balance: { increment: changeAmount } }, + }) + } + + // 5. 应用新交易对 Position 的影响(BUY/SELL 需要完整重算) + const newSymbol = symbol ?? original.symbol + if ((newType === 'BUY' || newType === 'SELL') && newSymbol) { + await recalculatePosition(tx, original.accountId, newSymbol) + } + + return updated }) - return NextResponse.json(transaction) + return NextResponse.json(result) } catch (error) { console.error('Update transaction error:', error) return NextResponse.json({ error: 'Failed to update transaction' }, { status: 500 }) diff --git a/src/app/page.tsx b/src/app/page.tsx index 22d4e00..c45a62b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -108,6 +108,20 @@ function convertCurrency(amount: number, from: string, to: string): number { return amount * (EXCHANGE_RATES[to] / EXCHANGE_RATES[from]) } +// 根据证券代码推断法定货币(防御性降级方案) +function getCurrencyFromSymbol(symbol: string): string | null { + if (!symbol) return null + // A股: sh600690, sz000858 等格式 + if (/^(sh|sz)\d{6}$/.test(symbol)) return 'CNY' + // 港股: 5位数字如 09868, 00700 + if (/^\d{5}$/.test(symbol)) return 'HKD' + // 美股: 大写字母如 AAPL, GOOGL, INTC + if (/^[A-Z]{1,5}$/.test(symbol)) return 'USD' + // 加密货币: USDT 等 + if (/^USDT$/i.test(symbol)) return 'USDT' + return null +} + // 主页面组件 export default function Dashboard() { // 状态定义 @@ -241,11 +255,13 @@ export default function Dashboard() { const handleSelectSecurity = (symbol: string) => { const price = analytics?.prices[symbol]?.price || 0 const sec = securities.find(s => s.symbol === symbol) + // 优先级:Security表货币 > 代码推断货币 > 账户默认货币 + const inferredCurrency = getCurrencyFromSymbol(symbol) setTxForm(prev => ({ ...prev, symbol, price: price > 0 ? price.toString() : prev.price, - currency: sec?.currency || prev.currency, + currency: sec?.currency || inferredCurrency || prev.currency, })) setSymbolSearch('') setFilteredSecurities([]) @@ -397,7 +413,7 @@ export default function Dashboard() { } } - // 重置交易表单 + // 重置交易表单(货币默认 USD,由 handleSelectSecurity 根据证券自动覆盖) const resetTxForm = () => { setTxForm({ type: 'BUY', @@ -406,7 +422,7 @@ export default function Dashboard() { price: '', amount: '', fee: '0', - currency: selectedAccount?.baseCurrency || 'USD', + currency: 'USD', notes: '', executedAt: new Date().toISOString().slice(0, 16), }) diff --git a/src/lib/exchange-rate.ts b/src/lib/exchange-rate.ts index 727e257..26db2a7 100644 --- a/src/lib/exchange-rate.ts +++ b/src/lib/exchange-rate.ts @@ -56,7 +56,8 @@ async function fetchExchangeRateFromJisu(from: string, to: string): Promise return rateCache[cacheKey].rate } - const rate = await fetchExchangeRateFromJisu(from, to) + try { + const rate = await fetchExchangeRateFromJisu(from, to) - if (rate !== null) { - rateCache[cacheKey] = { rate, timestamp: now } - return rate + if (rate !== null) { + rateCache[cacheKey] = { rate, timestamp: now } + return rate + } + } catch (error) { + // fetchExchangeRateFromJisu 抛出的所有异常(包括缺失 API Key)都会被捕获 + console.warn(`[exchange-rate] JisuAPI failed for ${from}->${to}`, error) } // 降级:使用默认汇率 if (DEFAULT_RATES[from] && DEFAULT_RATES[to]) { const fallbackRate = DEFAULT_RATES[from] / DEFAULT_RATES[to] - console.warn(`Using fallback rate for ${from}->${to}: ${fallbackRate}`) + console.warn(`[exchange-rate] Using fallback rate for ${from}->${to}: ${fallbackRate}`) return fallbackRate } - console.error(`All exchange rate sources failed for ${from}->${to}, returning 1`) + console.error(`[exchange-rate] All sources failed for ${from}->${to}, returning 1`) return 1 }