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 的问题彻底修复)
This commit is contained in:
parent
0051f92b2b
commit
58b221a27d
14
README.md
14
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)
|
||||
|
||||
@ -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<string, any> = {}
|
||||
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({
|
||||
// 开启原子事务
|
||||
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,
|
||||
})
|
||||
|
||||
return NextResponse.json(transaction)
|
||||
// 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(result)
|
||||
} catch (error) {
|
||||
console.error('Update transaction error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update transaction' }, { status: 500 })
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -56,7 +56,8 @@ async function fetchExchangeRateFromJisu(from: string, to: string): Promise<numb
|
||||
const apiKey = process.env.JISU_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('Missing JISU_API_KEY in .env file. Cannot fetch exchange rates.')
|
||||
console.error('[exchange-rate] JISU_API_KEY environment variable is not set')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
@ -65,9 +66,7 @@ async function fetchExchangeRateFromJisu(from: string, to: string): Promise<numb
|
||||
|
||||
const url = `${JISU_API_BASE}?appkey=${apiKey}&from=${fromCode}&to=${toCode}&amount=1`
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`JisuAPI HTTP error: ${response.status}`)
|
||||
@ -115,21 +114,26 @@ export async function getExchangeRate(from: string, to: string): Promise<number>
|
||||
return rateCache[cacheKey].rate
|
||||
}
|
||||
|
||||
try {
|
||||
const rate = await fetchExchangeRateFromJisu(from, to)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user