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:
kennethcheng 2026-04-13 19:58:50 +08:00
parent 0051f92b2b
commit 58b221a27d
4 changed files with 202 additions and 18 deletions

View File

@ -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 修复:新建交易时证券切换自动更新 currencyresetTxForm 默认 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)

View File

@ -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 })

View File

@ -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),
})

View File

@ -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
}